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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- 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,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
|
data/lib/rail_verdict.rb
ADDED
|
@@ -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
|
+
}
|