rail_verdict 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63845a29f84295397de20a23b72c7d6e0a9d6c969be35ac8081e2536b0a37cec
4
- data.tar.gz: 6e778d530c6abe87db5997b9d5fe328ebd750ac1722ede75042195b5d98d92cf
3
+ metadata.gz: 220003ceaeba89b1fb2f8f7082a8a2e6f6dcdcc070005221000602524e3b7576
4
+ data.tar.gz: 13fb2353b109fed755c762be216060174b3a89a59bbc6e443f06d133edbf0537
5
5
  SHA512:
6
- metadata.gz: beabe5b069a803549a6af55d03f16256e5ebe7e5ab6152672a7a1fa415a75827656108c01207860251fc8936fdc3bfb6397c1f53ca53f342da5ee913051d1c2a
7
- data.tar.gz: 72e637013c3a4216a90fcb466c947b6b6e97bc9035166bb8b96b76b62579c76f1253eb765d1c56ac1e39dd82197489c3535a411fece8535c6cd340bd0e741ac9
6
+ metadata.gz: 7a58b08566a039a6c32fae4a4e4154be529a2c4e0567afc2ee7ac8b24c6e944e0b0555359eb8ea532c02f6143aa4e7594cce208c41bc284243d519d704f07d62
7
+ data.tar.gz: a79f0ce2a5eb249cd902f60f74b41318dba4f52112004109b42d394540f9ab8aac846a0e5e8d22c1cee009d649016fbf75dc8d715a2440fb498e61b3600247ee
@@ -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
@@ -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
@@ -63,7 +64,7 @@ module RailVerdict
63
64
  end
64
65
 
65
66
  invocation = Shared.invocation_for(command, ["run"])
66
- output_path = File.join(repository_root, ".railverdict-minitest-#{SecureRandom.hex(6)}.json")
67
+ output_path = File.join(Dir.tmpdir, "railverdict-minitest-#{SecureRandom.hex(8)}.json")
67
68
  env_reset_required = false
68
69
  previous_env = ENV["RAILVERDICT_MINITEST_OUTPUT"]
69
70
  begin
@@ -93,7 +94,25 @@ module RailVerdict
93
94
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
94
95
  end
95
96
 
96
- analyzer_result = AnalyzerResult.new(
97
+ # Process exit reconciliation (RH-03):
98
+ failures_and_errors = (summary["failures"] || 0) + (summary["errors"] || 0)
99
+ if result.exit_code == 0
100
+ if failures_and_errors > 0 || !findings.empty?
101
+ 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), []]
102
+ end
103
+ elsif result.exit_code == 1
104
+ if failures_and_errors == 0 && findings.empty?
105
+ detail = Shared.detail_for(result)
106
+ msg = detail.strip.empty? ? "Minitest exited with status 1 but reported 0 failed tests" : detail
107
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
108
+ end
109
+ else
110
+ detail = Shared.detail_for(result)
111
+ msg = detail.strip.empty? ? "Minitest exited with unexpected status #{result.exit_code}" : detail
112
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "failed", message: msg, tool_version: tool_version), []]
113
+ end
114
+
115
+ analyzer_result = AnalyzerResult.new(
97
116
  analyzer: ANALYZER_ID,
98
117
  tool_version: tool_version,
99
118
  invocation: invocation,
@@ -141,16 +160,11 @@ module RailVerdict
141
160
 
142
161
  def resolve_reporter_path
143
162
  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
163
  candidates << File.expand_path("../../../exe/railverdict-minitest-reporter.rb", __dir__)
164
+ if defined?(Gem) && Gem.respond_to?(:loaded_specs) && Gem.loaded_specs["rail_verdict"]
165
+ spec = Gem.loaded_specs["rail_verdict"]
166
+ candidates << File.join(spec.full_gem_path, "exe", "railverdict-minitest-reporter.rb")
167
+ end
154
168
  candidates.find { |path| File.file?(path) && File.readable?(path) }
155
169
  end
156
170
 
@@ -177,31 +191,30 @@ module RailVerdict
177
191
  end
178
192
 
179
193
  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
194
+ unless File.file?(output_path)
195
+ detail = Shared.detail_for(run_result)
196
+ msg = detail.strip.empty? ? "Minitest reporter did not produce output" : detail
197
+ status = run_result.exit_code && run_result.exit_code != 0 ? "failed" : "malformed"
198
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: status, message: msg, tool_version: tool_version), []]
198
199
  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), []]
200
+
201
+ begin
202
+ bytes = File.binread(output_path)
203
+ rescue SystemCallError => error
204
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
202
205
  end
206
+
207
+ if bytes.bytesize > 4 * 1024 * 1024
208
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "Minitest reporter output exceeds 4 MiB", tool_version: tool_version), []]
209
+ end
210
+
211
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
212
+ unless text.valid_encoding?
213
+ 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), []]
214
+ end
215
+
203
216
  begin
204
- JSON.parse(stdout)
217
+ JSON.parse(text)
205
218
  rescue JSON::ParserError => error
206
219
  [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
207
220
  end
@@ -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
@@ -47,61 +52,136 @@ module RailVerdict
47
52
 
48
53
  def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil, configuration: 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.
64
+ output_path = File.join(Dir.tmpdir, "railverdict-rspec-#{SecureRandom.hex(8)}.json")
65
+ public_invocation = Shared.invocation_for(clean_command, ["--format", "json"])
66
+ run_argv = clean_prefix.dup.concat(["--format", "json", "--out", output_path])
67
+
59
68
  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
69
  tool_version = probe_result.version
68
70
 
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
73
-
74
71
  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), []]
72
+ result = runner.run(
73
+ clean_command.fetch(:executable),
74
+ run_argv,
75
+ chdir: repository_root,
76
+ timeout_seconds: timeout_seconds,
77
+ max_stdout_bytes: max_stdout
78
+ )
79
+
80
+ 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
81
+ 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)
82
+ 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
83
+ 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
84
+
85
+ unless File.file?(output_path)
86
+ detail = Shared.detail_for(result)
87
+ msg = detail.strip.empty? ? "RSpec did not produce structured output" : detail
88
+ status = result.exit_code && result.exit_code != 0 ? "failed" : "malformed"
89
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: status, message: msg, tool_version: tool_version), []]
90
+ end
91
+
92
+ begin
93
+ bytes = File.binread(output_path)
94
+ rescue SystemCallError => error
95
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
96
+ end
97
+
98
+ if bytes.bytesize > max_stdout
99
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "truncated", message: "RSpec output exceeds #{max_stdout} bytes", tool_version: tool_version), []]
100
+ end
101
+
102
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
103
+ unless text.valid_encoding?
104
+ 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), []]
105
+ end
106
+
107
+ begin
108
+ document = JSON.parse(text)
109
+ rescue JSON::ParserError => error
110
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
111
+ end
112
+
113
+ begin
114
+ summary, findings = normalize_document(document)
115
+ rescue MalformedOutput => error
116
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
117
+ end
118
+
119
+ # Process exit reconciliation (RH-02):
120
+ # 0: all examples passed, 0 failures/errors
121
+ # 1: failed examples present (failures > 0 or findings non-empty)
122
+ # Any other exit code or contradiction: fail closed
123
+ failures_count = summary["failures"] || 0
124
+ if result.exit_code == 0
125
+ if failures_count > 0 || !findings.empty?
126
+ 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), []]
127
+ end
128
+ elsif result.exit_code == 1
129
+ if failures_count == 0 && findings.empty?
130
+ detail = Shared.detail_for(result)
131
+ msg = detail.strip.empty? ? "RSpec exited with status 1 but reported 0 failed examples" : detail
132
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
133
+ end
134
+ else
135
+ detail = Shared.detail_for(result)
136
+ msg = detail.strip.empty? ? "RSpec exited with unexpected status #{result.exit_code}" : detail
137
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: public_invocation, status: "failed", message: msg, tool_version: tool_version), []]
138
+ end
139
+
140
+ analyzer_result = AnalyzerResult.new(
141
+ analyzer: ANALYZER_ID,
142
+ tool_version: tool_version,
143
+ invocation: public_invocation,
144
+ execution_status: "succeeded",
145
+ finding_ids: findings.map(&:id),
146
+ evidence_summary: summary
147
+ )
148
+ [analyzer_result, findings]
149
+ ensure
150
+ begin
151
+ File.unlink(output_path) if output_path && File.exist?(output_path)
152
+ rescue StandardError
153
+ nil
154
+ end
78
155
  end
79
-
80
- 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), []]
84
- 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
156
  end
96
157
 
97
158
  private
98
159
 
99
160
  def default_command(repository_root)
100
161
  if File.file?(File.join(repository_root, "Gemfile"))
101
- { executable: "bundle", args_prefix: ["exec", "rspec", "--format", "json"] }
162
+ { executable: "bundle", args_prefix: ["exec", "rspec"] }
102
163
  else
103
- { executable: "rspec", args_prefix: ["--format", "json"] }
164
+ { executable: "rspec", args_prefix: [] }
165
+ end
166
+ end
167
+
168
+ def clean_args_prefix(prefix)
169
+ cleaned = []
170
+ skip_next = false
171
+ Array(prefix).each_with_index do |arg, i|
172
+ if skip_next
173
+ skip_next = false
174
+ next
175
+ end
176
+ if arg == "--format" && Array(prefix)[i + 1] == "json"
177
+ skip_next = true
178
+ next
179
+ elsif arg == "--format=json"
180
+ next
181
+ end
182
+ cleaned << arg
104
183
  end
184
+ cleaned
105
185
  end
106
186
 
107
187
  def normalize_document(document)
@@ -142,7 +222,7 @@ module RailVerdict
142
222
  raw_msg = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || nil
143
223
  message = Shared.normalize_finding_message(ANALYZER_ID, raw_msg.nil? || raw_msg.to_s.strip.empty? ? "rspec example failed" : raw_msg)
144
224
 
145
- rule_id = "rspec/example:#{example['id'] || id_for(example, index)}"
225
+ rule_id = "rspec/example:#{example[id] || id_for(example, index)}"
146
226
  path = normalize_path(example["file_path"] || example["file"] || "spec/unknown_spec.rb")
147
227
  failure_line, failure_path = failure_location(example, path)
148
228
  start_line = failure_line || example["line_number"] || extract_line(example["id"])
@@ -273,14 +353,12 @@ module RailVerdict
273
353
  end
274
354
 
275
355
  def resolve_stdout_limit(configuration, repository_root, default_bytes)
276
- # Prefer explicit per-analyzer config if present (future-compatible), else default.
277
356
  raw_limit = nil
278
357
  if configuration
279
358
  sel = configuration.analyzers[ANALYZER_ID] rescue nil
280
359
  raw_limit = sel && sel["output_limit_bytes"]
281
360
  end
282
361
  raw_limit ||= default_bytes
283
- # Clamp to safe ceiling
284
362
  limit = Integer(raw_limit) rescue default_bytes
285
363
  limit = default_bytes if limit <= 0
286
364
  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