rail_verdict 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pathname"
4
5
 
5
6
  require_relative "_shared"
6
7
 
@@ -18,9 +19,9 @@ module RailVerdict
18
19
 
19
20
  def probe(repository_root, runner: nil, timeout_seconds: 5.0)
20
21
  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)
22
+ raw_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
23
+ full_path = resolve_contained_coverage_path(repository_root, raw_path)
24
+ return Probe.new(status: "unavailable", message: "coverage file is absent: #{raw_path}") unless File.file?(full_path)
24
25
 
25
26
  text = File.binread(full_path).force_encoding(Encoding::UTF_8)
26
27
  return Probe.new(status: "parse_failed", message: "coverage file is not valid UTF-8") unless text.valid_encoding?
@@ -30,11 +31,18 @@ module RailVerdict
30
31
  case detected
31
32
  when :coverage_v1
32
33
  version = document["version"].to_s
33
- return Probe.new(status: "unsupported", message: "unsupported SimpleCov version #{version}") unless version.start_with?("1")
34
+ v = Gem::Version.new(version) rescue nil
35
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
36
+ return Probe.new(status: "unsupported", version: version, message: "unsupported SimpleCov version #{version}")
37
+ end
34
38
  Probe.new(status: "succeeded", version: version)
35
39
  when :native_simplecov
36
40
  version = extract_native_version(document)
37
- # Accept any native simplecov version >=0.20 with coverage hash; version check is permissive.
41
+ return Probe.new(status: "unsupported", message: "native SimpleCov version could not be determined") unless version
42
+ v = Gem::Version.new(version) rescue nil
43
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
44
+ return Probe.new(status: "unsupported", version: version, message: "unsupported SimpleCov version #{version}")
45
+ end
38
46
  Probe.new(status: "succeeded", version: version)
39
47
  when :unsupported
40
48
  Probe.new(status: "unsupported", message: "unsupported coverage document shape")
@@ -51,13 +59,18 @@ module RailVerdict
51
59
 
52
60
  def run(repository_root, runner: nil, timeout_seconds: 10.0, probe_result: nil, configuration: nil)
53
61
  config = load_config(repository_root, configuration: configuration)
54
- coverage_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
62
+ raw_path = config.fetch("coverage_path") { DEFAULT_COVERAGE_PATH }
55
63
  freshness_window = config.fetch("freshness_window_seconds") { DEFAULT_FRESHNESS_WINDOW_SECONDS }
56
- invocation = { "executable" => "simplecov", "argv" => ["read", coverage_path] }
57
- full_path = File.expand_path(coverage_path, repository_root)
64
+ invocation = { "executable" => "simplecov", "argv" => ["read", raw_path] }
65
+
66
+ begin
67
+ full_path = resolve_contained_coverage_path(repository_root, raw_path)
68
+ rescue MalformedOutput => error
69
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message)), []]
70
+ end
58
71
 
59
72
  unless File.file?(full_path)
60
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{coverage_path}"), []]
73
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unavailable", message: "coverage file is absent: #{raw_path}"), []]
61
74
  end
62
75
 
63
76
  begin
@@ -92,24 +105,29 @@ module RailVerdict
92
105
  version = nil
93
106
  if detected == :coverage_v1
94
107
  version = document["version"].to_s
95
- probe_result ||= probe(repository_root, timeout_seconds: timeout_seconds)
96
- unless version.start_with?("1")
97
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: probe_result.version || version), []]
108
+ v = Gem::Version.new(version) rescue nil
109
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
110
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: version), []]
98
111
  end
99
112
  errors = validate_coverage_schema(document)
100
113
  unless errors.empty?
101
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: probe_result.version || version), []]
114
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: version), []]
102
115
  end
103
116
  normalized = normalize_coverage_v1(document)
104
- version = document["version"].to_s
105
117
  elsif detected == :native_simplecov
118
+ version = extract_native_version(document)
119
+ unless version
120
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "native SimpleCov version could not be determined", tool_version: nil), []]
121
+ end
122
+ v = Gem::Version.new(version) rescue nil
123
+ if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
124
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "unsupported", message: "unsupported SimpleCov version #{version}", tool_version: version), []]
125
+ end
106
126
  begin
107
127
  normalized = normalize_native_document(document, repository_root)
108
- version = extract_native_version(document)
109
128
  rescue MalformedOutput => error
110
- return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: probe_result&.version), []]
129
+ return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: Shared.bounded_message(error.message), tool_version: version), []]
111
130
  end
112
- # Validate normalized shape via same schema
113
131
  errors = validate_coverage_schema(normalized)
114
132
  unless errors.empty?
115
133
  return [Shared.failure_result(analyzer_id: ANALYZER_ID, invocation: invocation, status: "malformed", message: errors.first, tool_version: version), []]
@@ -129,7 +147,7 @@ module RailVerdict
129
147
  "percent" => executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2),
130
148
  "stale" => stale,
131
149
  "freshness_window_seconds" => Integer(freshness_window),
132
- "coverage_path" => coverage_path.to_s[0, 512],
150
+ "coverage_path" => raw_path.to_s[0, 512],
133
151
  "files" => normalized["files"],
134
152
  "_coverage_document" => normalized
135
153
  }
@@ -150,47 +168,38 @@ module RailVerdict
150
168
 
151
169
  private
152
170
 
153
- def detect_format(document)
154
- return :malformed unless document.is_a?(Hash)
171
+ def resolve_contained_coverage_path(repository_root, configured_path)
172
+ rel_path = configured_path.to_s.strip
173
+ rel_path = DEFAULT_COVERAGE_PATH if rel_path.empty?
155
174
 
156
- # coverage-v1 has version starting with "1" and files array
157
- if document["version"].is_a?(String) && document["files"].is_a?(Array)
158
- return :coverage_v1
159
- end
175
+ root = File.realpath(repository_root) rescue File.expand_path(repository_root)
176
+ full_path = File.expand_path(rel_path, root)
160
177
 
161
- # Native SimpleCov JSON: has "coverage" hash where values contain lines
162
- if document["coverage"].is_a?(Hash) && !document["coverage"].empty?
163
- sample = document["coverage"].values.first
164
- # Native values are hashes with "lines" key
165
- if sample.is_a?(Hash) && sample.key?("lines")
166
- return :native_simplecov
167
- end
178
+ rel = Pathname.new(full_path).relative_path_from(Pathname.new(root)).to_s rescue nil
179
+ if rel.nil? || rel.start_with?("..")
180
+ raise MalformedOutput, "configured coverage_path escapes repository root: #{configured_path}"
168
181
  end
169
182
 
170
- # Native may have empty coverage hash (empty file)
171
- if document.key?("coverage") && document["coverage"].is_a?(Hash) && document["meta"].is_a?(Hash)
172
- return :native_simplecov
173
- end
183
+ full_path
184
+ end
174
185
 
175
- # Also consider empty coverage hash without meta but with groups
176
- if document.key?("coverage") && document["coverage"].is_a?(Hash) && document["coverage"].empty?
177
- # Could be native empty or malformed; treat as native if meta or groups present
178
- return :native_simplecov if document.key?("meta") || document.key?("groups")
179
- # Fallback: if no files and no version, it's unsupported rather than malformed
186
+ def detect_format(document)
187
+ return :malformed unless document.is_a?(Hash)
188
+
189
+ if document.key?("version") && document.key?("timestamp") && document.key?("files")
190
+ return :coverage_v1 if document["files"].is_a?(Array)
191
+ return :malformed if document.key?("files")
180
192
  return :unsupported
181
193
  end
182
194
 
183
- # If document has no recognizable shape but is Hash with coverage key mismatched
184
195
  if document.key?("coverage") || document.key?("meta") || document.key?("groups")
185
196
  return :native_simplecov if document["coverage"].is_a?(Hash)
186
197
  end
187
198
 
188
- # If it has version but not files, or files but wrong shape => unsupported/malformed via validator
189
199
  if document.key?("version") || document.key?("files") || document.key?("timestamp")
190
- # Let schema validation decide malformed vs unsupported
191
- # If version exists but not starting 1 => unsupported
192
- if document["version"].is_a?(String) && !document["version"].start_with?("1")
193
- return :unsupported
200
+ if document["version"].is_a?(String)
201
+ v = Gem::Version.new(document["version"]) rescue nil
202
+ return :unsupported if v.nil? || !SUPPORTED_VERSION_RANGE.satisfied_by?(v)
194
203
  end
195
204
  return :malformed if document.key?("files") || document.key?("version")
196
205
  end
@@ -206,8 +215,7 @@ module RailVerdict
206
215
  if document["version"].is_a?(String) && !document["version"].empty?
207
216
  return document["version"].to_s[0, 64]
208
217
  end
209
- # Default for native without explicit version
210
- "1.0"
218
+ nil
211
219
  end
212
220
 
213
221
  def normalize_native_document(document, repository_root)
@@ -229,7 +237,6 @@ module RailVerdict
229
237
  when NilClass
230
238
  nil
231
239
  when String
232
- # SimpleCov uses "ignored" for skipped lines
233
240
  hit == "ignored" ? nil : (raise MalformedOutput, "unexpected string in coverage lines: #{hit.inspect}")
234
241
  else
235
242
  raise MalformedOutput, "coverage lines must be integers, null, or \"ignored\""
@@ -237,8 +244,6 @@ module RailVerdict
237
244
  end
238
245
 
239
246
  filename = normalize_native_path(raw_path, repository_root)
240
-
241
- # Skip empty paths after normalization
242
247
  raise MalformedOutput, "normalized path is empty for #{raw_path}" if filename.empty?
243
248
  unless filename.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
244
249
  raise MalformedOutput, "coverage filename is not a clean repository-relative path: #{filename.inspect}"
@@ -251,7 +256,7 @@ module RailVerdict
251
256
 
252
257
  {
253
258
  "version" => "1.0",
254
- "timestamp" => document["timestamp"].is_a?(Integer) ? document["timestamp"] : Time.now.to_i,
259
+ "timestamp" => document["timestamp"].is_a?(Integer) ? document["timestamp"] : 0,
255
260
  "files" => files
256
261
  }
257
262
  end
@@ -259,33 +264,24 @@ module RailVerdict
259
264
  def normalize_native_path(raw_path, repository_root)
260
265
  str = raw_path.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?").scrub("?").strip
261
266
  str = str.delete("\u0000")
262
- # Remove leading ./
263
- str = str.delete_prefix("./")
264
- # If absolute, make relative to repository_root if possible
265
- if str.start_with?("/")
266
- begin
267
- root = File.realpath(repository_root)
268
- abs = File.expand_path(str)
269
- # Use Pathname relative if inside root
270
- require "pathname"
271
- rel = Pathname.new(abs).relative_path_from(Pathname.new(root)).to_s rescue nil
272
- if rel && !rel.start_with?("..") && rel.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
273
- return rel
274
- end
275
- rescue StandardError
276
- nil
277
- end
278
- # Fallback: strip leading slash
279
- str = str.delete_prefix("/")
267
+ raise MalformedOutput, "coverage path is empty" if str.empty?
268
+
269
+ root = File.realpath(repository_root) rescue File.expand_path(repository_root)
270
+ full = File.expand_path(str, root)
271
+
272
+ rel = Pathname.new(full).relative_path_from(Pathname.new(root)).to_s rescue nil
273
+ if rel.nil? || rel.start_with?("..")
274
+ raise MalformedOutput, "coverage path is outside repository root: #{str}"
280
275
  end
281
- # Normalize redundant separators and strip
282
- str = str.gsub(%r{//+}, "/")
283
- str = str.strip
284
- str
276
+
277
+ unless rel.match?(RailVerdict::Finding::LOCATION_PATH_PATTERN)
278
+ raise MalformedOutput, "coverage path is not a clean repository-relative path: #{rel}"
279
+ end
280
+
281
+ rel
285
282
  end
286
283
 
287
284
  def normalize_coverage_v1(document)
288
- # Already validated; ensure deterministic ordering by filename
289
285
  files = document["files"].map do |f|
290
286
  { "filename" => f["filename"].to_s, "coverage" => { "lines" => f.dig("coverage", "lines") } }
291
287
  end.sort_by { |f| f["filename"] }
@@ -499,7 +499,7 @@ module RailVerdict
499
499
  adapter = build_adapter(name, rubocop_command_resolver)
500
500
  next unless adapter
501
501
 
502
- timeout_seconds = resolve_timeout_seconds(configuration, name, default_timeout_seconds)
502
+ timeout_seconds = [[resolve_timeout_seconds(configuration, name, default_timeout_seconds).to_f, 5.0].min, 1.0].max
503
503
  probes[name] = adapter.probe(root, runner: runner, timeout_seconds: timeout_seconds)
504
504
  end
505
505
  probes