rail_verdict 1.3.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/lib/rail_verdict/analyzers/brakeman.rb +309 -0
  4. data/lib/rail_verdict/analyzers/bundler_audit.rb +3 -2
  5. data/lib/rail_verdict/analyzers/minitest.rb +92 -43
  6. data/lib/rail_verdict/analyzers/rspec.rb +151 -44
  7. data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
  8. data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
  9. data/lib/rail_verdict/change_intelligence.rb +274 -0
  10. data/lib/rail_verdict/change_surfaces.rb +232 -0
  11. data/lib/rail_verdict/check.rb +84 -15
  12. data/lib/rail_verdict/cli.rb +640 -5
  13. data/lib/rail_verdict/comparison.rb +18 -2
  14. data/lib/rail_verdict/configuration.rb +12 -2
  15. data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
  16. data/lib/rail_verdict/contracts/finding.rb +6 -0
  17. data/lib/rail_verdict/doctor.rb +19 -6
  18. data/lib/rail_verdict/engineering_policy.rb +394 -0
  19. data/lib/rail_verdict/findings_command.rb +7 -1
  20. data/lib/rail_verdict/handoff.rb +125 -0
  21. data/lib/rail_verdict/mcp/cache.rb +10 -0
  22. data/lib/rail_verdict/mcp/server.rb +15 -1
  23. data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
  24. data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
  25. data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
  26. data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
  27. data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
  28. data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
  29. data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
  30. data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
  31. data/lib/rail_verdict/mcp.rb +12 -0
  32. data/lib/rail_verdict/pr_intelligence.rb +39 -1
  33. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
  34. data/lib/rail_verdict/repair/verification_plan.rb +33 -1
  35. data/lib/rail_verdict/reporters/console.rb +40 -0
  36. data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
  37. data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
  38. data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
  39. data/lib/rail_verdict/reporters/review.rb +73 -0
  40. data/lib/rail_verdict/reporters/sarif.rb +6 -1
  41. data/lib/rail_verdict/reuse.rb +274 -0
  42. data/lib/rail_verdict/review_observation.rb +95 -0
  43. data/lib/rail_verdict/review_packet.rb +159 -0
  44. data/lib/rail_verdict/schema_validator.rb +39 -2
  45. data/lib/rail_verdict/test_selection.rb +186 -0
  46. data/lib/rail_verdict/verification_environment.rb +20 -51
  47. data/lib/rail_verdict/verification_plan.rb +97 -0
  48. data/lib/rail_verdict/version.rb +1 -1
  49. data/lib/rail_verdict/workflow_receipt.rb +77 -0
  50. data/lib/rail_verdict.rb +13 -0
  51. data/schemas/configuration-v1.5.schema.json +2 -1
  52. data/schemas/configuration-v1.6.schema.json +177 -0
  53. data/schemas/configuration-v1.7.schema.json +499 -0
  54. data/schemas/engineering-policy-v1.schema.json +107 -0
  55. data/schemas/pr-intelligence-v1.1.schema.json +407 -0
  56. data/schemas/repair-packet-v1.schema.json +42 -0
  57. data/schemas/review-observation-v1.schema.json +68 -0
  58. data/schemas/review-packet-v1.schema.json +175 -0
  59. data/schemas/verification-handoff-v1.schema.json +69 -0
  60. data/schemas/workflow-receipt-v1.schema.json +47 -0
  61. metadata +29 -1
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "tmpdir"
5
+ require "securerandom"
4
6
 
5
7
  require_relative "_shared"
6
8
 
@@ -19,13 +21,16 @@ module RailVerdict
19
21
  end
20
22
 
21
23
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
24
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
22
25
  command = @command_resolver.call(repository_root)
23
- invocation = Shared.invocation_for(command, ["--version"])
26
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
27
+ clean_command = command.merge(args_prefix: clean_prefix)
28
+ invocation = Shared.invocation_for(clean_command, ["--version"])
24
29
  result = runner.run(
25
- command.fetch(:executable),
30
+ clean_command.fetch(:executable),
26
31
  invocation.fetch("argv"),
27
32
  chdir: repository_root,
28
- timeout_seconds: timeout_seconds
33
+ timeout_seconds: effective_timeout
29
34
  )
30
35
 
31
36
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -45,66 +50,167 @@ module RailVerdict
45
50
  Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
46
51
  end
47
52
 
48
- def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
53
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil, target_files: nil, test_scope: "full", fallback_reason: nil)
49
54
  command = @command_resolver.call(repository_root)
55
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
56
+ clean_command = command.merge(args_prefix: clean_prefix)
50
57
  probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
51
- version_invocation = Shared.invocation_for(command, ["--version"])
58
+ version_invocation = Shared.invocation_for(clean_command, ["--version"])
52
59
 
53
60
  unless probe_result.status == "succeeded"
54
61
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: version_invocation, status: probe_result.status, message: probe_result.message, tool_version: probe_result.version), []]
55
62
  end
56
63
 
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)
60
- result = runner.run(
61
- command.fetch(:executable),
62
- invocation.fetch("argv"),
63
- chdir: repository_root,
64
- timeout_seconds: timeout_seconds,
65
- max_stdout_bytes: max_stdout
66
- )
67
- tool_version = probe_result.version
64
+ if target_files.is_a?(Array) && target_files.empty? && test_scope == "targeted"
65
+ summary = {
66
+ "tests_total" => 0,
67
+ "duration_seconds" => 0.0,
68
+ "failures" => 0,
69
+ "errors" => 0,
70
+ "assertions" => 0,
71
+ "skips" => 0,
72
+ "runner" => "rspec #{probe_result.version}",
73
+ "test_scope" => "targeted",
74
+ "target_files" => [],
75
+ "fallback_reason" => fallback_reason
76
+ }.compact
77
+ analyzer_result = AnalyzerResult.new(
78
+ analyzer: ANALYZER_ID,
79
+ tool_version: probe_result.version,
80
+ invocation: Shared.invocation_for(clean_command, ["--format", "json"]),
81
+ execution_status: "succeeded",
82
+ finding_ids: [],
83
+ evidence_summary: summary
84
+ )
85
+ return [analyzer_result, []]
86
+ end
68
87
 
69
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :spawn_failed
70
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: Shared.detail_for(result), tool_version: tool_version), []] if Shared.truncated?(result)
71
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "timed_out", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :timed_out
72
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "signaled", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :signaled
88
+ output_path = File.join(Dir.tmpdir, "railverdict-rspec-#{SecureRandom.hex(8)}.json")
89
+ target_list = Array(target_files).compact.reject(&:empty?)
90
+ public_argv = target_list.empty? ? ["--format", "json"] : target_list + ["--format", "json"]
91
+ public_invocation = Shared.invocation_for(clean_command, public_argv)
92
+ run_argv = clean_prefix.dup.concat(target_list).concat(["--format", "json", "--out", output_path])
73
93
 
74
- begin
75
- document = JSON.parse(result.stdout)
76
- rescue JSON::ParserError => error
77
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
78
- end
94
+ max_stdout = resolve_stdout_limit(configuration, repository_root, 16 * 1024 * 1024)
95
+ tool_version = probe_result.version
79
96
 
80
97
  begin
81
- summary, findings = normalize_document(document)
82
- rescue MalformedOutput => error
83
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
98
+ result = runner.run(
99
+ clean_command.fetch(:executable),
100
+ run_argv,
101
+ chdir: repository_root,
102
+ timeout_seconds: timeout_seconds,
103
+ max_stdout_bytes: max_stdout
104
+ )
105
+
106
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "unavailable", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :spawn_failed
107
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: Shared.detail_for(result), tool_version: tool_version), []] if Shared.truncated?(result)
108
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "timed_out", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :timed_out
109
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "signaled", message: Shared.detail_for(result), tool_version: tool_version), []] if result.status == :signaled
110
+
111
+ unless File.file?(output_path)
112
+ detail = Shared.detail_for(result)
113
+ msg = detail.strip.empty? ? "RSpec did not produce structured output" : detail
114
+ status = result.exit_code && result.exit_code != 0 ? "failed" : "malformed"
115
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: status, message: msg, tool_version: tool_version), []]
116
+ end
117
+
118
+ begin
119
+ bytes = File.binread(output_path)
120
+ rescue SystemCallError => error
121
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
122
+ end
123
+
124
+ if bytes.bytesize > max_stdout
125
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: "RSpec output exceeds #{max_stdout} bytes", tool_version: tool_version), []]
126
+ end
127
+
128
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
129
+ unless text.valid_encoding?
130
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: "RSpec output is not valid UTF-8", tool_version: tool_version), []]
131
+ end
132
+
133
+ begin
134
+ document = JSON.parse(text)
135
+ rescue JSON::ParserError => error
136
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
137
+ end
138
+
139
+ begin
140
+ summary, findings = normalize_document(document, test_scope: test_scope, target_files: target_list, fallback_reason: fallback_reason)
141
+ rescue MalformedOutput => error
142
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
143
+ end
144
+
145
+ # Process exit reconciliation (RH-02):
146
+ # 0: all examples passed, 0 failures/errors
147
+ # 1: failed examples present (failures > 0 or findings non-empty)
148
+ # Any other exit code or contradiction: fail closed
149
+ failures_count = summary["failures"] || 0
150
+ if result.exit_code == 0
151
+ if failures_count > 0 || !findings.empty?
152
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: "RSpec exited with status 0 but reported #{failures_count} failures", tool_version: tool_version), []]
153
+ end
154
+ elsif result.exit_code == 1
155
+ if failures_count == 0 && findings.empty?
156
+ detail = Shared.detail_for(result)
157
+ msg = detail.strip.empty? ? "RSpec exited with status 1 but reported 0 failed examples" : detail
158
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
159
+ end
160
+ else
161
+ detail = Shared.detail_for(result)
162
+ msg = detail.strip.empty? ? "RSpec exited with unexpected status #{result.exit_code}" : detail
163
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
164
+ end
165
+
166
+ analyzer_result = AnalyzerResult.new(
167
+ analyzer: ANALYZER_ID,
168
+ tool_version: tool_version,
169
+ invocation: public_invocation,
170
+ execution_status: "succeeded",
171
+ finding_ids: findings.map(&:id),
172
+ evidence_summary: summary
173
+ )
174
+ [analyzer_result, findings]
175
+ ensure
176
+ begin
177
+ File.unlink(output_path) if output_path && File.exist?(output_path)
178
+ rescue StandardError
179
+ nil
180
+ end
84
181
  end
85
-
86
- analyzer_result = AnalyzerResult.new(
87
- analyzer: ANALYZER_ID,
88
- tool_version: tool_version,
89
- invocation: invocation,
90
- execution_status: "succeeded",
91
- finding_ids: findings.map(&:id),
92
- evidence_summary: summary
93
- )
94
- [analyzer_result, findings]
95
182
  end
96
183
 
97
184
  private
98
185
 
99
186
  def default_command(repository_root)
100
187
  if File.file?(File.join(repository_root, "Gemfile"))
101
- { executable: "bundle", args_prefix: ["exec", "rspec", "--format", "json"] }
188
+ { executable: "bundle", args_prefix: ["exec", "rspec"] }
102
189
  else
103
- { executable: "rspec", args_prefix: ["--format", "json"] }
190
+ { executable: "rspec", args_prefix: [] }
191
+ end
192
+ end
193
+
194
+ def clean_args_prefix(prefix)
195
+ cleaned = []
196
+ skip_next = false
197
+ Array(prefix).each_with_index do |arg, i|
198
+ if skip_next
199
+ skip_next = false
200
+ next
201
+ end
202
+ if arg == "--format" && Array(prefix)[i + 1] == "json"
203
+ skip_next = true
204
+ next
205
+ elsif arg == "--format=json"
206
+ next
207
+ end
208
+ cleaned << arg
104
209
  end
210
+ cleaned
105
211
  end
106
212
 
107
- def normalize_document(document)
213
+ def normalize_document(document, test_scope: "full", target_files: nil, fallback_reason: nil)
108
214
  raise MalformedOutput, "RSpec JSON root must be an object" unless document.is_a?(Hash)
109
215
 
110
216
  summary = document["summary"]
@@ -123,6 +229,9 @@ module RailVerdict
123
229
  findings = findings.uniq { |f| f.fingerprint }.sort_by(&:sort_key)
124
230
  summary_h = build_summary(summary, version)
125
231
  summary_h["tests_total"] = examples.length
232
+ summary_h["test_scope"] = test_scope if test_scope
233
+ summary_h["target_files"] = target_files if target_files && !target_files.empty?
234
+ summary_h["fallback_reason"] = fallback_reason if fallback_reason
126
235
 
127
236
  [summary_h, findings]
128
237
  end
@@ -142,7 +251,7 @@ module RailVerdict
142
251
  raw_msg = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || nil
143
252
  message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg.nil? || raw_msg.to_s.strip.empty? ? "rspec example failed" : raw_msg)
144
253
 
145
- rule_id = "rspec/example:#{example['id'] || id_for(example, index)}"
254
+ rule_id = "example:#{example[id] || id_for(example, index)}"
146
255
  path = normalize_path(example["file_path"] || example["file"] || "spec/unknown_spec.rb")
147
256
  failure_line, failure_path = failure_location(example, path)
148
257
  start_line = failure_line || example["line_number"] || extract_line(example["id"])
@@ -273,14 +382,12 @@ module RailVerdict
273
382
  end
274
383
 
275
384
  def resolve_stdout_limit(configuration, repository_root, default_bytes)
276
- # Prefer explicit per-analyzer config if present (future-compatible), else default.
277
385
  raw_limit = nil
278
386
  if configuration
279
387
  sel = configuration.analyzers[ANALYZER_ID] rescue nil
280
388
  raw_limit = sel && sel["output_limit_bytes"]
281
389
  end
282
390
  raw_limit ||= default_bytes
283
- # Clamp to safe ceiling
284
391
  limit = Integer(raw_limit) rescue default_bytes
285
392
  limit = default_bytes if limit <= 0
286
393
  max = RailVerdict::ProcessRunner::MAX_SAFE_STDOUT_BYTES
@@ -27,13 +27,14 @@ module RailVerdict
27
27
  end
28
28
 
29
29
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
30
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
30
31
  command = @command_resolver.call(repository_root)
31
32
  invocation = invocation_for(command, ["--version"])
32
33
  result = runner.run(
33
34
  command.fetch(:executable),
34
35
  invocation.fetch("argv"),
35
36
  chdir: repository_root,
36
- timeout_seconds: timeout_seconds
37
+ timeout_seconds: effective_timeout
37
38
  )
38
39
 
39
40
  return Probe.new(status: "unavailable", message: detail_for(result)) if result.status == :spawn_failed
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pathname"
4
5
 
5
6
  require_relative "_shared"
6
7
 
@@ -18,9 +19,9 @@ module RailVerdict
18
19
 
19
20
  def probe(repository_root, runner: nil, timeout_seconds: 5.0)
20
21
  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)
22
+ raw_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
23
+ full_path = resolve_contained_coverage_path(repository_root, raw_path)
24
+ return Probe.new(status: "unavailable", message: "coverage file is absent: #{raw_path}") unless File.file?(full_path)
24
25
 
25
26
  text = File.binread(full_path).force_encoding(Encoding::UTF_8)
26
27
  return Probe.new(status: "parse_failed", message: "coverage file is not valid UTF-8") unless text.valid_encoding?
@@ -30,11 +31,18 @@ module RailVerdict
30
31
  case detected
31
32
  when :coverage_v1
32
33
  version = document["version"].to_s
33
- return Probe.new(status: "unsupported", message: "unsupported SimpleCov version #{version}") unless version.start_with?("1")
34
+ v = Gem::Version.new(version) rescue nil
35
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
36
+ return Probe.new(status: "unsupported", version: version, message: "unsupported SimpleCov version #{version}")
37
+ end
34
38
  Probe.new(status: "succeeded", version: version)
35
39
  when :native_simplecov
36
40
  version = extract_native_version(document)
37
- # Accept any native simplecov version >=0.20 with coverage hash; version check is permissive.
41
+ return Probe.new(status: "unsupported", message: "native SimpleCov version could not be determined") unless version
42
+ v = Gem::Version.new(version) rescue nil
43
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
44
+ return Probe.new(status: "unsupported", version: version, message: "unsupported SimpleCov version #{version}")
45
+ end
38
46
  Probe.new(status: "succeeded", version: version)
39
47
  when :unsupported
40
48
  Probe.new(status: "unsupported", message: "unsupported coverage document shape")
@@ -51,13 +59,18 @@ module RailVerdict
51
59
 
52
60
  def run(repository_root, runner: nil, timeout_seconds: 10.0, probe_result: nil, configuration: nil)
53
61
  config = load_config(repository_root, configuration: configuration)
54
- coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
62
+ raw_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
55
63
  freshness_window = config.fetch("freshness_window_seconds") { DEFAULT_FRESHNESS_WINDOW_SECONDS }
56
- invocation = { "executable" => "simplecov", "argv" => ["read", coverage_path] }
57
- full_path = File.expand_path(coverage_path, repository_root)
64
+ invocation = { "executable" => "simplecov", "argv" => ["read", raw_path] }
65
+
66
+ begin
67
+ full_path = resolve_contained_coverage_path(repository_root, raw_path)
68
+ rescue MalformedOutput => error
69
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message)), []]
70
+ end
58
71
 
59
72
  unless File.file?(full_path)
60
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{coverage_path}"), []]
73
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{raw_path}"), []]
61
74
  end
62
75
 
63
76
  begin
@@ -92,24 +105,29 @@ module RailVerdict
92
105
  version = nil
93
106
  if detected == :coverage_v1
94
107
  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), []]
108
+ v = Gem::Version.new(version) rescue nil
109
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
110
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: version), []]
98
111
  end
99
112
  errors = validate_coverage_schema(document)
100
113
  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), []]
114
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: version), []]
102
115
  end
103
116
  normalized = normalize_coverage_v1(document)
104
- version = document["version"].to_s
105
117
  elsif detected == :native_simplecov
118
+ version = extract_native_version(document)
119
+ unless version
120
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "native SimpleCov version could not be determined", tool_version: nil), []]
121
+ end
122
+ v = Gem::Version.new(version) rescue nil
123
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
124
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: version), []]
125
+ end
106
126
  begin
107
127
  normalized = normalize_native_document(document, repository_root)
108
- version = extract_native_version(document)
109
128
  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), []]
129
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: version), []]
111
130
  end
112
- # Validate normalized shape via same schema
113
131
  errors = validate_coverage_schema(normalized)
114
132
  unless errors.empty?
115
133
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: version), []]
@@ -129,7 +147,7 @@ module RailVerdict
129
147
  "percent" => executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2),
130
148
  "stale" => stale,
131
149
  "freshness_window_seconds" => Integer(freshness_window),
132
- "coverage_path" => coverage_path.to_s[0, 512],
150
+ "coverage_path" => raw_path.to_s[0, 512],
133
151
  "files" => normalized["files"],
134
152
  "_coverage_document" => normalized
135
153
  }
@@ -150,47 +168,38 @@ module RailVerdict
150
168
 
151
169
  private
152
170
 
153
- def detect_format(document)
154
- return :malformed unless document.is_a?(Hash)
171
+ def resolve_contained_coverage_path(repository_root, configured_path)
172
+ rel_path = configured_path.to_s.strip
173
+ rel_path = DEFAULT_COVERAGE_PATH if rel_path.empty?
155
174
 
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
175
+ root = File.realpath(repository_root) rescue File.expand_path(repository_root)
176
+ full_path = File.expand_path(rel_path, root)
160
177
 
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
178
+ rel = Pathname.new(full_path).relative_path_from(Pathname.new(root)).to_s rescue nil
179
+ if rel.nil? || rel.start_with?("..")
180
+ raise MalformedOutput, "configured coverage_path escapes repository root: #{configured_path}"
168
181
  end
169
182
 
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
183
+ full_path
184
+ end
174
185
 
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
186
+ def detect_format(document)
187
+ return :malformed unless document.is_a?(Hash)
188
+
189
+ if document.key?("version") && document.key?("timestamp") && document.key?("files")
190
+ return :coverage_v1 if document["files"].is_a?(Array)
191
+ return :malformed if document.key?("files")
180
192
  return :unsupported
181
193
  end
182
194
 
183
- # If document has no recognizable shape but is Hash with coverage key mismatched
184
195
  if document.key?("coverage") || document.key?("meta") || document.key?("groups")
185
196
  return :native_simplecov if document["coverage"].is_a?(Hash)
186
197
  end
187
198
 
188
- # If it has version but not files, or files but wrong shape => unsupported/malformed via validator
189
199
  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
200
+ if document["version"].is_a?(String)
201
+ v = Gem::Version.new(document["version"]) rescue nil
202
+ return :unsupported if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
194
203
  end
195
204
  return :malformed if document.key?("files") || document.key?("version")
196
205
  end
@@ -206,8 +215,7 @@ module RailVerdict
206
215
  if document["version"].is_a?(String) && !document["version"].empty?
207
216
  return document["version"].to_s[0, 64]
208
217
  end
209
- # Default for native without explicit version
210
- "1.0"
218
+ nil
211
219
  end
212
220
 
213
221
  def normalize_native_document(document, repository_root)
@@ -229,7 +237,6 @@ module RailVerdict
229
237
  when NilClass
230
238
  nil
231
239
  when String
232
- # SimpleCov uses "ignored" for skipped lines
233
240
  hit == "ignored" ? nil : (raise MalformedOutput, "unexpected string in coverage lines: #{hit.inspect}")
234
241
  else
235
242
  raise MalformedOutput, "coverage lines must be integers, null, or \"ignored\""
@@ -237,8 +244,6 @@ module RailVerdict
237
244
  end
238
245
 
239
246
  filename = normalize_native_path(raw_path, repository_root)
240
-
241
- # Skip empty paths after normalization
242
247
  raise MalformedOutput, "normalized path is empty for #{raw_path}" if filename.empty?
243
248
  unless filename.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
244
249
  raise MalformedOutput, "coverage filename is not a clean repository-relative path: #{filename.inspect}"
@@ -251,7 +256,7 @@ module RailVerdict
251
256
 
252
257
  {
253
258
  "version" => "1.0",
254
- "timestamp" => document["timestamp"].is_a?(Integer) ? document["timestamp"] : Time.now.to_i,
259
+ "timestamp" => document["timestamp"].is_a?(Integer) ? document["timestamp"] : 0,
255
260
  "files" => files
256
261
  }
257
262
  end
@@ -259,33 +264,24 @@ module RailVerdict
259
264
  def normalize_native_path(raw_path, repository_root)
260
265
  str = raw_path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").scrub("?").strip
261
266
  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("/")
267
+ raise MalformedOutput, "coverage path is empty" if str.empty?
268
+
269
+ root = File.realpath(repository_root) rescue File.expand_path(repository_root)
270
+ full = File.expand_path(str, root)
271
+
272
+ rel = Pathname.new(full).relative_path_from(Pathname.new(root)).to_s rescue nil
273
+ if rel.nil? || rel.start_with?("..")
274
+ raise MalformedOutput, "coverage path is outside repository root: #{str}"
280
275
  end
281
- # Normalize redundant separators and strip
282
- str = str.gsub(%r{//+}, "/")
283
- str = str.strip
284
- str
276
+
277
+ unless rel.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
278
+ raise MalformedOutput, "coverage path is not a clean repository-relative path: #{rel}"
279
+ end
280
+
281
+ rel
285
282
  end
286
283
 
287
284
  def normalize_coverage_v1(document)
288
- # Already validated; ensure deterministic ordering by filename
289
285
  files = document["files"].map do |f|
290
286
  { "filename" => f["filename"].to_s, "coverage" => { "lines" => f.dig("coverage", "lines") } }
291
287
  end.sort_by { |f| f["filename"] }