rail_verdict 1.1.0 → 1.2.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 +4 -4
- data/README.md +16 -0
- data/lib/rail_verdict/analyzers/_shared.rb +53 -3
- data/lib/rail_verdict/analyzers/bundler_audit.rb +3 -4
- data/lib/rail_verdict/analyzers/minitest.rb +3 -7
- data/lib/rail_verdict/analyzers/rspec.rb +22 -5
- data/lib/rail_verdict/analyzers/rubocop.rb +14 -6
- data/lib/rail_verdict/analyzers/simplecov.rb +197 -15
- data/lib/rail_verdict/baseline.rb +5 -1
- data/lib/rail_verdict/canonical_json.rb +31 -0
- data/lib/rail_verdict/check.rb +99 -7
- data/lib/rail_verdict/cli.rb +234 -1
- data/lib/rail_verdict/contracts/analyzer_result.rb +6 -2
- data/lib/rail_verdict/mcp/cache.rb +102 -169
- data/lib/rail_verdict/mcp/repository_root.rb +2 -8
- data/lib/rail_verdict/mcp/server.rb +5 -1
- data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +88 -0
- data/lib/rail_verdict/mcp/tools/get_verification_receipt.rb +88 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +31 -2
- data/lib/rail_verdict/path_safety.rb +39 -0
- data/lib/rail_verdict/pr_intelligence.rb +25 -0
- data/lib/rail_verdict/process_runner.rb +29 -12
- data/lib/rail_verdict/receipt.rb +341 -0
- data/lib/rail_verdict/repository_state.rb +305 -0
- data/lib/rail_verdict/schema_validator.rb +10 -0
- data/lib/rail_verdict/version.rb +1 -1
- data/lib/rail_verdict.rb +4 -0
- data/schemas/receipt-validation-v1.schema.json +41 -0
- data/schemas/verification-receipt-v1.schema.json +167 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd73e695fc399f10d5e9dcde4f8f04294948160821c9f256234e79d6345bb8e0
|
|
4
|
+
data.tar.gz: dfb8a4b7ae5613ab75ef864dfbca00d3fa7c6241ad47937241e524aa5724b7ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9faf8ca3b218e240f87800727704caa02c588d5e88fa8a614a15f0862928ad4ccd36725bbd66300d89330c9994a4f16c2f7d63cd14df2c79899b8d4380bb95de
|
|
7
|
+
data.tar.gz: 05daa4f456785c207522f9c8be75066ee0a7c112ee5681b7ea311151b3e0b2180fee7008ff97e5b79f12f439751c115a43cefc7550a1a6e16c1637296d9415c5
|
data/README.md
CHANGED
|
@@ -334,6 +334,22 @@ exit code `2`.
|
|
|
334
334
|
- **Changed-Line Coverage:** Evaluates whether newly added or modified executable lines are covered by tests;
|
|
335
335
|
- **Fail-Closed Git Boundary:** If the base revision is missing or repository history is shallow, RailVerdict returns `INCOMPLETE` (exit code 2) rather than guessing or silently passing.
|
|
336
336
|
|
|
337
|
+
### Verification Receipts (1.2)
|
|
338
|
+
|
|
339
|
+
Verification is only meaningful for the exact state that was verified. RailVerdict 1.2 binds every guarded verification to a deterministic **Repository State Identity** (HEAD + Git index snapshot + worktree delta with content hashes + configuration/baseline/waiver digests) and issues a machine-readable **Verification Receipt**:
|
|
340
|
+
|
|
341
|
+
```console
|
|
342
|
+
$ railverdict receipt create --format json > receipt.json
|
|
343
|
+
$ railverdict receipt verify receipt.json --format json
|
|
344
|
+
{"schema_version":"1.0","status":"fresh","reasons":[],"gate":"PASS", ...}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Edit anything afterwards — source, staged index, untracked files, config, baseline, waivers — and the same receipt reports `stale` with a deterministic reason (`head_changed`, `index_changed`, `worktree_changed`, `configuration_changed`, `baseline_changed`, `waivers_changed`). If the repository mutates while analyzers run, receipt issuance fails closed with `repository_changed_during_verification`. Receipts exist for PASS, FAIL, and INCOMPLETE alike; they are deterministic integrity records, not signed attestations — a trusted CI remains the trust anchor when forgery is in scope. Coding agents follow the completion protocol in [docs/agent-verification.md](docs/agent-verification.md).
|
|
348
|
+
|
|
349
|
+
```
|
|
350
|
+
Deterministic Verification → PR Intelligence → Verification Receipt → Agent Verification Protocol
|
|
351
|
+
```
|
|
352
|
+
|
|
337
353
|
---
|
|
338
354
|
|
|
339
355
|
## Supported Analyzers
|
|
@@ -12,7 +12,15 @@ module RailVerdict
|
|
|
12
12
|
def detail_for(run_result)
|
|
13
13
|
detail = run_result.detail.to_s
|
|
14
14
|
stderr = run_result.stderr.to_s.lines.first.to_s.strip
|
|
15
|
-
|
|
15
|
+
combined = [detail, stderr].reject(&:empty?).join(": ")
|
|
16
|
+
if combined.empty?
|
|
17
|
+
combined = if run_result.status == :exited
|
|
18
|
+
"analyzer exited with status #{run_result.exit_code} and produced no diagnostic output"
|
|
19
|
+
else
|
|
20
|
+
"analyzer produced no diagnostic output"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
bounded_message(combined)
|
|
16
24
|
end
|
|
17
25
|
|
|
18
26
|
def execution_message(run_result)
|
|
@@ -40,15 +48,57 @@ module RailVerdict
|
|
|
40
48
|
|
|
41
49
|
def failure_result(analyzer_id:, invocation:, status:, message:, tool_version: nil)
|
|
42
50
|
status = status.to_s
|
|
51
|
+
bounded = bounded_message(message.to_s)
|
|
52
|
+
bounded = "#{status}: analyzer produced no diagnostic output" if bounded.strip.empty?
|
|
53
|
+
normalized_version = normalize_tool_version(tool_version)
|
|
43
54
|
AnalyzerResult.new(
|
|
44
55
|
analyzer: analyzer_id,
|
|
45
|
-
tool_version:
|
|
56
|
+
tool_version: normalized_version,
|
|
46
57
|
invocation: invocation,
|
|
47
58
|
execution_status: status,
|
|
48
59
|
finding_ids: [],
|
|
49
|
-
failure: { "code" => status, "message" =>
|
|
60
|
+
failure: { "code" => status, "message" => bounded }
|
|
50
61
|
)
|
|
51
62
|
end
|
|
63
|
+
|
|
64
|
+
def normalize_tool_version(version)
|
|
65
|
+
return nil if version.nil?
|
|
66
|
+
str = version.to_s.strip
|
|
67
|
+
return nil if str.empty?
|
|
68
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").scrub("?")[0, 128]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def canonical_tool_version(version)
|
|
72
|
+
normalized = normalize_tool_version(version)
|
|
73
|
+
normalized.nil? || normalized.empty? ? "unknown" : normalized
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Canonical finding message normalization — single path for all analyzers.
|
|
77
|
+
# Guarantees deterministic, bounded, valid non-empty UTF-8.
|
|
78
|
+
FALLBACK_MESSAGE_SUFFIX = "reported a finding without a message"
|
|
79
|
+
|
|
80
|
+
def normalize_finding_message(analyzer_id, raw_message)
|
|
81
|
+
raw = raw_message.to_s.dup
|
|
82
|
+
# Handle invalid UTF-8, null bytes
|
|
83
|
+
raw = raw.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
84
|
+
raw = raw.scrub("\uFFFD")
|
|
85
|
+
raw = raw.delete("\u0000")
|
|
86
|
+
# Strip ANSI escape sequences
|
|
87
|
+
raw = raw.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
|
|
88
|
+
raw = raw.gsub(/\e\][^\a]*\a/, "")
|
|
89
|
+
raw = raw.gsub(/\e\(B/, "")
|
|
90
|
+
# Remove control characters except tab/newline then normalize whitespace
|
|
91
|
+
raw = raw.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "")
|
|
92
|
+
raw = raw.strip
|
|
93
|
+
# Collapse whitespace including tabs/newlines
|
|
94
|
+
raw = raw.gsub(/\s+/, " ").strip
|
|
95
|
+
if raw.empty?
|
|
96
|
+
"#{analyzer_id} #{FALLBACK_MESSAGE_SUFFIX}"
|
|
97
|
+
else
|
|
98
|
+
raw = raw.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD").scrub("\uFFFD")
|
|
99
|
+
raw.bytesize > 4096 ? raw.byteslice(0, 4096).scrub("\uFFFD").strip : raw
|
|
100
|
+
end
|
|
101
|
+
end
|
|
52
102
|
end
|
|
53
103
|
end
|
|
54
104
|
end
|
|
@@ -47,7 +47,7 @@ module RailVerdict
|
|
|
47
47
|
Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
50
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
|
|
51
51
|
command = @command_resolver.call(repository_root)
|
|
52
52
|
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
53
53
|
version_invocation = Shared.invocation_for(command, ["version"])
|
|
@@ -143,9 +143,8 @@ module RailVerdict
|
|
|
143
143
|
gem_name = "unknown" if gem_name.to_s.empty?
|
|
144
144
|
|
|
145
145
|
severity = map_severity(advisory["criticality"] || advisory["severity"] || entry["criticality"] || entry["severity"])
|
|
146
|
-
|
|
147
|
-
message =
|
|
148
|
-
message = "vulnerability in #{gem_name}" if message.empty?
|
|
146
|
+
raw_msg = advisory["title"] || advisory["description"] || entry["title"] || entry["description"] || "vulnerability in #{gem_name}"
|
|
147
|
+
message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
|
|
149
148
|
|
|
150
149
|
rule_id = "bundler_audit/advisory:#{id}"
|
|
151
150
|
category = "dependency"
|
|
@@ -47,7 +47,7 @@ module RailVerdict
|
|
|
47
47
|
Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
50
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
|
|
51
51
|
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
52
52
|
|
|
53
53
|
unless probe_result.status == "succeeded"
|
|
@@ -248,12 +248,8 @@ module RailVerdict
|
|
|
248
248
|
severity = status == "errored" ? "critical" : "high"
|
|
249
249
|
category = "test"
|
|
250
250
|
rule_id = "minitest/test:#{class_name}##{method_name}"
|
|
251
|
-
|
|
252
|
-
message =
|
|
253
|
-
message = "test failed: #{method_name}" if message.empty?
|
|
254
|
-
unless message.is_a?(String) && message.valid_encoding? && !message.empty? && message.bytesize <= 4096
|
|
255
|
-
raise MalformedOutput, "Minitest finding message is invalid"
|
|
256
|
-
end
|
|
251
|
+
raw_msg = test["failure_message"] || test["method_name"] || "test failed: #{method_name}"
|
|
252
|
+
message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
|
|
257
253
|
|
|
258
254
|
path = normalize_path(test["file"], class_name)
|
|
259
255
|
start_line = test["line"]
|
|
@@ -45,7 +45,7 @@ module RailVerdict
|
|
|
45
45
|
Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
48
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
|
|
49
49
|
command = @command_resolver.call(repository_root)
|
|
50
50
|
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
51
51
|
version_invocation = Shared.invocation_for(command, ["--version"])
|
|
@@ -55,11 +55,14 @@ module RailVerdict
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
invocation = Shared.invocation_for(command, ["--format", "json"])
|
|
58
|
+
# Real-world large RSpec suites can exceed 4 MiB; use bounded but larger capture.
|
|
59
|
+
max_stdout = resolve_stdout_limit(configuration, repository_root, 16 * 1024 * 1024)
|
|
58
60
|
result = runner.run(
|
|
59
61
|
command.fetch(:executable),
|
|
60
62
|
invocation.fetch("argv"),
|
|
61
63
|
chdir: repository_root,
|
|
62
|
-
timeout_seconds: timeout_seconds
|
|
64
|
+
timeout_seconds: timeout_seconds,
|
|
65
|
+
max_stdout_bytes: max_stdout
|
|
63
66
|
)
|
|
64
67
|
tool_version = probe_result.version
|
|
65
68
|
|
|
@@ -136,9 +139,8 @@ module RailVerdict
|
|
|
136
139
|
return nil
|
|
137
140
|
end
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
message =
|
|
141
|
-
message = "rspec example failed" if message.empty?
|
|
142
|
+
raw_msg = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || nil
|
|
143
|
+
message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg.nil? || raw_msg.to_s.strip.empty? ? "rspec example failed" : raw_msg)
|
|
142
144
|
|
|
143
145
|
rule_id = "rspec/example:#{example['id'] || id_for(example, index)}"
|
|
144
146
|
path = normalize_path(example["file_path"] || example["file"] || "spec/unknown_spec.rb")
|
|
@@ -269,6 +271,21 @@ module RailVerdict
|
|
|
269
271
|
rescue ArgumentError, TypeError
|
|
270
272
|
raise MalformedOutput, "RSpec summary fields have invalid types"
|
|
271
273
|
end
|
|
274
|
+
|
|
275
|
+
def resolve_stdout_limit(configuration, repository_root, default_bytes)
|
|
276
|
+
# Prefer explicit per-analyzer config if present (future-compatible), else default.
|
|
277
|
+
raw_limit = nil
|
|
278
|
+
if configuration
|
|
279
|
+
sel = configuration.analyzers[ANALYZER_ID] rescue nil
|
|
280
|
+
raw_limit = sel && sel["output_limit_bytes"]
|
|
281
|
+
end
|
|
282
|
+
raw_limit ||= default_bytes
|
|
283
|
+
# Clamp to safe ceiling
|
|
284
|
+
limit = Integer(raw_limit) rescue default_bytes
|
|
285
|
+
limit = default_bytes if limit <= 0
|
|
286
|
+
max = RailVerdict::ProcessRunner::MAX_SAFE_STDOUT_BYTES
|
|
287
|
+
[[limit, max].min, 1024].max
|
|
288
|
+
end
|
|
272
289
|
end
|
|
273
290
|
end
|
|
274
291
|
end
|
|
@@ -55,7 +55,7 @@ module RailVerdict
|
|
|
55
55
|
Probe.new(status: "malformed", message: bounded_message(error.message))
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
|
|
58
|
+
def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
|
|
59
59
|
command = @command_resolver.call(repository_root)
|
|
60
60
|
probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
|
|
61
61
|
version_invocation = invocation_for(command, ["--version"])
|
|
@@ -65,11 +65,13 @@ module RailVerdict
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
invocation = invocation_for(command, ["--format", "json"])
|
|
68
|
+
max_stdout = resolve_stdout_limit(configuration, 8 * 1024 * 1024)
|
|
68
69
|
result = runner.run(
|
|
69
70
|
command.fetch(:executable),
|
|
70
71
|
invocation.fetch("argv"),
|
|
71
72
|
chdir: repository_root,
|
|
72
|
-
timeout_seconds: timeout_seconds
|
|
73
|
+
timeout_seconds: timeout_seconds,
|
|
74
|
+
max_stdout_bytes: max_stdout
|
|
73
75
|
)
|
|
74
76
|
tool_version = probe_result.version
|
|
75
77
|
|
|
@@ -255,10 +257,8 @@ module RailVerdict
|
|
|
255
257
|
raise MalformedOutput, "RuboCop offense line range is invalid"
|
|
256
258
|
end
|
|
257
259
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
raise MalformedOutput, "RuboCop offense message is invalid"
|
|
261
|
-
end
|
|
260
|
+
raw_msg = offense["message"]
|
|
261
|
+
message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
|
|
262
262
|
|
|
263
263
|
fingerprint = Finding.fingerprint_for(
|
|
264
264
|
analyzer: ANALYZER_ID,
|
|
@@ -284,6 +284,14 @@ module RailVerdict
|
|
|
284
284
|
rescue ArgumentError => error
|
|
285
285
|
raise MalformedOutput, "RuboCop offense #{file_index}:#{offense_index} is malformed: #{error.message}"
|
|
286
286
|
end
|
|
287
|
+
|
|
288
|
+
def resolve_stdout_limit(configuration, default_bytes)
|
|
289
|
+
raw = configuration && configuration.analyzers["rubocop"] && configuration.analyzers["rubocop"]["output_limit_bytes"]
|
|
290
|
+
raw ||= default_bytes
|
|
291
|
+
limit = Integer(raw) rescue default_bytes
|
|
292
|
+
limit = default_bytes if limit <= 0
|
|
293
|
+
[[limit, RailVerdict::ProcessRunner::MAX_SAFE_STDOUT_BYTES].min, 1024].max
|
|
294
|
+
end
|
|
287
295
|
end
|
|
288
296
|
end
|
|
289
297
|
end
|
|
@@ -26,12 +26,21 @@ module RailVerdict
|
|
|
26
26
|
return Probe.new(status: "parse_failed", message: "coverage file is not valid UTF-8") unless text.valid_encoding?
|
|
27
27
|
|
|
28
28
|
document = JSON.parse(text)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
detected = detect_format(document)
|
|
30
|
+
case detected
|
|
31
|
+
when :coverage_v1
|
|
32
|
+
version = document["version"].to_s
|
|
33
|
+
return Probe.new(status: "unsupported", message: "unsupported SimpleCov version #{version}") unless version.start_with?("1")
|
|
34
|
+
Probe.new(status: "succeeded", version: version)
|
|
35
|
+
when :native_simplecov
|
|
36
|
+
version = extract_native_version(document)
|
|
37
|
+
# Accept any native simplecov version >=0.20 with coverage hash; version check is permissive.
|
|
38
|
+
Probe.new(status: "succeeded", version: version)
|
|
39
|
+
when :unsupported
|
|
40
|
+
Probe.new(status: "unsupported", message: "unsupported coverage document shape")
|
|
41
|
+
else
|
|
42
|
+
Probe.new(status: "malformed", message: "coverage document is malformed")
|
|
43
|
+
end
|
|
35
44
|
rescue JSON::ParserError => error
|
|
36
45
|
Probe.new(status: "parse_failed", message: Shared.bounded_message(error.message))
|
|
37
46
|
rescue MalformedOutput => error
|
|
@@ -72,21 +81,47 @@ module RailVerdict
|
|
|
72
81
|
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message)), []]
|
|
73
82
|
end
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
detected = detect_format(document)
|
|
85
|
+
if detected == :unsupported
|
|
86
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported coverage document shape", tool_version: probe_result&.version), []]
|
|
87
|
+
elsif detected == :malformed
|
|
88
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "coverage document is malformed", tool_version: probe_result&.version), []]
|
|
79
89
|
end
|
|
80
90
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
normalized = nil
|
|
92
|
+
version = nil
|
|
93
|
+
if detected == :coverage_v1
|
|
94
|
+
version = document["version"].to_s
|
|
95
|
+
probe_result ||= probe(repository_root, timeout_seconds: timeout_seconds)
|
|
96
|
+
unless version.start_with?("1")
|
|
97
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: probe_result.version || version), []]
|
|
98
|
+
end
|
|
99
|
+
errors = validate_coverage_schema(document)
|
|
100
|
+
unless errors.empty?
|
|
101
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: probe_result.version || version), []]
|
|
102
|
+
end
|
|
103
|
+
normalized = normalize_coverage_v1(document)
|
|
104
|
+
version = document["version"].to_s
|
|
105
|
+
elsif detected == :native_simplecov
|
|
106
|
+
begin
|
|
107
|
+
normalized = normalize_native_document(document, repository_root)
|
|
108
|
+
version = extract_native_version(document)
|
|
109
|
+
rescue MalformedOutput => error
|
|
110
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: probe_result&.version), []]
|
|
111
|
+
end
|
|
112
|
+
# Validate normalized shape via same schema
|
|
113
|
+
errors = validate_coverage_schema(normalized)
|
|
114
|
+
unless errors.empty?
|
|
115
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: version), []]
|
|
116
|
+
end
|
|
117
|
+
else
|
|
118
|
+
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "coverage document shape is not supported", tool_version: probe_result&.version), []]
|
|
84
119
|
end
|
|
85
120
|
|
|
86
121
|
mtime = File.mtime(full_path)
|
|
87
122
|
stale = (Time.now - mtime) > freshness_window
|
|
88
123
|
|
|
89
|
-
covered, executable = coverage_totals(
|
|
124
|
+
covered, executable = coverage_totals(normalized)
|
|
90
125
|
|
|
91
126
|
summary = {
|
|
92
127
|
"covered_lines" => covered,
|
|
@@ -95,7 +130,8 @@ module RailVerdict
|
|
|
95
130
|
"stale" => stale,
|
|
96
131
|
"freshness_window_seconds" => Integer(freshness_window),
|
|
97
132
|
"coverage_path" => coverage_path.to_s[0, 512],
|
|
98
|
-
"files" =>
|
|
133
|
+
"files" => normalized["files"],
|
|
134
|
+
"_coverage_document" => normalized
|
|
99
135
|
}
|
|
100
136
|
|
|
101
137
|
result = AnalyzerResult.new(
|
|
@@ -114,6 +150,152 @@ module RailVerdict
|
|
|
114
150
|
|
|
115
151
|
private
|
|
116
152
|
|
|
153
|
+
def detect_format(document)
|
|
154
|
+
return :malformed unless document.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
# coverage-v1 has version starting with "1" and files array
|
|
157
|
+
if document["version"].is_a?(String) && document["files"].is_a?(Array)
|
|
158
|
+
return :coverage_v1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Native SimpleCov JSON: has "coverage" hash where values contain lines
|
|
162
|
+
if document["coverage"].is_a?(Hash) && !document["coverage"].empty?
|
|
163
|
+
sample = document["coverage"].values.first
|
|
164
|
+
# Native values are hashes with "lines" key
|
|
165
|
+
if sample.is_a?(Hash) && sample.key?("lines")
|
|
166
|
+
return :native_simplecov
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Native may have empty coverage hash (empty file)
|
|
171
|
+
if document.key?("coverage") && document["coverage"].is_a?(Hash) && document["meta"].is_a?(Hash)
|
|
172
|
+
return :native_simplecov
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Also consider empty coverage hash without meta but with groups
|
|
176
|
+
if document.key?("coverage") && document["coverage"].is_a?(Hash) && document["coverage"].empty?
|
|
177
|
+
# Could be native empty or malformed; treat as native if meta or groups present
|
|
178
|
+
return :native_simplecov if document.key?("meta") || document.key?("groups")
|
|
179
|
+
# Fallback: if no files and no version, it's unsupported rather than malformed
|
|
180
|
+
return :unsupported
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# If document has no recognizable shape but is Hash with coverage key mismatched
|
|
184
|
+
if document.key?("coverage") || document.key?("meta") || document.key?("groups")
|
|
185
|
+
return :native_simplecov if document["coverage"].is_a?(Hash)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# If it has version but not files, or files but wrong shape => unsupported/malformed via validator
|
|
189
|
+
if document.key?("version") || document.key?("files") || document.key?("timestamp")
|
|
190
|
+
# Let schema validation decide malformed vs unsupported
|
|
191
|
+
# If version exists but not starting 1 => unsupported
|
|
192
|
+
if document["version"].is_a?(String) && !document["version"].start_with?("1")
|
|
193
|
+
return :unsupported
|
|
194
|
+
end
|
|
195
|
+
return :malformed if document.key?("files") || document.key?("version")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
:unsupported
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def extract_native_version(document)
|
|
202
|
+
meta = document["meta"]
|
|
203
|
+
if meta.is_a?(Hash) && meta["simplecov_version"].is_a?(String) && !meta["simplecov_version"].empty?
|
|
204
|
+
return meta["simplecov_version"].to_s[0, 64]
|
|
205
|
+
end
|
|
206
|
+
if document["version"].is_a?(String) && !document["version"].empty?
|
|
207
|
+
return document["version"].to_s[0, 64]
|
|
208
|
+
end
|
|
209
|
+
# Default for native without explicit version
|
|
210
|
+
"1.0"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def normalize_native_document(document, repository_root)
|
|
214
|
+
coverage_hash = document["coverage"]
|
|
215
|
+
raise MalformedOutput, "native SimpleCov document has no coverage hash" unless coverage_hash.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
files = []
|
|
218
|
+
coverage_hash.keys.sort.each do |raw_path|
|
|
219
|
+
entry = coverage_hash[raw_path]
|
|
220
|
+
raise MalformedOutput, "coverage entry for #{raw_path} must be an object" unless entry.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
lines_raw = entry["lines"]
|
|
223
|
+
raise MalformedOutput, "coverage lines for #{raw_path} must be an array" unless lines_raw.is_a?(Array)
|
|
224
|
+
|
|
225
|
+
lines = lines_raw.map do |hit|
|
|
226
|
+
case hit
|
|
227
|
+
when Integer
|
|
228
|
+
hit >= 0 ? hit : (raise MalformedOutput, "negative coverage hit")
|
|
229
|
+
when NilClass
|
|
230
|
+
nil
|
|
231
|
+
when String
|
|
232
|
+
# SimpleCov uses "ignored" for skipped lines
|
|
233
|
+
hit == "ignored" ? nil : (raise MalformedOutput, "unexpected string in coverage lines: #{hit.inspect}")
|
|
234
|
+
else
|
|
235
|
+
raise MalformedOutput, "coverage lines must be integers, null, or \"ignored\""
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
filename = normalize_native_path(raw_path, repository_root)
|
|
240
|
+
|
|
241
|
+
# Skip empty paths after normalization
|
|
242
|
+
raise MalformedOutput, "normalized path is empty for #{raw_path}" if filename.empty?
|
|
243
|
+
unless filename.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
|
|
244
|
+
raise MalformedOutput, "coverage filename is not a clean repository-relative path: #{filename.inspect}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
files << { "filename" => filename, "coverage" => { "lines" => lines } }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
files.sort_by! { |f| f["filename"] }
|
|
251
|
+
|
|
252
|
+
{
|
|
253
|
+
"version" => "1.0",
|
|
254
|
+
"timestamp" => document["timestamp"].is_a?(Integer) ? document["timestamp"] : Time.now.to_i,
|
|
255
|
+
"files" => files
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def normalize_native_path(raw_path, repository_root)
|
|
260
|
+
str = raw_path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").scrub("?").strip
|
|
261
|
+
str = str.delete("\u0000")
|
|
262
|
+
# Remove leading ./
|
|
263
|
+
str = str.delete_prefix("./")
|
|
264
|
+
# If absolute, make relative to repository_root if possible
|
|
265
|
+
if str.start_with?("/")
|
|
266
|
+
begin
|
|
267
|
+
root = File.realpath(repository_root)
|
|
268
|
+
abs = File.expand_path(str)
|
|
269
|
+
# Use Pathname relative if inside root
|
|
270
|
+
require "pathname"
|
|
271
|
+
rel = Pathname.new(abs).relative_path_from(Pathname.new(root)).to_s rescue nil
|
|
272
|
+
if rel && !rel.start_with?("..") && rel.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
|
|
273
|
+
return rel
|
|
274
|
+
end
|
|
275
|
+
rescue StandardError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
# Fallback: strip leading slash
|
|
279
|
+
str = str.delete_prefix("/")
|
|
280
|
+
end
|
|
281
|
+
# Normalize redundant separators and strip
|
|
282
|
+
str = str.gsub(%r{//+}, "/")
|
|
283
|
+
str = str.strip
|
|
284
|
+
str
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def normalize_coverage_v1(document)
|
|
288
|
+
# Already validated; ensure deterministic ordering by filename
|
|
289
|
+
files = document["files"].map do |f|
|
|
290
|
+
{ "filename" => f["filename"].to_s, "coverage" => { "lines" => f.dig("coverage", "lines") } }
|
|
291
|
+
end.sort_by { |f| f["filename"] }
|
|
292
|
+
{
|
|
293
|
+
"version" => document["version"].to_s,
|
|
294
|
+
"timestamp" => document["timestamp"],
|
|
295
|
+
"files" => files
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
|
|
117
299
|
def load_config(repository_root, configuration: nil)
|
|
118
300
|
if configuration
|
|
119
301
|
sel = configuration.analyzers["simplecov"]
|
|
@@ -51,6 +51,10 @@ module RailVerdict
|
|
|
51
51
|
}
|
|
52
52
|
end
|
|
53
53
|
entries = entries.uniq { |entry| entry.fetch("fingerprint") }.sort_by { |entry| entry.fetch("fingerprint") }
|
|
54
|
+
canonical_versions = {}
|
|
55
|
+
analyzer_versions.each do |key, value|
|
|
56
|
+
canonical_versions[key.to_s] = RailVerdict::Analyzers::Shared.canonical_tool_version(value)
|
|
57
|
+
end
|
|
54
58
|
hash = {
|
|
55
59
|
"schema_version" => SCHEMA_VERSION,
|
|
56
60
|
"fingerprint_version" => Fingerprint::VERSION,
|
|
@@ -59,7 +63,7 @@ module RailVerdict
|
|
|
59
63
|
"created_at" => created_at,
|
|
60
64
|
"created_by" => "railverdict #{RailVerdict::VERSION}",
|
|
61
65
|
"configuration_digest" => configuration.digest,
|
|
62
|
-
"analyzer_versions" =>
|
|
66
|
+
"analyzer_versions" => canonical_versions.sort.to_h,
|
|
63
67
|
"entries" => entries
|
|
64
68
|
}
|
|
65
69
|
errors = SchemaValidator.validate_baseline(hash)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailVerdict
|
|
6
|
+
module CanonicalJSON
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def generate(value)
|
|
10
|
+
JSON.generate(canonicalize(value))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def canonicalize(value)
|
|
14
|
+
case value
|
|
15
|
+
when Hash
|
|
16
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key, canonicalize(value[key])] }
|
|
17
|
+
when Array
|
|
18
|
+
value.map { |item| canonicalize(item) }
|
|
19
|
+
when String
|
|
20
|
+
scrub_text(value)
|
|
21
|
+
else
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scrub_text(text)
|
|
27
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
28
|
+
.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "?")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|