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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- 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
|