Module: Scryer::FixVerifier
- Defined in:
- lib/scryer/fix_verifier.rb
Overview
Best-effort verification that an AI-rewritten suggested_fix actually clears the finding it was generated for — "AI-verified remediation" in the sense of "Scryer independently re-checked this," not in the sense of a human review being unnecessary (suggested_fix is still never auto-applied to a real file; see AiFixSuggester's own header comment).
How: AiFixSuggester's prompt (see static_prompt_for) asks the model to end its reply with a fenced "AFTER:" code block containing a drop-in replacement for the single offending source line. This class extracts that block, substitutes it for that one line in an in-memory copy of the file (nothing is ever written to disk), re-parses the result, and re-runs only the one rule that flagged this finding against it. If that rule no longer fires anywhere in the modified file, the fix is marked verified.
Deliberately narrow, same spirit as scryer verify (the CLI command
this shares its core logic with): this confirms the ONE finding it
targeted is gone, not that the fix is otherwise correct, idiomatic, or
free of introducing a different problem — a full rescan (or scryer verify again with a different --rule) answers that.
Returns true (rule no longer fires), false (attempted but the rule still fires, or the rewritten line doesn't even parse), or nil (verification wasn't attempted at all — no AFTER: block in the AI's reply, the original file isn't readable, or this isn't a rule-backed Finding to begin with). nil is deliberately distinct from false: it means "we don't know," not "we checked and it's still broken."
Constant Summary collapse
- AFTER_BLOCK =
/AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
Class Method Summary collapse
- .apply_line_replacement(lines, line_number, replacement) ⇒ Object
- .extract_after_snippet(suggested_fix) ⇒ Object
- .verify(finding:, root:) ⇒ Object
Class Method Details
.apply_line_replacement(lines, line_number, replacement) ⇒ Object
75 76 77 78 79 80 |
# File 'lib/scryer/fix_verifier.rb', line 75 def apply_line_replacement(lines, line_number, replacement) replacement_text = replacement.end_with?("\n") ? replacement : "#{replacement}\n" modified = lines.dup modified[line_number - 1] = replacement_text modified.join end |
.extract_after_snippet(suggested_fix) ⇒ Object
67 68 69 70 71 72 73 |
# File 'lib/scryer/fix_verifier.rb', line 67 def extract_after_snippet(suggested_fix) match = AFTER_BLOCK.match(suggested_fix.to_s) return nil unless match content = match[1].to_s content.strip.empty? ? nil : content end |
.verify(finding:, root:) ⇒ Object
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/scryer/fix_verifier.rb', line 36 def verify(finding:, root:) return nil unless finding.is_a?(Scryer::Finding) return nil unless finding.line && finding.rule_id after_snippet = extract_after_snippet(finding.suggested_fix) return nil unless after_snippet abs_path = File.join(root, finding.file.to_s) return nil unless File.file?(abs_path) lines = File.read(abs_path).lines return nil unless finding.line.between?(1, lines.size) modified_source = apply_line_replacement(lines, finding.line, after_snippet) sexp = begin Ripper.sexp(modified_source) rescue StandardError nil end return false if sexp.nil? # the rewritten line doesn't even parse — not a usable fix rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id } return nil unless rule_class remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan remaining.none? { |f| f.rule_id == finding.rule_id } rescue StandardError nil end |