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