rail_verdict 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. metadata +192 -0
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module RailVerdict
7
+ module Repair
8
+ module ContextAssembler
9
+ MAX_PACKET_BYTES = 256 * 1024
10
+
11
+ class StaleFindingError < RailVerdict::Error; end
12
+
13
+ def self.build(outcome:, finding_ref:, repository_root: nil, runner: nil, ai_analysis: nil, clock: Time.now.utc)
14
+ raise ArgumentError, "outcome required" unless outcome
15
+ raise ArgumentError, "finding_ref required" if finding_ref.to_s.strip.empty?
16
+
17
+ findings = outcome.findings || []
18
+ target = findings.find { |f| f.id == finding_ref || f.fingerprint == finding_ref }
19
+ raise StaleFindingError, "finding not found: #{finding_ref}" unless target
20
+
21
+ configuration = outcome.configuration
22
+ result = outcome.result
23
+ context = outcome.context
24
+
25
+ root = repository_root || context&.repository_root || Dir.pwd
26
+ runner ||= ProcessRunner.new
27
+
28
+ blocking = blocking_for(result, target)
29
+ finding_state = target.state
30
+
31
+ analyzer_result = analyzer_result_for(result, target)
32
+ evidence = build_evidence(target, analyzer_result)
33
+ git_ctx = build_git_context(result, context, target)
34
+ rails = build_rails_context(result)
35
+ source_ctx = build_source_context(root, target)
36
+ diff = build_diff(root, runner, target, context)
37
+
38
+ verification = build_verification(result, configuration)
39
+ policy = {
40
+ "mode" => configuration&.mode || "strict",
41
+ "configuration_digest" => configuration&.digest || Digest::SHA256.hexdigest("")
42
+ }
43
+ baseline_state = build_baseline_state(root, configuration)
44
+ waivers_state = build_waivers_state(root, configuration)
45
+ boundary = Boundary.snapshot(repository_root: root, configuration: configuration, outcome: outcome)
46
+ base_for_plan = begin; result&.git&.fetch("base", nil); rescue StandardError; nil; end
47
+ verification_plan = VerificationPlan.build(outcome: outcome, base_revision: base_for_plan)
48
+ constraints = Constraints.default
49
+ instructions = Constraints.instructions
50
+
51
+ source_revision = context&.revision
52
+ base_revision = begin; result&.git&.fetch("base", nil); rescue StandardError; nil; end
53
+ merge_base = begin; result&.git&.fetch("merge_base", nil); rescue StandardError; nil; end
54
+
55
+ analyzer_versions = context&.analyzer_versions || {}
56
+ baseline_digest = boundary["baseline_digest"]
57
+ packet_id = Packet.packet_id_for(
58
+ fingerprint: target.fingerprint,
59
+ source_revision: source_revision,
60
+ base_revision: base_revision,
61
+ configuration_digest: policy["configuration_digest"],
62
+ baseline_digest: baseline_digest,
63
+ analyzer_versions: analyzer_versions
64
+ )
65
+
66
+ truncated = source_ctx["truncated"] || diff["truncated"] || rails["truncated"] || false
67
+ completeness = {
68
+ "deterministic" => "complete",
69
+ "repair_context" => truncated ? "partial" : "complete",
70
+ "truncated" => truncated
71
+ }
72
+ gate = result&.gate || "FAIL"
73
+ success = {
74
+ "description" => "Target finding no longer blocking and gate is PASS/WARN with complete verification and unchanged boundary.",
75
+ "gate_must_be" => %w[PASS WARN],
76
+ "target_must_be_fixed" => true,
77
+ "completion_must_be" => "complete",
78
+ "boundary_must_be_unchanged" => true
79
+ }
80
+
81
+ packet_hash = {
82
+ "schema_version" => Packet::SCHEMA_VERSION,
83
+ "packet_id" => packet_id,
84
+ "created_at" => clock.utc.iso8601,
85
+ "railverdict_version" => RailVerdict::VERSION,
86
+ "source_revision" => source_revision,
87
+ "target" => {
88
+ "finding" => target.to_schema_h,
89
+ "finding_state" => finding_state,
90
+ "blocking" => blocking,
91
+ "severity" => target.severity
92
+ },
93
+ "verification" => verification,
94
+ "evidence" => evidence,
95
+ "git_context" => git_ctx,
96
+ "diff_context" => diff,
97
+ "rails_context" => rails,
98
+ "source_context" => source_ctx,
99
+ "policy" => policy,
100
+ "baseline_state" => baseline_state,
101
+ "waivers_state" => waivers_state,
102
+ "verification_plan" => verification_plan,
103
+ "constraints" => constraints,
104
+ "instructions" => instructions,
105
+ "completeness" => completeness,
106
+ "success_criteria" => success,
107
+ "boundary" => boundary
108
+ }
109
+ packet_hash["base_revision"] = base_revision unless base_revision.nil?
110
+ packet_hash["merge_base"] = merge_base unless merge_base.nil?
111
+ packet_hash["ai_analysis"] = ai_analysis.to_h if ai_analysis
112
+
113
+ errors = SchemaValidator.validate_repair_packet(packet_hash)
114
+ raise ArgumentError, "invalid packet: #{errors.join('; ')}" unless errors.empty?
115
+
116
+ if JSON.generate(packet_hash).bytesize > MAX_PACKET_BYTES
117
+ packet_hash["source_context"] = { "snippets" => packet_hash["source_context"]["snippets"].first(1), "truncated" => true }
118
+ packet_hash["diff_context"] = { "hunk" => packet_hash["diff_context"]["hunk"].byteslice(0, 4096).to_s, "truncated" => true }
119
+ packet_hash["completeness"] = { "deterministic" => "complete", "repair_context" => "partial", "truncated" => true }
120
+ end
121
+
122
+ Packet.new(
123
+ packet_id: packet_hash["packet_id"],
124
+ created_at: packet_hash["created_at"],
125
+ railverdict_version: packet_hash["railverdict_version"],
126
+ source_revision: packet_hash["source_revision"],
127
+ base_revision: packet_hash["base_revision"],
128
+ merge_base: packet_hash["merge_base"],
129
+ target: packet_hash["target"],
130
+ verification: packet_hash["verification"],
131
+ evidence: packet_hash["evidence"],
132
+ git_context: packet_hash["git_context"],
133
+ diff_context: packet_hash["diff_context"],
134
+ rails_context: packet_hash["rails_context"],
135
+ source_context: packet_hash["source_context"],
136
+ policy: packet_hash["policy"],
137
+ baseline_state: packet_hash["baseline_state"],
138
+ waivers_state: packet_hash["waivers_state"],
139
+ ai_analysis: packet_hash["ai_analysis"],
140
+ verification_plan: packet_hash["verification_plan"],
141
+ constraints: packet_hash["constraints"],
142
+ instructions: packet_hash["instructions"],
143
+ completeness: packet_hash["completeness"],
144
+ success_criteria: packet_hash["success_criteria"],
145
+ boundary: packet_hash["boundary"]
146
+ )
147
+ end
148
+
149
+ def self.blocking_for(result, finding)
150
+ summary = result&.findings&.find { |f| f["fingerprint"] == finding.fingerprint }
151
+ return summary["blocking"] unless summary.nil?
152
+
153
+ true
154
+ end
155
+ private_class_method :blocking_for
156
+
157
+ def self.analyzer_result_for(result, finding)
158
+ (result&.analyzer_results || []).find { |ar| ar.analyzer == finding.analyzer }
159
+ end
160
+ private_class_method :analyzer_result_for
161
+
162
+ def self.build_evidence(finding, analyzer_result)
163
+ {
164
+ "analyzer" => finding.analyzer,
165
+ "tool_version" => analyzer_result&.tool_version,
166
+ "rule_id" => finding.rule_id,
167
+ "location" => finding.location,
168
+ "message" => finding.message,
169
+ "evidence_summary" => analyzer_result&.evidence_summary || {}
170
+ }.compact
171
+ end
172
+ private_class_method :build_evidence
173
+
174
+ def self.build_verification(result, configuration)
175
+ {
176
+ "gate" => result&.gate || "FAIL",
177
+ "policy_status" => result&.policy_status || "fail",
178
+ "mode" => configuration&.mode || "strict",
179
+ "decision_reasons" => result&.decision_reasons || [],
180
+ "comparison_counts" => result&.comparison&.fetch("counts", {}) || {}
181
+ }
182
+ end
183
+ private_class_method :build_verification
184
+
185
+ def self.build_git_context(result, context, finding)
186
+ git = result&.git || {}
187
+ path = finding.location["path"]
188
+ changed_lines = []
189
+ changed_file = nil
190
+ rename = nil
191
+ if context&.git_context
192
+ gc = context.git_context
193
+ changed_file = gc.changed_files.find { |f| f.path == path }
194
+ if changed_file
195
+ changed_file_h = { "path" => changed_file.path, "status" => changed_file.status.to_s, "score" => changed_file.score }
196
+ changed_file_h["old_path"] = changed_file.old_path if changed_file.old_path
197
+ changed_file_h["new_path"] = changed_file.new_path if changed_file.new_path
198
+ changed_file_h["binary"] = changed_file.binary if changed_file.respond_to?(:binary)
199
+ changed_file = changed_file_h
200
+ end
201
+ changed_lines = (gc.changed_line_set[path] || []).sort
202
+ if changed_file && changed_file["old_path"]
203
+ rename = { "new_path" => path, "old_path" => changed_file["old_path"] }
204
+ end
205
+ end
206
+ {
207
+ "head" => git["head"],
208
+ "base" => git["base"],
209
+ "merge_base" => git["merge_base"],
210
+ "changed_file" => changed_file,
211
+ "changed_lines_for_target" => changed_lines,
212
+ "rename" => rename
213
+ }
214
+ end
215
+ private_class_method :build_git_context
216
+
217
+ def self.build_rails_context(result)
218
+ rc = result&.rails_context || {}
219
+ detected = rc["detected"] || {}
220
+ entries = rc["entries"] || []
221
+ truncated = entries.length > 20
222
+ {
223
+ "detected" => detected,
224
+ "entry" => entries.first,
225
+ "related" => Array(entries.first&.fetch("related", nil)).first(20) || [],
226
+ "truncated" => truncated
227
+ }
228
+ rescue StandardError
229
+ { "detected" => {}, "entry" => nil, "related" => [], "truncated" => false }
230
+ end
231
+ private_class_method :build_rails_context
232
+
233
+ def self.build_source_context(root, finding)
234
+ path = finding.location["path"]
235
+ line = finding.location["start_line"]
236
+ snippets = []
237
+ if path
238
+ raw = Intelligence::SourceReader.read_snippet(repository_root: root, path: path, target_line: line)
239
+ if raw
240
+ content = raw[:content].to_s
241
+ if Intelligence::SecretDetector.content_secret?(content) || Intelligence::SecretDetector.filename_secret?(path)
242
+ content = "[REDACTED: probable secret detected]"
243
+ end
244
+ content = content.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?")
245
+ snippets << { "path" => raw[:path], "content" => content, "truncated" => raw[:truncated] }
246
+ end
247
+ end
248
+ { "snippets" => snippets, "truncated" => snippets.any? { |s| s["truncated"] } }
249
+ rescue StandardError
250
+ { "snippets" => [], "truncated" => false }
251
+ end
252
+ private_class_method :build_source_context
253
+
254
+ def self.build_diff(root, runner, finding, context)
255
+ git_ctx = context&.git_context
256
+ return { "hunk" => "", "truncated" => false } unless git_ctx
257
+
258
+ h = DiffContext.build(repository_root: root, runner: runner, finding: finding, git_context: git_ctx)
259
+ h["hunk"] = h["hunk"].encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?") rescue h["hunk"]
260
+ if Intelligence::SecretDetector.content_secret?(h["hunk"])
261
+ h["hunk"] = "[REDACTED: probable secret detected]"
262
+ h["truncated"] = true
263
+ end
264
+ h
265
+ rescue StandardError
266
+ { "hunk" => "", "truncated" => false }
267
+ end
268
+ private_class_method :build_diff
269
+
270
+ def self.build_baseline_state(root, configuration)
271
+ return { "loaded" => false, "path" => nil, "digest" => nil } unless configuration
272
+
273
+ path = Baseline.resolve_path(repository_root: root, configuration: configuration)
274
+ digest = Boundary.file_digest(path) rescue nil
275
+ loaded = !digest.nil?
276
+ { "loaded" => loaded, "path" => path, "digest" => digest }
277
+ rescue StandardError
278
+ { "loaded" => false, "path" => nil, "digest" => nil }
279
+ end
280
+ private_class_method :build_baseline_state
281
+
282
+ def self.build_waivers_state(root, configuration)
283
+ return { "count" => 0, "path" => nil, "digest" => nil } unless configuration
284
+
285
+ path = WaiverStore.resolve_path(repository_root: root, configuration: configuration)
286
+ digest = Boundary.file_digest(path) rescue nil
287
+ count = 0
288
+ begin
289
+ waivers = WaiverStore.read_optional(path)
290
+ count = waivers.length
291
+ rescue StandardError
292
+ count = 0
293
+ end
294
+ { "count" => count, "path" => path, "digest" => digest }
295
+ rescue StandardError
296
+ { "count" => 0, "path" => nil, "digest" => nil }
297
+ end
298
+ private_class_method :build_waivers_state
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Repair
5
+ module DiffContext
6
+ MAX_BYTES = 16 * 1024
7
+ CONTEXT_LINES = 15
8
+
9
+ def self.build(repository_root:, runner:, finding:, git_context:)
10
+ hunk = ""
11
+ truncated = false
12
+ return { "hunk" => "", "truncated" => false } unless finding && git_context
13
+
14
+ path = finding.location["path"] || finding.location[:path]
15
+ line = finding.location["start_line"] || finding.location[:start_line]
16
+ return { "hunk" => "", "truncated" => false } unless path
17
+
18
+ result = runner.run("git", ["diff", "-U#{CONTEXT_LINES}", git_context.merge_base, git_context.head, "--", path], chdir: repository_root, timeout_seconds: 5.0, max_stdout_bytes: MAX_BYTES + 1024)
19
+ raw = result.stdout.to_s
20
+ raw = raw.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
21
+ raw = raw.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?")
22
+ if raw.bytesize > MAX_BYTES
23
+ raw = raw.byteslice(0, MAX_BYTES)
24
+ truncated = true
25
+ end
26
+ if line
27
+ hunk = extract_target_hunk(raw, line)
28
+ hunk = raw if hunk.empty?
29
+ else
30
+ hunk = raw
31
+ end
32
+ if hunk.bytesize > MAX_BYTES
33
+ hunk = hunk.byteslice(0, MAX_BYTES)
34
+ truncated = true
35
+ end
36
+ { "hunk" => hunk, "truncated" => truncated }
37
+ rescue StandardError
38
+ { "hunk" => "", "truncated" => false }
39
+ end
40
+
41
+ def self.extract_target_hunk(diff, target_line)
42
+ hunks = diff.split(/^@@.*@@/)
43
+ hunks.shift
44
+ hunks.find { |h| h.lines.any? { |l| l.start_with?("+") } } || ""
45
+ rescue StandardError
46
+ ""
47
+ end
48
+ private_class_method :extract_target_hunk
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module RailVerdict
7
+ module Repair
8
+ class Packet
9
+ SCHEMA_VERSION = "1.0"
10
+ PACKET_ID_PATTERN = /\Asha256:[0-9a-f]{64}\z/
11
+
12
+ def self.packet_id_for(fingerprint:, source_revision:, base_revision:, configuration_digest:, baseline_digest:, analyzer_versions:)
13
+ payload = {
14
+ "packet_schema_version" => SCHEMA_VERSION,
15
+ "fingerprint" => fingerprint,
16
+ "source_revision" => source_revision,
17
+ "base_revision" => base_revision,
18
+ "configuration_digest" => configuration_digest,
19
+ "baseline_digest" => baseline_digest,
20
+ "analyzer_versions" => (analyzer_versions || {}).sort.to_h
21
+ }
22
+ sorted = payload.keys.sort.to_h { |k| [k, payload[k]] }
23
+ "sha256:#{Digest::SHA256.hexdigest(JSON.generate(sorted))}"
24
+ end
25
+
26
+ def self.validate_hash(hash)
27
+ errors = SchemaValidator.validate_repair_packet(hash)
28
+ raise ArgumentError, "invalid repair packet: #{errors.join('; ')}" unless errors.empty?
29
+
30
+ hash
31
+ end
32
+
33
+ attr_reader :packet_id, :created_at, :railverdict_version, :source_revision,
34
+ :base_revision, :merge_base, :target, :verification, :evidence,
35
+ :git_context, :diff_context, :rails_context, :source_context,
36
+ :policy, :baseline_state, :waivers_state, :ai_analysis,
37
+ :verification_plan, :constraints, :instructions, :completeness,
38
+ :success_criteria, :boundary
39
+
40
+ def initialize(**kwargs)
41
+ @packet_id = kwargs.fetch(:packet_id).dup.freeze
42
+ @created_at = kwargs.fetch(:created_at).dup.freeze
43
+ @railverdict_version = kwargs.fetch(:railverdict_version).dup.freeze
44
+ @source_revision = kwargs[:source_revision]&.dup&.freeze
45
+ @base_revision = kwargs[:base_revision]&.dup&.freeze
46
+ @merge_base = kwargs[:merge_base]&.dup&.freeze
47
+ @target = deep_freeze(kwargs.fetch(:target))
48
+ @verification = deep_freeze(kwargs.fetch(:verification))
49
+ @evidence = deep_freeze(kwargs.fetch(:evidence))
50
+ @git_context = deep_freeze(kwargs.fetch(:git_context))
51
+ @diff_context = deep_freeze(kwargs.fetch(:diff_context) { { "hunk" => "", "truncated" => false } })
52
+ @rails_context = deep_freeze(kwargs.fetch(:rails_context))
53
+ @source_context = deep_freeze(kwargs.fetch(:source_context))
54
+ @policy = deep_freeze(kwargs.fetch(:policy))
55
+ @baseline_state = deep_freeze(kwargs.fetch(:baseline_state))
56
+ @waivers_state = deep_freeze(kwargs.fetch(:waivers_state))
57
+ @ai_analysis = kwargs[:ai_analysis] ? deep_freeze(kwargs[:ai_analysis]) : nil
58
+ @verification_plan = deep_freeze(kwargs.fetch(:verification_plan))
59
+ @constraints = deep_freeze(kwargs.fetch(:constraints))
60
+ @instructions = (kwargs[:instructions] || []).map(&:dup).map(&:freeze).freeze
61
+ @completeness = deep_freeze(kwargs.fetch(:completeness))
62
+ @success_criteria = deep_freeze(kwargs.fetch(:success_criteria))
63
+ @boundary = deep_freeze(kwargs.fetch(:boundary))
64
+ validate!
65
+ freeze
66
+ end
67
+
68
+ def to_h
69
+ h = {
70
+ "schema_version" => SCHEMA_VERSION,
71
+ "packet_id" => packet_id,
72
+ "created_at" => created_at,
73
+ "railverdict_version" => railverdict_version,
74
+ "source_revision" => source_revision,
75
+ "target" => target,
76
+ "verification" => verification,
77
+ "evidence" => evidence,
78
+ "git_context" => git_context,
79
+ "rails_context" => rails_context,
80
+ "source_context" => source_context,
81
+ "policy" => policy,
82
+ "baseline_state" => baseline_state,
83
+ "waivers_state" => waivers_state,
84
+ "verification_plan" => verification_plan,
85
+ "constraints" => constraints,
86
+ "completeness" => completeness,
87
+ "success_criteria" => success_criteria,
88
+ "boundary" => boundary
89
+ }
90
+ h["base_revision"] = base_revision unless base_revision.nil?
91
+ h["merge_base"] = merge_base unless merge_base.nil?
92
+ h["diff_context"] = diff_context if diff_context
93
+ h["ai_analysis"] = ai_analysis if ai_analysis
94
+ h["instructions"] = instructions unless instructions.empty?
95
+ h
96
+ end
97
+
98
+ def to_json(*args)
99
+ JSON.generate(to_h, *args)
100
+ end
101
+
102
+ private
103
+
104
+ def validate!
105
+ raise ArgumentError, "packet_id invalid" unless packet_id.match?(PACKET_ID_PATTERN)
106
+
107
+ errors = SchemaValidator.validate_repair_packet(to_h_without_validation)
108
+ raise ArgumentError, "invalid repair packet: #{errors.join('; ')}" unless errors.empty?
109
+ end
110
+
111
+ def to_h_without_validation
112
+ h = {
113
+ "schema_version" => SCHEMA_VERSION,
114
+ "packet_id" => packet_id,
115
+ "created_at" => created_at,
116
+ "railverdict_version" => railverdict_version,
117
+ "source_revision" => source_revision,
118
+ "target" => target,
119
+ "verification" => verification,
120
+ "evidence" => evidence,
121
+ "git_context" => git_context,
122
+ "rails_context" => rails_context,
123
+ "source_context" => source_context,
124
+ "policy" => policy,
125
+ "baseline_state" => baseline_state,
126
+ "waivers_state" => waivers_state,
127
+ "verification_plan" => verification_plan,
128
+ "constraints" => constraints,
129
+ "completeness" => completeness,
130
+ "success_criteria" => success_criteria,
131
+ "boundary" => boundary
132
+ }
133
+ h["base_revision"] = base_revision unless base_revision.nil?
134
+ h["merge_base"] = merge_base unless merge_base.nil?
135
+ h["diff_context"] = diff_context if diff_context
136
+ h["ai_analysis"] = ai_analysis if ai_analysis
137
+ h["instructions"] = instructions unless instructions.empty?
138
+ h
139
+ end
140
+
141
+ def deep_freeze(value)
142
+ case value
143
+ when Hash
144
+ value.each { |k, v| k.freeze if k.is_a?(String); deep_freeze(v) }
145
+ value.freeze
146
+ when Array
147
+ value.each { |v| deep_freeze(v) }
148
+ value.freeze
149
+ when String
150
+ value.freeze
151
+ else
152
+ value
153
+ end
154
+ value
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Repair
5
+ module PromptRenderer
6
+ def self.render(packet_hash)
7
+ trusted = packet_hash["instructions"] || Constraints.instructions
8
+ plan = packet_hash["verification_plan"] || {}
9
+ untrusted = {
10
+ "finding" => packet_hash.dig("target", "finding"),
11
+ "evidence" => packet_hash["evidence"],
12
+ "source_context" => packet_hash["source_context"],
13
+ "diff_context" => packet_hash["diff_context"],
14
+ "rails_context" => packet_hash["rails_context"]
15
+ }
16
+ <<~PROMPT
17
+ TRUSTED_RAILVERDICT_INSTRUCTIONS:
18
+ #{trusted.join("\n")}
19
+ Verification required: #{plan.dig("required", 0, "display") || "bundle exec railverdict check"}
20
+
21
+ UNTRUSTED_REPOSITORY_DATA:
22
+ #{JSON.generate(untrusted)}
23
+ PROMPT
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Repair
5
+ module VerificationPlan
6
+ def self.build(outcome:, repository_root: nil, base_revision: nil)
7
+ required = [required_check(base_revision: base_revision)]
8
+ suggested = suggested_commands(outcome)
9
+ { "required" => required, "suggested" => suggested }
10
+ end
11
+
12
+ def self.required_check(base_revision: nil)
13
+ argv = ["exec", "railverdict", "check"]
14
+ if base_revision && !base_revision.strip.empty?
15
+ argv += ["--changed", "--base", base_revision.strip]
16
+ end
17
+ {
18
+ "executable" => "bundle",
19
+ "argv" => argv,
20
+ "display" => "bundle #{argv.join(' ')}"
21
+ }
22
+ end
23
+
24
+ def self.suggested_commands(outcome)
25
+ findings = outcome.findings || []
26
+ analyzers = findings.map(&:analyzer).uniq.sort
27
+ analyzers.first(3).map do |analyzer|
28
+ case analyzer
29
+ when "rubocop"
30
+ { "executable" => "bundle", "argv" => ["exec", "rubocop"], "display" => "bundle exec rubocop" }
31
+ when "minitest"
32
+ { "executable" => "bundle", "argv" => ["exec", "rake", "test"], "display" => "bundle exec rake test" }
33
+ when "rspec"
34
+ { "executable" => "bundle", "argv" => ["exec", "rspec"], "display" => "bundle exec rspec" }
35
+ when "bundler_audit"
36
+ { "executable" => "bundle", "argv" => ["exec", "bundler-audit", "check"], "display" => "bundle exec bundler-audit check" }
37
+ else
38
+ nil
39
+ end
40
+ end.compact
41
+ end
42
+ private_class_method :suggested_commands
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Repair
5
+ module Verifier
6
+ Result = Struct.new(:target_status, :gate, :completion_status, :new_blocking_findings, :verification_boundary_changed, :regressed, :overall_status, keyword_init: true) do
7
+ def overall_status
8
+ self[:overall_status] || derive_overall
9
+ end
10
+
11
+ private
12
+
13
+ def derive_overall
14
+ return "incomplete" if completion_status == "incomplete" || target_status == "incomplete"
15
+ return "boundary_changed" if verification_boundary_changed && verification_boundary_changed != false
16
+ return "successful" if target_status == "fixed" && %w[PASS WARN].include?(gate) && !regressed
17
+
18
+ "unsuccessful"
19
+ end
20
+ end
21
+
22
+ def self.verify(packet:, new_outcome:)
23
+ packet_hash = packet.is_a?(Packet) ? packet.to_h : packet
24
+ fingerprint = packet_hash.dig("target", "finding", "fingerprint")
25
+ new_findings = new_outcome.findings || []
26
+ new_result = new_outcome.result
27
+
28
+ if new_result&.incomplete?
29
+ return Result.new(
30
+ target_status: "incomplete",
31
+ gate: new_result.gate,
32
+ completion_status: new_result.completion_status,
33
+ new_blocking_findings: new_result.findings.count { |f| f["blocking"] },
34
+ verification_boundary_changed: boundary_changed?(packet_hash, new_outcome),
35
+ regressed: false
36
+ )
37
+ end
38
+
39
+ target_still = new_findings.find { |f| f.fingerprint == fingerprint }
40
+ target_status = if target_still.nil?
41
+ changed = new_findings.find { |f| f.location["path"] == packet_hash.dig("target", "finding", "location", "path") && f.analyzer == packet_hash.dig("target", "finding", "analyzer") && f.rule_id == packet_hash.dig("target", "finding", "rule_id") }
42
+ if changed
43
+ changed.message == packet_hash.dig("target", "finding", "message") ? "moved" : "changed"
44
+ else
45
+ "fixed"
46
+ end
47
+ else
48
+ "still_present"
49
+ end
50
+
51
+ boundary = boundary_changed?(packet_hash, new_outcome)
52
+ blocking_now = new_result.findings.count { |f| f["blocking"] }
53
+ old_blocking = packet_hash.dig("verification", "comparison_counts") || {}
54
+ regressed = blocking_now > 0 && target_status == "fixed"
55
+
56
+ res = Result.new(
57
+ target_status: target_status,
58
+ gate: new_result.gate,
59
+ completion_status: new_result.completion_status,
60
+ new_blocking_findings: blocking_now,
61
+ verification_boundary_changed: boundary,
62
+ regressed: regressed
63
+ )
64
+ res[:overall_status] = res.overall_status
65
+ res
66
+ end
67
+
68
+ def self.boundary_changed?(packet_hash, new_outcome)
69
+ old = packet_hash["boundary"] || {}
70
+ root = new_outcome.context&.repository_root || Dir.pwd
71
+ config = new_outcome.configuration
72
+ current = Boundary.snapshot(repository_root: root, configuration: config, outcome: new_outcome)
73
+ changed = {}
74
+ changed["config"] = true if old["configuration_digest"] && current["configuration_digest"] && old["configuration_digest"] != current["configuration_digest"]
75
+ changed["baseline"] = true if old["baseline_digest"] != current["baseline_digest"]
76
+ changed["waivers"] = true if old["waivers_digest"] != current["waivers_digest"]
77
+ changed["base"] = true if old["base_revision"] != current["base_revision"] && !(old["base_revision"].nil? && current["base_revision"].nil?)
78
+ changed["source"] = true if old["source_revision"] != current["source_revision"] && !(old["source_revision"].nil? && current["source_revision"].nil?)
79
+ changed.empty? ? false : changed
80
+ end
81
+ private_class_method :boundary_changed?
82
+ end
83
+ end
84
+ end