kettle-soup-cover 1.0.10 → 1.1.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.
data/REEK ADDED
File without changes
data/SECURITY.md CHANGED
File without changes
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Combined coverage report script
5
+ # Usage: kettle-soup-cover [options] [coverage.json path]
6
+ # -d, --detail Show detailed uncovered line/branch locations
7
+ # -f, --file FILE Show details for specific file (partial path/glob match)
8
+ # -p, --path PATH Path to coverage.json to read (default: $K_SOUP_COV_DIR/coverage.json)
9
+ # -n, --num NUM Max uncovered line/branch details to show per file (default: 20)
10
+ # -l, --lines Show only line coverage
11
+ # -b, --branches Show only branch coverage
12
+ # --no-lines Don't include line coverage
13
+ # --no-branches Don't include branch coverage
14
+ # -h, --help Show help message
15
+
16
+ require "json"
17
+ require "optparse"
18
+ require "kettle/soup/cover"
19
+
20
+ file_filter = nil
21
+ max_details = 20
22
+ show_detail = false
23
+ coverage_path = nil
24
+ explicit_coverage_path = false
25
+
26
+ opts = OptionParser.new do |opt|
27
+ opt.banner = "Usage: kettle-soup-cover [options] [coverage.json path]"
28
+ opt.on("-d", "--detail", "Show detailed uncovered line/branch locations") { show_detail = true }
29
+ opt.on("-fFILE", "--file=FILE", String, "Show details for specific file (partial path/glob match)") { |v| file_filter = v }
30
+ opt.on("-pPATH", "--path=PATH", String, "Path to coverage.json to read (default: $K_SOUP_COV_DIR/coverage.json)") do |v|
31
+ coverage_path = v
32
+ explicit_coverage_path = true
33
+ end
34
+ opt.on("-nNUM", "--num=NUM", Integer, "Max uncovered line/branch details to show per file (default: 20)") do |v|
35
+ max_details = v
36
+ end
37
+ opt.on_tail("-h", "--help", "Show this message") do
38
+ puts opt
39
+ exit
40
+ end
41
+ opt.on("-l", "--lines", "Show only line coverage") { @show_lines = true }
42
+ opt.on("-b", "--branches", "Show only branch coverage") { @show_branches = true }
43
+ opt.on("--no-lines", "Don't include line coverage") { @show_lines = false }
44
+ opt.on("--no-branches", "Don't include branch coverage") { @show_branches = false }
45
+ end
46
+
47
+ opts.parse!(ARGV)
48
+ # If a positional arg is present (leftover after parsing) treat it as coverage json path
49
+ coverage_dir = Kettle::Soup::Cover::COVERAGE_DIR
50
+ if ARGV.any?
51
+ coverage_path ||= ARGV.shift
52
+ explicit_coverage_path = true
53
+ end
54
+ coverage_path ||= "#{coverage_dir}/coverage.json"
55
+
56
+ # Helper to match a file path against the provided file filter
57
+ def path_matches_filter?(path, short_path, filter)
58
+ return true if filter.nil? || filter == ""
59
+
60
+ # If filter looks like a glob (contains wildcard chars), use fnmatch.
61
+ if /[*?\[\]]/.match?(filter)
62
+ File.fnmatch?(filter, short_path, File::FNM_EXTGLOB) || File.fnmatch?(filter, path, File::FNM_EXTGLOB)
63
+ else
64
+ short_path.include?(filter) || path.include?(filter)
65
+ end
66
+ end
67
+
68
+ # Fail early if JSON formatter is not active; coverage.json may be stale or missing
69
+ unless explicit_coverage_path || Kettle::Soup::Cover::FORMATTERS.any? { |f| f[:type] == :json }
70
+ abort <<~MSG
71
+ Kettle::Soup::Cover is not configured to generate a JSON coverage report.
72
+ The 'kettle-soup-cover' script requires the json formatter (K_SOUP_COV_FORMATTERS includes "json")
73
+ so that coverage/coverage.json is available and consistent with the HTML/LCOV/XML output.
74
+
75
+ Configure K_SOUP_COV_FORMATTERS to include json:
76
+ export K_SOUP_COV_FORMATTERS="json,html"
77
+
78
+ Or explicitly pass a path to a valid coverage.json generated with json formatter using the -p/--path option or as a positional arg:
79
+ kettle-soup-cover -p ./coverage/coverage.json
80
+ kettle-soup-cover ./coverage/coverage.json
81
+
82
+ Note: `-f/--file` is a file-filter (partial path/glob match) and is not a path-to-json option.
83
+ MSG
84
+ end
85
+
86
+ if Kettle::Soup::Cover::VERBOSE || Kettle::Soup::Cover::DEBUG
87
+ puts "Using Kettle::Soup::Cover::COVERAGE_DIR=#{coverage_dir}"
88
+ puts "Using coverage_path=#{coverage_path}"
89
+ puts "K_SOUP_COV_FORMATTERS=#{Kettle::Soup::Cover::FORMATTERS.map { |f| f[:type].to_s }.join(",")}"
90
+ end
91
+
92
+ unless File.exist?(coverage_path)
93
+ puts "Coverage file not found: #{coverage_path}"
94
+ puts "Run bin/rake coverage first to generate coverage data."
95
+ exit 1
96
+ end
97
+
98
+ # If neither -l/--no-lines nor -b/--no-branches provided, show both
99
+ # Otherwise, compute defaults so explicit flags control output
100
+ if @show_lines.nil? && @show_branches.nil?
101
+ @show_lines = true
102
+ @show_branches = true
103
+ elsif @show_lines.nil?
104
+ # show_lines unspecified: derive from show_branches
105
+ @show_lines = !@show_branches
106
+ elsif @show_branches.nil?
107
+ # show_branches unspecified: derive from show_lines
108
+ @show_branches = !@show_lines
109
+ end
110
+ data = JSON.parse(File.read(coverage_path))
111
+ files = data["coverage"]
112
+
113
+ def collect_line_info(files, file_filter: nil)
114
+ results = []
115
+ files.each do |path, info|
116
+ next unless info.is_a?(Hash)
117
+ next unless info["lines"]
118
+
119
+ lines = info["lines"]
120
+ total = 0
121
+ covered = 0
122
+ uncovered_lines = []
123
+
124
+ lines.each_with_index do |hit_count, index|
125
+ next if hit_count.nil? || hit_count == "ignored"
126
+
127
+ line_num = index + 1
128
+ total += 1
129
+ if hit_count.to_i > 0
130
+ covered += 1
131
+ else
132
+ uncovered_lines << line_num
133
+ end
134
+ end
135
+
136
+ uncovered = total - covered
137
+ next if uncovered == 0
138
+
139
+ pct = (total > 0) ? (covered.to_f / total * 100).round(1) : 100.0
140
+ short_path = path.split("/lib/").last || path.split("/").last(2).join("/")
141
+
142
+ results << {
143
+ uncovered: uncovered,
144
+ total: total,
145
+ covered: covered,
146
+ pct: pct,
147
+ path: short_path,
148
+ full_path: path,
149
+ uncovered_lines: uncovered_lines,
150
+ }
151
+ end
152
+
153
+ results.select! { |r| path_matches_filter?(r[:full_path], r[:path], file_filter) } if file_filter
154
+
155
+ results.sort_by! { |r| -r[:uncovered] }
156
+ results
157
+ end
158
+
159
+ def collect_branch_info(files, file_filter: nil)
160
+ results = []
161
+ files.each do |path, info|
162
+ next unless info.is_a?(Hash)
163
+ next unless info["branches"]
164
+
165
+ branches = info["branches"]
166
+ total = 0
167
+ covered = 0
168
+ uncovered_details = []
169
+
170
+ if branches.is_a?(Array)
171
+ branches.each do |branch|
172
+ next unless branch.is_a?(Hash)
173
+ next if branch["coverage"] == "ignored"
174
+
175
+ total += 1
176
+ if branch["coverage"].to_i > 0
177
+ covered += 1
178
+ else
179
+ uncovered_details << {
180
+ condition: branch["type"],
181
+ cond_line: branch["start_line"],
182
+ branch: branch["type"],
183
+ branch_line: branch["start_line"],
184
+ }
185
+ end
186
+ end
187
+ end
188
+
189
+ uncovered = total - covered
190
+ next if uncovered == 0
191
+
192
+ pct = (total > 0) ? (covered.to_f / total * 100).round(1) : 100.0
193
+ short_path = path.split("/lib/").last || path.split("/").last(2).join("/")
194
+
195
+ results << {
196
+ uncovered: uncovered,
197
+ total: total,
198
+ covered: covered,
199
+ pct: pct,
200
+ path: short_path,
201
+ full_path: path,
202
+ details: uncovered_details.sort_by do |d|
203
+ d[:branch_line] || 0
204
+ end,
205
+ }
206
+ end
207
+
208
+ results.select! { |r| path_matches_filter?(r[:full_path], r[:path], file_filter) } if file_filter
209
+
210
+ results.sort_by! { |r| -r[:uncovered] }
211
+ results
212
+ end
213
+
214
+ def calculate_line_totals(files, file_filter: nil)
215
+ total_lines = 0
216
+ covered_lines = 0
217
+ file_count = 0
218
+
219
+ files.each do |path, info|
220
+ next unless info.is_a?(Hash)
221
+ next unless info["lines"]
222
+ short_path = path.split("/lib/").last || path.split("/").last(2).join("/")
223
+ next if file_filter && !path_matches_filter?(path, short_path, file_filter)
224
+
225
+ file_count += 1
226
+ info["lines"].each do |hit|
227
+ next if hit.nil? || hit == "ignored"
228
+ total_lines += 1
229
+ covered_lines += 1 if hit.to_i > 0
230
+ end
231
+ end
232
+
233
+ {total: total_lines, covered: covered_lines, files: file_count}
234
+ end
235
+
236
+ def calculate_branch_totals(files, file_filter: nil)
237
+ total_branches = 0
238
+ covered_branches = 0
239
+ file_count = 0
240
+
241
+ files.each do |path, info|
242
+ next unless info.is_a?(Hash)
243
+ next unless info["branches"]
244
+ short_path = path.split("/lib/").last || path.split("/").last(2).join("/")
245
+ next if file_filter && !path_matches_filter?(path, short_path, file_filter)
246
+
247
+ file_count += 1
248
+ branches = info["branches"]
249
+ if branches.is_a?(Hash)
250
+ branches.each do |_cond, branch_data|
251
+ next unless branch_data.is_a?(Hash)
252
+ branch_data.each do |_id, hit_count|
253
+ next if hit_count == "ignored"
254
+ total_branches += 1
255
+ covered_branches += 1 if hit_count.to_i > 0
256
+ end
257
+ end
258
+ elsif branches.is_a?(Array)
259
+ branches.each do |b|
260
+ next unless b.is_a?(Hash)
261
+ next if b["coverage"] == "ignored"
262
+ total_branches += 1
263
+ covered_branches += 1 if b["coverage"].to_i > 0
264
+ end
265
+ end
266
+ end
267
+
268
+ {total: total_branches, covered: covered_branches, files: file_count}
269
+ end
270
+
271
+ line_totals = calculate_line_totals(files, file_filter: file_filter)
272
+ branch_totals = calculate_branch_totals(files, file_filter: file_filter)
273
+
274
+ if @show_lines
275
+ puts "==== Line coverage ===="
276
+ line_results = collect_line_info(files, file_filter: file_filter)
277
+ if line_results.empty?
278
+ puts "All files fully covered (lines)!"
279
+ else
280
+ puts "Files with uncovered (-d to expand) lines (-n=#{max_details}; use n=0 for unlimited):"
281
+ puts "-" * 70
282
+ line_results.each do |r|
283
+ puts format("%3d uncovered | %5.1f%% | %s", r[:uncovered], r[:pct], r[:path])
284
+ next unless show_detail && r[:uncovered_lines].any?
285
+
286
+ lines_to_show = r[:uncovered_lines].first(max_details)
287
+ ranges = []
288
+ current_range = nil
289
+ lines_to_show.each do |line|
290
+ if current_range.nil?
291
+ current_range = [line, line]
292
+ elsif line == current_range[1] + 1
293
+ current_range[1] = line
294
+ else
295
+ ranges << current_range
296
+ current_range = [line, line]
297
+ end
298
+ end
299
+ ranges << current_range if current_range
300
+ range_strs = ranges.map { |r| (r[0] == r[1]) ? r[0].to_s : "#{r[0]}-#{r[1]}" }
301
+ puts " Lines: [#{range_strs.join(", ")}]"
302
+ puts " ... and #{r[:uncovered_lines].length - max_details} more" if r[:uncovered_lines].length > max_details
303
+ end
304
+ puts "-" * 70
305
+ total_uncovered = line_results.sum { |r| r[:uncovered] }
306
+ total_lines = line_totals[:total]
307
+ total_pct = (total_lines > 0) ? (line_totals[:covered].to_f / total_lines * 100).round(1) : 100.0
308
+ puts format("Total: %d uncovered lines out of %d (%.1f%% coverage)", total_uncovered, total_lines, total_pct)
309
+ end
310
+ end
311
+
312
+ ## Summary will be printed at the end of the report
313
+
314
+ if @show_branches
315
+ puts "\n==== Branch coverage ===="
316
+ branch_results = collect_branch_info(files, file_filter: file_filter)
317
+ if branch_results.empty?
318
+ puts "All files fully covered (branches)!"
319
+ else
320
+ puts "Files with uncovered (-d to expand) branches (-n=#{max_details}; use n=0 for unlimited):"
321
+ puts "-" * 70
322
+ branch_results.each do |r|
323
+ puts format("%3d uncovered | %5.1f%% | %s", r[:uncovered], r[:pct], r[:path])
324
+ next unless show_detail && r[:details].any?
325
+
326
+ details_to_show = r[:details].first(max_details)
327
+ details_to_show.each do |d|
328
+ puts format(
329
+ " Line %3d: %s -> %s branch not taken",
330
+ d[:branch_line] || d[:cond_line] || 0,
331
+ d[:condition],
332
+ d[:branch],
333
+ )
334
+ end
335
+ puts " ... and #{r[:details].length - max_details} more" if r[:details].length > max_details
336
+ end
337
+ puts "-" * 70
338
+ total_uncovered = branch_results.sum { |r| r[:uncovered] }
339
+ total_branches = branch_totals[:total]
340
+ total_pct = (total_branches > 0) ? (branch_totals[:covered].to_f / total_branches * 100).round(1) : 100.0
341
+ puts format("Total: %d uncovered branches out of %d (%.1f%% coverage)", total_uncovered, total_branches, total_pct)
342
+ end
343
+ end
344
+
345
+ # Final summary
346
+ line_totals = calculate_line_totals(files, file_filter: file_filter)
347
+ branch_totals = calculate_branch_totals(files, file_filter: file_filter)
348
+
349
+ line_pct = if line_totals[:total] > 0
350
+ (line_totals[:covered].to_f / line_totals[:total] * 100)
351
+ else
352
+ 100.0
353
+ end
354
+ branch_pct = if branch_totals[:total] > 0
355
+ (branch_totals[:covered].to_f / branch_totals[:total] * 100)
356
+ else
357
+ 100.0
358
+ end
359
+
360
+ puts "\n==== Summary Report ===="
361
+ puts format("LINE COVERAGE: %6.2f%% -- %d/%d lines in %d files", line_pct, line_totals[:covered], line_totals[:total], line_totals[:files])
362
+ puts format("BRANCH COVERAGE: %6.2f%% -- %d/%d branches in %d files", branch_pct, branch_totals[:covered], branch_totals[:total], branch_totals[:files])
@@ -34,9 +34,11 @@ SimpleCov.configure do
34
34
  formatter SimpleCov::Formatter::HTMLFormatter
35
35
  end
36
36
 
37
- # Use Merging (merges RSpec + Cucumber Test Results)
37
+ # Use Merging (merges coverage from multiple test runs, e.g., RSpec + Cucumber Test Results)
38
+ # This is essential for projects that split tests into multiple rake tasks
39
+ # (e.g., FFI specs, integration specs, unit specs run separately)
38
40
  use_merging(Kettle::Soup::Cover::Constants::USE_MERGING) unless Kettle::Soup::Cover::Constants::USE_MERGING.nil?
39
- merge_timeout(Kettle::Soup::Cover::Constants::MERGE_TIMEOUT) if Kettle::Soup::Cover::Constants::MERGE_TIMEOUT
41
+ merge_timeout(Kettle::Soup::Cover::Constants::MERGE_TIMEOUT) if Kettle::Soup::Cover::Constants::MERGE_TIMEOUT.nonzero?
40
42
 
41
43
  # Fail build when missed coverage targets
42
44
  # NOTE: Checking SpecTracker.instance.full_suite? here would be awesome, but won't work
@@ -57,12 +57,23 @@ module Kettle
57
57
  )
58
58
  .split(",")
59
59
  .map { |dir_name| %r{^/#{Regexp.escape(dir_name)}/} }
60
- FORMATTERS = ENV_GET.call(
61
- "FORMATTERS",
62
- IS_CI ? "html,xml,rcov,lcov,json,tty" : "html,tty",
63
- )
64
- .split(",")
65
- .map { |fmt_name| FORMATTER_PLUGINS[fmt_name.strip.to_sym] }
60
+ FORMATTERS = begin
61
+ list = ENV_GET.call(
62
+ "FORMATTERS",
63
+ IS_CI ? "html,xml,rcov,lcov,json,tty" : "html,tty",
64
+ )
65
+ .split(",")
66
+ .map { |fmt_name| FORMATTER_PLUGINS[fmt_name.strip.to_sym] }
67
+ .compact
68
+
69
+ # If MAX_ROWS is explicitly set to "0", skip tty output from simplecov-console
70
+ max_rows = ENV.fetch("MAX_ROWS", nil)
71
+ if max_rows && max_rows.strip == "0"
72
+ list = list.reject { |f| f && f[:type] == :tty }
73
+ end
74
+
75
+ list
76
+ end
66
77
  MIN_COVERAGE_HARD = ENV_GET.call("MIN_HARD", CI).casecmp?(TRUE)
67
78
  MIN_COVERAGE_BRANCH = ENV_GET.call("MIN_BRANCH", "80").to_i
68
79
  MIN_COVERAGE_LINE = ENV_GET.call("MIN_LINE", "80").to_i
@@ -76,8 +87,13 @@ module Kettle
76
87
  is_mac = RbConfig::CONFIG["host_os"].include?("darwin")
77
88
  # Set to "" to prevent opening a browser with the coverage rake task
78
89
  OPEN_BIN = ENV_GET.call("OPEN_BIN", is_mac ? "open" : "xdg-open")
79
- USE_MERGING = ENV_GET.call("USE_MERGING", nil)&.casecmp?(TRUE)
80
- MERGE_TIMEOUT = ENV_GET.call("MERGE_TIMEOUT", nil)&.to_i
90
+ # Enable merging by default to aggregate coverage across multiple test runs
91
+ # (e.g., separate RSpec tasks for FFI tests, integration tests, unit tests)
92
+ # Set K_SOUP_COV_USE_MERGING=false to disable
93
+ USE_MERGING = ENV_GET.call("USE_MERGING", TRUE).casecmp?(TRUE)
94
+ # Default merge timeout of 1 hour (3600 seconds) - enough for most test suites
95
+ # Set K_SOUP_COV_MERGE_TIMEOUT to override
96
+ MERGE_TIMEOUT = ENV_GET.call("MERGE_TIMEOUT", "3600").to_i
81
97
  VERBOSE = ENV_GET.call("VERBOSE", FALSE).casecmp?(TRUE)
82
98
 
83
99
  include Kettle::Change.new(
@@ -4,7 +4,7 @@ module Kettle
4
4
  module Soup
5
5
  module Cover
6
6
  module Version
7
- VERSION = "1.0.10"
7
+ VERSION = "1.1.0"
8
8
  end
9
9
  end
10
10
  end
@@ -1,4 +1,5 @@
1
- # rubocop:disable Naming/FileName# USAGE:
1
+ # rubocop:disable Naming/FileName
2
+ # USAGE:
2
3
  # In your `spec/spec_helper.rb`,
3
4
  # just prior to loading the library under test:
4
5
  #
@@ -26,15 +26,15 @@ module Kettle
26
26
  VERBOSE: bool
27
27
  VERSION: String
28
28
 
29
- def delete_const: -> void
29
+ def delete_const: () -> void
30
30
 
31
- def install_tasks: -> void
31
+ def install_tasks: () -> void
32
32
 
33
- def load_filters: -> void
33
+ def load_filters: () -> void
34
34
 
35
- def load_formatters: -> void
35
+ def load_formatters: () -> void
36
36
 
37
- def reset_const: -> void
37
+ def reset_const: () -> void
38
38
  end
39
39
  end
40
40
  end
data.tar.gz.sig CHANGED
Binary file