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,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ module RailVerdict
7
+ class CLI
8
+ EXIT_OK = 0
9
+ EXIT_POLICY_FAIL = 1
10
+ EXIT_NO_GATE = 2
11
+ EXIT_INTERRUPTED = 130
12
+
13
+ FORMATS = %w[console json sarif].freeze
14
+ DEFAULT_CONFIG_PATH = ".railverdict.yml"
15
+
16
+ USAGE = <<~USAGE
17
+ Usage: railverdict <command> [options]
18
+
19
+ Commands:
20
+ init Write the default .railverdict.yml configuration
21
+ doctor Report configuration and analyzer observations
22
+ check Run verification and print the gate result
23
+ baseline create Deferred boundary; Phase 3 owns baseline writes
24
+ findings Print normalized findings from the evidence run
25
+ explain Explain a finding with optional AI
26
+ investigate Investigate top findings with optional AI
27
+ repair Build a deterministic repair packet for a finding
28
+ mcp MCP adapter (serve)
29
+
30
+ Global options:
31
+ --help Show this usage
32
+ --version Show the railverdict version
33
+ USAGE
34
+
35
+ def initialize(stdout: $stdout, stderr: $stderr, working_directory: Dir.pwd)
36
+ @stdout = stdout
37
+ @stderr = stderr
38
+ @working_directory = working_directory
39
+ end
40
+
41
+ def run(argv)
42
+ command = argv.first
43
+ case command
44
+ when nil, "-h", "--help", "help"
45
+ @stdout.puts USAGE
46
+ EXIT_OK
47
+ when "--version", "-V"
48
+ @stdout.puts "railverdict #{RailVerdict::VERSION}"
49
+ EXIT_OK
50
+ when "init"
51
+ command_init(argv.drop(1))
52
+ when "doctor"
53
+ command_doctor(argv.drop(1))
54
+ when "check"
55
+ command_check(argv.drop(1))
56
+ when "baseline"
57
+ command_baseline(argv.drop(1))
58
+ when "findings"
59
+ command_findings(argv.drop(1))
60
+ when "explain"
61
+ command_explain(argv.drop(1))
62
+ when "investigate"
63
+ command_investigate(argv.drop(1))
64
+ when "repair"
65
+ command_repair(argv.drop(1))
66
+ when "mcp"
67
+ command_mcp(argv.drop(1))
68
+ else
69
+ @stderr.puts "railverdict: unknown command: #{command}"
70
+ @stderr.puts USAGE
71
+ EXIT_NO_GATE
72
+ end
73
+ rescue RailVerdict::UsageError => error
74
+ @stderr.puts "railverdict: #{error.message}"
75
+ @stderr.puts USAGE
76
+ EXIT_NO_GATE
77
+ end
78
+
79
+ private
80
+
81
+ def command_init(argv)
82
+ options = { config: DEFAULT_CONFIG_PATH, force: false }
83
+ parser = OptionParser.new do |opts|
84
+ opts.banner = "Usage: railverdict init [--config PATH] [--force]"
85
+ opts.on("--config PATH", String) { |value| options[:config] = value }
86
+ opts.on("--force") { options[:force] = true }
87
+ end
88
+ parse!(parser, argv)
89
+ exit_code, message = Init.write(
90
+ root: @working_directory,
91
+ config_path: options[:config],
92
+ force: options[:force]
93
+ )
94
+ if exit_code.zero?
95
+ @stdout.puts message
96
+ else
97
+ @stderr.puts "railverdict init: #{message}"
98
+ end
99
+ exit_code
100
+ end
101
+
102
+ def command_doctor(argv)
103
+ options = { config: DEFAULT_CONFIG_PATH, format: "console" }
104
+ parser = OptionParser.new do |opts|
105
+ opts.banner = "Usage: railverdict doctor [--config PATH] [--format console|json]"
106
+ opts.on("--config PATH", String) { |value| options[:config] = value }
107
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
108
+ end
109
+ parse!(parser, argv)
110
+ validate_format!(options[:format])
111
+ outcome = Doctor.execute(
112
+ repository_root: @working_directory,
113
+ config_path: options[:config]
114
+ )
115
+ if options[:format] == "json"
116
+ @stdout.write(JSON.generate(outcome.report))
117
+ @stdout.write("\n")
118
+ else
119
+ @stdout.write(render_doctor_console(outcome.report))
120
+ end
121
+ outcome.exit_code
122
+ end
123
+
124
+ def command_check(argv)
125
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", changed: false, base: nil, baseline: nil, waiver: nil }
126
+ parser = OptionParser.new do |opts|
127
+ opts.banner = "Usage: railverdict check [--config PATH] [--format console|json|sarif] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
128
+ opts.on("--config PATH", String) { |value| options[:config] = value }
129
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
130
+ opts.on("--changed") { options[:changed] = true }
131
+ opts.on("--base REV", String) { |value| options[:base] = value }
132
+ opts.on("--baseline PATH", String) { |value| options[:baseline] = value }
133
+ opts.on("--waiver PATH", String) { |value| options[:waiver] = value }
134
+ end
135
+ parse!(parser, argv)
136
+ validate_format!(options[:format])
137
+ if options[:base] && !options[:changed]
138
+ raise RailVerdict::UsageError, "--base requires --changed"
139
+ end
140
+
141
+ outcome, interrupted = execute_check(options)
142
+ return EXIT_NO_GATE unless render_result(outcome.result, options[:format])
143
+
144
+ exit_code_for(outcome.result, interrupted: interrupted)
145
+ end
146
+
147
+ def command_baseline(argv)
148
+ subcommand = argv.first
149
+ raise RailVerdict::UsageError, "unknown baseline subcommand: #{subcommand.inspect}; only `baseline create` exists" unless subcommand == "create"
150
+
151
+ options = { config: DEFAULT_CONFIG_PATH, output: nil, format: "console", force: false }
152
+ parser = OptionParser.new do |opts|
153
+ opts.banner = "Usage: railverdict baseline create [--config PATH] [--output PATH] [--format console|json] [--force]"
154
+ opts.on("--config PATH", String) { |value| options[:config] = value }
155
+ opts.on("--output PATH", String) { |value| options[:output] = value }
156
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
157
+ opts.on("--force") { options[:force] = true }
158
+ end
159
+ parse!(parser, argv.drop(1))
160
+ validate_format!(options[:format])
161
+
162
+ outcome, interrupted = execute_check({ config: options[:config], format: options[:format] })
163
+ return EXIT_INTERRUPTED if interrupted || outcome.result.completion_status == "interrupted"
164
+ baseline_required_only = outcome.result.completion_status == "incomplete" &&
165
+ outcome.result.decision_reasons.any? { |r| r["code"] == "baseline_required" } &&
166
+ outcome.result.operational_failures.all? { |f| f["message"]&.include?("baseline required") }
167
+ unless outcome.result.completion_status == "complete" || baseline_required_only
168
+ @stderr.puts "railverdict baseline create: refusing to create baseline from incomplete run (#{outcome.result.operational_failures.map { |failure| failure.fetch('code') }.join(', ')})"
169
+ return EXIT_NO_GATE
170
+ end
171
+ if outcome.context.nil? || outcome.configuration.nil?
172
+ @stderr.puts "railverdict baseline create: refusing to create baseline without a complete context"
173
+ return EXIT_NO_GATE
174
+ end
175
+
176
+ path = Baseline.resolve_path(repository_root: @working_directory, configuration: outcome.configuration, output_override: options[:output])
177
+ baseline = Baseline.create(
178
+ findings: outcome.findings,
179
+ configuration: outcome.configuration,
180
+ analyzer_versions: outcome.context.analyzer_versions,
181
+ clock: Time.now.utc
182
+ )
183
+ Baseline.write(path: path, baseline: baseline, force: options[:force])
184
+
185
+ if options[:format] == "json"
186
+ @stdout.write(JSON.generate({ "baseline" => baseline.to_h }) + "\n")
187
+ else
188
+ @stdout.puts "Baseline created at #{path} (#{baseline.entries.length} entries)"
189
+ end
190
+ EXIT_OK
191
+ rescue RailVerdict::UsageError => error
192
+ @stderr.puts "railverdict baseline create: #{error.message}"
193
+ EXIT_NO_GATE
194
+ rescue RailVerdict::Baseline::IncompatibleError, RailVerdict::Error => error
195
+ @stderr.puts "railverdict baseline create: #{error.message}"
196
+ EXIT_NO_GATE
197
+ end
198
+
199
+ def command_findings(argv)
200
+ options = { config: DEFAULT_CONFIG_PATH, format: "console" }
201
+ parser = OptionParser.new do |opts|
202
+ opts.banner = "Usage: railverdict findings [--config PATH] [--format console|json]"
203
+ opts.on("--config PATH", String) { |value| options[:config] = value }
204
+ opts.on("--format FORMAT", String) { |value| options[:format] = value }
205
+ end
206
+ parse!(parser, argv)
207
+ validate_format!(options[:format])
208
+ outcome, interrupted = execute_check(options)
209
+ if options[:format] == "json"
210
+ @stdout.write(JSON.generate(FindingsCommand.document(outcome)))
211
+ @stdout.write("\n")
212
+ else
213
+ @stdout.write(FindingsCommand.console(outcome))
214
+ end
215
+ @stderr.puts "railverdict findings: required evidence is incomplete" if outcome.result.incomplete?
216
+ return EXIT_INTERRUPTED if interrupted || outcome.result.completion_status == "interrupted"
217
+ return EXIT_NO_GATE if outcome.result.incomplete?
218
+
219
+ EXIT_OK
220
+ end
221
+
222
+ def parse!(parser, argv)
223
+ rest = parser.parse(argv.dup)
224
+ return if rest.empty?
225
+
226
+ raise RailVerdict::UsageError, "unexpected arguments: #{rest.join(' ')}"
227
+ rescue OptionParser::ParseError => error
228
+ raise RailVerdict::UsageError, error.message
229
+ end
230
+
231
+ def validate_format!(format)
232
+ return if FORMATS.include?(format)
233
+
234
+ raise RailVerdict::UsageError, "invalid --format #{format.inspect}; expected console, json or sarif"
235
+ end
236
+
237
+ def execute_check(options)
238
+ interrupted = false
239
+ previous = Signal.trap("INT") do
240
+ interrupted = true
241
+ RailVerdict::ProcessRunner.registry.terminate_all
242
+ end
243
+ execute_options = {
244
+ repository_root: @working_directory,
245
+ config_path: options[:config],
246
+ interrupted: -> { interrupted }
247
+ }
248
+ execute_options[:baseline_path_override] = options[:baseline] if options.key?(:baseline) && options[:baseline]
249
+ execute_options[:waiver_path_override] = options[:waiver] if options.key?(:waiver) && options[:waiver]
250
+ execute_options[:baseline_path_override] = options[:output] if options.key?(:output) && options[:output]
251
+ execute_options[:changed] = options[:changed] if options.key?(:changed)
252
+ execute_options[:base] = options[:base] if options.key?(:base)
253
+ outcome = Check.execute(**execute_options)
254
+ [outcome, interrupted]
255
+ ensure
256
+ Signal.trap("INT", previous) if previous
257
+ end
258
+
259
+ def render_result(result, format)
260
+ rendered = if format == "json"
261
+ Reporters::JsonReporter.render(result)
262
+ elsif format == "sarif"
263
+ Reporters::Sarif.render_json(result)
264
+ else
265
+ Reporters::Console.render(result)
266
+ end
267
+ @stdout.write(rendered)
268
+ true
269
+ rescue RailVerdict::Error => error
270
+ @stderr.puts "railverdict: #{error.message}"
271
+ false
272
+ end
273
+
274
+ def exit_code_for(result, interrupted: false)
275
+ return EXIT_INTERRUPTED if interrupted || result.completion_status == "interrupted"
276
+ return EXIT_NO_GATE if result.completion_status != "complete"
277
+ return EXIT_POLICY_FAIL if result.gate == "FAIL"
278
+
279
+ EXIT_OK
280
+ end
281
+
282
+ def render_doctor_console(report)
283
+ lines = ["RailVerdict doctor", "", "Ruby: #{report['ruby_version'] || 'unknown'}"]
284
+ lines << "Target Ruby: #{report['target_ruby_version']}" if report.key?("target_ruby_version")
285
+ lines << "Rails: #{report['rails_version']}" if report.key?("rails_version")
286
+ configuration = report.fetch("configuration")
287
+ lines << "Configuration: #{configuration['valid'] ? 'valid' : 'invalid'}"
288
+ report.fetch("analyzers", {}).each do |name, details|
289
+ lines << "#{name}: #{details['status']}#{details['version'] ? " (#{details['version']})" : ''}"
290
+ end
291
+ lines.join("\n") + "\n"
292
+ end
293
+
294
+ def command_explain(argv)
295
+ finding_ref = argv.first
296
+ raise RailVerdict::UsageError, "explain requires a finding id or fingerprint" unless finding_ref && !finding_ref.start_with?("-")
297
+
298
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", preview: false }
299
+ parser = OptionParser.new do |opts|
300
+ opts.banner = "Usage: railverdict explain <finding-id|fingerprint> [--config PATH] [--format console|json] [--preview-context]"
301
+ opts.on("--config PATH", String) { |v| options[:config] = v }
302
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
303
+ opts.on("--preview-context") { options[:preview] = true }
304
+ end
305
+ parse!(parser, argv.drop(1))
306
+ validate_format!(options[:format])
307
+
308
+ outcome, interrupted = execute_check({ config: options[:config], format: options[:format] })
309
+ return EXIT_INTERRUPTED if interrupted
310
+
311
+ if options[:preview]
312
+ return explain_preview(outcome, finding_ref, options[:format])
313
+ end
314
+
315
+ result = Intelligence::Orchestrator.explain(
316
+ outcome: outcome,
317
+ finding_ref: finding_ref,
318
+ configuration: outcome.configuration
319
+ )
320
+ render_intelligence(result, options[:format])
321
+ EXIT_OK
322
+ rescue RailVerdict::UsageError => e
323
+ @stderr.puts "railverdict explain: #{e.message}"
324
+ EXIT_NO_GATE
325
+ rescue StandardError => e
326
+ @stderr.puts "railverdict explain: #{e.message}"
327
+ EXIT_NO_GATE
328
+ end
329
+
330
+ def command_investigate(argv)
331
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", preview: false, limit: 3 }
332
+ parser = OptionParser.new do |opts|
333
+ opts.banner = "Usage: railverdict investigate [--config PATH] [--format console|json] [--preview-context] [--limit N]"
334
+ opts.on("--config PATH", String) { |v| options[:config] = v }
335
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
336
+ opts.on("--preview-context") { options[:preview] = true }
337
+ opts.on("--limit N", Integer) { |v| options[:limit] = v }
338
+ end
339
+ parse!(parser, argv)
340
+ validate_format!(options[:format])
341
+
342
+ outcome, interrupted = execute_check({ config: options[:config], format: options[:format] })
343
+ return EXIT_INTERRUPTED if interrupted
344
+
345
+ if options[:preview]
346
+ findings = Intelligence::Budget.select_findings(outcome.findings || [], limit: [options[:limit], 3].min)
347
+ manifests = findings.map do |f|
348
+ Intelligence::ContextBuilder.build(outcome: outcome, finding_ref: f.id).to_json_hash rescue {}
349
+ end
350
+ if options[:format] == "json"
351
+ @stdout.write(JSON.generate({ "manifests" => manifests }) + "\n")
352
+ else
353
+ @stdout.puts "Preview: #{manifests.length} findings selected"
354
+ manifests.each { |m| @stdout.puts " #{m['finding_id']} #{m['fingerprint']}" }
355
+ end
356
+ return EXIT_OK
357
+ end
358
+
359
+ results = Intelligence::Orchestrator.investigate(
360
+ outcome: outcome,
361
+ configuration: outcome.configuration,
362
+ limit: options[:limit]
363
+ )
364
+ results.each { |r| render_intelligence(r, options[:format]) }
365
+ EXIT_OK
366
+ rescue RailVerdict::UsageError => e
367
+ @stderr.puts "railverdict investigate: #{e.message}"
368
+ EXIT_NO_GATE
369
+ rescue StandardError => e
370
+ @stderr.puts "railverdict investigate: #{e.message}"
371
+ EXIT_NO_GATE
372
+ end
373
+
374
+ def explain_preview(outcome, finding_ref, format)
375
+ manifest = Intelligence::ContextBuilder.build(outcome: outcome, finding_ref: finding_ref)
376
+ if format == "json"
377
+ @stdout.write(JSON.generate(manifest.to_json_hash) + "\n")
378
+ else
379
+ @stdout.puts "Finding: #{manifest.finding_id}"
380
+ @stdout.puts "Fingerprint: #{manifest.fingerprint}"
381
+ @stdout.puts "Snippets: #{manifest.snippets.length}"
382
+ manifest.snippets.each { |s| @stdout.puts " #{s['path']}" }
383
+ end
384
+ EXIT_OK
385
+ rescue ArgumentError => e
386
+ @stderr.puts "railverdict explain: #{e.message}"
387
+ EXIT_NO_GATE
388
+ end
389
+
390
+ def render_intelligence(result, format)
391
+ if result[:failure]
392
+ if format == "json"
393
+ @stdout.write(JSON.generate({ "failure" => result[:failure].to_h }) + "\n")
394
+ else
395
+ @stdout.puts "Intelligence: #{result[:failure].code}: #{result[:failure].message}"
396
+ end
397
+ elsif result[:analysis]
398
+ if format == "json"
399
+ @stdout.write(JSON.generate(result[:analysis].to_h) + "\n")
400
+ else
401
+ @stdout.puts "Analysis: #{result[:analysis].summary} (confidence: #{result[:analysis].confidence})"
402
+ end
403
+ end
404
+ end
405
+
406
+ def command_repair(argv)
407
+ finding_ref = argv.first
408
+ raise RailVerdict::UsageError, "repair requires a finding id or fingerprint" unless finding_ref && !finding_ref.start_with?("-")
409
+
410
+ options = { config: DEFAULT_CONFIG_PATH, format: "console", output: nil, changed: false, base: nil, baseline: nil, waiver: nil }
411
+ parser = OptionParser.new do |opts|
412
+ opts.banner = "Usage: railverdict repair <finding-id|fingerprint> [--config PATH] [--format console|json] [--output PATH] [--changed] [--base REV] [--baseline PATH] [--waiver PATH]"
413
+ opts.on("--config PATH", String) { |v| options[:config] = v }
414
+ opts.on("--format FORMAT", String) { |v| options[:format] = v }
415
+ opts.on("--output PATH", String) { |v| options[:output] = v }
416
+ opts.on("--changed") { options[:changed] = true }
417
+ opts.on("--base REV", String) { |v| options[:base] = v }
418
+ opts.on("--baseline PATH", String) { |v| options[:baseline] = v }
419
+ opts.on("--waiver PATH", String) { |v| options[:waiver] = v }
420
+ end
421
+ parse!(parser, argv.drop(1))
422
+ validate_format!(options[:format])
423
+ raise RailVerdict::UsageError, "--base requires --changed" if options[:base] && !options[:changed]
424
+
425
+ code, _packet = RailVerdict::Repair::Command.execute(
426
+ repository_root: @working_directory,
427
+ config_path: options[:config],
428
+ finding_ref: finding_ref,
429
+ format: options[:format],
430
+ output_path: options[:output],
431
+ changed: options[:changed],
432
+ base: options[:base],
433
+ baseline_override: options[:baseline],
434
+ waiver_override: options[:waiver],
435
+ stdout: @stdout,
436
+ stderr: @stderr
437
+ )
438
+ code
439
+ rescue RailVerdict::UsageError => e
440
+ @stderr.puts "railverdict repair: #{e.message}"
441
+ EXIT_NO_GATE
442
+ end
443
+
444
+ def command_mcp(argv)
445
+ sub = argv.first
446
+ raise RailVerdict::UsageError, "mcp requires subcommand: serve" unless sub == "serve"
447
+
448
+ options = { repository_root: @working_directory }
449
+ parser = OptionParser.new do |opts|
450
+ opts.banner = "Usage: railverdict mcp serve [--repository-root PATH]"
451
+ opts.on("--repository-root PATH", String) { |v| options[:repository_root] = v }
452
+ end
453
+ parse!(parser, argv.drop(1))
454
+ require_relative "mcp"
455
+ server = RailVerdict::MCP::Server.new(repository_root: options[:repository_root])
456
+ server.serve
457
+ EXIT_OK
458
+ rescue RailVerdict::UsageError => e
459
+ @stderr.puts "railverdict mcp: #{e.message}"
460
+ EXIT_NO_GATE
461
+ rescue StandardError => e
462
+ @stderr.puts "railverdict mcp: #{e.message}"
463
+ EXIT_NO_GATE
464
+ end
465
+
466
+ def not_implemented(command)
467
+ @stderr.puts "railverdict #{command}: not yet implemented in this build step."
468
+ EXIT_NO_GATE
469
+ end
470
+ end
471
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module RailVerdict
6
+ class Comparison
7
+ Result = Struct.new(:existing, :introduced, :resolved, :changed, :moved, :counts, :classified_findings, :orphaned_waivers, keyword_init: true)
8
+
9
+ def self.classify(findings:, baseline:, waivers: [], clock: Time.now.utc, rename_map: nil)
10
+ findings = findings.sort_by(&:sort_key)
11
+ if baseline.nil?
12
+ introduced = findings.map { |finding| restate(finding, "introduced") }
13
+ resolved = []
14
+ existing = []
15
+ changed = []
16
+ moved = []
17
+ if waivers.any?
18
+ waived, orphaned = apply_waivers(nil, findings, baseline, waivers, clock)
19
+ if waived.any?
20
+ current_by_fp = findings.to_h { |finding| [finding.fingerprint, finding] }
21
+ introduced = introduced.reject { |finding| waived.include?(finding.fingerprint) }
22
+ waived_findings = waived.map { |fingerprint| current_by_fp[fingerprint] }.compact.map { |finding| restate(finding, "waived") }.sort_by(&:sort_key)
23
+ classified = (introduced + waived_findings).sort_by(&:sort_key)
24
+ return build_result(existing: existing, introduced: introduced, resolved: resolved, changed: changed, moved: moved, classified_findings: classified, waived: waived_findings, orphaned_waivers: orphaned, baseline: baseline)
25
+ end
26
+ return build_result(existing: existing, introduced: introduced, resolved: resolved, changed: changed, moved: moved, classified_findings: introduced, waived: [], orphaned_waivers: orphaned, baseline: baseline)
27
+ end
28
+ return build_result(existing: existing, introduced: introduced, resolved: resolved, changed: changed, moved: moved, findings: findings, baseline: baseline, waivers: waivers, clock: clock)
29
+ end
30
+
31
+ baseline_fps = Set.new(baseline.entries.map { |entry| entry.fetch("fingerprint") })
32
+ baseline_by_fp = baseline.entries.to_h { |entry| [entry.fetch("fingerprint"), entry] }
33
+ current_by_fp = findings.to_h { |finding| [finding.fingerprint, finding] }
34
+ current_fps = Set.new(current_by_fp.keys)
35
+
36
+ existing_fps = baseline_fps & current_fps
37
+ resolved_fps = baseline_fps - current_fps
38
+ introduced_fps = current_fps - baseline_fps
39
+
40
+ existing = existing_fps.map { |fingerprint| restate(current_by_fp.fetch(fingerprint), "existing") }.sort_by(&:sort_key)
41
+ resolved_entries = resolved_fps.map { |fingerprint| baseline_by_fp.fetch(fingerprint) }.sort_by { |entry| entry.fetch("fingerprint") }
42
+ introduced_candidates = introduced_fps.map { |fingerprint| current_by_fp.fetch(fingerprint) }.sort_by(&:sort_key)
43
+
44
+ resolved_by_key = build_resolved_index(resolved_entries)
45
+ changed, moved, introduced = partition_introduced(introduced_candidates, resolved_by_key, resolved_entries, rename_map: rename_map)
46
+ resolved = resolved_entries.map { |entry| { "fingerprint" => entry.fetch("fingerprint"), "analyzer" => entry.fetch("analyzer"), "rule_id" => entry.fetch("rule_id"), "path" => entry.fetch("path"), "message" => entry.fetch("message"), "state" => "resolved" } }
47
+ if changed.any? || moved.any?
48
+ consumed_fps = Set.new((changed + moved).flat_map { |pair| [pair[:resolved].fetch("fingerprint")] })
49
+ resolved = resolved.reject { |entry| consumed_fps.include?(entry.fetch("fingerprint")) }
50
+ end
51
+
52
+ waived = Set.new
53
+ orphaned = []
54
+ if waivers.any?
55
+ waived, orphaned = apply_waivers(nil, findings, baseline, waivers, clock)
56
+ end
57
+ waived_findings = []
58
+ if waived.any?
59
+ existing = existing.reject { |finding| waived.include?(finding.fingerprint) }
60
+ introduced = introduced.reject { |finding| waived.include?(finding.fingerprint) }
61
+ changed_pairs = changed
62
+ moved_pairs = moved
63
+ changed_findings_tmp = changed_pairs.map { |pair| pair[:finding] }.reject { |finding| waived.include?(finding.fingerprint) }
64
+ moved_findings_tmp = moved_pairs.map { |pair| pair[:finding] }.reject { |finding| waived.include?(finding.fingerprint) }
65
+ changed = changed_pairs.select { |pair| changed_findings_tmp.include?(pair[:finding]) }
66
+ moved = moved_pairs.select { |pair| moved_findings_tmp.include?(pair[:finding]) }
67
+ waived_findings = waived.map { |fingerprint| current_by_fp[fingerprint] }.compact.map { |finding| restate(finding, "waived") }.sort_by(&:sort_key)
68
+ classified_extra = waived_findings
69
+ changed_findings = changed.map { |pair| pair[:finding] }
70
+ moved_findings = moved.map { |pair| pair[:finding] }
71
+ classified = (existing + introduced + changed_findings + moved_findings + classified_extra).sort_by(&:sort_key)
72
+ return build_result(existing: existing, introduced: introduced, resolved: resolved, changed: changed_findings, moved: moved_findings, classified_findings: classified, waived: classified_extra, orphaned_waivers: orphaned, baseline: baseline)
73
+ end
74
+ changed_findings = changed.map { |pair| pair[:finding] }
75
+ moved_findings = moved.map { |pair| pair[:finding] }
76
+ classified = (existing + introduced + changed_findings + moved_findings).sort_by(&:sort_key)
77
+ build_result(existing: existing, introduced: introduced, resolved: resolved, changed: changed_findings, moved: moved_findings, classified_findings: classified, waived: [], orphaned_waivers: orphaned, baseline: baseline)
78
+ end
79
+
80
+ def self.build_result(existing:, introduced:, resolved:, changed:, moved:, classified_findings: nil, waived: [], orphaned_waivers: [], baseline: nil, findings: nil, waivers: nil, clock: nil)
81
+ classified_findings ||= (existing + introduced + changed + moved + waived).sort_by { |finding| finding.is_a?(Hash) ? finding.fetch("fingerprint") : finding.sort_key }
82
+ counts = {
83
+ "introduced" => introduced.length,
84
+ "existing" => existing.length,
85
+ "resolved" => resolved.length,
86
+ "changed" => changed.length,
87
+ "moved" => moved.length,
88
+ "waived" => waived.length
89
+ }
90
+ Result.new(existing: existing.freeze, introduced: introduced.freeze, resolved: resolved.freeze, changed: changed.freeze, moved: moved.freeze, counts: counts.freeze, classified_findings: classified_findings.freeze, orphaned_waivers: orphaned_waivers.freeze)
91
+ end
92
+ private_class_method :build_result
93
+
94
+ def self.restate(finding, state)
95
+ Finding.new(
96
+ fingerprint: finding.fingerprint,
97
+ origin: finding.origin,
98
+ analyzer: finding.analyzer,
99
+ rule_id: finding.rule_id,
100
+ category: finding.category,
101
+ severity: finding.severity,
102
+ confidence: finding.confidence,
103
+ state: state,
104
+ evidence_ref: finding.evidence_ref,
105
+ location: finding.location,
106
+ message: finding.message
107
+ )
108
+ end
109
+ private_class_method :restate
110
+
111
+ def self.build_resolved_index(resolved_entries)
112
+ index = Hash.new { |hash, key| hash[key] = [] }
113
+ resolved_entries.each do |entry|
114
+ key = [entry.fetch("analyzer"), entry.fetch("rule_id"), entry.fetch("message")]
115
+ index[key] << entry
116
+ end
117
+ index
118
+ end
119
+ private_class_method :build_resolved_index
120
+
121
+ def self.partition_introduced(candidates, resolved_by_key, resolved_entries, rename_map: nil)
122
+ changed = []
123
+ moved = []
124
+ introduced = []
125
+ consumed_resolved = Set.new
126
+ candidates.each do |finding|
127
+ key_exact = [finding.analyzer, finding.rule_id, finding.message]
128
+ exact_matches = resolved_by_key[key_exact].reject { |entry| consumed_resolved.include?(entry.fetch("fingerprint")) }
129
+ if exact_matches.length == 1
130
+ resolved_entry = exact_matches.first
131
+ if resolved_entry.fetch("path") != finding.location.fetch("path")
132
+ if rename_map
133
+ new_path = finding.location.fetch("path")
134
+ expected_old = rename_map[new_path]
135
+ if expected_old && resolved_entry.fetch("path") == expected_old
136
+ moved << { finding: restate(finding, "moved"), resolved: resolved_entry }
137
+ consumed_resolved.add(resolved_entry.fetch("fingerprint"))
138
+ next
139
+ elsif expected_old && resolved_entry.fetch("path") != expected_old
140
+ introduced << restate(finding, "introduced")
141
+ next
142
+ end
143
+ end
144
+ moved << { finding: restate(finding, "moved"), resolved: resolved_entry }
145
+ consumed_resolved.add(resolved_entry.fetch("fingerprint"))
146
+ next
147
+ end
148
+ elsif rename_map && exact_matches.length > 1
149
+ new_path = finding.location.fetch("path")
150
+ expected_old = rename_map[new_path]
151
+ if expected_old
152
+ filtered = exact_matches.select { |entry| entry.fetch("path") == expected_old }
153
+ if filtered.length == 1
154
+ resolved_entry = filtered.first
155
+ moved << { finding: restate(finding, "moved"), resolved: resolved_entry }
156
+ consumed_resolved.add(resolved_entry.fetch("fingerprint"))
157
+ next
158
+ end
159
+ end
160
+ introduced << restate(finding, "introduced")
161
+ next
162
+ end
163
+ same_path_key = resolved_entries.select { |entry| entry.fetch("path") == finding.location.fetch("path") && entry.fetch("analyzer") == finding.analyzer && entry.fetch("rule_id") == finding.rule_id }
164
+ same_path_unconsumed = same_path_key.reject { |entry| consumed_resolved.include?(entry.fetch("fingerprint")) }
165
+ if same_path_unconsumed.length == 1 && same_path_unconsumed.first.fetch("message") != finding.message
166
+ resolved_entry = same_path_unconsumed.first
167
+ changed << { finding: restate(finding, "changed"), resolved: resolved_entry }
168
+ consumed_resolved.add(resolved_entry.fetch("fingerprint"))
169
+ next
170
+ end
171
+ introduced << restate(finding, "introduced")
172
+ end
173
+ [changed, moved, introduced]
174
+ end
175
+ private_class_method :partition_introduced
176
+
177
+ def self.apply_waivers(grouped, findings, baseline, waivers, clock)
178
+ current_fps = Set.new(findings.map(&:fingerprint))
179
+ baseline_fps = baseline ? Set.new(baseline.entries.map { |entry| entry.fetch("fingerprint") }) : Set.new
180
+ all_fps = current_fps | baseline_fps
181
+ active = Set.new
182
+ orphaned = []
183
+ waivers.each do |waiver|
184
+ hash = waiver.is_a?(Hash) ? waiver : waiver.to_h
185
+ fingerprint = hash["fingerprint"] || hash[:fingerprint]
186
+ next unless fingerprint
187
+
188
+ if !all_fps.include?(fingerprint)
189
+ orphaned << hash
190
+ next
191
+ end
192
+ active.add(fingerprint) if waiver_active?(hash, clock)
193
+ end
194
+ [active, orphaned]
195
+ end
196
+ private_class_method :apply_waivers
197
+
198
+ def self.waiver_active?(waiver, clock)
199
+ created_at = parse_time(waiver["created_at"] || waiver[:created_at])
200
+ expires_at = parse_time(waiver["expires_at"] || waiver[:expires_at])
201
+ return false unless created_at && expires_at
202
+
203
+ clock >= created_at && clock < expires_at
204
+ end
205
+ private_class_method :waiver_active?
206
+
207
+ def self.parse_time(value)
208
+ return nil if value.nil?
209
+
210
+ Time.iso8601(value.to_s).utc
211
+ rescue ArgumentError
212
+ nil
213
+ end
214
+ private_class_method :parse_time
215
+ end
216
+ end