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