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,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ module Verification
5
+ module Policy
6
+ module_function
7
+
8
+ def evaluate(configuration:, analyzer_results:, findings:, comparison: nil, baseline_meta: nil, git_context: nil)
9
+ analyzer_results = analyzer_results.sort_by(&:analyzer)
10
+ findings = findings.sort_by(&:sort_key)
11
+ operational_failures = []
12
+ required_incomplete = []
13
+
14
+ configuration.analyzers.each do |analyzer, selection|
15
+ next unless selection.fetch("enabled")
16
+
17
+ result = analyzer_results.find { |candidate| candidate.analyzer == analyzer }
18
+ if result.nil?
19
+ failure = { "code" => "unavailable", "analyzer" => analyzer, "message" => "required analyzer did not produce a result" }
20
+ operational_failures << failure
21
+ required_incomplete << failure if selection.fetch("required")
22
+ elsif result.evidence_status == "incomplete"
23
+ failure = {
24
+ "code" => result.failure.fetch("code"),
25
+ "analyzer" => analyzer,
26
+ "message" => result.failure.fetch("message")
27
+ }
28
+ operational_failures << failure
29
+ required_incomplete << failure if selection.fetch("required")
30
+ elsif selection.fetch("required") && (failure = incomplete_evidence_failure(analyzer, result))
31
+ operational_failures << failure
32
+ required_incomplete << failure
33
+ end
34
+ end
35
+
36
+ unless required_incomplete.empty?
37
+ return GateResult.new(
38
+ completion_status: "incomplete",
39
+ gate: "INCOMPLETE",
40
+ policy_status: "not_evaluated",
41
+ findings: summaries(findings, blocking: false),
42
+ analyzer_results: analyzer_results,
43
+ operational_failures: required_incomplete,
44
+ decision_reasons: [{
45
+ "code" => "required_evidence_incomplete",
46
+ "message" => "Required analyzer evidence is incomplete; policy was not evaluated."
47
+ }]
48
+ )
49
+ end
50
+
51
+ optional_failures = operational_failures.reject { |failure| required_incomplete.include?(failure) }
52
+
53
+ changed_findings = if git_context
54
+ filter_to_changed_scope(findings, git_context)
55
+ else
56
+ findings
57
+ end
58
+
59
+ mode = configuration.mode
60
+
61
+ if mode == "advisory" && git_context
62
+ scoped = changed_findings
63
+ gate = scoped.empty? ? "PASS" : "WARN"
64
+ policy_status = gate.downcase
65
+ reasons = []
66
+ reasons << if scoped.empty?
67
+ if findings.empty?
68
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
69
+ else
70
+ { "code" => "advisory_no_changed_findings", "message" => "No findings in changed scope; #{findings.length} findings outside changed scope are advisory." }
71
+ end
72
+ else
73
+ { "code" => "advisory_findings_in_changed_scope", "message" => "#{scoped.length} findings in changed scope are advisory and do not block." }
74
+ end
75
+ reasons << { "code" => "changed_scope", "message" => "Evaluated #{scoped.length} findings in changed scope (#{findings.length} total)." }
76
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
77
+ blocking_map = Set.new(scoped.map(&:fingerprint))
78
+ summaries_scoped = findings.sort_by(&:sort_key).map do |finding|
79
+ blocking = false
80
+ { "id" => finding.id, "fingerprint" => finding.fingerprint, "severity" => finding.severity, "state" => finding.state, "blocking" => blocking }
81
+ end
82
+ return GateResult.new(
83
+ completion_status: "complete",
84
+ gate: gate,
85
+ policy_status: policy_status,
86
+ findings: summaries_scoped,
87
+ analyzer_results: analyzer_results,
88
+ operational_failures: optional_failures,
89
+ decision_reasons: reasons
90
+ )
91
+ end
92
+
93
+ if mode == "advisory"
94
+ gate = findings.empty? ? "PASS" : "WARN"
95
+ policy_status = gate.downcase
96
+ reasons = []
97
+ reasons << if findings.empty?
98
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
99
+ else
100
+ { "code" => "advisory_findings_present", "message" => "#{findings.length} findings are advisory and do not block." }
101
+ end
102
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
103
+ return GateResult.new(
104
+ completion_status: "complete",
105
+ gate: gate,
106
+ policy_status: policy_status,
107
+ findings: summaries(findings, blocking: false),
108
+ analyzer_results: analyzer_results,
109
+ operational_failures: optional_failures,
110
+ decision_reasons: reasons
111
+ )
112
+ end
113
+
114
+ if mode == "no_new_debt" && comparison.nil? && !findings.empty?
115
+ return GateResult.new(
116
+ completion_status: "incomplete",
117
+ gate: "INCOMPLETE",
118
+ policy_status: "not_evaluated",
119
+ findings: summaries(findings, blocking: false),
120
+ analyzer_results: analyzer_results,
121
+ operational_failures: [{ "code" => "failed", "message" => "baseline required for no_new_debt policy; run `railverdict baseline create`" }],
122
+ decision_reasons: [{ "code" => "baseline_required", "message" => "no_new_debt requires a baseline; create one with `railverdict baseline create`" }]
123
+ )
124
+ end
125
+
126
+ if mode == "no_new_debt" && comparison.nil? && findings.empty?
127
+ return GateResult.new(
128
+ completion_status: "complete",
129
+ gate: "PASS",
130
+ policy_status: "pass",
131
+ findings: summaries(findings, blocking: false),
132
+ analyzer_results: analyzer_results,
133
+ operational_failures: optional_failures,
134
+ decision_reasons: [{ "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }]
135
+ )
136
+ end
137
+
138
+ if mode == "no_new_debt" && comparison
139
+ counts = comparison.fetch("counts") rescue comparison["counts"] if comparison.is_a?(Hash)
140
+ counts ||= {}
141
+ waived_fps = Set.new((comparison.is_a?(Hash) ? (comparison["waived"] || []) : []))
142
+ introduced_fps = Set.new((comparison.is_a?(Hash) ? (comparison["introduced"] || []) : []))
143
+ changed_fps = Set.new((comparison.is_a?(Hash) ? (comparison["changed"] || []) : []))
144
+ moved_fps = Set.new((comparison.is_a?(Hash) ? (comparison["moved"] || []) : []))
145
+ regression_fps = introduced_fps | changed_fps | moved_fps
146
+ if git_context
147
+ regression_findings = changed_findings.select { |finding| regression_fps.include?(finding.fingerprint) && !waived_fps.include?(finding.fingerprint) }
148
+ existing_count = counts["existing"] || 0
149
+ gate = regression_findings.empty? ? "PASS" : "FAIL"
150
+ policy_status = gate.downcase
151
+ reasons = []
152
+ reasons << { "code" => "existing_debt_retained", "message" => "#{existing_count} existing findings retained from baseline." } if existing_count > 0
153
+ reasons << { "code" => "changed_scope", "message" => "Evaluated #{changed_findings.length} findings in changed scope (#{findings.length} total)." }
154
+ reasons << if regression_findings.empty?
155
+ if findings.empty?
156
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
157
+ elsif changed_findings.empty?
158
+ { "code" => "no_new_debt_pass_changed_scope", "message" => "No introduced regressions in changed scope." }
159
+ else
160
+ { "code" => "no_new_debt_pass", "message" => "No introduced regressions detected." }
161
+ end
162
+ else
163
+ { "code" => "introduced_findings_blocking", "message" => "#{regression_findings.length} introduced findings in changed scope violate no_new_debt policy." }
164
+ end
165
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
166
+ summaries_with_state = findings.sort_by(&:sort_key).map do |finding|
167
+ in_scope = changed_findings.any? { |scope_finding| scope_finding.fingerprint == finding.fingerprint }
168
+ blocking = in_scope && regression_fps.include?(finding.fingerprint) && !waived_fps.include?(finding.fingerprint)
169
+ blocking = false if finding.state == "existing"
170
+ blocking = false if finding.state == "waived"
171
+ { "id" => finding.id, "fingerprint" => finding.fingerprint, "severity" => finding.severity, "state" => finding.state, "blocking" => blocking }
172
+ end
173
+ return GateResult.new(
174
+ completion_status: "complete",
175
+ gate: gate,
176
+ policy_status: policy_status,
177
+ findings: summaries_with_state,
178
+ analyzer_results: analyzer_results,
179
+ operational_failures: optional_failures,
180
+ decision_reasons: reasons
181
+ )
182
+ end
183
+ blocking_findings = findings.select { |finding| regression_fps.include?(finding.fingerprint) && !waived_fps.include?(finding.fingerprint) }
184
+ existing_count = counts["existing"] || 0
185
+ gate = blocking_findings.empty? ? "PASS" : "FAIL"
186
+ policy_status = gate.downcase
187
+ reasons = []
188
+ reasons << { "code" => "existing_debt_retained", "message" => "#{existing_count} existing findings retained from baseline." } if existing_count > 0
189
+ reasons << if blocking_findings.empty?
190
+ if findings.empty?
191
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
192
+ else
193
+ { "code" => "no_new_debt_pass", "message" => "No introduced regressions detected." }
194
+ end
195
+ else
196
+ { "code" => "introduced_findings_blocking", "message" => "#{blocking_findings.length} introduced findings violate no_new_debt policy." }
197
+ end
198
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
199
+ summaries_with_state = findings.sort_by(&:sort_key).map do |finding|
200
+ blocking = regression_fps.include?(finding.fingerprint) && !waived_fps.include?(finding.fingerprint)
201
+ blocking = false if finding.state == "existing"
202
+ blocking = false if finding.state == "waived"
203
+ { "id" => finding.id, "fingerprint" => finding.fingerprint, "severity" => finding.severity, "state" => finding.state, "blocking" => blocking }
204
+ end
205
+ return GateResult.new(
206
+ completion_status: "complete",
207
+ gate: gate,
208
+ policy_status: policy_status,
209
+ findings: summaries_with_state,
210
+ analyzer_results: analyzer_results,
211
+ operational_failures: optional_failures,
212
+ decision_reasons: reasons
213
+ )
214
+ end
215
+
216
+ if git_context
217
+ scoped = changed_findings
218
+ gate = scoped.empty? ? "PASS" : "FAIL"
219
+ policy_status = gate.downcase
220
+ reasons = []
221
+ reasons << { "code" => "changed_scope", "message" => "Evaluated #{scoped.length} findings in changed scope (#{findings.length} total)." }
222
+ reasons << if scoped.empty?
223
+ if findings.empty?
224
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
225
+ else
226
+ { "code" => "no_findings_in_changed_scope", "message" => "No findings in changed scope." }
227
+ end
228
+ else
229
+ { "code" => "blocking_findings_in_changed_scope", "message" => "#{scoped.length} findings in changed scope require policy FAIL." }
230
+ end
231
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
232
+ scoped_fps = Set.new(scoped.map(&:fingerprint))
233
+ summaries_scoped = findings.sort_by(&:sort_key).map do |finding|
234
+ blocking = scoped_fps.include?(finding.fingerprint)
235
+ { "id" => finding.id, "fingerprint" => finding.fingerprint, "severity" => finding.severity, "state" => finding.state, "blocking" => blocking }
236
+ end
237
+ return GateResult.new(
238
+ completion_status: "complete",
239
+ gate: gate,
240
+ policy_status: policy_status,
241
+ findings: summaries_scoped,
242
+ analyzer_results: analyzer_results,
243
+ operational_failures: optional_failures,
244
+ decision_reasons: reasons
245
+ )
246
+ end
247
+
248
+ strict = true
249
+ blocking = true
250
+ gate = findings.empty? ? "PASS" : "FAIL"
251
+ policy_status = gate.downcase
252
+ reasons = []
253
+ reasons << if findings.empty?
254
+ { "code" => "no_findings_detected", "message" => "All enabled analyzer evidence contains no findings." }
255
+ else
256
+ { "code" => "blocking_findings_present", "message" => "#{findings.length} findings require policy FAIL." }
257
+ end
258
+ reasons << { "code" => "optional_evidence_unavailable", "message" => "Optional analyzer evidence was unavailable and was retained as an operational failure." } unless optional_failures.empty?
259
+
260
+ GateResult.new(
261
+ completion_status: "complete",
262
+ gate: gate,
263
+ policy_status: policy_status,
264
+ findings: summaries(findings, blocking: blocking),
265
+ analyzer_results: analyzer_results,
266
+ operational_failures: optional_failures,
267
+ decision_reasons: reasons
268
+ )
269
+ end
270
+
271
+ def incomplete_result(operational_failures:, analyzer_results: [], findings: [], code: "required_evidence_incomplete", message: "Required evidence is incomplete; policy was not evaluated.")
272
+ GateResult.new(
273
+ completion_status: "incomplete",
274
+ gate: "INCOMPLETE",
275
+ policy_status: "not_evaluated",
276
+ findings: summaries(findings, blocking: false),
277
+ analyzer_results: analyzer_results,
278
+ operational_failures: operational_failures,
279
+ decision_reasons: [{ "code" => code, "message" => message }]
280
+ )
281
+ end
282
+
283
+ def interrupted_result(analyzer_results: [], findings: [])
284
+ incomplete_result(
285
+ analyzer_results: analyzer_results,
286
+ findings: findings,
287
+ operational_failures: [{ "code" => "interrupted", "message" => "Execution was interrupted after child-process cleanup." }],
288
+ code: "interrupted",
289
+ message: "Execution was interrupted; no trustworthy gate was produced."
290
+ )
291
+ end
292
+
293
+ def summaries(findings, blocking:)
294
+ findings.sort_by(&:sort_key).map do |finding|
295
+ {
296
+ "id" => finding.id,
297
+ "fingerprint" => finding.fingerprint,
298
+ "severity" => finding.severity,
299
+ "state" => finding.state,
300
+ "blocking" => blocking
301
+ }
302
+ end
303
+ end
304
+ private_class_method :summaries
305
+
306
+ def incomplete_evidence_failure(analyzer, result)
307
+ summary = result.evidence_summary
308
+ return nil unless summary
309
+
310
+ if %w[minitest rspec].include?(analyzer)
311
+ total = summary["tests_total"]
312
+ if total.is_a?(Integer) && total == 0
313
+ return { "code" => "incomplete_evidence", "analyzer" => analyzer, "message" => "#{analyzer} reported zero tests" }
314
+ end
315
+ end
316
+
317
+ if analyzer == "simplecov"
318
+ fresh = summary["stale"]
319
+ if fresh == true || fresh == "true"
320
+ return { "code" => "incomplete_evidence", "analyzer" => analyzer, "message" => "SimpleCov coverage is stale" }
321
+ end
322
+ end
323
+
324
+ nil
325
+ end
326
+ private_class_method :incomplete_evidence_failure
327
+
328
+ def filter_to_changed_scope(findings, git_context)
329
+ changed_paths = Set.new(git_context.changed_files.map { |file| file.path }.compact)
330
+ changed_line_set = git_context.changed_line_set
331
+
332
+ findings.select do |finding|
333
+ path = finding.location.fetch("path")
334
+ next false unless changed_paths.include?(path)
335
+
336
+ if finding.category == "test"
337
+ lines = changed_line_set[path]
338
+ if lines.nil? || lines.empty?
339
+ next true
340
+ end
341
+ line = finding.location["start_line"]
342
+ next true if line.nil?
343
+ next true if lines.include?(line.to_i)
344
+ next true
345
+ end
346
+
347
+ lines = changed_line_set[path]
348
+ next true if lines.nil? || lines.empty?
349
+
350
+ line = finding.location["start_line"]
351
+ next true if line.nil?
352
+
353
+ lines.include?(line.to_i)
354
+ end
355
+ end
356
+ private_class_method :filter_to_changed_scope
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailVerdict
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module RailVerdict
7
+ class Waiver
8
+ SCHEMA_VERSION = "1.0"
9
+
10
+ class IncompatibleError < RailVerdict::Error; end
11
+
12
+ attr_reader :hash
13
+
14
+ def initialize(hash)
15
+ @hash = hash.freeze
16
+ freeze
17
+ end
18
+
19
+ def fingerprint
20
+ @hash.fetch("fingerprint")
21
+ end
22
+
23
+ def to_h
24
+ @hash
25
+ end
26
+
27
+ def self.valid_fingerprint?(value)
28
+ value.is_a?(String) && value.match?(Fingerprint::FINGERPRINT_PATTERN)
29
+ end
30
+
31
+ def self.active?(waiver, clock: Time.now.utc)
32
+ hash = waiver.is_a?(Hash) ? waiver : waiver.to_h
33
+ created_at = parse_time(hash["created_at"] || hash[:created_at])
34
+ expires_at = parse_time(hash["expires_at"] || hash[:expires_at])
35
+ return false unless created_at && expires_at
36
+
37
+ clock >= created_at && clock < expires_at
38
+ end
39
+
40
+ def self.validate_hash(hash)
41
+ errors = SchemaValidator.validate_waiver(hash)
42
+ unless errors.empty?
43
+ raise IncompatibleError, "waiver invalid: #{errors.join('; ')}"
44
+ end
45
+ created_at = parse_time(hash["created_at"])
46
+ expires_at = parse_time(hash["expires_at"])
47
+ raise IncompatibleError, "waiver expires_at must be after created_at" unless created_at && expires_at && expires_at > created_at
48
+ raise IncompatibleError, "waiver timestamps must be UTC (Z)" unless utc_string?(hash["created_at"]) && utc_string?(hash["expires_at"])
49
+
50
+ hash
51
+ end
52
+
53
+ def self.parse_time(value)
54
+ return nil if value.nil?
55
+
56
+ Time.iso8601(value.to_s).utc
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+ private_class_method :parse_time
61
+
62
+ def self.utc_string?(value)
63
+ value.to_s.end_with?("Z")
64
+ end
65
+ private_class_method :utc_string?
66
+ end
67
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module RailVerdict
7
+ class WaiverStore
8
+ DEFAULT_FILENAME = ".railverdict-waivers.json"
9
+
10
+ def self.default_path(repository_root)
11
+ File.join(File.realpath(repository_root), DEFAULT_FILENAME)
12
+ end
13
+
14
+ def self.resolve_path(repository_root:, configuration:, waiver_override: nil)
15
+ root = File.realpath(repository_root)
16
+ if waiver_override && !waiver_override.to_s.strip.empty?
17
+ path = waiver_override.to_s
18
+ return Pathname.new(path).absolute? ? path : File.expand_path(path, root)
19
+ end
20
+ if configuration.respond_to?(:waivers_path) && configuration.waivers_path
21
+ configured = configuration.waivers_path.to_s
22
+ return Pathname.new(configured).absolute? ? configured : File.expand_path(configured, root)
23
+ end
24
+ File.join(root, DEFAULT_FILENAME)
25
+ end
26
+
27
+ def self.read(path)
28
+ unless File.file?(path)
29
+ raise Waiver::IncompatibleError, "waiver file not found at #{path}"
30
+ end
31
+ raw = File.binread(path)
32
+ begin
33
+ data = JSON.parse(raw)
34
+ rescue JSON::ParserError => error
35
+ raise Waiver::IncompatibleError, "waiver file at #{path} is corrupted: #{error.message}; fix the file or remove the waiver"
36
+ end
37
+ errors = SchemaValidator.validate_waivers(data)
38
+ unless errors.empty?
39
+ raise Waiver::IncompatibleError, "waiver file at #{path} is incompatible: #{errors.join('; ')}; fix the file or remove the waiver"
40
+ end
41
+ seen = {}
42
+ data.fetch("waivers").each do |waiver|
43
+ fingerprint = waiver.fetch("fingerprint")
44
+ if seen.key?(fingerprint)
45
+ raise Waiver::IncompatibleError, "waiver file at #{path} contains duplicate fingerprint #{fingerprint}"
46
+ end
47
+ seen[fingerprint] = true
48
+ Waiver.validate_hash(waiver)
49
+ end
50
+ data["waivers"].sort_by { |waiver| waiver.fetch("fingerprint") }.map { |hash| Waiver.new(hash) }
51
+ end
52
+
53
+ def self.read_optional(path)
54
+ return [] unless File.file?(path)
55
+
56
+ read(path)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rail_verdict/version"
4
+ require_relative "rail_verdict/errors"
5
+ require_relative "rail_verdict/schema_validator"
6
+ require_relative "rail_verdict/strict_yaml"
7
+ require_relative "rail_verdict/configuration"
8
+ require_relative "rail_verdict/run_context"
9
+ require_relative "rail_verdict/process_runner"
10
+ require_relative "rail_verdict/fingerprint"
11
+ require_relative "rail_verdict/contracts/finding"
12
+ require_relative "rail_verdict/contracts/analyzer_result"
13
+ require_relative "rail_verdict/analyzers/_shared"
14
+ require_relative "rail_verdict/analyzers/rubocop"
15
+ require_relative "rail_verdict/analyzers/minitest"
16
+ require_relative "rail_verdict/analyzers/rspec"
17
+ require_relative "rail_verdict/analyzers/simplecov"
18
+ require_relative "rail_verdict/analyzers/bundler_audit"
19
+ require_relative "rail_verdict/coverage/changed_line_evaluator"
20
+ require_relative "rail_verdict/check"
21
+ require_relative "rail_verdict/init"
22
+ require_relative "rail_verdict/doctor"
23
+ require_relative "rail_verdict/findings_command"
24
+ require_relative "rail_verdict/baseline"
25
+ require_relative "rail_verdict/comparison"
26
+ require_relative "rail_verdict/waiver"
27
+ require_relative "rail_verdict/waiver_store"
28
+ require_relative "rail_verdict/git/errors"
29
+ require_relative "rail_verdict/git/command"
30
+ require_relative "rail_verdict/git/diff_parser"
31
+ require_relative "rail_verdict/git/context"
32
+ require_relative "rail_verdict/contracts/gate_result"
33
+ require_relative "rail_verdict/rails_context"
34
+ require_relative "rail_verdict/verification/policy"
35
+ require_relative "rail_verdict/reporters/console"
36
+ require_relative "rail_verdict/reporters/json_reporter"
37
+ require_relative "rail_verdict/reporters/sarif"
38
+ require_relative "rail_verdict/reporters/github_annotations"
39
+ require_relative "rail_verdict/cli"
40
+ require_relative "rail_verdict/intelligence"
41
+ require_relative "rail_verdict/intelligence/source_reader"
42
+ require_relative "rail_verdict/intelligence/manifest"
43
+ require_relative "rail_verdict/intelligence/secret_detector"
44
+ require_relative "rail_verdict/intelligence/redactor"
45
+ require_relative "rail_verdict/intelligence/prompt"
46
+ require_relative "rail_verdict/intelligence/budget"
47
+ require_relative "rail_verdict/intelligence/cache"
48
+ require_relative "rail_verdict/intelligence/context_builder"
49
+ require_relative "rail_verdict/intelligence/orchestrator"
50
+ require_relative "rail_verdict/intelligence/provider"
51
+ require_relative "rail_verdict/intelligence/providers/fake_provider"
52
+ require_relative "rail_verdict/intelligence/providers/openai_compat"
53
+ require_relative "rail_verdict/repair/constraints"
54
+ require_relative "rail_verdict/repair/verification_plan"
55
+ require_relative "rail_verdict/repair/boundary"
56
+ require_relative "rail_verdict/repair/diff_context"
57
+ require_relative "rail_verdict/repair/prompt_renderer"
58
+ require_relative "rail_verdict/repair/packet"
59
+ require_relative "rail_verdict/repair/context_assembler"
60
+ require_relative "rail_verdict/repair/verifier"
61
+ require_relative "rail_verdict/repair/command"
62
+
63
+ module RailVerdict
64
+ end
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://railverdict.dev/schemas/ai-analysis/v1.schema.json",
4
+ "title": "RailVerdict AIAnalysis v1",
5
+ "description": "Advisory, bounded AI explanation for a deterministic finding. Never gate authority.",
6
+ "type": "object",
7
+ "required": [
8
+ "schema_version",
9
+ "finding_id",
10
+ "fingerprint",
11
+ "assessment",
12
+ "confidence",
13
+ "summary",
14
+ "provenance"
15
+ ],
16
+ "properties": {
17
+ "schema_version": { "const": "1.0" },
18
+ "finding_id": { "type": "string", "minLength": 1, "maxLength": 256 },
19
+ "fingerprint": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
20
+ "assessment": { "enum": ["likely_cause", "needs_investigation", "uncertain"] },
21
+ "confidence": { "enum": ["low", "medium", "high"] },
22
+ "summary": { "type": "string", "minLength": 1, "maxLength": 2048 },
23
+ "root_cause": { "type": "string", "minLength": 1, "maxLength": 2048 },
24
+ "suggested_fix": { "type": "string", "minLength": 1, "maxLength": 2048 },
25
+ "recommended_tests": {
26
+ "type": "array",
27
+ "maxItems": 20,
28
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
29
+ },
30
+ "evidence_notes": {
31
+ "type": "array",
32
+ "maxItems": 10,
33
+ "items": { "type": "string", "minLength": 1, "maxLength": 1024 }
34
+ },
35
+ "provenance": { "$ref": "#/$defs/provenance" }
36
+ },
37
+ "additionalProperties": false,
38
+ "unevaluatedProperties": false,
39
+ "$defs": {
40
+ "provenance": {
41
+ "type": "object",
42
+ "required": ["provider", "model", "prompt_version", "created_at"],
43
+ "properties": {
44
+ "provider": { "type": "string", "minLength": 1, "maxLength": 128 },
45
+ "model": { "type": "string", "minLength": 1, "maxLength": 128 },
46
+ "prompt_version": { "type": "string", "minLength": 1, "maxLength": 64 },
47
+ "created_at": { "type": "string", "format": "date-time" },
48
+ "schema_version": { "type": "string", "const": "1.0" }
49
+ },
50
+ "additionalProperties": false,
51
+ "unevaluatedProperties": false
52
+ }
53
+ }
54
+ }