rail_verdict 1.3.0 → 1.8.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/lib/rail_verdict/analyzers/brakeman.rb +309 -0
  4. data/lib/rail_verdict/analyzers/bundler_audit.rb +3 -2
  5. data/lib/rail_verdict/analyzers/minitest.rb +92 -43
  6. data/lib/rail_verdict/analyzers/rspec.rb +151 -44
  7. data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
  8. data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
  9. data/lib/rail_verdict/change_intelligence.rb +274 -0
  10. data/lib/rail_verdict/change_surfaces.rb +232 -0
  11. data/lib/rail_verdict/check.rb +84 -15
  12. data/lib/rail_verdict/cli.rb +640 -5
  13. data/lib/rail_verdict/comparison.rb +18 -2
  14. data/lib/rail_verdict/configuration.rb +12 -2
  15. data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
  16. data/lib/rail_verdict/contracts/finding.rb +6 -0
  17. data/lib/rail_verdict/doctor.rb +19 -6
  18. data/lib/rail_verdict/engineering_policy.rb +394 -0
  19. data/lib/rail_verdict/findings_command.rb +7 -1
  20. data/lib/rail_verdict/handoff.rb +125 -0
  21. data/lib/rail_verdict/mcp/cache.rb +10 -0
  22. data/lib/rail_verdict/mcp/server.rb +15 -1
  23. data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
  24. data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
  25. data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
  26. data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
  27. data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
  28. data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
  29. data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
  30. data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
  31. data/lib/rail_verdict/mcp.rb +12 -0
  32. data/lib/rail_verdict/pr_intelligence.rb +39 -1
  33. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
  34. data/lib/rail_verdict/repair/verification_plan.rb +33 -1
  35. data/lib/rail_verdict/reporters/console.rb +40 -0
  36. data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
  37. data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
  38. data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
  39. data/lib/rail_verdict/reporters/review.rb +73 -0
  40. data/lib/rail_verdict/reporters/sarif.rb +6 -1
  41. data/lib/rail_verdict/reuse.rb +274 -0
  42. data/lib/rail_verdict/review_observation.rb +95 -0
  43. data/lib/rail_verdict/review_packet.rb +159 -0
  44. data/lib/rail_verdict/schema_validator.rb +39 -2
  45. data/lib/rail_verdict/test_selection.rb +186 -0
  46. data/lib/rail_verdict/verification_environment.rb +20 -51
  47. data/lib/rail_verdict/verification_plan.rb +97 -0
  48. data/lib/rail_verdict/version.rb +1 -1
  49. data/lib/rail_verdict/workflow_receipt.rb +77 -0
  50. data/lib/rail_verdict.rb +13 -0
  51. data/schemas/configuration-v1.5.schema.json +2 -1
  52. data/schemas/configuration-v1.6.schema.json +177 -0
  53. data/schemas/configuration-v1.7.schema.json +499 -0
  54. data/schemas/engineering-policy-v1.schema.json +107 -0
  55. data/schemas/pr-intelligence-v1.1.schema.json +407 -0
  56. data/schemas/repair-packet-v1.schema.json +42 -0
  57. data/schemas/review-observation-v1.schema.json +68 -0
  58. data/schemas/review-packet-v1.schema.json +175 -0
  59. data/schemas/verification-handoff-v1.schema.json +69 -0
  60. data/schemas/workflow-receipt-v1.schema.json +47 -0
  61. metadata +29 -1
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
  require "json"
5
5
  require "fileutils"
6
+ require "open3"
6
7
  require "securerandom"
7
8
 
8
9
  module RailVerdict
@@ -10,6 +11,7 @@ module RailVerdict
10
11
  EXIT_OK = 0
11
12
  EXIT_POLICY_FAIL = 1
12
13
  EXIT_NO_GATE = 2
14
+ EXIT_REVIEW_REQUIRED = 3
13
15
  EXIT_INTERRUPTED = 130
14
16
 
15
17
  FORMATS = %w[console json sarif].freeze
@@ -22,13 +24,17 @@ module RailVerdict
22
24
  init Write the default .railverdict.yml configuration
23
25
  doctor Report configuration and analyzer observations
24
26
  check Run verification and print the gate result
27
+ policy Evaluate engineering policy requirements over the gate
25
28
  pr Summarize one changed-scope verification for review
26
29
  baseline create Deferred boundary; Phase 3 owns baseline writes
27
30
  findings Print normalized findings from the evidence run
28
31
  explain Explain a finding with optional AI
29
32
  investigate Investigate top findings with optional AI
30
33
  repair Build a deterministic repair packet for a finding
34
+ repair verify Re-verify a repair packet against fresh evidence
35
+ review Show the bounded review packet for a verification
31
36
  receipt Create or verify a Verification Receipt
37
+ handoff Create or verify a Verification Handoff (evidence reuse)
32
38
  mcp MCP adapter (serve)
33
39
 
34
40
  Global options:
@@ -59,18 +65,22 @@ module RailVerdict
59
65
  command_check(argv.drop(1))
60
66
  when "pr"
61
67
  command_pr(argv.drop(1))
68
+ when "policy"
69
+ command_policy(argv.drop(1))
62
70
  when "baseline"
63
71
  command_baseline(argv.drop(1))
64
72
  when "findings"
65
73
  command_findings(argv.drop(1))
66
74
  when "explain"
67
75
  command_explain(argv.drop(1))
68
- when "investigate"
69
- command_investigate(argv.drop(1))
70
76
  when "repair"
71
77
  command_repair(argv.drop(1))
72
- when "receipt"
78
+ when "review"
79
+ command_review(argv.drop(1))
80
+ when "receipt"
73
81
  command_receipt(argv.drop(1))
82
+ when "handoff"
83
+ command_handoff(argv.drop(1))
74
84
  when "mcp"
75
85
  command_mcp(argv.drop(1))
76
86
  else
@@ -130,15 +140,16 @@ module RailVerdict
130
140
  end
131
141
 
132
142
  def command_check(argv)
133
- options = { config: DEFAULT_CONFIG_PATH, format: "console", changed: false, base: nil, baseline: nil, waiver: nil }
143
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", changed: false, base: nil, baseline: nil, waiver: nil, handoff: nil }
134
144
  parser = OptionParser.new do |opts|
135
- opts.banner = "Usage: railverdict check [--config PATH] [--format console|json|sarif] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
145
+ opts.banner = "Usage: railverdict check [--config PATH] [--format console|json|sarif] [--changed] [--base REV] [--baseline PATH] [--waiver PATH] [--handoff PATH]"
136
146
  opts.on("--config PATH", String) { |value| options[:config] = value }
137
147
  opts.on("--format FORMAT", String) { |value| options[:format] = value }
138
148
  opts.on("--changed") { options[:changed] = true }
139
149
  opts.on("--base REV", String) { |value| options[:base] = value }
140
150
  opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
141
151
  opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
152
+ opts.on("--handoff PATH", String) { |value| options[:handoff] = value }
142
153
  end
143
154
  parse!(parser, argv)
144
155
  validate_format!(options[:format])
@@ -146,12 +157,144 @@ module RailVerdict
146
157
  raise RailVerdict::UsageError, "--base requires --changed"
147
158
  end
148
159
 
160
+ if options[:handoff]
161
+ return command_check_with_handoff(options)
162
+ end
163
+
149
164
  outcome, interrupted = execute_check(options)
150
165
  return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
151
166
 
152
167
  exit_code_for(outcome.result, interrupted: interrupted)
153
168
  end
154
169
 
170
+ def command_check_with_handoff(options)
171
+ handoff_path = options[:handoff]
172
+ begin
173
+ text = File.binread(handoff_path)
174
+ rescue StandardError => e
175
+ @stderr.puts "railverdict check --handoff: cannot read handoff: #{e.message}"
176
+ return EXIT_NO_GATE
177
+ end
178
+ handoff_doc, err = Handoff.parse(text)
179
+ unless handoff_doc
180
+ @stderr.puts "railverdict check --handoff: handoff invalid (#{err}); running full verification"
181
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff })
182
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
183
+ return exit_code_for(outcome.result, interrupted: interrupted)
184
+ end
185
+
186
+ # Independent observation
187
+ effective_paths = Check.effective_input_paths(root: @working_directory, config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)), baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil), waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil))
188
+ before_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
189
+ current_state = before_state
190
+ current_env_obj = VerificationEnvironment.capture(repository_root: @working_directory)
191
+ current_env = {
192
+ "ruby_engine" => current_env_obj.ruby_engine,
193
+ "ruby_version" => current_env_obj.ruby_version,
194
+ "railverdict_version" => current_env_obj.railverdict_version,
195
+ "analyzer_versions" => current_env_obj.analyzer_versions
196
+ }
197
+ config = Configuration.load(effective_paths[:config]) rescue nil
198
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
199
+ contract = { "required_analyzers" => required, "verification_mode" => handoff_doc.dig("source_scope", "verification_mode"), "changed_base" => handoff_doc.dig("source_scope", "changed_base") }
200
+ reuse_result = Reuse.evaluate(handoff_document: handoff_doc, current_repository_state: current_state, current_environment: current_env, current_contract: contract)
201
+ after_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
202
+ if before_state && after_state && before_state.digest != after_state.digest
203
+ reuse_result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: reuse_result.reasons + ["reuse_toctou"], handoff_valid: reuse_result.handoff_valid, receipt_fresh: reuse_result.receipt_fresh)
204
+ end
205
+
206
+ if reuse_result.decision == Reuse::REUSABLE
207
+ # Whole-set reuse: re-derive GateResult from reused normalized evidence through current baseline/waivers/policy.
208
+ # This satisfies invariants 6-9: old GateResult never becomes second authority.
209
+ begin
210
+ # Reconstruct findings as observed (state observed) for reclassification
211
+ reused_finding_hashes = handoff_doc.dig("evidence_set", "analyzer_results")&.flat_map { |ar| ar["findings"] || [] } || []
212
+ reused_findings = reused_finding_hashes.map do |fh|
213
+ # fh is a Finding schema hash; convert to observed Finding
214
+ begin
215
+ Finding.new(
216
+ fingerprint: fh["fingerprint"],
217
+ origin: fh["origin"] || "deterministic",
218
+ analyzer: fh["analyzer"],
219
+ rule_id: fh["rule_id"],
220
+ category: fh["category"] || "lint",
221
+ severity: fh["severity"] || "medium",
222
+ confidence: fh["confidence"] || "high",
223
+ state: "observed",
224
+ evidence_ref: fh["evidence_ref"] || "native:#{fh['analyzer']}:#{fh['fingerprint'][0,8]}",
225
+ location: fh["location"] || { "path" => fh["path"] || "unknown.rb" },
226
+ message: fh["message"] || "reused"
227
+ )
228
+ rescue StandardError
229
+ nil
230
+ end
231
+ end.compact
232
+
233
+ # Reconstruct analyzer_results for policy (finding_ids derived from findings)
234
+ handoff_ars = handoff_doc.dig("evidence_set", "analyzer_results") || []
235
+ reused_analyzer_results = handoff_ars.map do |ar|
236
+ fid_map = reused_findings.select { |f| f.analyzer == ar["analyzer"] }.map(&:id)
237
+ # Also include fingerprint mapping for policy compatibility (GateResult uses finding_ids)
238
+ # Use fingerprint-based ids as in original
239
+ AnalyzerResult.new(
240
+ analyzer: ar["analyzer"],
241
+ invocation: { "executable" => ar["analyzer"], "argv" => [] },
242
+ execution_status: ar["execution_status"] == "succeeded" ? "succeeded" : "succeeded",
243
+ finding_ids: fid_map,
244
+ tool_version: ar["tool_version"]
245
+ )
246
+ rescue StandardError
247
+ nil
248
+ end.compact
249
+
250
+ # Load current baseline/waivers (respect effective paths)
251
+ cfg = Configuration.load(effective_paths[:config]) rescue nil
252
+ baseline_path = effective_paths[:baseline]
253
+ waiver_path = effective_paths[:waivers]
254
+ baseline = File.file?(baseline_path) ? (Baseline.read(baseline_path) rescue nil) : nil
255
+ waivers = WaiverStore.read_optional(waiver_path) rescue []
256
+ # Comparison
257
+ comparison = nil
258
+ classified = reused_findings
259
+ if baseline || waivers.any?
260
+ cmp = Comparison.classify(findings: reused_findings, baseline: baseline, waivers: waivers.map(&:to_h), clock: Time.now.utc)
261
+ comparison = { "counts" => cmp.counts, "introduced" => cmp.introduced.map(&:fingerprint).sort, "existing" => cmp.existing.map(&:fingerprint).sort, "resolved" => cmp.resolved.map { |e| e.fetch("fingerprint") }.sort, "changed" => cmp.changed.map(&:fingerprint).sort, "moved" => cmp.moved.map(&:fingerprint).sort, "waived" => cmp.counts.fetch("waived", 0) == 0 ? [] : cmp.classified_findings.select { |it| it.state == "waived" }.map(&:fingerprint).sort, "orphaned_waivers" => cmp.orphaned_waivers.map { |w| w["fingerprint"] || w[:fingerprint] }.compact.sort }
262
+ classified = cmp.classified_findings
263
+ end
264
+
265
+ # Policy evaluation with current config
266
+ policy_result = Verification::Policy.evaluate(configuration: cfg, analyzer_results: reused_analyzer_results, findings: classified, comparison: comparison, baseline_meta: baseline ? { "loaded" => true, "path" => baseline_path } : nil, git_context: nil)
267
+ # Build final GateResult (reuse policy_result but attach handoff metadata)
268
+ gate_result = policy_result
269
+ # Render with handoff_reuse envelope for machine output
270
+ if options[:format] == "json"
271
+ payload = gate_result.to_schema_h
272
+ payload["handoff_reuse"] = { "decision" => Reuse::REUSABLE, "reasons" => reuse_result.reasons, "handoff_id" => handoff_doc["handoff_id"], "receipt_id" => handoff_doc.dig("receipt", "receipt_id"), "receipt_fresh" => true }
273
+ @stdout.write(JSON.generate(payload) + "\n")
274
+ else
275
+ @stdout.puts "Handoff REUSABLE: #{handoff_doc['handoff_id']} (receipt #{handoff_doc.dig('receipt','receipt_id')})"
276
+ @stdout.puts "Gate: #{gate_result.gate} (reused evidence, policy re-evaluated, no analyzer execution)"
277
+ end
278
+ return gate_result.gate == "FAIL" ? EXIT_POLICY_FAIL : EXIT_OK
279
+ rescue StandardError => e
280
+ @stderr.puts "handoff reuse re-evaluation failed (#{e.class}: #{e.message}); falling back to full verification"
281
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff })
282
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
283
+ return exit_code_for(outcome.result, interrupted: interrupted)
284
+ end
285
+ else
286
+ @stderr.puts "Handoff #{reuse_result.decision}: #{reuse_result.reasons.join(', ')}; executing selective verification" if options[:format] != "json"
287
+ # Partial reuse / selective verification through VerificationPlan
288
+ outcome, interrupted = execute_check(options.reject { |k, _| k == :handoff }.merge(handoff_document: handoff_doc))
289
+ # Annotate result with reuse info when json
290
+ if options[:format] == "json" && outcome.result.respond_to?(:to_schema_h)
291
+ @stderr.puts JSON.generate({ "handoff_reuse" => { "decision" => reuse_result.decision, "reasons" => reuse_result.reasons } })
292
+ end
293
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
294
+ return exit_code_for(outcome.result, interrupted: interrupted)
295
+ end
296
+ end
297
+
155
298
  def command_pr(argv)
156
299
  options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil }
157
300
  parser = OptionParser.new do |opts|
@@ -184,6 +327,228 @@ module RailVerdict
184
327
  EXIT_NO_GATE
185
328
  end
186
329
 
330
+ def command_policy(argv)
331
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil, receipt: nil, handoff: nil }
332
+ parser = OptionParser.new do |opts|
333
+ opts.banner = "Usage: railverdict policy [--config PATH] [--format console|json] [--base REV] [--baseline PATH] [--waiver PATH] [--receipt PATH] [--handoff PATH]"
334
+ opts.on("--config PATH", String) { |value| options[:config] = value }
335
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
336
+ opts.on("--base REV", String) { |value| options[:base] = value }
337
+ opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
338
+ opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
339
+ opts.on("--receipt PATH", String) { |value| options[:receipt] = value }
340
+ opts.on("--handoff PATH", String) { |value| options[:handoff] = value }
341
+ end
342
+ parse!(parser, argv)
343
+ unless %w[console json].include?(options[:format])
344
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
345
+ end
346
+ if !options[:receipt].nil? && !options[:handoff].nil?
347
+ raise RailVerdict::UsageError, "only one of --receipt and --handoff may be given"
348
+ end
349
+
350
+ outcome, interrupted = execute_check(options.merge(changed: true))
351
+ return EXIT_INTERRUPTED if interrupted
352
+ receipt = load_policy_binding(path: options[:receipt], kind: "receipt")
353
+ handoff = load_policy_binding(path: options[:handoff], kind: "handoff")
354
+ envelope = EngineeringPolicy.evaluate(outcome: outcome, receipt: receipt, handoff: handoff)
355
+ if options[:format] == "json"
356
+ @stdout.write("#{CanonicalJSON.generate(envelope)}\n")
357
+ else
358
+ @stdout.write(Reporters::EngineeringPolicy.render(envelope))
359
+ end
360
+ exit_code_for_policy(envelope, outcome)
361
+ rescue RailVerdict::UsageError => error
362
+ @stderr.puts "railverdict policy: #{error.message}"
363
+ @stderr.puts USAGE
364
+ EXIT_NO_GATE
365
+ rescue RailVerdict::Error => error
366
+ @stderr.puts "railverdict policy: #{error.message}"
367
+ EXIT_NO_GATE
368
+ end
369
+
370
+ def load_policy_binding(path:, kind:)
371
+ return nil if path.nil?
372
+ text = begin
373
+ File.binread(path)
374
+ rescue StandardError
375
+ raise RailVerdict::Error, "cannot read #{kind}: #{path}"
376
+ end
377
+ document, error = kind == "receipt" ? Receipt.parse(text) : Handoff.parse(text)
378
+ raise RailVerdict::Error, "invalid #{kind}: #{error}" if document.nil?
379
+ document
380
+ end
381
+
382
+ def exit_code_for_policy(envelope, outcome)
383
+ return EXIT_INTERRUPTED if outcome.result.completion_status == "interrupted"
384
+ case envelope["decision"]
385
+ when "PASS" then EXIT_OK
386
+ when "FAIL" then EXIT_POLICY_FAIL
387
+ when "REVIEW_REQUIRED" then EXIT_REVIEW_REQUIRED
388
+ else EXIT_NO_GATE
389
+ end
390
+ end
391
+ def command_review(argv)
392
+ if argv.first.nil? || argv.first.start_with?("-")
393
+ return command_review_show(argv)
394
+ elsif %w[-h --help help].include?(argv.first)
395
+ @stdout.puts "Usage: railverdict review [show|observe|complete] [options]"
396
+ return EXIT_OK
397
+ end
398
+ sub = argv.first
399
+ if sub == "show"
400
+ command_review_show(argv.drop(1))
401
+ elsif sub == "observe"
402
+ command_review_observe(argv.drop(1))
403
+ elsif sub == "complete"
404
+ command_review_complete(argv.drop(1))
405
+ else
406
+ raise RailVerdict::UsageError, "review requires a subcommand: show, observe, or complete"
407
+ end
408
+ rescue RailVerdict::UsageError => error
409
+ @stderr.puts "railverdict review: #{error.message}"
410
+ @stderr.puts USAGE
411
+ EXIT_NO_GATE
412
+ rescue RailVerdict::Error => error
413
+ @stderr.puts "railverdict review: #{error.message}"
414
+ EXIT_NO_GATE
415
+ end
416
+
417
+ def command_review_show(argv)
418
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil }
419
+ parser = OptionParser.new do |opts|
420
+ opts.banner = "Usage: railverdict review [show] [--config PATH] [--format console|json] [--base REV] [--baseline PATH] [--waiver PATH]"
421
+ opts.on("--config PATH", String) { |value| options[:config] = value }
422
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
423
+ opts.on("--base REV", String) { |value| options[:base] = value }
424
+ opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
425
+ opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
426
+ end
427
+ parse!(parser, argv)
428
+ unless %w[console json].include?(options[:format])
429
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
430
+ end
431
+
432
+ outcome, interrupted = execute_check(options.merge(changed: true))
433
+ return EXIT_INTERRUPTED if interrupted
434
+ packet = ReviewPacket.build(outcome: outcome)
435
+ if options[:format] == "json"
436
+ @stdout.write("#{CanonicalJSON.generate(packet)}\n")
437
+ else
438
+ @stdout.write(Reporters::Review.render_packet(packet))
439
+ end
440
+ exit_code_for_policy(EngineeringPolicy.evaluate(outcome: outcome), outcome)
441
+ end
442
+
443
+ def command_review_observe(argv)
444
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", observations: [] }
445
+ parser = OptionParser.new do |opts|
446
+ opts.banner = "Usage: railverdict review observe --observation PATH [--observation PATH] [--config PATH] [--format console|json]"
447
+ opts.on("--observation PATH", String) { |value| options[:observations] << value }
448
+ opts.on("--config PATH", String) { |value| options[:config] = value }
449
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
450
+ end
451
+ parse!(parser, argv)
452
+ raise RailVerdict::UsageError, "observe requires --observation PATH" if options[:observations].empty?
453
+ raise RailVerdict::UsageError, "too many observations (max 32)" if options[:observations].length > 32
454
+ unless %w[console json].include?(options[:format])
455
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
456
+ end
457
+
458
+ current = observe_workflow_state(config_path: options[:config])
459
+ verdicts = options[:observations].map do |path|
460
+ document = read_bounded_json(path, "observation")
461
+ ReviewObservation.validate(observation: document, current: current)
462
+ end
463
+ if options[:format] == "json"
464
+ @stdout.write("#{CanonicalJSON.generate({ 'observations' => verdicts })}\n")
465
+ else
466
+ @stdout.write(Reporters::Review.render_observations(verdicts))
467
+ end
468
+ verdicts.all? { |verdict| verdict["status"] == "valid_bound" } ? EXIT_OK : EXIT_NO_GATE
469
+ end
470
+
471
+ def command_review_complete(argv)
472
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", base: nil, baseline: nil, waiver: nil, observations: [] }
473
+ parser = OptionParser.new do |opts|
474
+ opts.banner = "Usage: railverdict review complete [--observation PATH] [--config PATH] [--format console|json] [--base REV] [--baseline PATH] [--waiver PATH]"
475
+ opts.on("--observation PATH", String) { |value| options[:observations] << value }
476
+ opts.on("--config PATH", String) { |value| options[:config] = value }
477
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
478
+ opts.on("--base REV", String) { |value| options[:base] = value }
479
+ opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
480
+ opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
481
+ end
482
+ parse!(parser, argv)
483
+ raise RailVerdict::UsageError, "too many observations (max 32)" if options[:observations].length > 32
484
+ unless %w[console json].include?(options[:format])
485
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
486
+ end
487
+
488
+ outcome, interrupted = execute_check(options.merge(changed: true))
489
+ return EXIT_INTERRUPTED if interrupted
490
+ packet = ReviewPacket.build(outcome: outcome)
491
+ policy = EngineeringPolicy.evaluate(outcome: outcome)
492
+ current = {
493
+ "head" => packet["provenance"]["head"],
494
+ "configuration_digest" => packet["provenance"]["configuration_digest"],
495
+ "policy_digest" => policy["policy_digest"]
496
+ }
497
+ validated = options[:observations].map do |path|
498
+ document = read_bounded_json(path, "observation")
499
+ verdict = ReviewObservation.validate(observation: document, current: current)
500
+ { "observation_id" => verdict["observation_id"].to_s,
501
+ "author" => document.is_a?(Hash) ? document["author"].to_s : "unknown",
502
+ "binding" => verdict["status"] }
503
+ end
504
+ receipt = WorkflowReceipt.build(packet: packet, policy_decision: policy["decision"],
505
+ gate: outcome.result.gate, observations: validated)
506
+ if options[:format] == "json"
507
+ @stdout.write("#{CanonicalJSON.generate(receipt)}\n")
508
+ else
509
+ @stdout.write(Reporters::Review.render_workflow(receipt))
510
+ end
511
+ exit_code_for_readiness(receipt["readiness"])
512
+ end
513
+
514
+ def observe_workflow_state(config_path:)
515
+ configuration = Configuration.load(resolved_override_path(config_path, File.join(@working_directory, DEFAULT_CONFIG_PATH)))
516
+ head = begin
517
+ output, _, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @working_directory)
518
+ status.success? ? output.strip : nil
519
+ rescue StandardError
520
+ nil
521
+ end
522
+ {
523
+ "head" => head,
524
+ "configuration_digest" => configuration.digest,
525
+ "policy_digest" => EngineeringPolicy.policy_digest(EngineeringPolicy.effective_policy(configuration))
526
+ }
527
+ end
528
+
529
+ def read_bounded_json(path, kind)
530
+ text = begin
531
+ File.binread(path)
532
+ rescue StandardError
533
+ raise RailVerdict::Error, "cannot read #{kind}: #{path}"
534
+ end
535
+ raise RailVerdict::Error, "#{kind} exceeds 256 KiB: #{path}" if text.bytesize > 256 * 1024
536
+ begin
537
+ JSON.parse(text)
538
+ rescue JSON::ParserError
539
+ raise RailVerdict::Error, "invalid #{kind} JSON: #{path}"
540
+ end
541
+ end
542
+
543
+ def exit_code_for_readiness(readiness)
544
+ case readiness
545
+ when "ready" then EXIT_OK
546
+ when "blocked_by_gate" then EXIT_POLICY_FAIL
547
+ when "review_pending" then EXIT_REVIEW_REQUIRED
548
+ else EXIT_NO_GATE
549
+ end
550
+ end
551
+
187
552
  def command_baseline(argv)
188
553
  subcommand = argv.first
189
554
  raise RailVerdict::UsageError, "unknown baseline subcommand: #{subcommand.inspect}; only `baseline create` exists" unless subcommand == "create"
@@ -290,6 +655,7 @@ module RailVerdict
290
655
  execute_options[:baseline_path_override] = options[:output] if options.key?(:output) && options[:output]
291
656
  execute_options[:changed] = options[:changed] if options.key?(:changed)
292
657
  execute_options[:base] = options[:base] if options.key?(:base)
658
+ execute_options[:handoff_document] = options[:handoff_document] if options.key?(:handoff_document) && options[:handoff_document]
293
659
  outcome = Check.execute(**execute_options)
294
660
  [outcome, interrupted]
295
661
  ensure
@@ -444,6 +810,7 @@ module RailVerdict
444
810
  end
445
811
 
446
812
  def command_repair(argv)
813
+ return command_repair_verify(argv.drop(1)) if argv.first == "verify"
447
814
  finding_ref = argv.first
448
815
  raise RailVerdict::UsageError, "repair requires a finding id or fingerprint" unless finding_ref && !finding_ref.start_with?("-")
449
816
 
@@ -483,6 +850,64 @@ module RailVerdict
483
850
  EXIT_NO_GATE
484
851
  end
485
852
 
853
+ def command_repair_verify(argv)
854
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", packet: nil, changed: false, base: nil, baseline: nil, waiver: nil }
855
+ parser = OptionParser.new do |opts|
856
+ opts.banner = "Usage: railverdict repair verify --packet PATH [--config PATH] [--format console|json] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
857
+ opts.on("--packet PATH", String) { |v| options[:packet] = v }
858
+ opts.on("--config PATH", String) { |v| options[:config] = v }
859
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
860
+ opts.on("--changed") { options[:changed] = true }
861
+ opts.on("--base REV", String) { |v| options[:base] = v }
862
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
863
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
864
+ end
865
+ parse!(parser, argv)
866
+ raise RailVerdict::UsageError, "repair verify requires --packet PATH" if options[:packet].nil?
867
+ raise RailVerdict::UsageError, "--base requires --changed" if options[:base] && !options[:changed]
868
+ unless %w[console json].include?(options[:format])
869
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
870
+ end
871
+
872
+ packet = read_bounded_json(options[:packet], "repair packet")
873
+ raise RailVerdict::Error, "invalid repair packet: not a document" unless packet.is_a?(Hash)
874
+ errors = SchemaValidator.validate_repair_packet(packet)
875
+ raise RailVerdict::Error, "invalid repair packet: #{errors.first}" unless errors.empty?
876
+
877
+ outcome, interrupted = execute_check(options.merge(changed: options[:changed] || !options[:base].nil?))
878
+ return EXIT_INTERRUPTED if interrupted
879
+ result = Repair::Verifier.verify(packet: packet, new_outcome: outcome)
880
+ if options[:format] == "json"
881
+ @stdout.write("#{CanonicalJSON.generate(verifier_result_h(result))}\n")
882
+ else
883
+ @stdout.write(Reporters::Review.render_repair_verify(result))
884
+ end
885
+ case result.overall_status
886
+ when "successful" then EXIT_OK
887
+ when "unsuccessful" then EXIT_POLICY_FAIL
888
+ else EXIT_NO_GATE
889
+ end
890
+ rescue RailVerdict::UsageError => error
891
+ @stderr.puts "railverdict repair verify: #{error.message}"
892
+ @stderr.puts USAGE
893
+ EXIT_NO_GATE
894
+ rescue RailVerdict::Error => error
895
+ @stderr.puts "railverdict repair verify: #{error.message}"
896
+ EXIT_NO_GATE
897
+ end
898
+
899
+ def verifier_result_h(result)
900
+ {
901
+ "target_status" => result.target_status,
902
+ "gate" => result.gate,
903
+ "completion_status" => result.completion_status,
904
+ "new_blocking_findings" => result.new_blocking_findings,
905
+ "verification_boundary_changed" => result.verification_boundary_changed,
906
+ "regressed" => result.regressed,
907
+ "overall_status" => result.overall_status
908
+ }
909
+ end
910
+
486
911
  def command_receipt(argv)
487
912
  sub = argv.first
488
913
  raise RailVerdict::UsageError, "receipt requires a subcommand: create or verify" unless %w[create verify].include?(sub)
@@ -711,6 +1136,216 @@ module RailVerdict
711
1136
  end
712
1137
  end
713
1138
 
1139
+ def command_handoff(argv)
1140
+ sub = argv.first
1141
+ raise RailVerdict::UsageError, "handoff requires a subcommand: create, inspect, or verify" unless %w[create inspect verify].include?(sub)
1142
+
1143
+ case sub
1144
+ when "create" then command_handoff_create(argv.drop(1))
1145
+ when "inspect" then command_handoff_inspect(argv.drop(1))
1146
+ when "verify" then command_handoff_verify(argv.drop(1))
1147
+ end
1148
+ rescue RailVerdict::UsageError => e
1149
+ @stderr.puts "railverdict handoff: #{e.message}"
1150
+ @stderr.puts handoff_usage
1151
+ EXIT_NO_GATE
1152
+ end
1153
+
1154
+ def handoff_usage
1155
+ <<~USAGE
1156
+ Usage: railverdict handoff create [options]
1157
+ railverdict handoff inspect PATH [options]
1158
+ railverdict handoff verify PATH [options]
1159
+
1160
+ Create options:
1161
+ --config PATH, --format console|json, --output PATH
1162
+ --changed --base REV --baseline PATH --waiver PATH
1163
+
1164
+ Inspect options:
1165
+ --format console|json
1166
+
1167
+ Verify options:
1168
+ --format console|json
1169
+ --config PATH, --baseline PATH, --waiver PATH
1170
+ USAGE
1171
+ end
1172
+
1173
+ def command_handoff_create(argv)
1174
+ options = { config: DEFAULT_CONFIG_PATH, format: "json", output: nil, changed: false, base: nil, baseline: nil, waiver: nil }
1175
+ parser = OptionParser.new do |opts|
1176
+ opts.banner = "Usage: railverdict handoff create [--config PATH] [--format console|json] [--output PATH] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
1177
+ opts.on("--config PATH", String) { |v| options[:config] = v }
1178
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
1179
+ opts.on("--output PATH", String) { |v| options[:output] = v }
1180
+ opts.on("--changed") { options[:changed] = true }
1181
+ opts.on("--base REV", String) { |v| options[:base] = v }
1182
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
1183
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
1184
+ end
1185
+ parse!(parser, argv)
1186
+ raise RailVerdict::UsageError, "--base requires --changed" if options[:base] && !options[:changed]
1187
+ unless %w[console json].include?(options[:format])
1188
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}; expected console or json"
1189
+ end
1190
+ options[:config] = resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH))
1191
+ options[:baseline] = resolved_override_path(options[:baseline], nil) if options[:baseline]
1192
+ options[:waiver] = resolved_override_path(options[:waiver], nil) if options[:waiver]
1193
+
1194
+ outcome, interrupted = execute_check_with_guard(options)
1195
+ return EXIT_INTERRUPTED if interrupted || outcome.result.completion_status == "interrupted"
1196
+
1197
+ receipt = Receipt.build(outcome: outcome, pr_intelligence_document: pr_intelligence_document_for(outcome))
1198
+ evidence_set = {
1199
+ "analyzer_results" => outcome.result.analyzer_results.map do |ar|
1200
+ h = { "analyzer" => ar.analyzer, "execution_status" => ar.execution_status, "tool_version" => ar.tool_version }
1201
+ # Include normalized findings for reuse (bounded) — use canonical Finding hashes
1202
+ findings = outcome.findings.select { |f| f.analyzer == ar.analyzer }.map(&:to_schema_h).sort_by { |ff| ff["fingerprint"] }
1203
+ h["findings"] = findings if findings.any?
1204
+ h
1205
+ end
1206
+ }
1207
+ provenance = { "analyzer_versions" => outcome.context&.analyzer_versions || {} }
1208
+ scope = {
1209
+ "verification_mode" => outcome.result.git ? "changed" : "full",
1210
+ "changed_base" => outcome.result.git && outcome.result.git["base"],
1211
+ "changed_merge_base" => outcome.result.git && outcome.result.git["merge_base"]
1212
+ }
1213
+ handoff = Handoff.build(receipt: receipt, evidence_set: evidence_set, evidence_provenance: provenance, source_scope: scope)
1214
+ write_handoff_file(handoff, options[:output]) if options[:output]
1215
+ render_handoff(handoff, options[:format], output_path: options[:output])
1216
+ exit_code_for(outcome.result, interrupted: interrupted)
1217
+ rescue Handoff::BuildError, Receipt::BuildError => e
1218
+ payload = { "status" => "unavailable", "reasons" => ["#{e.code}: #{e.message}"] }
1219
+ render_handoff_unavailable(payload, options && options[:format])
1220
+ EXIT_NO_GATE
1221
+ end
1222
+
1223
+ def command_handoff_inspect(argv)
1224
+ options = { format: "json" }
1225
+ parser = OptionParser.new do |opts|
1226
+ opts.banner = "Usage: railverdict handoff inspect PATH [--format console|json]"
1227
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
1228
+ end
1229
+ rest = begin parser.parse(argv.dup) rescue raise RailVerdict::UsageError, $!.message end
1230
+ path = rest.first
1231
+ raise RailVerdict::UsageError, "inspect requires the handoff file path" if path.nil?
1232
+ unless %w[console json].include?(options[:format])
1233
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}"
1234
+ end
1235
+ text = begin File.binread(path) rescue (@stderr.puts "railverdict handoff inspect: cannot read handoff: #{$!.message}"; return EXIT_NO_GATE) end
1236
+ doc, err = Handoff.parse(text)
1237
+ if doc
1238
+ render_handoff(doc, options[:format])
1239
+ EXIT_OK
1240
+ else
1241
+ payload = { "status" => "invalid", "reasons" => [err.to_s] }
1242
+ render_handoff_unavailable(payload, options[:format])
1243
+ EXIT_NO_GATE
1244
+ end
1245
+ end
1246
+
1247
+ def command_handoff_verify(argv)
1248
+ options = { format: "json", config: DEFAULT_CONFIG_PATH, baseline: nil, waiver: nil }
1249
+ parser = OptionParser.new do |opts|
1250
+ opts.banner = "Usage: railverdict handoff verify PATH [--format console|json] [--config PATH] [--baseline PATH] [--waiver PATH]"
1251
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
1252
+ opts.on("--config PATH", String) { |v| options[:config] = v }
1253
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
1254
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
1255
+ end
1256
+ rest = begin parser.parse(argv.dup) rescue raise RailVerdict::UsageError, $!.message end
1257
+ path = rest.first
1258
+ raise RailVerdict::UsageError, "verify requires the handoff file path" if path.nil?
1259
+ unless %w[console json].include?(options[:format])
1260
+ raise RailVerdict::UsageError, "invalid --format #{options[:format].inspect}"
1261
+ end
1262
+ text = begin File.binread(path) rescue (@stderr.puts "railverdict handoff verify: cannot read handoff: #{$!.message}"; return EXIT_NO_GATE) end
1263
+ doc, err = Handoff.parse(text)
1264
+ unless doc
1265
+ payload = { "handoff_valid" => false, "receipt_fresh" => false, "decision" => Reuse::INVALID, "reasons" => [err.to_s] }
1266
+ render_handoff_verify(payload, options[:format])
1267
+ return EXIT_NO_GATE
1268
+ end
1269
+ effective_paths = Check.effective_input_paths(root: @working_directory, config_path: resolved_override_path(options[:config], File.join(@working_directory, DEFAULT_CONFIG_PATH)), baseline_path_override: options[:baseline] && resolved_override_path(options[:baseline], nil), waiver_path_override: options[:waiver] && resolved_override_path(options[:waiver], nil))
1270
+ before_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
1271
+ current_state = before_state
1272
+ current_env_obj = VerificationEnvironment.capture(repository_root: @working_directory)
1273
+ current_env = {
1274
+ "ruby_engine" => current_env_obj.ruby_engine,
1275
+ "ruby_version" => current_env_obj.ruby_version,
1276
+ "railverdict_version" => current_env_obj.railverdict_version,
1277
+ "analyzer_versions" => current_env_obj.analyzer_versions
1278
+ }
1279
+ # Evaluate via canonical Reuse
1280
+ config = Configuration.load(effective_paths[:config]) rescue nil
1281
+ required = config ? config.analyzers.select { |_, s| s["enabled"] && s["required"] }.keys.map(&:to_s) : []
1282
+ contract = { "required_analyzers" => required, "verification_mode" => doc.dig("source_scope", "verification_mode"), "changed_base" => doc.dig("source_scope", "changed_base") }
1283
+ result = Reuse.evaluate(handoff_document: doc, current_repository_state: current_state, current_environment: current_env, current_contract: contract)
1284
+ # TOCTOU guard: re-observe repository state
1285
+ after_state = RepositoryState.capture(repository_root: @working_directory, configuration_paths: effective_paths)
1286
+ if before_state && after_state && before_state.digest != after_state.digest
1287
+ result = Reuse::Result.new(decision: Reuse::VERIFICATION_REQUIRED, reasons: result.reasons + ["reuse_toctou"], handoff_valid: result.handoff_valid, receipt_fresh: result.receipt_fresh)
1288
+ end
1289
+ payload = { "handoff_valid" => result.handoff_valid, "receipt_fresh" => result.receipt_fresh, "decision" => result.decision, "reasons" => result.reasons, "handoff_id" => doc["handoff_id"], "receipt_id" => doc.dig("receipt", "receipt_id") }
1290
+ render_handoff_verify(payload, options[:format])
1291
+ result.decision == Reuse::REUSABLE ? EXIT_OK : EXIT_NO_GATE
1292
+ end
1293
+
1294
+ def capture_current_env
1295
+ # Minimal env capture for reuse freshness — reuse VerificationEnvironment where possible
1296
+ begin
1297
+ VerificationEnvironment.capture(repository_root: @working_directory)
1298
+ rescue StandardError
1299
+ { "ruby_engine" => RUBY_ENGINE, "ruby_version" => RUBY_VERSION, "railverdict_version" => RailVerdict::VERSION, "analyzer_versions" => {} }
1300
+ end
1301
+ end
1302
+
1303
+ def write_handoff_file(document, path)
1304
+ dir = File.dirname(File.expand_path(path))
1305
+ FileUtils.mkdir_p(dir)
1306
+ tmp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
1307
+ begin
1308
+ File.open(tmp, "wb", 0o600) do |file|
1309
+ file.write(JSON.generate(document) + "\n")
1310
+ file.flush
1311
+ file.fsync
1312
+ end
1313
+ File.rename(tmp, path)
1314
+ ensure
1315
+ File.unlink(tmp) if File.exist?(tmp)
1316
+ end
1317
+ end
1318
+
1319
+ def render_handoff(document, format, output_path: nil)
1320
+ if format == "json"
1321
+ @stdout.write(JSON.generate(document) + "\n")
1322
+ else
1323
+ @stdout.puts "Handoff: #{document['handoff_id']}"
1324
+ @stdout.puts "Receipt: #{document.dig('receipt', 'receipt_id')}"
1325
+ @stdout.puts "Evidence analyzers: #{Array(document.dig('evidence_set', 'analyzer_results')).map { |ar| ar['analyzer'] }.join(', ')}"
1326
+ @stdout.puts "Written to #{output_path}" if output_path
1327
+ end
1328
+ end
1329
+
1330
+ def render_handoff_unavailable(payload, format)
1331
+ if format == "json"
1332
+ @stdout.write(JSON.generate(payload) + "\n")
1333
+ else
1334
+ @stdout.puts "Handoff unavailable: #{payload['reasons'].join('; ')}"
1335
+ end
1336
+ end
1337
+
1338
+ def render_handoff_verify(payload, format)
1339
+ if format == "json"
1340
+ @stdout.write(JSON.generate(payload) + "\n")
1341
+ else
1342
+ @stdout.puts "Handoff: #{payload['handoff_id']} valid=#{payload['handoff_valid']}"
1343
+ @stdout.puts "Receipt fresh: #{payload['receipt_fresh']}"
1344
+ @stdout.puts "Decision: #{payload['decision']}"
1345
+ @stdout.puts "Reasons: #{payload['reasons'].join(', ')}" unless payload["reasons"].empty?
1346
+ end
1347
+ end
1348
+
714
1349
  def command_mcp(argv)
715
1350
  sub = argv.first
716
1351
  raise RailVerdict::UsageError, "mcp requires subcommand: serve" unless sub == "serve"