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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63845a29f84295397de20a23b72c7d6e0a9d6c969be35ac8081e2536b0a37cec
4
- data.tar.gz: 6e778d530c6abe87db5997b9d5fe328ebd750ac1722ede75042195b5d98d92cf
3
+ metadata.gz: 564018ea0a8253a34b54578dc59864000267937ac5175aa437903a5afb67ece8
4
+ data.tar.gz: ed89e1c5d4b7610b12eca7d66eca82e268dc17297ac040107ff26f79e620ac86
5
5
  SHA512:
6
- metadata.gz: beabe5b069a803549a6af55d03f16256e5ebe7e5ab6152672a7a1fa415a75827656108c01207860251fc8936fdc3bfb6397c1f53ca53f342da5ee913051d1c2a
7
- data.tar.gz: 72e637013c3a4216a90fcb466c947b6b6e97bc9035166bb8b96b76b62579c76f1253eb765d1c56ac1e39dd82197489c3535a411fece8535c6cd340bd0e741ac9
6
+ metadata.gz: b41423f2cedfac216e9997745f93d8480fc78d8b0b4b0d7274c2c4f9a32d430287b7250d0229e5c23374f91097dfe178df2164bc3f5bc9e0d1dcd0282382e378
7
+ data.tar.gz: efef3d68cf12a5f40788a946b086a74428b06afca0f1f68b9cad79d8dbbc8fdfe81ca0def0d8de3090d050afbee9e2e224f25be7b1f1961c5788a599c40efa34
data/README.md CHANGED
@@ -35,7 +35,8 @@ Running test suites alone is not enough to answer a fundamental question:
35
35
  Existing tools produce fragmented formats and disparate semantics:
36
36
 
37
37
  - **RuboCop** reports style and lint offenses;
38
- - **RSpec** and **Minitest** report test outcomes and failures;
38
+ - **Brakeman** reports Rails security vulnerabilities;
39
+ - **RSpec** and **Minitest** report test outcomes and failures (with intelligent Rails-aware targeted test selection);
39
40
  - **SimpleCov** reports line coverage metrics;
40
41
  - **bundler-audit** reports gem dependency advisories;
41
42
  - **Git** tracks what actually changed.
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+ require "securerandom"
6
+
7
+ require_relative "_shared"
8
+
9
+ module RailVerdict
10
+ module Analyzers
11
+ class Brakeman
12
+ ANALYZER_ID = "brakeman"
13
+ SUPPORTED_VERSIONS = Gem::Requirement.new(">= 7.0", "< 9")
14
+
15
+ SEVERITY_MAP = {
16
+ "High" => "high",
17
+ "Medium" => "medium",
18
+ "Weak" => "low"
19
+ }.freeze
20
+
21
+ CONFIDENCE_MAP = {
22
+ "High" => "high",
23
+ "Medium" => "medium",
24
+ "Weak" => "low"
25
+ }.freeze
26
+
27
+ Probe = Struct.new(:status, :version, :message, keyword_init: true)
28
+
29
+ class MalformedOutput < StandardError; end
30
+
31
+ def initialize(command_resolver: nil)
32
+ @command_resolver = command_resolver || method(:default_command)
33
+ end
34
+
35
+ def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
36
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
37
+ command = @command_resolver.call(repository_root)
38
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
39
+ clean_command = command.merge(args_prefix: clean_prefix)
40
+ invocation = Shared.invocation_for(clean_command, ["--version"])
41
+ result = runner.run(
42
+ clean_command.fetch(:executable),
43
+ invocation.fetch("argv"),
44
+ chdir: repository_root,
45
+ timeout_seconds: effective_timeout
46
+ )
47
+
48
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
49
+ return Probe.new(status: "timed_out", message: Shared.detail_for(result)) if result.status == :timed_out
50
+ return Probe.new(status: "signaled", message: Shared.detail_for(result)) if result.status == :signaled
51
+ return Probe.new(status: "truncated", message: Shared.detail_for(result)) if Shared.truncated?(result)
52
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) unless result.exit_code == 0
53
+
54
+ version = Shared.parse_semver(result.stdout)
55
+ return Probe.new(status: "unsupported", message: "Brakeman version could not be parsed") unless version
56
+ return Probe.new(status: "unsupported", version: version, message: "unsupported Brakeman version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
57
+
58
+ Probe.new(status: "succeeded", version: version)
59
+ rescue ArgumentError
60
+ Probe.new(status: "unsupported", message: "Brakeman reported an invalid version")
61
+ rescue KeyError, ArgumentError => error
62
+ Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
63
+ end
64
+
65
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
66
+ command = @command_resolver.call(repository_root)
67
+ clean_prefix = clean_args_prefix(command.fetch(:args_prefix))
68
+ clean_command = command.merge(args_prefix: clean_prefix)
69
+ probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
70
+ version_invocation = Shared.invocation_for(clean_command, ["--version"])
71
+
72
+ unless probe_result.status == "succeeded"
73
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: version_invocation, status: probe_result.status, message: probe_result.message, tool_version: probe_result.version), []]
74
+ end
75
+
76
+ output_path = File.join(Dir.tmpdir, "railverdict-brakeman-#{SecureRandom.hex(8)}.json")
77
+ public_invocation = Shared.invocation_for(clean_command, ["-q", "--no-pager", "-f", "json"])
78
+ run_argv = clean_prefix.dup.concat(["-q", "--no-pager", "-f", "json", "-o", output_path])
79
+
80
+ max_stdout = resolve_stdout_limit(configuration, repository_root, 16 * 1024 * 1024)
81
+ tool_version = probe_result.version
82
+
83
+ begin
84
+ result = runner.run(
85
+ clean_command.fetch(:executable),
86
+ run_argv,
87
+ chdir: repository_root,
88
+ timeout_seconds: timeout_seconds,
89
+ max_stdout_bytes: max_stdout
90
+ )
91
+
92
+ 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
93
+ 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)
94
+ 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
95
+ 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
96
+
97
+ unless File.file?(output_path)
98
+ detail = Shared.detail_for(result)
99
+ msg = detail.strip.empty? ? "Brakeman did not produce structured output" : detail
100
+ status = result.exit_code && result.exit_code != 0 ? "failed" : "malformed"
101
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: status, message: msg, tool_version: tool_version), []]
102
+ end
103
+
104
+ begin
105
+ bytes = File.binread(output_path)
106
+ rescue SystemCallError => error
107
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
108
+ end
109
+
110
+ if bytes.bytesize > max_stdout
111
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: "Brakeman output exceeds #{max_stdout} bytes", tool_version: tool_version), []]
112
+ end
113
+
114
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
115
+ unless text.valid_encoding?
116
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: "Brakeman output is not valid UTF-8", tool_version: tool_version), []]
117
+ end
118
+
119
+ begin
120
+ document = JSON.parse(text)
121
+ rescue JSON::ParserError => error
122
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
123
+ end
124
+
125
+ begin
126
+ summary, findings = normalize_document(document)
127
+ rescue MalformedOutput => error
128
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
129
+ end
130
+
131
+ # Process exit reconciliation:
132
+ # 0: Clean scan (0 warnings) or explicit no-exit-on-warn
133
+ # 3: Warnings reported by Brakeman (findings > 0)
134
+ # Any other status: execution failure
135
+ warnings_count = summary["security_warnings"] || 0
136
+ if result.exit_code == 0
137
+ # Clean scan or non-failing mode
138
+ elsif result.exit_code == 3
139
+ if warnings_count == 0 && findings.empty?
140
+ detail = Shared.detail_for(result)
141
+ msg = detail.strip.empty? ? "Brakeman exited with status 3 but reported 0 warnings" : detail
142
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
143
+ end
144
+ else
145
+ detail = Shared.detail_for(result)
146
+ msg = detail.strip.empty? ? "Brakeman exited with unexpected status #{result.exit_code}" : detail
147
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
148
+ end
149
+
150
+ analyzer_result = AnalyzerResult.new(
151
+ analyzer: ANALYZER_ID,
152
+ tool_version: tool_version,
153
+ invocation: public_invocation,
154
+ execution_status: "succeeded",
155
+ finding_ids: findings.map(&:id),
156
+ evidence_summary: summary
157
+ )
158
+ [analyzer_result, findings]
159
+ ensure
160
+ begin
161
+ File.unlink(output_path) if output_path && File.exist?(output_path)
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def default_command(repository_root)
171
+ if File.file?(File.join(repository_root, "Gemfile"))
172
+ { executable: "bundle", args_prefix: ["exec", "brakeman"] }
173
+ else
174
+ { executable: "brakeman", args_prefix: [] }
175
+ end
176
+ end
177
+
178
+ def clean_args_prefix(prefix)
179
+ cleaned = []
180
+ skip_next = false
181
+ Array(prefix).each_with_index do |arg, i|
182
+ if skip_next
183
+ skip_next = false
184
+ next
185
+ end
186
+ if (arg == "-f" || arg == "--format" || arg == "-o" || arg == "--output") && Array(prefix)[i + 1]
187
+ skip_next = true
188
+ next
189
+ elsif arg.start_with?("-f=") || arg.start_with?("--format=") || arg.start_with?("-o=") || arg.start_with?("--output=")
190
+ next
191
+ elsif arg == "-q" || arg == "--quiet" || arg == "--no-pager"
192
+ next
193
+ end
194
+ cleaned << arg
195
+ end
196
+ cleaned
197
+ end
198
+
199
+ def normalize_document(document)
200
+ raise MalformedOutput, "Brakeman JSON root must be an object" unless document.is_a?(Hash)
201
+
202
+ scan_info = document["scan_info"]
203
+ warnings = document["warnings"]
204
+ errors = document["errors"]
205
+
206
+ raise MalformedOutput, "Brakeman JSON is missing scan_info" unless scan_info.is_a?(Hash)
207
+ raise MalformedOutput, "Brakeman JSON is missing warnings array" unless warnings.is_a?(Array)
208
+
209
+ findings = []
210
+ warnings.each_with_index do |warning, index|
211
+ finding = normalize_warning(warning, index)
212
+ findings << finding if finding
213
+ end
214
+
215
+ findings = findings.uniq(&:fingerprint).sort_by(&:sort_key)
216
+ summary_h = build_summary(scan_info, findings.length)
217
+
218
+ [summary_h, findings]
219
+ end
220
+
221
+ def normalize_warning(warning, index)
222
+ raise MalformedOutput, "Brakeman warning #{index} must be an object" unless warning.is_a?(Hash)
223
+
224
+ check_name = warning["check_name"] || warning["warning_type"] || "warning_#{warning['warning_code'] || index}"
225
+ rule_id = check_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 64]
226
+ rule_id = "warning_#{index}" if rule_id.empty?
227
+
228
+ raw_msg = warning["message"] || warning["warning_type"] || "security warning"
229
+ message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
230
+
231
+ confidence_str = warning["confidence"].to_s
232
+ confidence = CONFIDENCE_MAP.fetch(confidence_str) { "medium" }
233
+ severity = SEVERITY_MAP.fetch(confidence_str) { "medium" }
234
+
235
+ category = "security"
236
+ path = normalize_path(warning["file"] || "unknown")
237
+ start_line = extract_line(warning["line"])
238
+
239
+ location = { "path" => path }
240
+ location["start_line"] = start_line if start_line && start_line >= 1
241
+ location["end_line"] = location["start_line"] if location.key?("start_line")
242
+
243
+ fingerprint = Finding.fingerprint_for(
244
+ analyzer: ANALYZER_ID,
245
+ rule_id: rule_id,
246
+ path: path,
247
+ message: message
248
+ )
249
+
250
+ Finding.new(
251
+ fingerprint: fingerprint,
252
+ origin: "deterministic",
253
+ analyzer: ANALYZER_ID,
254
+ rule_id: rule_id,
255
+ category: category,
256
+ severity: severity,
257
+ confidence: confidence,
258
+ state: "observed",
259
+ evidence_ref: "native:brakeman:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
260
+ location: location,
261
+ message: message
262
+ )
263
+ rescue ArgumentError => error
264
+ raise MalformedOutput, "Brakeman warning #{index} is malformed: #{error.message}"
265
+ end
266
+
267
+ def normalize_path(file)
268
+ return "unknown" unless file.is_a?(String) && !file.empty?
269
+
270
+ cleaned = file.delete_prefix("./").delete_prefix("/")
271
+ return cleaned if cleaned.match?(Finding::LOCATION_PATH_PATTERN)
272
+
273
+ "unknown"
274
+ end
275
+
276
+ def extract_line(line)
277
+ return nil if line.nil?
278
+ val = Integer(line) rescue nil
279
+ val && val >= 1 ? val : nil
280
+ end
281
+
282
+ def build_summary(scan_info, security_warnings_count)
283
+ {
284
+ "security_warnings" => security_warnings_count,
285
+ "checks_performed" => scan_info["checks_performed"].is_a?(Array) ? scan_info["checks_performed"].length : 0,
286
+ "duration_seconds" => Float(scan_info["duration"] || scan_info["duration_seconds"] || 0),
287
+ "rails_version" => scan_info["rails_version"].to_s,
288
+ "ruby_version" => scan_info["ruby_version"].to_s,
289
+ "brakeman_version" => scan_info["brakeman_version"].to_s
290
+ }
291
+ rescue ArgumentError, TypeError
292
+ raise MalformedOutput, "Brakeman scan_info fields have invalid types"
293
+ end
294
+
295
+ def resolve_stdout_limit(configuration, repository_root, default_bytes)
296
+ raw_limit = nil
297
+ if configuration
298
+ sel = configuration.analyzers[ANALYZER_ID] rescue nil
299
+ raw_limit = sel && sel["output_limit_bytes"]
300
+ end
301
+ raw_limit ||= default_bytes
302
+ limit = Integer(raw_limit) rescue default_bytes
303
+ limit = default_bytes if limit <= 0
304
+ max = RailVerdict::ProcessRunner::MAX_SAFE_STDOUT_BYTES
305
+ [[limit, max].min, 1024].max
306
+ end
307
+ end
308
+ end
309
+ end
@@ -19,13 +19,14 @@ module RailVerdict
19
19
  end
20
20
 
21
21
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
22
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
22
23
  command = @command_resolver.call(repository_root)
23
24
  invocation = Shared.invocation_for(command, ["version"])
24
25
  result = runner.run(
25
26
  command.fetch(:executable),
26
27
  invocation.fetch("argv"),
27
28
  chdir: repository_root,
28
- timeout_seconds: timeout_seconds
29
+ timeout_seconds: effective_timeout
29
30
  )
30
31
 
31
32
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -146,7 +147,7 @@ module RailVerdict
146
147
  raw_msg = advisory["title"] || advisory["description"] || entry["title"] || entry["description"] || "vulnerability in #{gem_name}"
147
148
  message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
148
149
 
149
- rule_id = "bundler_audit/advisory:#{id}"
150
+ rule_id = "advisory:#{id}"
150
151
  category = "dependency"
151
152
 
152
153
  path = "Gemfile.lock"
@@ -21,13 +21,14 @@ module RailVerdict
21
21
  end
22
22
 
23
23
  def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
24
+ effective_timeout = [timeout_seconds.to_f, 5.0].min
24
25
  command = @command_resolver.call(repository_root)
25
26
  probe_argv = probe_argv_for(command, repository_root)
26
27
  result = runner.run(
27
28
  command.fetch(:executable),
28
29
  probe_argv,
29
30
  chdir: repository_root,
30
- timeout_seconds: timeout_seconds
31
+ timeout_seconds: effective_timeout
31
32
  )
32
33
 
33
34
  return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
@@ -47,7 +48,7 @@ module RailVerdict
47
48
  Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
48
49
  end
49
50
 
50
- def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil)
51
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: nil, target_files: nil, test_scope: "full", fallback_reason: nil)
51
52
  probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
52
53
 
53
54
  unless probe_result.status == "succeeded"
@@ -56,21 +57,48 @@ module RailVerdict
56
57
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: version_invocation, status: probe_result.status, message: probe_result.message, tool_version: probe_result.version), []]
57
58
  end
58
59
 
60
+ target_list = Array(target_files).compact.reject(&:empty?)
61
+ if target_files.is_a?(Array) && target_files.empty? && test_scope == "targeted"
62
+ summary = {
63
+ "schema_version" => "1.0",
64
+ "runner" => "minitest #{probe_result.version}",
65
+ "seed" => 0,
66
+ "tests_total" => 0,
67
+ "assertions" => 0,
68
+ "failures" => 0,
69
+ "errors" => 0,
70
+ "skips" => 0,
71
+ "duration_seconds" => 0.0,
72
+ "test_scope" => "targeted",
73
+ "target_files" => [],
74
+ "fallback_reason" => fallback_reason
75
+ }.compact
76
+ analyzer_result = AnalyzerResult.new(
77
+ analyzer: ANALYZER_ID,
78
+ tool_version: probe_result.version,
79
+ invocation: Shared.invocation_for(@command_resolver.call(repository_root), ["run"]),
80
+ execution_status: "succeeded",
81
+ finding_ids: [],
82
+ evidence_summary: summary
83
+ )
84
+ return [analyzer_result, []]
85
+ end
86
+
59
87
  command = @command_resolver.call(repository_root)
60
88
  reporter_path = resolve_reporter_path
61
89
  unless reporter_path
62
90
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: Shared.invocation_for(command, ["run"]), status: "malformed", message: "installed Minitest reporter is missing", tool_version: probe_result.version), []]
63
91
  end
64
92
 
65
- invocation = Shared.invocation_for(command, ["run"])
66
- output_path = File.join(repository_root, ".railverdict-minitest-#{SecureRandom.hex(6)}.json")
93
+ invocation = Shared.invocation_for(command, target_list.empty? ? ["run"] : ["run"] + target_list)
94
+ output_path = File.join(Dir.tmpdir, "railverdict-minitest-#{SecureRandom.hex(8)}.json")
67
95
  env_reset_required = false
68
96
  previous_env = ENV["RAILVERDICT_MINITEST_OUTPUT"]
69
97
  begin
70
98
  ENV["RAILVERDICT_MINITEST_OUTPUT"] = output_path
71
99
  env_reset_required = true
72
100
  augmented_argv = invocation.fetch("argv").dup
73
- augmented_argv = inject_reporter_require(augmented_argv, reporter_path)
101
+ augmented_argv = inject_reporter_require(augmented_argv, reporter_path, target_files: target_list)
74
102
  result = runner.run(
75
103
  command.fetch(:executable),
76
104
  augmented_argv,
@@ -88,12 +116,30 @@ module RailVerdict
88
116
  return document if document.is_a?(Array) && document.first.is_a?(AnalyzerResult)
89
117
 
90
118
  begin
91
- summary, findings = normalize_document(document)
119
+ summary, findings = normalize_document(document, test_scope: test_scope, target_files: target_list, fallback_reason: fallback_reason)
92
120
  rescue MalformedOutput => error
93
121
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
94
122
  end
95
123
 
96
- analyzer_result = AnalyzerResult.new(
124
+ # Process exit reconciliation (RH-03):
125
+ failures_and_errors = (summary["failures"] || 0) + (summary["errors"] || 0)
126
+ if result.exit_code == 0
127
+ if failures_and_errors > 0 || !findings.empty?
128
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "Minitest exited with status 0 but reported #{failures_and_errors} failures/errors", tool_version: tool_version), []]
129
+ end
130
+ elsif result.exit_code == 1
131
+ if failures_and_errors == 0 && findings.empty?
132
+ detail = Shared.detail_for(result)
133
+ msg = detail.strip.empty? ? "Minitest exited with status 1 but reported 0 failed tests" : detail
134
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
135
+ end
136
+ else
137
+ detail = Shared.detail_for(result)
138
+ msg = detail.strip.empty? ? "Minitest exited with unexpected status #{result.exit_code}" : detail
139
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
140
+ end
141
+
142
+ analyzer_result = AnalyzerResult.new(
97
143
  analyzer: ANALYZER_ID,
98
144
  tool_version: tool_version,
99
145
  invocation: invocation,
@@ -141,16 +187,11 @@ module RailVerdict
141
187
 
142
188
  def resolve_reporter_path
143
189
  candidates = []
144
- begin
145
- specs = Gem::Specification.find_all_by_name("rail_verdict")
146
- if specs.any?
147
- best = specs.max_by(&:version)
148
- candidates << File.join(best.full_gem_path, "exe", "railverdict-minitest-reporter.rb")
149
- end
150
- rescue StandardError
151
- nil
152
- end
153
190
  candidates << File.expand_path("../../../exe/railverdict-minitest-reporter.rb", __dir__)
191
+ if defined?(Gem) && Gem.respond_to?(:loaded_specs) && Gem.loaded_specs["rail_verdict"]
192
+ spec = Gem.loaded_specs["rail_verdict"]
193
+ candidates << File.join(spec.full_gem_path, "exe", "railverdict-minitest-reporter.rb")
194
+ end
154
195
  candidates.find { |path| File.file?(path) && File.readable?(path) }
155
196
  end
156
197
 
@@ -161,53 +202,58 @@ module RailVerdict
161
202
  base
162
203
  end
163
204
 
164
- def inject_reporter_require(argv, reporter_path)
205
+ def inject_reporter_require(argv, reporter_path, target_files: nil)
165
206
  argv = argv.dup
207
+ test_expr = if target_files && !target_files.empty?
208
+ files_list = target_files.map { |f| File.expand_path(f) }.inspect
209
+ "#{files_list}.each{|f| require f }"
210
+ else
211
+ "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }"
212
+ end
166
213
  if argv.include?("run")
167
214
  idx = argv.index("run")
168
215
  argv[idx] = "-r"
169
216
  argv.insert(idx + 1, reporter_path)
170
217
  argv.insert(idx + 2, "-e")
171
- argv.insert(idx + 3, "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }")
218
+ argv.insert(idx + 3, test_expr)
172
219
  else
173
220
  argv.concat(["-r", reporter_path])
174
- argv.concat(["-e", "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }"])
221
+ argv.concat(["-e", test_expr])
175
222
  end
176
223
  argv
177
224
  end
178
225
 
179
226
  def load_reporter_document(output_path, run_result, invocation, tool_version)
180
- if File.file?(output_path)
181
- begin
182
- bytes = File.binread(output_path)
183
- rescue SystemCallError => error
184
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
185
- end
186
- if bytes.bytesize > 4 * 1024 * 1024
187
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "Minitest reporter output exceeds 4 MiB", tool_version: tool_version), []]
188
- end
189
- text = bytes.dup.force_encoding(Encoding::UTF_8)
190
- unless text.valid_encoding?
191
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "Minitest reporter output is not valid UTF-8", tool_version: tool_version), []]
192
- end
193
- begin
194
- return JSON.parse(text)
195
- rescue JSON::ParserError => error
196
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
197
- end
227
+ unless File.file?(output_path)
228
+ detail = Shared.detail_for(run_result)
229
+ msg = detail.strip.empty? ? "Minitest reporter did not produce output" : detail
230
+ status = run_result.exit_code && run_result.exit_code != 0 ? "failed" : "malformed"
231
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: status, message: msg, tool_version: tool_version), []]
198
232
  end
199
- stdout = run_result.stdout.to_s
200
- if stdout.strip.empty?
201
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: "Minitest reporter did not produce output", tool_version: tool_version), []]
233
+
234
+ begin
235
+ bytes = File.binread(output_path)
236
+ rescue SystemCallError => error
237
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
238
+ end
239
+
240
+ if bytes.bytesize > 4 * 1024 * 1024
241
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "Minitest reporter output exceeds 4 MiB", tool_version: tool_version), []]
202
242
  end
243
+
244
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
245
+ unless text.valid_encoding?
246
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "Minitest reporter output is not valid UTF-8", tool_version: tool_version), []]
247
+ end
248
+
203
249
  begin
204
- JSON.parse(stdout)
250
+ JSON.parse(text)
205
251
  rescue JSON::ParserError => error
206
252
  [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
207
253
  end
208
254
  end
209
255
 
210
- def normalize_document(document)
256
+ def normalize_document(document, test_scope: "full", target_files: nil, fallback_reason: nil)
211
257
  raise MalformedOutput, "Minitest JSON root must be an object" unless document.is_a?(Hash)
212
258
 
213
259
  required = %w[schema_version runner seed tests_total assertions failures errors skips duration_seconds tests]
@@ -229,6 +275,9 @@ module RailVerdict
229
275
 
230
276
  findings = findings.uniq { |f| f.fingerprint }.sort_by(&:sort_key)
231
277
  summary = build_summary(document)
278
+ summary["test_scope"] = test_scope if test_scope
279
+ summary["target_files"] = target_files if target_files && !target_files.empty?
280
+ summary["fallback_reason"] = fallback_reason if fallback_reason
232
281
  [summary, findings]
233
282
  end
234
283
 
@@ -247,7 +296,7 @@ module RailVerdict
247
296
 
248
297
  severity = status == "errored" ? "critical" : "high"
249
298
  category = "test"
250
- rule_id = "minitest/test:#{class_name}##{method_name}"
299
+ rule_id = "test:#{class_name}##{method_name}"
251
300
  raw_msg = test["failure_message"] || test["method_name"] || "test failed: #{method_name}"
252
301
  message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg)
253
302