rail_verdict 1.0.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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +14 -0
  4. data/README.md +192 -0
  5. data/exe/railverdict +6 -0
  6. data/exe/railverdict-minitest-reporter.rb +90 -0
  7. data/lib/rail_verdict/analyzers/_shared.rb +54 -0
  8. data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
  9. data/lib/rail_verdict/analyzers/minitest.rb +312 -0
  10. data/lib/rail_verdict/analyzers/rspec.rb +274 -0
  11. data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
  12. data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
  13. data/lib/rail_verdict/baseline.rb +151 -0
  14. data/lib/rail_verdict/check.rb +415 -0
  15. data/lib/rail_verdict/cli.rb +471 -0
  16. data/lib/rail_verdict/comparison.rb +216 -0
  17. data/lib/rail_verdict/configuration.rb +165 -0
  18. data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
  19. data/lib/rail_verdict/contracts/finding.rb +154 -0
  20. data/lib/rail_verdict/contracts/gate_result.rb +171 -0
  21. data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
  22. data/lib/rail_verdict/doctor.rb +132 -0
  23. data/lib/rail_verdict/errors.rb +17 -0
  24. data/lib/rail_verdict/findings_command.rb +24 -0
  25. data/lib/rail_verdict/fingerprint.rb +72 -0
  26. data/lib/rail_verdict/git/command.rb +48 -0
  27. data/lib/rail_verdict/git/context.rb +361 -0
  28. data/lib/rail_verdict/git/diff_parser.rb +200 -0
  29. data/lib/rail_verdict/git/errors.rb +16 -0
  30. data/lib/rail_verdict/init.rb +38 -0
  31. data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
  32. data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
  33. data/lib/rail_verdict/intelligence/budget.rb +36 -0
  34. data/lib/rail_verdict/intelligence/cache.rb +74 -0
  35. data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
  36. data/lib/rail_verdict/intelligence/manifest.rb +70 -0
  37. data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
  38. data/lib/rail_verdict/intelligence/prompt.rb +28 -0
  39. data/lib/rail_verdict/intelligence/provider.rb +14 -0
  40. data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
  41. data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
  42. data/lib/rail_verdict/intelligence/redactor.rb +51 -0
  43. data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
  44. data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
  45. data/lib/rail_verdict/intelligence.rb +11 -0
  46. data/lib/rail_verdict/mcp/cache.rb +233 -0
  47. data/lib/rail_verdict/mcp/repository_root.rb +31 -0
  48. data/lib/rail_verdict/mcp/serializers.rb +98 -0
  49. data/lib/rail_verdict/mcp/server.rb +110 -0
  50. data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
  51. data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
  52. data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
  53. data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
  54. data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
  55. data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
  56. data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
  57. data/lib/rail_verdict/mcp/validators.rb +81 -0
  58. data/lib/rail_verdict/mcp.rb +7 -0
  59. data/lib/rail_verdict/process_runner.rb +292 -0
  60. data/lib/rail_verdict/rails_context/classifier.rb +57 -0
  61. data/lib/rail_verdict/rails_context/confidence.rb +13 -0
  62. data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
  63. data/lib/rail_verdict/rails_context/context.rb +115 -0
  64. data/lib/rail_verdict/rails_context/detector.rb +141 -0
  65. data/lib/rail_verdict/rails_context/limits.rb +17 -0
  66. data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
  67. data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
  68. data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
  69. data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
  70. data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
  71. data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
  72. data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
  73. data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
  74. data/lib/rail_verdict/rails_context.rb +21 -0
  75. data/lib/rail_verdict/repair/boundary.rb +36 -0
  76. data/lib/rail_verdict/repair/command.rb +89 -0
  77. data/lib/rail_verdict/repair/constraints.rb +31 -0
  78. data/lib/rail_verdict/repair/context_assembler.rb +301 -0
  79. data/lib/rail_verdict/repair/diff_context.rb +51 -0
  80. data/lib/rail_verdict/repair/packet.rb +158 -0
  81. data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
  82. data/lib/rail_verdict/repair/verification_plan.rb +45 -0
  83. data/lib/rail_verdict/repair/verifier.rb +84 -0
  84. data/lib/rail_verdict/reporters/console.rb +107 -0
  85. data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
  86. data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
  87. data/lib/rail_verdict/reporters/sarif.rb +138 -0
  88. data/lib/rail_verdict/run_context.rb +99 -0
  89. data/lib/rail_verdict/schema_validator.rb +85 -0
  90. data/lib/rail_verdict/strict_yaml.rb +134 -0
  91. data/lib/rail_verdict/verification/policy.rb +359 -0
  92. data/lib/rail_verdict/version.rb +5 -0
  93. data/lib/rail_verdict/waiver.rb +67 -0
  94. data/lib/rail_verdict/waiver_store.rb +59 -0
  95. data/lib/rail_verdict.rb +64 -0
  96. data/schemas/ai-analysis-v1.schema.json +54 -0
  97. data/schemas/baseline-v1.schema.json +108 -0
  98. data/schemas/configuration-v1.1.schema.json +150 -0
  99. data/schemas/configuration-v1.2.schema.json +182 -0
  100. data/schemas/configuration-v1.3.schema.json +198 -0
  101. data/schemas/configuration-v1.4.schema.json +146 -0
  102. data/schemas/configuration-v1.schema.json +72 -0
  103. data/schemas/coverage-v1.schema.json +51 -0
  104. data/schemas/finding-v1.schema.json +131 -0
  105. data/schemas/minitest-reporter-v1.schema.json +113 -0
  106. data/schemas/repair-packet-v1.schema.json +364 -0
  107. data/schemas/result-v1.schema.json +378 -0
  108. data/schemas/waiver-v1.schema.json +45 -0
  109. data/schemas/waivers-v1.schema.json +67 -0
  110. metadata +192 -0
@@ -0,0 +1,312 @@
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 Minitest
12
+ ANALYZER_ID = "minitest"
13
+ SUPPORTED_VERSIONS = Gem::Requirement.new(">= 5", "< 7")
14
+
15
+ Probe = Struct.new(:status, :version, :message, keyword_init: true)
16
+
17
+ class MalformedOutput < StandardError; end
18
+
19
+ def initialize(command_resolver: nil)
20
+ @command_resolver = command_resolver || method(:default_command)
21
+ end
22
+
23
+ def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
24
+ command = @command_resolver.call(repository_root)
25
+ probe_argv = probe_argv_for(command, repository_root)
26
+ result = runner.run(
27
+ command.fetch(:executable),
28
+ probe_argv,
29
+ chdir: repository_root,
30
+ timeout_seconds: timeout_seconds
31
+ )
32
+
33
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
34
+ return Probe.new(status: "timed_out", message: Shared.detail_for(result)) if result.status == :timed_out
35
+ return Probe.new(status: "signaled", message: Shared.detail_for(result)) if result.status == :signaled
36
+ return Probe.new(status: "truncated", message: Shared.detail_for(result)) if Shared.truncated?(result)
37
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) unless result.exit_code == 0
38
+
39
+ version = Shared.parse_semver(result.stdout)
40
+ return Probe.new(status: "unsupported", message: "Minitest version could not be parsed") unless version
41
+ return Probe.new(status: "unsupported", version: version, message: "unsupported Minitest version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
42
+
43
+ Probe.new(status: "succeeded", version: version)
44
+ rescue ArgumentError
45
+ Probe.new(status: "unsupported", message: "Minitest reported an invalid version")
46
+ rescue KeyError, ArgumentError => error
47
+ Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
48
+ end
49
+
50
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
51
+ probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
52
+
53
+ unless probe_result.status == "succeeded"
54
+ fallback_command = @command_resolver.call(repository_root)
55
+ version_invocation = Shared.invocation_for(fallback_command, ["--version"])
56
+ 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
+ end
58
+
59
+ command = @command_resolver.call(repository_root)
60
+ reporter_path = resolve_reporter_path
61
+ unless reporter_path
62
+ 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
+ end
64
+
65
+ invocation = Shared.invocation_for(command, ["run"])
66
+ output_path = File.join(repository_root, ".railverdict-minitest-#{SecureRandom.hex(6)}.json")
67
+ env_reset_required = false
68
+ previous_env = ENV["RAILVERDICT_MINITEST_OUTPUT"]
69
+ begin
70
+ ENV["RAILVERDICT_MINITEST_OUTPUT"] = output_path
71
+ env_reset_required = true
72
+ augmented_argv = invocation.fetch("argv").dup
73
+ augmented_argv = inject_reporter_require(augmented_argv, reporter_path)
74
+ result = runner.run(
75
+ command.fetch(:executable),
76
+ augmented_argv,
77
+ chdir: repository_root,
78
+ timeout_seconds: timeout_seconds
79
+ )
80
+ tool_version = probe_result.version
81
+
82
+ 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
83
+ 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)
84
+ 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
85
+ 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
86
+
87
+ document = load_reporter_document(output_path, result, invocation, tool_version)
88
+ return document if document.is_a?(Array) && document.first.is_a?(AnalyzerResult)
89
+
90
+ begin
91
+ summary, findings = normalize_document(document)
92
+ rescue MalformedOutput => error
93
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
94
+ end
95
+
96
+ analyzer_result = AnalyzerResult.new(
97
+ analyzer: ANALYZER_ID,
98
+ tool_version: tool_version,
99
+ invocation: invocation,
100
+ execution_status: "succeeded",
101
+ finding_ids: findings.map(&:id),
102
+ evidence_summary: summary
103
+ )
104
+ [analyzer_result, findings]
105
+ ensure
106
+ if env_reset_required
107
+ if previous_env.nil?
108
+ ENV.delete("RAILVERDICT_MINITEST_OUTPUT")
109
+ else
110
+ ENV["RAILVERDICT_MINITEST_OUTPUT"] = previous_env
111
+ end
112
+ end
113
+ begin
114
+ File.unlink(output_path) if File.file?(output_path)
115
+ rescue StandardError
116
+ nil
117
+ end
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def default_command(repository_root)
124
+ if File.file?(File.join(repository_root, "Gemfile"))
125
+ { executable: "bundle", args_prefix: ["exec", "ruby", "-I", "test", "-r", "test_helper"] }
126
+ else
127
+ { executable: "ruby", args_prefix: ["-I", "test", "-r", "test_helper"] }
128
+ end
129
+ end
130
+
131
+ def probe_argv_for(command, repository_root)
132
+ args = command.fetch(:args_prefix).dup
133
+ return args.concat(["--version"]) unless args.include?("test_helper")
134
+
135
+ if File.file?(File.join(repository_root, "Gemfile"))
136
+ ["exec", "ruby", "-rminitest", "-e", "puts Minitest::VERSION"]
137
+ else
138
+ ["-rminitest", "-e", "puts Minitest::VERSION"]
139
+ end
140
+ end
141
+
142
+ def resolve_reporter_path
143
+ 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
+ candidates << File.expand_path("../../../exe/railverdict-minitest-reporter.rb", __dir__)
154
+ candidates.find { |path| File.file?(path) && File.readable?(path) }
155
+ end
156
+
157
+ def build_run_argv(command, reporter_path)
158
+ base = command.fetch(:args_prefix).dup
159
+ base.concat(["-r", reporter_path])
160
+ base.concat(["-e", "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }"])
161
+ base
162
+ end
163
+
164
+ def inject_reporter_require(argv, reporter_path)
165
+ argv = argv.dup
166
+ if argv.include?("run")
167
+ idx = argv.index("run")
168
+ argv[idx] = "-r"
169
+ argv.insert(idx + 1, reporter_path)
170
+ argv.insert(idx + 2, "-e")
171
+ argv.insert(idx + 3, "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }")
172
+ else
173
+ argv.concat(["-r", reporter_path])
174
+ argv.concat(["-e", "Dir['test/**/*_test.rb'].sort.each{|f| require File.expand_path(f) }"])
175
+ end
176
+ argv
177
+ end
178
+
179
+ 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
198
+ 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), []]
202
+ end
203
+ begin
204
+ JSON.parse(stdout)
205
+ rescue JSON::ParserError => error
206
+ [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
207
+ end
208
+ end
209
+
210
+ def normalize_document(document)
211
+ raise MalformedOutput, "Minitest JSON root must be an object" unless document.is_a?(Hash)
212
+
213
+ required = %w[schema_version runner seed tests_total assertions failures errors skips duration_seconds tests]
214
+ missing = required - document.keys.map(&:to_s)
215
+ raise MalformedOutput, "Minitest JSON is missing keys: #{missing.join(', ')}" unless missing.empty?
216
+
217
+ unless document["schema_version"] == "1.0"
218
+ raise MalformedOutput, "Minitest reporter schema_version must be 1.0"
219
+ end
220
+
221
+ tests = document["tests"]
222
+ raise MalformedOutput, "Minitest tests must be an array" unless tests.is_a?(Array)
223
+
224
+ findings = []
225
+ tests.each_with_index do |test, index|
226
+ finding = normalize_test(test, index, document["tests_total"])
227
+ findings << finding if finding
228
+ end
229
+
230
+ findings = findings.uniq { |f| f.fingerprint }.sort_by(&:sort_key)
231
+ summary = build_summary(document)
232
+ [summary, findings]
233
+ end
234
+
235
+ def normalize_test(test, index, _total)
236
+ raise MalformedOutput, "Minitest test #{index} must be an object" unless test.is_a?(Hash)
237
+
238
+ class_name = test["class_name"]
239
+ method_name = test["method_name"]
240
+ status = test["status"]
241
+ raise MalformedOutput, "Minitest test #{index} has invalid status" unless %w[passed failed errored skipped].include?(status)
242
+ return nil if status == "passed" || status == "skipped"
243
+
244
+ unless class_name.is_a?(String) && !class_name.empty? && method_name.is_a?(String) && !method_name.empty?
245
+ raise MalformedOutput, "Minitest test #{index} has invalid class/method"
246
+ end
247
+
248
+ severity = status == "errored" ? "critical" : "high"
249
+ category = "test"
250
+ rule_id = "minitest/test:#{class_name}##{method_name}"
251
+ message = (test["failure_message"] || test["method_name"]).to_s
252
+ message = message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
253
+ message = "test failed: #{method_name}" if message.empty?
254
+ unless message.is_a?(String) && message.valid_encoding? && !message.empty? && message.bytesize <= 4096
255
+ raise MalformedOutput, "Minitest finding message is invalid"
256
+ end
257
+
258
+ path = normalize_path(test["file"], class_name)
259
+ start_line = test["line"]
260
+ location = { "path" => path }
261
+ location["start_line"] = start_line if start_line.is_a?(Integer) && start_line >= 1
262
+ location["end_line"] = location["start_line"] if location.key?("start_line")
263
+
264
+ fingerprint = Finding.fingerprint_for(
265
+ analyzer: ANALYZER_ID,
266
+ rule_id: rule_id,
267
+ path: path,
268
+ message: message
269
+ )
270
+ Finding.new(
271
+ fingerprint: fingerprint,
272
+ origin: "deterministic",
273
+ analyzer: ANALYZER_ID,
274
+ rule_id: rule_id,
275
+ category: category,
276
+ severity: severity,
277
+ confidence: "high",
278
+ state: "observed",
279
+ evidence_ref: "native:minitest:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
280
+ location: location,
281
+ message: message
282
+ )
283
+ rescue ArgumentError => error
284
+ raise MalformedOutput, "Minitest test #{index} is malformed: #{error.message}"
285
+ end
286
+
287
+ def normalize_path(file, fallback_class)
288
+ if file.is_a?(String) && !file.empty? && file.match?(Finding::LOCATION_PATH_PATTERN)
289
+ return file.delete_prefix("./")
290
+ end
291
+
292
+ fallback = fallback_class.to_s.gsub("::", "/").downcase
293
+ "test/#{fallback}_test.rb"
294
+ end
295
+
296
+ def build_summary(document)
297
+ {
298
+ "tests_total" => Integer(document["tests_total"]),
299
+ "assertions" => Integer(document["assertions"]),
300
+ "failures" => Integer(document["failures"]),
301
+ "errors" => Integer(document["errors"]),
302
+ "skips" => Integer(document["skips"]),
303
+ "duration_seconds" => Float(document["duration_seconds"]),
304
+ "seed" => document["seed"] ? Integer(document["seed"]) : nil,
305
+ "runner" => document["runner"].to_s[0, 128]
306
+ }
307
+ rescue ArgumentError, TypeError
308
+ raise MalformedOutput, "Minitest summary fields have invalid types"
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "_shared"
6
+
7
+ module RailVerdict
8
+ module Analyzers
9
+ class RSpec
10
+ ANALYZER_ID = "rspec"
11
+ SUPPORTED_VERSIONS = Gem::Requirement.new(">= 3.13", "< 4")
12
+
13
+ Probe = Struct.new(:status, :version, :message, keyword_init: true)
14
+
15
+ class MalformedOutput < StandardError; end
16
+
17
+ def initialize(command_resolver: nil)
18
+ @command_resolver = command_resolver || method(:default_command)
19
+ end
20
+
21
+ def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
22
+ command = @command_resolver.call(repository_root)
23
+ invocation = Shared.invocation_for(command, ["--version"])
24
+ result = runner.run(
25
+ command.fetch(:executable),
26
+ invocation.fetch("argv"),
27
+ chdir: repository_root,
28
+ timeout_seconds: timeout_seconds
29
+ )
30
+
31
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) if result.status == :spawn_failed
32
+ return Probe.new(status: "timed_out", message: Shared.detail_for(result)) if result.status == :timed_out
33
+ return Probe.new(status: "signaled", message: Shared.detail_for(result)) if result.status == :signaled
34
+ return Probe.new(status: "truncated", message: Shared.detail_for(result)) if Shared.truncated?(result)
35
+ return Probe.new(status: "unavailable", message: Shared.detail_for(result)) unless result.exit_code == 0
36
+
37
+ version = Shared.parse_semver(result.stdout)
38
+ return Probe.new(status: "unsupported", message: "RSpec version could not be parsed") unless version
39
+ return Probe.new(status: "unsupported", version: version, message: "unsupported RSpec version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
40
+
41
+ Probe.new(status: "succeeded", version: version)
42
+ rescue ArgumentError
43
+ Probe.new(status: "unsupported", message: "RSpec reported an invalid version")
44
+ rescue KeyError, ArgumentError => error
45
+ Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
46
+ end
47
+
48
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
49
+ command = @command_resolver.call(repository_root)
50
+ probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
51
+ version_invocation = Shared.invocation_for(command, ["--version"])
52
+
53
+ unless probe_result.status == "succeeded"
54
+ 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
+ end
56
+
57
+ invocation = Shared.invocation_for(command, ["--format", "json"])
58
+ result = runner.run(
59
+ command.fetch(:executable),
60
+ invocation.fetch("argv"),
61
+ chdir: repository_root,
62
+ timeout_seconds: timeout_seconds
63
+ )
64
+ tool_version = probe_result.version
65
+
66
+ 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
67
+ 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)
68
+ 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
69
+ 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
70
+
71
+ begin
72
+ document = JSON.parse(result.stdout)
73
+ rescue JSON::ParserError => error
74
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
75
+ end
76
+
77
+ begin
78
+ summary, findings = normalize_document(document)
79
+ rescue MalformedOutput => error
80
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: tool_version), []]
81
+ end
82
+
83
+ analyzer_result = AnalyzerResult.new(
84
+ analyzer: ANALYZER_ID,
85
+ tool_version: tool_version,
86
+ invocation: invocation,
87
+ execution_status: "succeeded",
88
+ finding_ids: findings.map(&:id),
89
+ evidence_summary: summary
90
+ )
91
+ [analyzer_result, findings]
92
+ end
93
+
94
+ private
95
+
96
+ def default_command(repository_root)
97
+ if File.file?(File.join(repository_root, "Gemfile"))
98
+ { executable: "bundle", args_prefix: ["exec", "rspec", "--format", "json"] }
99
+ else
100
+ { executable: "rspec", args_prefix: ["--format", "json"] }
101
+ end
102
+ end
103
+
104
+ def normalize_document(document)
105
+ raise MalformedOutput, "RSpec JSON root must be an object" unless document.is_a?(Hash)
106
+
107
+ summary = document["summary"]
108
+ examples = document["examples"]
109
+ raise MalformedOutput, "RSpec JSON is missing summary" unless summary.is_a?(Hash)
110
+ raise MalformedOutput, "RSpec JSON is missing examples array" unless examples.is_a?(Array)
111
+
112
+ version = document["version"] || document["rspec_version"] || "unknown"
113
+
114
+ findings = []
115
+ examples.each_with_index do |example, index|
116
+ finding = normalize_example(example, index)
117
+ findings << finding if finding
118
+ end
119
+
120
+ findings = findings.uniq { |f| f.fingerprint }.sort_by(&:sort_key)
121
+ summary_h = build_summary(summary, version)
122
+ summary_h["tests_total"] = examples.length
123
+
124
+ [summary_h, findings]
125
+ end
126
+
127
+ def normalize_example(example, index)
128
+ raise MalformedOutput, "RSpec example #{index} must be an object" unless example.is_a?(Hash)
129
+
130
+ status = example["status"]
131
+ raise MalformedOutput, "RSpec example #{index} has invalid status" unless %w[passed failed pending].include?(status.to_s)
132
+ return nil if status == "passed"
133
+
134
+ if status == "pending"
135
+ id = example["full_description"] || example["description"] || "example:#{index}"
136
+ return nil
137
+ end
138
+
139
+ message = (example["exception"] && example["exception"]["message"]) || example["full_description"] || example["description"] || "rspec example failed"
140
+ message = message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")[0, 4096]
141
+ message = "rspec example failed" if message.empty?
142
+
143
+ rule_id = "rspec/example:#{example['id'] || id_for(example, index)}"
144
+ path = normalize_path(example["file_path"] || example["file"] || "spec/unknown_spec.rb")
145
+ failure_line, failure_path = failure_location(example, path)
146
+ start_line = failure_line || example["line_number"] || extract_line(example["id"])
147
+
148
+ if failure_path && failure_path != path
149
+ path = failure_path if failure_path.match?(Finding::LOCATION_PATH_PATTERN)
150
+ end
151
+
152
+ severity = "high"
153
+ category = "test"
154
+
155
+ location = { "path" => path }
156
+ location["start_line"] = Integer(start_line) if start_line && Integer(start_line) >= 1 rescue nil
157
+ location["end_line"] = location["start_line"] if location.key?("start_line")
158
+
159
+ fingerprint = Finding.fingerprint_for(
160
+ analyzer: ANALYZER_ID,
161
+ rule_id: rule_id,
162
+ path: path,
163
+ message: message
164
+ )
165
+ Finding.new(
166
+ fingerprint: fingerprint,
167
+ origin: "deterministic",
168
+ analyzer: ANALYZER_ID,
169
+ rule_id: rule_id,
170
+ category: category,
171
+ severity: severity,
172
+ confidence: "high",
173
+ state: "observed",
174
+ evidence_ref: "native:rspec:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
175
+ location: location,
176
+ message: message
177
+ )
178
+ rescue ArgumentError => error
179
+ raise MalformedOutput, "RSpec example #{index} is malformed: #{error.message}"
180
+ end
181
+
182
+ def failure_location(example, default_path)
183
+ exception = example["exception"]
184
+ return [nil, nil] unless exception.is_a?(Hash)
185
+
186
+ backtrace = exception["backtrace"]
187
+ return [nil, nil] unless backtrace.is_a?(Array)
188
+
189
+ backtrace.each do |frame|
190
+ next unless frame.is_a?(String) && !frame.empty? && frame.bytesize <= 4096
191
+
192
+ stripped = frame.strip
193
+ next if stripped.empty?
194
+
195
+ match = stripped.match(/\A(.+?):(\d+)(?::in |\z)/)
196
+ next unless match
197
+
198
+ raw_path = match[1].to_s.strip
199
+ raw_line = match[2].to_s
200
+ next if raw_path.empty?
201
+
202
+ normalized = normalize_path_for_backtrace(raw_path)
203
+ next unless normalized
204
+
205
+ repository_relative = normalized.delete_prefix("./")
206
+ next unless repository_relative.match?(Finding::LOCATION_PATH_PATTERN)
207
+ next if repository_relative.start_with?("gems/") || repository_relative.include?("/gems/")
208
+
209
+ base = File.basename(repository_relative)
210
+ next if base.start_with?("rspec-") || base.start_with?("minitest")
211
+
212
+ default_base = File.basename(default_path)
213
+ same_file = repository_relative == default_path
214
+ same_dir = File.dirname(repository_relative) == File.dirname(default_path)
215
+ next unless same_file || (repository_relative.end_with?(default_base) && same_dir) || repository_relative.start_with?("spec/")
216
+
217
+ line = begin Integer(raw_line) rescue nil end
218
+ next unless line && line >= 1
219
+
220
+ return [line, repository_relative]
221
+ end
222
+ [nil, nil]
223
+ rescue StandardError
224
+ [nil, nil]
225
+ end
226
+
227
+ def normalize_path_for_backtrace(raw_path)
228
+ cleaned = raw_path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").strip[0, 1024]
229
+ return nil if cleaned.empty?
230
+
231
+ cleaned = cleaned.sub(/\A\.\//, "")
232
+ cleaned = cleaned.sub(%r{\A#{Regexp.escape(Dir.pwd)}/}, "") rescue cleaned
233
+ cleaned.split(":").first.to_s.strip
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
238
+ def id_for(example, index)
239
+ (example["full_description"] || example["description"] || "example_#{index}").to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 64]
240
+ end
241
+
242
+ def normalize_path(file)
243
+ return file.delete_prefix("./").then { |cleaned| cleaned.match?(Finding::LOCATION_PATH_PATTERN) ? cleaned : "spec/unknown_spec.rb" } if file.is_a?(String) && !file.empty?
244
+
245
+ stripped = file.to_s.delete_prefix("./")
246
+ return stripped if stripped.match?(Finding::LOCATION_PATH_PATTERN)
247
+
248
+ "spec/unknown_spec.rb"
249
+ end
250
+
251
+ def extract_line(id)
252
+ return nil unless id.is_a?(String)
253
+
254
+ match = id.match(/:(\d+)\]?\z/)
255
+ match && match[1]
256
+ end
257
+
258
+ def build_summary(summary, version)
259
+ {
260
+ "tests_total" => 0,
261
+ "duration_seconds" => Float(summary["duration"] || summary["duration_seconds"] || 0),
262
+ "failures" => Integer(summary["failure_count"] || summary["failures"] || 0),
263
+ "errors" => Integer(summary["errors"] || 0),
264
+ "assertions" => Integer(summary["example_count"] || summary["tests_total"] || 0),
265
+ "skips" => Integer(summary["pending_count"] || summary["pending"] || 0),
266
+ "seed" => summary["seed"] ? Integer(summary["seed"]) : nil,
267
+ "runner" => "rspec #{version}"[0, 128]
268
+ }
269
+ rescue ArgumentError, TypeError
270
+ raise MalformedOutput, "RSpec summary fields have invalid types"
271
+ end
272
+ end
273
+ end
274
+ end