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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 808405ac6448a462283966840c5b9074afff5cd43f0c2c3b477d9951e7dfe0a8
4
- data.tar.gz: 2f3aa97c547c6db980c284ca4081bc09230d4e13f63369cced690f60d18ccddd
3
+ metadata.gz: cd73e695fc399f10d5e9dcde4f8f04294948160821c9f256234e79d6345bb8e0
4
+ data.tar.gz: dfb8a4b7ae5613ab75ef864dfbca00d3fa7c6241ad47937241e524aa5724b7ff
5
5
  SHA512:
6
- metadata.gz: a70e6b7d070e6d4990b0b7991af2332c572e0ad2c9438b0ada3afb7e095729285b5b5714c8be8be512e1c7d8907e089bec08d5e4b62524a401719d215bc2eb28
7
- data.tar.gz: 6881881853b9073e8add4c6946d1bdb0da79928a57b6d27a057e9b1646dcbe9e960a9f646c9fb9f62743f5ba3c0d61f93fc2a1fa572b529e8071268f97b69f0b
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
- bounded_message([detail, stderr].reject(&:empty?).join(": "))
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: 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" => bounded_message(message.to_s) }
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
- message = (advisory["title"] || advisory["description"] || entry["title"] || entry["description"] || "vulnerability in #{gem_name}").to_s
147
- message = message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
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
- message = (test["failure_message"] || test["method_name"]).to_s
252
- message = message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
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
- message = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || "rspec example failed"
140
- message = message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
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
- 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
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
- 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)
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
- 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), []]
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
- 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), []]
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(document)
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" => document["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" => analyzer_versions.transform_keys(&:to_s).sort.to_h,
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