Module: Scryer::FixRunner

Defined in:
lib/scryer/fix_runner.rb

Overview

The "apply verified AI fixes to real files" loop, shared behind both scryer fix (CLI) and rails scryer:fix (rake task) — the actual file-writing logic lives here exactly once; each caller formats its own console output around it (same house style as ScryerTasks/CLI already duplicating summary-printing methods rather than sharing them — see lib/tasks/scryer.rake).

Class Method Summary collapse

Class Method Details

.apply(candidates, client:, root:, dry_run: false, confirm: nil, &on_result) ⇒ Object

Groups by file and processes each file's findings from the highest line number down — replacing a later line first means every not-yet- processed line number earlier in the same file stays valid even when a fix's replacement spans a different number of lines than the original one. (Two distinct findings landing on the exact same line in the same file is a narrow, unhandled-further edge case — FixVerifier.apply! always re-verifies against the file's current on-disk content right before writing, so it can't silently apply a fix that no longer actually matches what's there, but the second finding on that line may end up needing a second fix pass to resolve.)

Returns [fixed, skipped] — both plain arrays of Finding (a user-declined finding, when confirm is given, lands in skipped too — it was verified but deliberately not written, same bucket as "not written" for any other reason). Yields (finding, status, error) to the given block as each candidate is resolved — status is :fixed, :would_fix (dry_run), :declined (confirm said no), :ai_error (the ai_client raised — error is that exception; nil for every other status), :cancelled (confirm said stop — see below), or :skipped — so a caller (see CLI#print_fix_progress / ScryerTasks#print_fix_progress) can print progress as it happens instead of only a summary once everything's done. Blocks in Ruby don't enforce arity, so an existing { |finding, status| ... } callback still works fine and just never sees the third arg. The block is entirely optional; omitting it changes nothing else.

confirm, when given, is called with each independently-verified finding right before it would be written (or counted as "would fix", under dry_run) — returning false skips it without writing, and returning the symbol :cancel stops entirely: every remaining candidate (including the current one) is marked :cancelled without confirm being asked about any of them again — and, critically, without spending an AiFixSuggester call or a MechanicalFixer/ FixVerifier pass on any of them either, since "cancel" means stop working, not just "don't write what's already been verified." This is the model-layer half of scryer fix's per-finding "yes / skip / yes to all remaining / cancel" review; the actual prompt (reading stdin, tracking a latched "yes to all" choice across calls) lives in the caller (CLI#build_fix_confirmer / ScryerTasks) so this stays testable without a real terminal. Omitting confirm (the default) applies every verified fix automatically, same as before this option existed.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/scryer/fix_runner.rb', line 54

def apply(candidates, client:, root:, dry_run: false, confirm: nil, &on_result)
  fixed = []
  skipped = []
  cancelled = false

  candidates.group_by(&:file).each_value do |findings_in_file|
    findings_in_file.sort_by { |f| -(f.line || 0) }.each do |finding|
      if cancelled
        skipped << finding
        on_result&.call(finding, :cancelled, nil)
        next
      end

      # An ai_client, when configured, is tried first for every rule —
      # including the ones a mechanical fixer could also handle — so a
      # "real developer" fix (context-aware, not just the one
      # mechanically-derivable rewrite) is what gets written whenever
      # AI is actually available. The deterministic mechanical fixer is
      # the fallback: it runs whenever AI isn't configured at all,
      # declined to produce anything usable, raised, or its rewrite
      # didn't independently verify — same safety gate either way, so
      # neither path is ever trusted more than the other.
      ai_error = nil
      AiFixSuggester.enhance!(finding, client: client, root: root, on_error: ->(_f, e) { ai_error = e }) if client

      if finding.fix_verified != true
        mechanical_fix = MechanicalFixer.suggest(finding, root: root)
        if mechanical_fix
          finding.suggested_fix = mechanical_fix
          finding.fix_verified = FixVerifier.verify(finding: finding, root: root)
        end
      end

      if finding.fix_verified != true
        skipped << finding
        on_result&.call(finding, ai_error ? :ai_error : :skipped, ai_error)
        next
      end

      if confirm
        decision = confirm.call(finding)
        if decision == :cancel
          cancelled = true
          skipped << finding
          on_result&.call(finding, :cancelled, nil)
          next
        elsif !decision
          skipped << finding
          on_result&.call(finding, :declined)
          next
        end
      end

      if dry_run
        fixed << finding
        on_result&.call(finding, :would_fix)
      elsif FixVerifier.apply!(finding: finding, root: root)
        fixed << finding
        on_result&.call(finding, :fixed)
      else
        skipped << finding
        on_result&.call(finding, :skipped)
      end
    end
  end

  [fixed, skipped]
end

.explain(suggested_fix, max_sentences: 2) ⇒ Object

Best-effort short human explanation of a fix — the first couple of sentences of the AI's own reply, before its code block(s)/AFTER: marker. AiFixSuggester's prompt asks the model to lead with 1-3 sentences of plain-English explanation before any code, so this is just trimming that reply down to something that reads well as a single console line, not a new source of information. Returns "" (never nil) when there's nothing usable to show.



130
131
132
133
134
135
136
137
# File 'lib/scryer/fix_runner.rb', line 130

def explain(suggested_fix, max_sentences: 2)
  text = suggested_fix.to_s.strip
  return "" if text.empty?

  cut_at = [text.index("```"), text.index(/^AFTER:/m)].compact.min
  text = text[0...cut_at] if cut_at
  text.strip.split(/(?<=[.!?])\s+/).first(max_sentences).join(" ").strip
end

.verify(fixed, root:, dirs:, skip_rules:) ⇒ Object

Re-scans root and returns the subset of fixed that still shows up (matched by fingerprint — rule + file + offending code, not line number, since every fixed line's line number just changed) — normally empty. FixVerifier.apply! only re-checks the single rule against the single file it just edited in isolation; this is the broader, whole-project confirmation that no interaction between edits (two fixes in the same file, one fix's line-count change shifting another finding's line number) left anything still firing.



147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/scryer/fix_runner.rb', line 147

def verify(fixed, root:, dirs:, skip_rules:)
  # detect_duplicates: false — only security/performance/style findings
  # are read below; duplicate_groups are never part of `fixed`
  # (duplicate-code groups aren't Findings — see Scanner#initialize's
  # comment on detect_duplicates), so there's nothing to gain from that
  # pass here.
  rescanned = Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: false).call
  still_present = Baseline.fingerprints(
    (rescanned.security_findings + rescanned.performance_findings + rescanned.style_findings).map(&:to_h)
  ).to_set

  fixed.select { |f| still_present.include?(Baseline.fingerprint(f.to_h)) }
end