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