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.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/lib/rail_verdict/analyzers/brakeman.rb +309 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +3 -2
- data/lib/rail_verdict/analyzers/minitest.rb +92 -43
- data/lib/rail_verdict/analyzers/rspec.rb +151 -44
- data/lib/rail_verdict/analyzers/rubocop.rb +2 -1
- data/lib/rail_verdict/analyzers/simplecov.rb +71 -75
- data/lib/rail_verdict/change_intelligence.rb +274 -0
- data/lib/rail_verdict/change_surfaces.rb +232 -0
- data/lib/rail_verdict/check.rb +84 -15
- data/lib/rail_verdict/cli.rb +640 -5
- data/lib/rail_verdict/comparison.rb +18 -2
- data/lib/rail_verdict/configuration.rb +12 -2
- data/lib/rail_verdict/contracts/analyzer_result.rb +1 -1
- data/lib/rail_verdict/contracts/finding.rb +6 -0
- data/lib/rail_verdict/doctor.rb +19 -6
- data/lib/rail_verdict/engineering_policy.rb +394 -0
- data/lib/rail_verdict/findings_command.rb +7 -1
- data/lib/rail_verdict/handoff.rb +125 -0
- data/lib/rail_verdict/mcp/cache.rb +10 -0
- data/lib/rail_verdict/mcp/server.rb +15 -1
- data/lib/rail_verdict/mcp/tools/create_handoff.rb +115 -0
- data/lib/rail_verdict/mcp/tools/create_workflow_receipt.rb +135 -0
- data/lib/rail_verdict/mcp/tools/get_engineering_policy.rb +117 -0
- data/lib/rail_verdict/mcp/tools/get_pr_intelligence.rb +1 -1
- data/lib/rail_verdict/mcp/tools/get_review_packet.rb +116 -0
- data/lib/rail_verdict/mcp/tools/inspect_handoff.rb +72 -0
- data/lib/rail_verdict/mcp/tools/verify_handoff.rb +117 -0
- data/lib/rail_verdict/mcp/tools/verify_review_observation.rb +120 -0
- data/lib/rail_verdict/mcp.rb +12 -0
- data/lib/rail_verdict/pr_intelligence.rb +39 -1
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +38 -4
- data/lib/rail_verdict/repair/verification_plan.rb +33 -1
- data/lib/rail_verdict/reporters/console.rb +40 -0
- data/lib/rail_verdict/reporters/engineering_policy.rb +50 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +6 -1
- data/lib/rail_verdict/reporters/pr_intelligence.rb +54 -5
- data/lib/rail_verdict/reporters/review.rb +73 -0
- data/lib/rail_verdict/reporters/sarif.rb +6 -1
- data/lib/rail_verdict/reuse.rb +274 -0
- data/lib/rail_verdict/review_observation.rb +95 -0
- data/lib/rail_verdict/review_packet.rb +159 -0
- data/lib/rail_verdict/schema_validator.rb +39 -2
- data/lib/rail_verdict/test_selection.rb +186 -0
- data/lib/rail_verdict/verification_environment.rb +20 -51
- data/lib/rail_verdict/verification_plan.rb +97 -0
- data/lib/rail_verdict/version.rb +1 -1
- data/lib/rail_verdict/workflow_receipt.rb +77 -0
- data/lib/rail_verdict.rb +13 -0
- data/schemas/configuration-v1.5.schema.json +2 -1
- data/schemas/configuration-v1.6.schema.json +177 -0
- data/schemas/configuration-v1.7.schema.json +499 -0
- data/schemas/engineering-policy-v1.schema.json +107 -0
- data/schemas/pr-intelligence-v1.1.schema.json +407 -0
- data/schemas/repair-packet-v1.schema.json +42 -0
- data/schemas/review-observation-v1.schema.json +68 -0
- data/schemas/review-packet-v1.schema.json +175 -0
- data/schemas/verification-handoff-v1.schema.json +69 -0
- data/schemas/workflow-receipt-v1.schema.json +47 -0
- 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
|
-
|
|
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
|
-
|
|
30
|
+
clean_command.fetch(:executable),
|
|
26
31
|
invocation.fetch("argv"),
|
|
27
32
|
chdir: repository_root,
|
|
28
|
-
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(
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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"
|
|
188
|
+
{ executable: "bundle", args_prefix: ["exec", "rspec"] }
|
|
102
189
|
else
|
|
103
|
-
{ executable: "rspec", args_prefix: [
|
|
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 = "
|
|
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:
|
|
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
|
-
|
|
22
|
-
full_path =
|
|
23
|
-
return Probe.new(status: "unavailable", message: "coverage file is absent: #{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
57
|
-
|
|
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: #{
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_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:
|
|
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:
|
|
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" =>
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
return :native_simplecov
|
|
173
|
-
end
|
|
183
|
+
full_path
|
|
184
|
+
end
|
|
174
185
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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"] :
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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"] }
|