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,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "_shared"
|
|
6
|
+
|
|
7
|
+
module RailVerdict
|
|
8
|
+
module Analyzers
|
|
9
|
+
class RuboCop
|
|
10
|
+
ANALYZER_ID = "rubocop"
|
|
11
|
+
SUPPORTED_VERSIONS = Gem::Requirement.new(">= 1.72", "< 2")
|
|
12
|
+
SEVERITY_MAP = {
|
|
13
|
+
"info" => "info",
|
|
14
|
+
"refactor" => "low",
|
|
15
|
+
"convention" => "low",
|
|
16
|
+
"warning" => "medium",
|
|
17
|
+
"error" => "high",
|
|
18
|
+
"fatal" => "critical"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
Probe = Struct.new(:status, :version, :message, :rubocop_rails_version, :rubocop_config_digest, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
class MalformedOutput < StandardError; end
|
|
24
|
+
|
|
25
|
+
def initialize(command_resolver: nil)
|
|
26
|
+
@command_resolver = command_resolver || method(:default_command)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
|
|
30
|
+
command = @command_resolver.call(repository_root)
|
|
31
|
+
invocation = invocation_for(command, ["--version"])
|
|
32
|
+
result = runner.run(
|
|
33
|
+
command.fetch(:executable),
|
|
34
|
+
invocation.fetch("argv"),
|
|
35
|
+
chdir: repository_root,
|
|
36
|
+
timeout_seconds: timeout_seconds
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return Probe.new(status: "unavailable", message: detail_for(result)) if result.status == :spawn_failed
|
|
40
|
+
return Probe.new(status: "timed_out", message: detail_for(result)) if result.status == :timed_out
|
|
41
|
+
return Probe.new(status: "signaled", message: detail_for(result)) if result.status == :signaled
|
|
42
|
+
return Probe.new(status: "truncated", message: detail_for(result)) if truncated?(result)
|
|
43
|
+
return Probe.new(status: "unavailable", message: detail_for(result)) unless result.exit_code == 0
|
|
44
|
+
|
|
45
|
+
version = parse_version(result.stdout)
|
|
46
|
+
return Probe.new(status: "unsupported", message: "RuboCop version could not be parsed") unless version
|
|
47
|
+
return Probe.new(status: "unsupported", version: version, message: "unsupported RuboCop version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
|
|
48
|
+
|
|
49
|
+
provenance = probe_provenance(repository_root)
|
|
50
|
+
|
|
51
|
+
Probe.new(status: "succeeded", version: version, rubocop_rails_version: provenance[:rubocop_rails_version], rubocop_config_digest: provenance[:rubocop_config_digest])
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
Probe.new(status: "unsupported", message: "RuboCop reported an invalid version")
|
|
54
|
+
rescue KeyError, ArgumentError => error
|
|
55
|
+
Probe.new(status: "malformed", message: bounded_message(error.message))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
59
|
+
command = @command_resolver.call(repository_root)
|
|
60
|
+
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
61
|
+
version_invocation = invocation_for(command, ["--version"])
|
|
62
|
+
|
|
63
|
+
unless probe_result.status == "succeeded"
|
|
64
|
+
return [failure_result(version_invocation, probe_result.status, probe_result.message, tool_version: probe_result.version), []]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
invocation = invocation_for(command, ["--format", "json"])
|
|
68
|
+
result = runner.run(
|
|
69
|
+
command.fetch(:executable),
|
|
70
|
+
invocation.fetch("argv"),
|
|
71
|
+
chdir: repository_root,
|
|
72
|
+
timeout_seconds: timeout_seconds
|
|
73
|
+
)
|
|
74
|
+
tool_version = probe_result.version
|
|
75
|
+
|
|
76
|
+
if result.status == :spawn_failed
|
|
77
|
+
return [failure_result(invocation, "unavailable", detail_for(result), tool_version: tool_version), []]
|
|
78
|
+
end
|
|
79
|
+
if truncated?(result)
|
|
80
|
+
return [failure_result(invocation, "truncated", detail_for(result), tool_version: tool_version), []]
|
|
81
|
+
end
|
|
82
|
+
if result.status == :timed_out
|
|
83
|
+
return [failure_result(invocation, "timed_out", detail_for(result), tool_version: tool_version), []]
|
|
84
|
+
end
|
|
85
|
+
if result.status == :signaled
|
|
86
|
+
return [failure_result(invocation, "signaled", detail_for(result), tool_version: tool_version), []]
|
|
87
|
+
end
|
|
88
|
+
if result.exit_code && result.exit_code >= 2
|
|
89
|
+
return [failure_result(invocation, "failed", execution_message(result), tool_version: tool_version), []]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
parsed = JSON.parse(result.stdout)
|
|
94
|
+
rescue JSON::ParserError => error
|
|
95
|
+
return [failure_result(invocation, "parse_failed", bounded_message(error.message), tool_version: tool_version), []]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
findings = normalize_findings(parsed)
|
|
99
|
+
evidence_summary = build_evidence_summary(probe_result)
|
|
100
|
+
analyzer_result = AnalyzerResult.new(
|
|
101
|
+
analyzer: ANALYZER_ID,
|
|
102
|
+
tool_version: tool_version,
|
|
103
|
+
invocation: invocation,
|
|
104
|
+
execution_status: "succeeded",
|
|
105
|
+
finding_ids: findings.map(&:id),
|
|
106
|
+
evidence_summary: evidence_summary.empty? ? nil : evidence_summary
|
|
107
|
+
)
|
|
108
|
+
[analyzer_result, findings]
|
|
109
|
+
rescue MalformedOutput => error
|
|
110
|
+
[failure_result(invocation || { "executable" => "rubocop", "argv" => [] }, "malformed", bounded_message(error.message)), []]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def default_command(repository_root)
|
|
116
|
+
if File.file?(File.join(repository_root, "Gemfile"))
|
|
117
|
+
{ executable: "bundle", args_prefix: ["exec", "rubocop"] }
|
|
118
|
+
else
|
|
119
|
+
{ executable: "rubocop", args_prefix: [] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def invocation_for(command, arguments)
|
|
124
|
+
Shared.invocation_for(command, arguments)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_version(output)
|
|
128
|
+
Shared.parse_semver(output)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def truncated?(result)
|
|
132
|
+
Shared.truncated?(result)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def detail_for(result)
|
|
136
|
+
Shared.detail_for(result)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def execution_message(result)
|
|
140
|
+
message = result.stderr.to_s.lines.first.to_s.strip
|
|
141
|
+
message = "RuboCop exited with status #{result.exit_code}" if message.empty?
|
|
142
|
+
Shared.bounded_message(message)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def bounded_message(message)
|
|
146
|
+
Shared.bounded_message(message)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_evidence_summary(probe_result)
|
|
150
|
+
summary = {}
|
|
151
|
+
if probe_result.respond_to?(:rubocop_rails_version) && probe_result.rubocop_rails_version
|
|
152
|
+
summary["rubocop_rails_version"] = probe_result.rubocop_rails_version.to_s[0, 64]
|
|
153
|
+
end
|
|
154
|
+
if probe_result.respond_to?(:rubocop_config_digest) && probe_result.rubocop_config_digest
|
|
155
|
+
summary["rubocop_config_digest"] = probe_result.rubocop_config_digest.to_s[0, 64]
|
|
156
|
+
end
|
|
157
|
+
summary
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def probe_provenance(repository_root)
|
|
161
|
+
rubocop_rails_version = parse_gem_version_from_lockfile(repository_root, "rubocop-rails")
|
|
162
|
+
rubocop_config_digest = digest_file(File.join(repository_root, ".rubocop.yml"))
|
|
163
|
+
{ rubocop_rails_version: rubocop_rails_version, rubocop_config_digest: rubocop_config_digest }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_gem_version_from_lockfile(repository_root, gem_name)
|
|
167
|
+
lockfile = File.join(repository_root, "Gemfile.lock")
|
|
168
|
+
return nil unless File.file?(lockfile)
|
|
169
|
+
|
|
170
|
+
text = File.binread(lockfile).force_encoding(Encoding::UTF_8)
|
|
171
|
+
return nil unless text.valid_encoding?
|
|
172
|
+
|
|
173
|
+
text.each_line do |line|
|
|
174
|
+
stripped = line.strip
|
|
175
|
+
next unless stripped.start_with?("#{gem_name} (")
|
|
176
|
+
|
|
177
|
+
match = stripped.match(/\A#{Regexp.escape(gem_name)} \(([^)]+)\)/)
|
|
178
|
+
return match[1] if match
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def digest_file(path)
|
|
186
|
+
return nil unless File.file?(path)
|
|
187
|
+
|
|
188
|
+
require "digest"
|
|
189
|
+
text = File.binread(path)
|
|
190
|
+
Digest::SHA256.hexdigest(text)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def failure_result(invocation, status, message, tool_version: nil)
|
|
196
|
+
Shared.failure_result(
|
|
197
|
+
analyzer_id: ANALYZER_ID,
|
|
198
|
+
invocation: invocation,
|
|
199
|
+
status: status,
|
|
200
|
+
message: message,
|
|
201
|
+
tool_version: tool_version
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def normalize_findings(document)
|
|
206
|
+
raise MalformedOutput, "RuboCop JSON root must be an object" unless document.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
files = document["files"]
|
|
209
|
+
raise MalformedOutput, "RuboCop JSON is missing files array" unless files.is_a?(Array)
|
|
210
|
+
|
|
211
|
+
findings = files.each_with_index.flat_map do |file, file_index|
|
|
212
|
+
normalize_file(file, file_index)
|
|
213
|
+
end
|
|
214
|
+
findings.uniq { |finding| finding.fingerprint }.sort_by(&:sort_key)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def normalize_file(file, file_index)
|
|
218
|
+
raise MalformedOutput, "RuboCop file entry #{file_index} must be an object" unless file.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
path = normalize_path(file["path"])
|
|
221
|
+
offenses = file["offenses"]
|
|
222
|
+
raise MalformedOutput, "RuboCop file #{path} is missing offenses array" unless offenses.is_a?(Array)
|
|
223
|
+
|
|
224
|
+
offenses.each_with_index.map do |offense, offense_index|
|
|
225
|
+
normalize_offense(offense, path, file_index, offense_index)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def normalize_path(path)
|
|
230
|
+
raise MalformedOutput, "RuboCop path must be a string" unless path.is_a?(String) && !path.empty?
|
|
231
|
+
|
|
232
|
+
normalized = path.delete_prefix("./")
|
|
233
|
+
unless normalized.match?(Finding::LOCATION_PATH_PATTERN)
|
|
234
|
+
raise MalformedOutput, "RuboCop emitted unsafe path"
|
|
235
|
+
end
|
|
236
|
+
normalized
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def normalize_offense(offense, path, file_index, offense_index)
|
|
240
|
+
raise MalformedOutput, "RuboCop offense must be an object" unless offense.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
rule_id = offense["cop_name"]
|
|
243
|
+
severity = SEVERITY_MAP.fetch(offense["severity"]) do
|
|
244
|
+
raise MalformedOutput, "RuboCop emitted unknown severity"
|
|
245
|
+
end
|
|
246
|
+
category = rule_id.to_s.split("/", 2).first
|
|
247
|
+
raise MalformedOutput, "RuboCop cop name has no department" unless rule_id.is_a?(String) && rule_id.include?("/") && !category.empty?
|
|
248
|
+
|
|
249
|
+
location = offense["location"]
|
|
250
|
+
raise MalformedOutput, "RuboCop offense location is invalid" unless location.is_a?(Hash)
|
|
251
|
+
|
|
252
|
+
start_line = location["start_line"]
|
|
253
|
+
end_line = location["last_line"]
|
|
254
|
+
unless start_line.is_a?(Integer) && end_line.is_a?(Integer) && start_line >= 1 && end_line >= start_line
|
|
255
|
+
raise MalformedOutput, "RuboCop offense line range is invalid"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
message = offense["message"]
|
|
259
|
+
unless message.is_a?(String) && message.valid_encoding? && !message.empty? && message.bytesize <= 4096
|
|
260
|
+
raise MalformedOutput, "RuboCop offense message is invalid"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
fingerprint = Finding.fingerprint_for(
|
|
264
|
+
analyzer: ANALYZER_ID,
|
|
265
|
+
rule_id: rule_id,
|
|
266
|
+
path: path,
|
|
267
|
+
message: message
|
|
268
|
+
)
|
|
269
|
+
Finding.new(
|
|
270
|
+
fingerprint: fingerprint,
|
|
271
|
+
origin: "deterministic",
|
|
272
|
+
analyzer: ANALYZER_ID,
|
|
273
|
+
rule_id: rule_id,
|
|
274
|
+
category: category.downcase,
|
|
275
|
+
severity: severity,
|
|
276
|
+
confidence: "high",
|
|
277
|
+
state: "observed",
|
|
278
|
+
evidence_ref: "native:rubocop:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
|
|
279
|
+
location: { "path" => path, "start_line" => start_line, "end_line" => end_line },
|
|
280
|
+
message: message
|
|
281
|
+
)
|
|
282
|
+
rescue KeyError
|
|
283
|
+
raise MalformedOutput, "RuboCop offense has unknown severity"
|
|
284
|
+
rescue ArgumentError => error
|
|
285
|
+
raise MalformedOutput, "RuboCop offense #{file_index}:#{offense_index} is malformed: #{error.message}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "_shared"
|
|
6
|
+
|
|
7
|
+
module RailVerdict
|
|
8
|
+
module Analyzers
|
|
9
|
+
class SimpleCov
|
|
10
|
+
ANALYZER_ID = "simplecov"
|
|
11
|
+
DEFAULT_COVERAGE_PATH = "coverage/coverage.json"
|
|
12
|
+
DEFAULT_FRESHNESS_WINDOW_SECONDS = 86_400
|
|
13
|
+
SUPPORTED_VERSION_RANGE = Gem::Requirement.new(">= 1", "< 2")
|
|
14
|
+
|
|
15
|
+
Probe = Struct.new(:status, :version, :message, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
class MalformedOutput < StandardError; end
|
|
18
|
+
|
|
19
|
+
def probe(repository_root, runner: nil, timeout_seconds: 5.0)
|
|
20
|
+
config = load_config(repository_root)
|
|
21
|
+
coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
|
|
22
|
+
full_path = File.expand_path(coverage_path, repository_root)
|
|
23
|
+
return Probe.new(status: "unavailable", message: "coverage file is absent: #{coverage_path}") unless File.file?(full_path)
|
|
24
|
+
|
|
25
|
+
text = File.binread(full_path).force_encoding(Encoding::UTF_8)
|
|
26
|
+
return Probe.new(status: "parse_failed", message: "coverage file is not valid UTF-8") unless text.valid_encoding?
|
|
27
|
+
|
|
28
|
+
document = JSON.parse(text)
|
|
29
|
+
return Probe.new(status: "unsupported", message: "coverage document has no version") unless document["version"].is_a?(String)
|
|
30
|
+
|
|
31
|
+
version = document["version"]
|
|
32
|
+
return Probe.new(status: "unsupported", message: "unsupported SimpleCov version #{version}") unless version.start_with?("1")
|
|
33
|
+
|
|
34
|
+
Probe.new(status: "succeeded", version: version)
|
|
35
|
+
rescue JSON::ParserError => error
|
|
36
|
+
Probe.new(status: "parse_failed", message: Shared.bounded_message(error.message))
|
|
37
|
+
rescue MalformedOutput => error
|
|
38
|
+
Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
|
|
39
|
+
rescue SystemCallError, ArgumentError => error
|
|
40
|
+
Probe.new(status: "unavailable", message: Shared.bounded_message(error.message))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run(repository_root, runner: nil, timeout_seconds: 10.0, probe_result: nil, configuration: nil)
|
|
44
|
+
config = load_config(repository_root, configuration: configuration)
|
|
45
|
+
coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
|
|
46
|
+
freshness_window = config.fetch("freshness_window_seconds") { DEFAULT_FRESHNESS_WINDOW_SECONDS }
|
|
47
|
+
invocation = { "executable" => "simplecov", "argv" => ["read", coverage_path] }
|
|
48
|
+
full_path = File.expand_path(coverage_path, repository_root)
|
|
49
|
+
|
|
50
|
+
unless File.file?(full_path)
|
|
51
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{coverage_path}"), []]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
bytes = File.binread(full_path)
|
|
56
|
+
rescue SystemCallError => error
|
|
57
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: Shared.bounded_message(error.message)), []]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if bytes.bytesize > 8 * 1024 * 1024
|
|
61
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "coverage file exceeds 8 MiB"), []]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
65
|
+
unless text.valid_encoding?
|
|
66
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "coverage file is not valid UTF-8"), []]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
document = JSON.parse(text)
|
|
71
|
+
rescue JSON::ParserError => error
|
|
72
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message)), []]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
version = document["version"].to_s
|
|
76
|
+
probe_result ||= probe(repository_root, timeout_seconds: timeout_seconds)
|
|
77
|
+
unless version.start_with?("1")
|
|
78
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: probe_result.version || version), []]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
errors = validate_coverage_schema(document)
|
|
82
|
+
unless errors.empty?
|
|
83
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: probe_result.version || version), []]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
mtime = File.mtime(full_path)
|
|
87
|
+
stale = (Time.now - mtime) > freshness_window
|
|
88
|
+
|
|
89
|
+
covered, executable = coverage_totals(document)
|
|
90
|
+
|
|
91
|
+
summary = {
|
|
92
|
+
"covered_lines" => covered,
|
|
93
|
+
"executable_lines" => executable,
|
|
94
|
+
"percent" => executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2),
|
|
95
|
+
"stale" => stale,
|
|
96
|
+
"freshness_window_seconds" => Integer(freshness_window),
|
|
97
|
+
"coverage_path" => coverage_path.to_s[0, 512],
|
|
98
|
+
"files" => document["files"]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
result = AnalyzerResult.new(
|
|
102
|
+
analyzer: ANALYZER_ID,
|
|
103
|
+
tool_version: version,
|
|
104
|
+
invocation: invocation,
|
|
105
|
+
execution_status: "succeeded",
|
|
106
|
+
finding_ids: [],
|
|
107
|
+
evidence_summary: summary
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
[result, []]
|
|
111
|
+
rescue StandardError => error
|
|
112
|
+
[Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: { "executable" => "simplecov", "argv" => ["read", DEFAULT_COVERAGE_PATH] }, status: "malformed", message: Shared.bounded_message(error.message)), []]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def load_config(repository_root, configuration: nil)
|
|
118
|
+
if configuration
|
|
119
|
+
sel = configuration.analyzers["simplecov"]
|
|
120
|
+
return {} unless sel
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"coverage_path" => sel["coverage_path"],
|
|
124
|
+
"freshness_window_seconds" => sel["freshness_window_seconds"]
|
|
125
|
+
}.compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
path = File.join(repository_root, ".railverdict.yml")
|
|
129
|
+
return {} unless File.file?(path)
|
|
130
|
+
|
|
131
|
+
bytes = File.binread(path) rescue nil
|
|
132
|
+
return {} unless bytes
|
|
133
|
+
|
|
134
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
135
|
+
return {} unless text.valid_encoding?
|
|
136
|
+
|
|
137
|
+
data = RailVerdict::StrictYaml.parse(text, path) rescue nil
|
|
138
|
+
return {} unless data.is_a?(Hash) && data["analyzers"].is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
sel = data["analyzers"]["simplecov"]
|
|
141
|
+
return {} unless sel.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
{ "coverage_path" => sel["coverage_path"], "freshness_window_seconds" => sel["freshness_window_seconds"] }.compact
|
|
144
|
+
rescue StandardError
|
|
145
|
+
{}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_coverage_schema(document)
|
|
149
|
+
schema_path = File.expand_path("../../../schemas/coverage-v1.schema.json", __dir__)
|
|
150
|
+
schema = JSON.parse(File.read(schema_path))
|
|
151
|
+
JSONSchemer.schema(schema).validate(document).map { |err|
|
|
152
|
+
pointer = err["data_pointer"].to_s
|
|
153
|
+
location = pointer.empty? ? "$" : "$#{pointer.gsub('/', '.')}"
|
|
154
|
+
"#{location}: #{err["error"]}"
|
|
155
|
+
}
|
|
156
|
+
rescue StandardError => error
|
|
157
|
+
["schema validation error: #{error.message}"]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def coverage_totals(document)
|
|
161
|
+
covered = 0
|
|
162
|
+
executable = 0
|
|
163
|
+
document.fetch("files", []).each do |file|
|
|
164
|
+
lines = file.dig("coverage", "lines")
|
|
165
|
+
next unless lines.is_a?(Array)
|
|
166
|
+
|
|
167
|
+
lines.each do |hit|
|
|
168
|
+
next if hit.nil?
|
|
169
|
+
|
|
170
|
+
executable += 1
|
|
171
|
+
covered += 1 if hit.to_i > 0
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
[covered, executable]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "tempfile"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
module RailVerdict
|
|
12
|
+
class Baseline
|
|
13
|
+
SCHEMA_VERSION = "1.0"
|
|
14
|
+
DEFAULT_FILENAME = ".railverdict-baseline.json"
|
|
15
|
+
|
|
16
|
+
class IncompatibleError < RailVerdict::Error; end
|
|
17
|
+
|
|
18
|
+
attr_reader :hash, :entries, :created_at, :configuration_digest, :analyzer_versions
|
|
19
|
+
|
|
20
|
+
def initialize(hash)
|
|
21
|
+
@hash = hash.freeze
|
|
22
|
+
@entries = hash.fetch("entries").freeze
|
|
23
|
+
@created_at = hash.fetch("created_at").freeze
|
|
24
|
+
@configuration_digest = hash.fetch("configuration_digest").freeze
|
|
25
|
+
@analyzer_versions = hash.fetch("analyzer_versions").freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
@hash
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fingerprint_set
|
|
34
|
+
@fingerprint_set ||= Set.new(@entries.map { |entry| entry.fetch("fingerprint") }).freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def entry_by_fingerprint
|
|
38
|
+
@entry_by_fingerprint ||= @entries.to_h { |entry| [entry.fetch("fingerprint"), entry] }.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.create(findings:, configuration:, analyzer_versions:, clock: Time.now.utc)
|
|
42
|
+
created_at = clock.utc.iso8601
|
|
43
|
+
entries = findings.sort_by(&:sort_key).map do |finding|
|
|
44
|
+
{
|
|
45
|
+
"fingerprint" => finding.fingerprint,
|
|
46
|
+
"analyzer" => finding.analyzer,
|
|
47
|
+
"rule_id" => finding.rule_id,
|
|
48
|
+
"path" => finding.location.fetch("path"),
|
|
49
|
+
"message" => finding.message,
|
|
50
|
+
"first_seen" => created_at
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
entries = entries.uniq { |entry| entry.fetch("fingerprint") }.sort_by { |entry| entry.fetch("fingerprint") }
|
|
54
|
+
hash = {
|
|
55
|
+
"schema_version" => SCHEMA_VERSION,
|
|
56
|
+
"fingerprint_version" => Fingerprint::VERSION,
|
|
57
|
+
"algorithm" => Fingerprint::ALGORITHM,
|
|
58
|
+
"payload_schema" => Fingerprint::PAYLOAD_SCHEMA,
|
|
59
|
+
"created_at" => created_at,
|
|
60
|
+
"created_by" => "railverdict #{RailVerdict::VERSION}",
|
|
61
|
+
"configuration_digest" => configuration.digest,
|
|
62
|
+
"analyzer_versions" => analyzer_versions.transform_keys(&:to_s).sort.to_h,
|
|
63
|
+
"entries" => entries
|
|
64
|
+
}
|
|
65
|
+
errors = SchemaValidator.validate_baseline(hash)
|
|
66
|
+
raise IncompatibleError, "baseline validation failed: #{errors.join('; ')}" unless errors.empty?
|
|
67
|
+
|
|
68
|
+
new(hash)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.default_path(repository_root)
|
|
72
|
+
File.join(File.realpath(repository_root), DEFAULT_FILENAME)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.resolve_path(repository_root:, configuration:, output_override: nil)
|
|
76
|
+
root = File.realpath(repository_root)
|
|
77
|
+
if output_override && !output_override.to_s.strip.empty?
|
|
78
|
+
path = output_override.to_s
|
|
79
|
+
return Pathname.new(path).absolute? ? path : File.expand_path(path, root)
|
|
80
|
+
end
|
|
81
|
+
if configuration.respond_to?(:baseline_path) && configuration.baseline_path
|
|
82
|
+
configured = configuration.baseline_path.to_s
|
|
83
|
+
return Pathname.new(configured).absolute? ? configured : File.expand_path(configured, root)
|
|
84
|
+
end
|
|
85
|
+
File.join(root, DEFAULT_FILENAME)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.write(path:, baseline:, force: false)
|
|
89
|
+
if File.exist?(path) && !force
|
|
90
|
+
raise UsageError, "baseline already exists at #{path}; use --force to overwrite"
|
|
91
|
+
end
|
|
92
|
+
dir = File.dirname(File.expand_path(path))
|
|
93
|
+
FileUtils.mkdir_p(dir)
|
|
94
|
+
tmp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
|
|
95
|
+
begin
|
|
96
|
+
File.open(tmp, "wb", 0o600) do |file|
|
|
97
|
+
file.write(JSON.pretty_generate(baseline.to_h) + "\n")
|
|
98
|
+
file.flush
|
|
99
|
+
file.fsync
|
|
100
|
+
end
|
|
101
|
+
errors = SchemaValidator.validate_baseline(baseline.to_h)
|
|
102
|
+
raise IncompatibleError, "baseline validation failed: #{errors.join('; ')}" unless errors.empty?
|
|
103
|
+
|
|
104
|
+
File.rename(tmp, path)
|
|
105
|
+
begin
|
|
106
|
+
dir_fd = File.open(dir, "r")
|
|
107
|
+
dir_fd.fsync
|
|
108
|
+
dir_fd.close
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
ensure
|
|
113
|
+
File.unlink(tmp) if File.exist?(tmp)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.read(path)
|
|
118
|
+
unless File.file?(path)
|
|
119
|
+
raise IncompatibleError, "baseline not found at #{path}"
|
|
120
|
+
end
|
|
121
|
+
raw = File.binread(path)
|
|
122
|
+
begin
|
|
123
|
+
data = JSON.parse(raw)
|
|
124
|
+
rescue JSON::ParserError => error
|
|
125
|
+
raise IncompatibleError, "baseline at #{path} is corrupted: #{error.message}; re-create with `railverdict baseline create`"
|
|
126
|
+
end
|
|
127
|
+
errors = SchemaValidator.validate_baseline(data)
|
|
128
|
+
unless errors.empty?
|
|
129
|
+
raise IncompatibleError, "baseline at #{path} is incompatible: #{errors.join('; ')}; re-create with `railverdict baseline create`"
|
|
130
|
+
end
|
|
131
|
+
fingerprints = data.fetch("entries").map { |entry| entry.fetch("fingerprint") }
|
|
132
|
+
if fingerprints.uniq.length != fingerprints.length
|
|
133
|
+
raise IncompatibleError, "baseline at #{path} contains duplicate fingerprints; re-create with `railverdict baseline create`"
|
|
134
|
+
end
|
|
135
|
+
new(data)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.read_optional(path)
|
|
139
|
+
return nil unless File.file?(path)
|
|
140
|
+
|
|
141
|
+
read(path)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.validate_hash(hash)
|
|
145
|
+
errors = SchemaValidator.validate_baseline(hash)
|
|
146
|
+
raise IncompatibleError, errors.join("; ") unless errors.empty?
|
|
147
|
+
|
|
148
|
+
hash
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|