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,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "_shared"
6
+
7
+ module RailVerdict
8
+ module Analyzers
9
+ class RuboCop
10
+ ANALYZER_ID = "rubocop"
11
+ SUPPORTED_VERSIONS = Gem::Requirement.new(">= 1.72", "< 2")
12
+ SEVERITY_MAP = {
13
+ "info" => "info",
14
+ "refactor" => "low",
15
+ "convention" => "low",
16
+ "warning" => "medium",
17
+ "error" => "high",
18
+ "fatal" => "critical"
19
+ }.freeze
20
+
21
+ Probe = Struct.new(:status, :version, :message, :rubocop_rails_version, :rubocop_config_digest, keyword_init: true)
22
+
23
+ class MalformedOutput < StandardError; end
24
+
25
+ def initialize(command_resolver: nil)
26
+ @command_resolver = command_resolver || method(:default_command)
27
+ end
28
+
29
+ def probe(repository_root, runner: ProcessRunner, timeout_seconds: 15.0)
30
+ command = @command_resolver.call(repository_root)
31
+ invocation = invocation_for(command, ["--version"])
32
+ result = runner.run(
33
+ command.fetch(:executable),
34
+ invocation.fetch("argv"),
35
+ chdir: repository_root,
36
+ timeout_seconds: timeout_seconds
37
+ )
38
+
39
+ return Probe.new(status: "unavailable", message: detail_for(result)) if result.status == :spawn_failed
40
+ return Probe.new(status: "timed_out", message: detail_for(result)) if result.status == :timed_out
41
+ return Probe.new(status: "signaled", message: detail_for(result)) if result.status == :signaled
42
+ return Probe.new(status: "truncated", message: detail_for(result)) if truncated?(result)
43
+ return Probe.new(status: "unavailable", message: detail_for(result)) unless result.exit_code == 0
44
+
45
+ version = parse_version(result.stdout)
46
+ return Probe.new(status: "unsupported", message: "RuboCop version could not be parsed") unless version
47
+ return Probe.new(status: "unsupported", version: version, message: "unsupported RuboCop version #{version}") unless SUPPORTED_VERSIONS.satisfied_by?(Gem::Version.new(version))
48
+
49
+ provenance = probe_provenance(repository_root)
50
+
51
+ Probe.new(status: "succeeded", version: version, rubocop_rails_version: provenance[:rubocop_rails_version], rubocop_config_digest: provenance[:rubocop_config_digest])
52
+ rescue ArgumentError
53
+ Probe.new(status: "unsupported", message: "RuboCop reported an invalid version")
54
+ rescue KeyError, ArgumentError => error
55
+ Probe.new(status: "malformed", message: bounded_message(error.message))
56
+ end
57
+
58
+ def run(repository_root, runner: ProcessRunner, timeout_seconds: 30.0, probe_result: nil)
59
+ command = @command_resolver.call(repository_root)
60
+ probe_result ||= probe(repository_root, runner: runner, timeout_seconds: timeout_seconds)
61
+ version_invocation = invocation_for(command, ["--version"])
62
+
63
+ unless probe_result.status == "succeeded"
64
+ return [failure_result(version_invocation, probe_result.status, probe_result.message, tool_version: probe_result.version), []]
65
+ end
66
+
67
+ invocation = invocation_for(command, ["--format", "json"])
68
+ result = runner.run(
69
+ command.fetch(:executable),
70
+ invocation.fetch("argv"),
71
+ chdir: repository_root,
72
+ timeout_seconds: timeout_seconds
73
+ )
74
+ tool_version = probe_result.version
75
+
76
+ if result.status == :spawn_failed
77
+ return [failure_result(invocation, "unavailable", detail_for(result), tool_version: tool_version), []]
78
+ end
79
+ if truncated?(result)
80
+ return [failure_result(invocation, "truncated", detail_for(result), tool_version: tool_version), []]
81
+ end
82
+ if result.status == :timed_out
83
+ return [failure_result(invocation, "timed_out", detail_for(result), tool_version: tool_version), []]
84
+ end
85
+ if result.status == :signaled
86
+ return [failure_result(invocation, "signaled", detail_for(result), tool_version: tool_version), []]
87
+ end
88
+ if result.exit_code && result.exit_code >= 2
89
+ return [failure_result(invocation, "failed", execution_message(result), tool_version: tool_version), []]
90
+ end
91
+
92
+ begin
93
+ parsed = JSON.parse(result.stdout)
94
+ rescue JSON::ParserError => error
95
+ return [failure_result(invocation, "parse_failed", bounded_message(error.message), tool_version: tool_version), []]
96
+ end
97
+
98
+ findings = normalize_findings(parsed)
99
+ evidence_summary = build_evidence_summary(probe_result)
100
+ analyzer_result = AnalyzerResult.new(
101
+ analyzer: ANALYZER_ID,
102
+ tool_version: tool_version,
103
+ invocation: invocation,
104
+ execution_status: "succeeded",
105
+ finding_ids: findings.map(&:id),
106
+ evidence_summary: evidence_summary.empty? ? nil : evidence_summary
107
+ )
108
+ [analyzer_result, findings]
109
+ rescue MalformedOutput => error
110
+ [failure_result(invocation || { "executable" => "rubocop", "argv" => [] }, "malformed", bounded_message(error.message)), []]
111
+ end
112
+
113
+ private
114
+
115
+ def default_command(repository_root)
116
+ if File.file?(File.join(repository_root, "Gemfile"))
117
+ { executable: "bundle", args_prefix: ["exec", "rubocop"] }
118
+ else
119
+ { executable: "rubocop", args_prefix: [] }
120
+ end
121
+ end
122
+
123
+ def invocation_for(command, arguments)
124
+ Shared.invocation_for(command, arguments)
125
+ end
126
+
127
+ def parse_version(output)
128
+ Shared.parse_semver(output)
129
+ end
130
+
131
+ def truncated?(result)
132
+ Shared.truncated?(result)
133
+ end
134
+
135
+ def detail_for(result)
136
+ Shared.detail_for(result)
137
+ end
138
+
139
+ def execution_message(result)
140
+ message = result.stderr.to_s.lines.first.to_s.strip
141
+ message = "RuboCop exited with status #{result.exit_code}" if message.empty?
142
+ Shared.bounded_message(message)
143
+ end
144
+
145
+ def bounded_message(message)
146
+ Shared.bounded_message(message)
147
+ end
148
+
149
+ def build_evidence_summary(probe_result)
150
+ summary = {}
151
+ if probe_result.respond_to?(:rubocop_rails_version) && probe_result.rubocop_rails_version
152
+ summary["rubocop_rails_version"] = probe_result.rubocop_rails_version.to_s[0, 64]
153
+ end
154
+ if probe_result.respond_to?(:rubocop_config_digest) && probe_result.rubocop_config_digest
155
+ summary["rubocop_config_digest"] = probe_result.rubocop_config_digest.to_s[0, 64]
156
+ end
157
+ summary
158
+ end
159
+
160
+ def probe_provenance(repository_root)
161
+ rubocop_rails_version = parse_gem_version_from_lockfile(repository_root, "rubocop-rails")
162
+ rubocop_config_digest = digest_file(File.join(repository_root, ".rubocop.yml"))
163
+ { rubocop_rails_version: rubocop_rails_version, rubocop_config_digest: rubocop_config_digest }
164
+ end
165
+
166
+ def parse_gem_version_from_lockfile(repository_root, gem_name)
167
+ lockfile = File.join(repository_root, "Gemfile.lock")
168
+ return nil unless File.file?(lockfile)
169
+
170
+ text = File.binread(lockfile).force_encoding(Encoding::UTF_8)
171
+ return nil unless text.valid_encoding?
172
+
173
+ text.each_line do |line|
174
+ stripped = line.strip
175
+ next unless stripped.start_with?("#{gem_name} (")
176
+
177
+ match = stripped.match(/\A#{Regexp.escape(gem_name)} \(([^)]+)\)/)
178
+ return match[1] if match
179
+ end
180
+ nil
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ def digest_file(path)
186
+ return nil unless File.file?(path)
187
+
188
+ require "digest"
189
+ text = File.binread(path)
190
+ Digest::SHA256.hexdigest(text)
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ def failure_result(invocation, status, message, tool_version: nil)
196
+ Shared.failure_result(
197
+ analyzer_id: ANALYZER_ID,
198
+ invocation: invocation,
199
+ status: status,
200
+ message: message,
201
+ tool_version: tool_version
202
+ )
203
+ end
204
+
205
+ def normalize_findings(document)
206
+ raise MalformedOutput, "RuboCop JSON root must be an object" unless document.is_a?(Hash)
207
+
208
+ files = document["files"]
209
+ raise MalformedOutput, "RuboCop JSON is missing files array" unless files.is_a?(Array)
210
+
211
+ findings = files.each_with_index.flat_map do |file, file_index|
212
+ normalize_file(file, file_index)
213
+ end
214
+ findings.uniq { |finding| finding.fingerprint }.sort_by(&:sort_key)
215
+ end
216
+
217
+ def normalize_file(file, file_index)
218
+ raise MalformedOutput, "RuboCop file entry #{file_index} must be an object" unless file.is_a?(Hash)
219
+
220
+ path = normalize_path(file["path"])
221
+ offenses = file["offenses"]
222
+ raise MalformedOutput, "RuboCop file #{path} is missing offenses array" unless offenses.is_a?(Array)
223
+
224
+ offenses.each_with_index.map do |offense, offense_index|
225
+ normalize_offense(offense, path, file_index, offense_index)
226
+ end
227
+ end
228
+
229
+ def normalize_path(path)
230
+ raise MalformedOutput, "RuboCop path must be a string" unless path.is_a?(String) && !path.empty?
231
+
232
+ normalized = path.delete_prefix("./")
233
+ unless normalized.match?(Finding::LOCATION_PATH_PATTERN)
234
+ raise MalformedOutput, "RuboCop emitted unsafe path"
235
+ end
236
+ normalized
237
+ end
238
+
239
+ def normalize_offense(offense, path, file_index, offense_index)
240
+ raise MalformedOutput, "RuboCop offense must be an object" unless offense.is_a?(Hash)
241
+
242
+ rule_id = offense["cop_name"]
243
+ severity = SEVERITY_MAP.fetch(offense["severity"]) do
244
+ raise MalformedOutput, "RuboCop emitted unknown severity"
245
+ end
246
+ category = rule_id.to_s.split("/", 2).first
247
+ raise MalformedOutput, "RuboCop cop name has no department" unless rule_id.is_a?(String) && rule_id.include?("/") && !category.empty?
248
+
249
+ location = offense["location"]
250
+ raise MalformedOutput, "RuboCop offense location is invalid" unless location.is_a?(Hash)
251
+
252
+ start_line = location["start_line"]
253
+ end_line = location["last_line"]
254
+ unless start_line.is_a?(Integer) && end_line.is_a?(Integer) && start_line >= 1 && end_line >= start_line
255
+ raise MalformedOutput, "RuboCop offense line range is invalid"
256
+ end
257
+
258
+ message = offense["message"]
259
+ unless message.is_a?(String) && message.valid_encoding? && !message.empty? && message.bytesize <= 4096
260
+ raise MalformedOutput, "RuboCop offense message is invalid"
261
+ end
262
+
263
+ fingerprint = Finding.fingerprint_for(
264
+ analyzer: ANALYZER_ID,
265
+ rule_id: rule_id,
266
+ path: path,
267
+ message: message
268
+ )
269
+ Finding.new(
270
+ fingerprint: fingerprint,
271
+ origin: "deterministic",
272
+ analyzer: ANALYZER_ID,
273
+ rule_id: rule_id,
274
+ category: category.downcase,
275
+ severity: severity,
276
+ confidence: "high",
277
+ state: "observed",
278
+ evidence_ref: "native:rubocop:#{fingerprint.delete_prefix("sha256:")[0, 12]}",
279
+ location: { "path" => path, "start_line" => start_line, "end_line" => end_line },
280
+ message: message
281
+ )
282
+ rescue KeyError
283
+ raise MalformedOutput, "RuboCop offense has unknown severity"
284
+ rescue ArgumentError => error
285
+ raise MalformedOutput, "RuboCop offense #{file_index}:#{offense_index} is malformed: #{error.message}"
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "_shared"
6
+
7
+ module RailVerdict
8
+ module Analyzers
9
+ class SimpleCov
10
+ ANALYZER_ID = "simplecov"
11
+ DEFAULT_COVERAGE_PATH = "coverage/coverage.json"
12
+ DEFAULT_FRESHNESS_WINDOW_SECONDS = 86_400
13
+ SUPPORTED_VERSION_RANGE = Gem::Requirement.new(">= 1", "< 2")
14
+
15
+ Probe = Struct.new(:status, :version, :message, keyword_init: true)
16
+
17
+ class MalformedOutput < StandardError; end
18
+
19
+ def probe(repository_root, runner: nil, timeout_seconds: 5.0)
20
+ config = load_config(repository_root)
21
+ coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
22
+ full_path = File.expand_path(coverage_path, repository_root)
23
+ return Probe.new(status: "unavailable", message: "coverage file is absent: #{coverage_path}") unless File.file?(full_path)
24
+
25
+ text = File.binread(full_path).force_encoding(Encoding::UTF_8)
26
+ return Probe.new(status: "parse_failed", message: "coverage file is not valid UTF-8") unless text.valid_encoding?
27
+
28
+ document = JSON.parse(text)
29
+ return Probe.new(status: "unsupported", message: "coverage document has no version") unless document["version"].is_a?(String)
30
+
31
+ version = document["version"]
32
+ return Probe.new(status: "unsupported", message: "unsupported SimpleCov version #{version}") unless version.start_with?("1")
33
+
34
+ Probe.new(status: "succeeded", version: version)
35
+ rescue JSON::ParserError => error
36
+ Probe.new(status: "parse_failed", message: Shared.bounded_message(error.message))
37
+ rescue MalformedOutput => error
38
+ Probe.new(status: "malformed", message: Shared.bounded_message(error.message))
39
+ rescue SystemCallError, ArgumentError => error
40
+ Probe.new(status: "unavailable", message: Shared.bounded_message(error.message))
41
+ end
42
+
43
+ def run(repository_root, runner: nil, timeout_seconds: 10.0, probe_result: nil, configuration: nil)
44
+ config = load_config(repository_root, configuration: configuration)
45
+ coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
46
+ freshness_window = config.fetch("freshness_window_seconds") { DEFAULT_FRESHNESS_WINDOW_SECONDS }
47
+ invocation = { "executable" => "simplecov", "argv" => ["read", coverage_path] }
48
+ full_path = File.expand_path(coverage_path, repository_root)
49
+
50
+ unless File.file?(full_path)
51
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{coverage_path}"), []]
52
+ end
53
+
54
+ begin
55
+ bytes = File.binread(full_path)
56
+ rescue SystemCallError => error
57
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: Shared.bounded_message(error.message)), []]
58
+ end
59
+
60
+ if bytes.bytesize > 8 * 1024 * 1024
61
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "truncated", message: "coverage file exceeds 8 MiB"), []]
62
+ end
63
+
64
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
65
+ unless text.valid_encoding?
66
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: "coverage file is not valid UTF-8"), []]
67
+ end
68
+
69
+ begin
70
+ document = JSON.parse(text)
71
+ rescue JSON::ParserError => error
72
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "parse_failed", message: Shared.bounded_message(error.message)), []]
73
+ end
74
+
75
+ version = document["version"].to_s
76
+ probe_result ||= probe(repository_root, timeout_seconds: timeout_seconds)
77
+ unless version.start_with?("1")
78
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: probe_result.version || version), []]
79
+ end
80
+
81
+ errors = validate_coverage_schema(document)
82
+ unless errors.empty?
83
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: probe_result.version || version), []]
84
+ end
85
+
86
+ mtime = File.mtime(full_path)
87
+ stale = (Time.now - mtime) > freshness_window
88
+
89
+ covered, executable = coverage_totals(document)
90
+
91
+ summary = {
92
+ "covered_lines" => covered,
93
+ "executable_lines" => executable,
94
+ "percent" => executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2),
95
+ "stale" => stale,
96
+ "freshness_window_seconds" => Integer(freshness_window),
97
+ "coverage_path" => coverage_path.to_s[0, 512],
98
+ "files" => document["files"]
99
+ }
100
+
101
+ result = AnalyzerResult.new(
102
+ analyzer: ANALYZER_ID,
103
+ tool_version: version,
104
+ invocation: invocation,
105
+ execution_status: "succeeded",
106
+ finding_ids: [],
107
+ evidence_summary: summary
108
+ )
109
+
110
+ [result, []]
111
+ rescue StandardError => error
112
+ [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: { "executable" => "simplecov", "argv" => ["read", DEFAULT_COVERAGE_PATH] }, status: "malformed", message: Shared.bounded_message(error.message)), []]
113
+ end
114
+
115
+ private
116
+
117
+ def load_config(repository_root, configuration: nil)
118
+ if configuration
119
+ sel = configuration.analyzers["simplecov"]
120
+ return {} unless sel
121
+
122
+ return {
123
+ "coverage_path" => sel["coverage_path"],
124
+ "freshness_window_seconds" => sel["freshness_window_seconds"]
125
+ }.compact
126
+ end
127
+
128
+ path = File.join(repository_root, ".railverdict.yml")
129
+ return {} unless File.file?(path)
130
+
131
+ bytes = File.binread(path) rescue nil
132
+ return {} unless bytes
133
+
134
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
135
+ return {} unless text.valid_encoding?
136
+
137
+ data = RailVerdict::StrictYaml.parse(text, path) rescue nil
138
+ return {} unless data.is_a?(Hash) && data["analyzers"].is_a?(Hash)
139
+
140
+ sel = data["analyzers"]["simplecov"]
141
+ return {} unless sel.is_a?(Hash)
142
+
143
+ { "coverage_path" => sel["coverage_path"], "freshness_window_seconds" => sel["freshness_window_seconds"] }.compact
144
+ rescue StandardError
145
+ {}
146
+ end
147
+
148
+ def validate_coverage_schema(document)
149
+ schema_path = File.expand_path("../../../schemas/coverage-v1.schema.json", __dir__)
150
+ schema = JSON.parse(File.read(schema_path))
151
+ JSONSchemer.schema(schema).validate(document).map { |err|
152
+ pointer = err["data_pointer"].to_s
153
+ location = pointer.empty? ? "$" : "$#{pointer.gsub('/', '.')}"
154
+ "#{location}: #{err["error"]}"
155
+ }
156
+ rescue StandardError => error
157
+ ["schema validation error: #{error.message}"]
158
+ end
159
+
160
+ def coverage_totals(document)
161
+ covered = 0
162
+ executable = 0
163
+ document.fetch("files", []).each do |file|
164
+ lines = file.dig("coverage", "lines")
165
+ next unless lines.is_a?(Array)
166
+
167
+ lines.each do |hit|
168
+ next if hit.nil?
169
+
170
+ executable += 1
171
+ covered += 1 if hit.to_i > 0
172
+ end
173
+ end
174
+ [covered, executable]
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "securerandom"
8
+ require "tempfile"
9
+ require "time"
10
+
11
+ module RailVerdict
12
+ class Baseline
13
+ SCHEMA_VERSION = "1.0"
14
+ DEFAULT_FILENAME = ".railverdict-baseline.json"
15
+
16
+ class IncompatibleError < RailVerdict::Error; end
17
+
18
+ attr_reader :hash, :entries, :created_at, :configuration_digest, :analyzer_versions
19
+
20
+ def initialize(hash)
21
+ @hash = hash.freeze
22
+ @entries = hash.fetch("entries").freeze
23
+ @created_at = hash.fetch("created_at").freeze
24
+ @configuration_digest = hash.fetch("configuration_digest").freeze
25
+ @analyzer_versions = hash.fetch("analyzer_versions").freeze
26
+ freeze
27
+ end
28
+
29
+ def to_h
30
+ @hash
31
+ end
32
+
33
+ def fingerprint_set
34
+ @fingerprint_set ||= Set.new(@entries.map { |entry| entry.fetch("fingerprint") }).freeze
35
+ end
36
+
37
+ def entry_by_fingerprint
38
+ @entry_by_fingerprint ||= @entries.to_h { |entry| [entry.fetch("fingerprint"), entry] }.freeze
39
+ end
40
+
41
+ def self.create(findings:, configuration:, analyzer_versions:, clock: Time.now.utc)
42
+ created_at = clock.utc.iso8601
43
+ entries = findings.sort_by(&:sort_key).map do |finding|
44
+ {
45
+ "fingerprint" => finding.fingerprint,
46
+ "analyzer" => finding.analyzer,
47
+ "rule_id" => finding.rule_id,
48
+ "path" => finding.location.fetch("path"),
49
+ "message" => finding.message,
50
+ "first_seen" => created_at
51
+ }
52
+ end
53
+ entries = entries.uniq { |entry| entry.fetch("fingerprint") }.sort_by { |entry| entry.fetch("fingerprint") }
54
+ hash = {
55
+ "schema_version" => SCHEMA_VERSION,
56
+ "fingerprint_version" => Fingerprint::VERSION,
57
+ "algorithm" => Fingerprint::ALGORITHM,
58
+ "payload_schema" => Fingerprint::PAYLOAD_SCHEMA,
59
+ "created_at" => created_at,
60
+ "created_by" => "railverdict #{RailVerdict::VERSION}",
61
+ "configuration_digest" => configuration.digest,
62
+ "analyzer_versions" => analyzer_versions.transform_keys(&:to_s).sort.to_h,
63
+ "entries" => entries
64
+ }
65
+ errors = SchemaValidator.validate_baseline(hash)
66
+ raise IncompatibleError, "baseline validation failed: #{errors.join('; ')}" unless errors.empty?
67
+
68
+ new(hash)
69
+ end
70
+
71
+ def self.default_path(repository_root)
72
+ File.join(File.realpath(repository_root), DEFAULT_FILENAME)
73
+ end
74
+
75
+ def self.resolve_path(repository_root:, configuration:, output_override: nil)
76
+ root = File.realpath(repository_root)
77
+ if output_override && !output_override.to_s.strip.empty?
78
+ path = output_override.to_s
79
+ return Pathname.new(path).absolute? ? path : File.expand_path(path, root)
80
+ end
81
+ if configuration.respond_to?(:baseline_path) && configuration.baseline_path
82
+ configured = configuration.baseline_path.to_s
83
+ return Pathname.new(configured).absolute? ? configured : File.expand_path(configured, root)
84
+ end
85
+ File.join(root, DEFAULT_FILENAME)
86
+ end
87
+
88
+ def self.write(path:, baseline:, force: false)
89
+ if File.exist?(path) && !force
90
+ raise UsageError, "baseline already exists at #{path}; use --force to overwrite"
91
+ end
92
+ dir = File.dirname(File.expand_path(path))
93
+ FileUtils.mkdir_p(dir)
94
+ tmp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
95
+ begin
96
+ File.open(tmp, "wb", 0o600) do |file|
97
+ file.write(JSON.pretty_generate(baseline.to_h) + "\n")
98
+ file.flush
99
+ file.fsync
100
+ end
101
+ errors = SchemaValidator.validate_baseline(baseline.to_h)
102
+ raise IncompatibleError, "baseline validation failed: #{errors.join('; ')}" unless errors.empty?
103
+
104
+ File.rename(tmp, path)
105
+ begin
106
+ dir_fd = File.open(dir, "r")
107
+ dir_fd.fsync
108
+ dir_fd.close
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ ensure
113
+ File.unlink(tmp) if File.exist?(tmp)
114
+ end
115
+ end
116
+
117
+ def self.read(path)
118
+ unless File.file?(path)
119
+ raise IncompatibleError, "baseline not found at #{path}"
120
+ end
121
+ raw = File.binread(path)
122
+ begin
123
+ data = JSON.parse(raw)
124
+ rescue JSON::ParserError => error
125
+ raise IncompatibleError, "baseline at #{path} is corrupted: #{error.message}; re-create with `railverdict baseline create`"
126
+ end
127
+ errors = SchemaValidator.validate_baseline(data)
128
+ unless errors.empty?
129
+ raise IncompatibleError, "baseline at #{path} is incompatible: #{errors.join('; ')}; re-create with `railverdict baseline create`"
130
+ end
131
+ fingerprints = data.fetch("entries").map { |entry| entry.fetch("fingerprint") }
132
+ if fingerprints.uniq.length != fingerprints.length
133
+ raise IncompatibleError, "baseline at #{path} contains duplicate fingerprints; re-create with `railverdict baseline create`"
134
+ end
135
+ new(data)
136
+ end
137
+
138
+ def self.read_optional(path)
139
+ return nil unless File.file?(path)
140
+
141
+ read(path)
142
+ end
143
+
144
+ def self.validate_hash(hash)
145
+ errors = SchemaValidator.validate_baseline(hash)
146
+ raise IncompatibleError, errors.join("; ") unless errors.empty?
147
+
148
+ hash
149
+ end
150
+ end
151
+ end