padded_cell 0.1.0 → 0.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Cargo.lock +2 -2
  4. data/README.md +148 -7
  5. data/ext/padded_cell_native/core/Cargo.toml +1 -1
  6. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  7. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  8. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  9. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  10. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  11. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  13. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  14. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  15. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  16. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  17. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  18. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  19. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  20. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  21. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  22. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  23. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  24. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  25. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  27. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  28. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  29. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  30. data/ext/padded_cell_native/core/src/error.rs +24 -0
  31. data/ext/padded_cell_native/core/src/lib.rs +5 -1
  32. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  33. data/ext/padded_cell_native/ffi/Cargo.toml +1 -1
  34. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  35. data/ext/padded_cell_native/ffi/src/lib.rs +3 -0
  36. data/lib/padded_cell/asylum.rb +91 -0
  37. data/lib/padded_cell/cell.rb +14 -8
  38. data/lib/padded_cell/chart/accumulator.rb +103 -0
  39. data/lib/padded_cell/chart/config.rb +129 -0
  40. data/lib/padded_cell/chart/delta.rb +148 -0
  41. data/lib/padded_cell/chart/document.rb +64 -0
  42. data/lib/padded_cell/chart/filter.rb +55 -0
  43. data/lib/padded_cell/chart/merge.rb +63 -0
  44. data/lib/padded_cell/chart/probe.rb +70 -0
  45. data/lib/padded_cell/chart/raw.rb +48 -0
  46. data/lib/padded_cell/chart/report.rb +128 -0
  47. data/lib/padded_cell/chart/source_files.rb +90 -0
  48. data/lib/padded_cell/chart.rb +38 -0
  49. data/lib/padded_cell/cli.rb +59 -4
  50. data/lib/padded_cell/loader.rb +5 -1
  51. data/lib/padded_cell/pool.rb +19 -42
  52. data/lib/padded_cell/reporters.rb +20 -3
  53. data/lib/padded_cell/results.rb +13 -0
  54. data/lib/padded_cell/runner.rb +125 -22
  55. data/lib/padded_cell/version.rb +1 -1
  56. data/lib/padded_cell/worker.rb +63 -0
  57. data/lib/padded_cell.rb +8 -0
  58. metadata +39 -1
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # The coverage of one process, made to cross to another one and be summed
6
+ # there:
7
+ #
8
+ # {path => {lines:, branches:, methods:}}
9
+ #
10
+ # lines Array, nil where there is no code (dense), or {index => hits}
11
+ # branches {[type, l1, c1, l2, c2] => {[type, l1, c1, l2, c2] => hits}}
12
+ # methods {[name, l1, c1, l2, c2] => [owner, hits]}
13
+ #
14
+ # A dense entry carries the structure of its file, zeros included. A
15
+ # sparse one holds only what ran, for files whose structure the receiver
16
+ # has from elsewhere (the runner loaded them before forking). Keys are
17
+ # source positions: Ruby's branch ids change with every compile, and
18
+ # method owners (anonymous classes) differ between processes.
19
+ module Delta
20
+ module_function
21
+
22
+ # result: Ruby's Coverage.result or peek_result. Files in known become
23
+ # sparse entries, or nothing when none of their code ran; files for
24
+ # which keep is false are left out.
25
+ def capture(result, known, keep)
26
+ delta = {}
27
+ result.each do |path, data|
28
+ next unless keep.call(path)
29
+
30
+ if known.include?(path)
31
+ entry = sparse(data)
32
+ delta[path] = entry if entry
33
+ else
34
+ delta[path] = dense(data)
35
+ end
36
+ end
37
+ delta
38
+ end
39
+
40
+ # Adds delta into total (both deltas). Sums hits; a dense entry brings
41
+ # its structure along. Order does not matter.
42
+ def merge!(total, delta)
43
+ delta.each do |path, entry|
44
+ into = total[path]
45
+ next total[path] = copy(entry) unless into
46
+
47
+ into[:lines] = merge_lines(into[:lines], entry[:lines])
48
+ merge_branches(into[:branches] ||= {}, entry[:branches]) if entry[:branches]
49
+ merge_methods(into[:methods] ||= {}, entry[:methods]) if entry[:methods]
50
+ end
51
+ total
52
+ end
53
+
54
+ # Coverage's arrays are frozen.
55
+ def dense(data)
56
+ {
57
+ lines: data[:lines].dup,
58
+ branches: data[:branches] && branches(data[:branches], all: true),
59
+ methods: data[:methods] && methods(data[:methods], all: true)
60
+ }.compact
61
+ end
62
+
63
+ # nil when nothing in the file ran. Branches only run on lines that ran;
64
+ # a method can run without a line (an empty body), so methods are
65
+ # looked at either way.
66
+ def sparse(data)
67
+ lines = data[:lines]
68
+ ran = (lines.compact.max || 0).positive?
69
+ methods = data[:methods] && methods(data[:methods], all: false)
70
+ return if !ran && (methods.nil? || methods.empty?)
71
+
72
+ entry = { lines: ran ? sparse_lines(lines) : {} }
73
+ entry[:branches] = branches(data[:branches], all: false) if ran && data[:branches]
74
+ entry[:methods] = methods if methods
75
+ entry
76
+ end
77
+
78
+ def sparse_lines(lines)
79
+ hits = {}
80
+ lines.each_with_index { |count, index| hits[index] = count if count&.positive? }
81
+ hits
82
+ end
83
+
84
+ def branches(raw, all:)
85
+ raw.each_with_object({}) do |(condition, arms), out|
86
+ kept = {}
87
+ arms.each { |arm, hits| kept[position(arm)] = hits if all || hits.positive? }
88
+ out[position(condition)] = kept if all || !kept.empty?
89
+ end
90
+ end
91
+
92
+ def methods(raw, all:)
93
+ raw.each_with_object({}) do |(key, hits), out|
94
+ next unless all || hits.positive?
95
+
96
+ owner, name, *span = key
97
+ entry = out[[name, *span]]
98
+ entry ? entry[1] += hits : out[[name, *span]] = [owner.name || owner.inspect, hits]
99
+ end
100
+ end
101
+
102
+ # [type, id, first line, first column, last line, last column], minus
103
+ # the id.
104
+ def position(key) = [key[0], key[2], key[3], key[4], key[5]]
105
+
106
+ def copy(entry)
107
+ {
108
+ lines: entry[:lines].dup,
109
+ branches: entry[:branches]&.transform_values(&:dup),
110
+ methods: entry[:methods]&.transform_values(&:dup)
111
+ }.compact
112
+ end
113
+
114
+ # Dense + dense sums by index (an eval'd file can grow); dense + sparse
115
+ # adds the sparse hits; sparse + sparse sums by index.
116
+ def merge_lines(into, lines)
117
+ return add_sparse(lines.dup, into) if into.is_a?(Hash) && lines.is_a?(Array)
118
+ return add_sparse(into, lines) if lines.is_a?(Hash)
119
+
120
+ lines.each_with_index do |hits, index|
121
+ next unless hits
122
+
123
+ into[index] = (into[index] || 0) + hits
124
+ end
125
+ into
126
+ end
127
+
128
+ def add_sparse(into, sparse)
129
+ sparse.each { |index, hits| into[index] = (into[index] || 0) + hits }
130
+ into
131
+ end
132
+
133
+ def merge_branches(into, branches)
134
+ branches.each do |condition, arms|
135
+ kept = into[condition] ||= {}
136
+ arms.each { |arm, hits| kept[arm] = (kept[arm] || 0) + hits }
137
+ end
138
+ end
139
+
140
+ def merge_methods(into, methods)
141
+ methods.each do |key, (owner, hits)|
142
+ entry = into[key]
143
+ entry ? entry[1] += hits : into[key] = [owner, hits]
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+
7
+ module PaddedCell
8
+ module Chart
9
+ # What a report is made from, as stored in coverage/.padded_cell.json:
10
+ # the files once filtered, tracked and grouped, and the report settings.
11
+ # --coverage-merge sums the documents of shards without loading the
12
+ # suite.
13
+ #
14
+ # files {name => {digest:, loaded:, lines:, branches:, methods:}} (see Raw)
15
+ # groups {group name => [file name, ...]}
16
+ # shard [index, count], 0-based
17
+ # whole the run selected no test by name or line
18
+ Document = Data.define(:root, :criteria, :lost, :shard, :whole, :settings, :files, :groups, :unmeasured) do
19
+ def self.build(raw, config:, shard:, whole:)
20
+ source_files = SourceFiles.new(raw[:files], config:, root: raw[:root])
21
+ files = source_files.rows.to_h do |path, name, lines, branches, methods, loaded|
22
+ [name, { digest: Digest::SHA256.file(path).hexdigest, loaded:, lines:, branches:, methods: }.compact]
23
+ end
24
+ groups = source_files.groups.to_h { |name, indices| [name, indices.map { source_files.rows[it][1] }] }
25
+ new(root: raw[:root], criteria: raw[:criteria].map(&:to_s), lost: raw[:lost], shard:, whole:,
26
+ settings: config.settings, files:, groups:, unmeasured: source_files.unmeasured)
27
+ end
28
+
29
+ def self.load(dir)
30
+ data = JSON.parse(File.read(File.join(dir, '.padded_cell.json')), symbolize_names: true)
31
+ raise ArgumentError, "#{dir}: not a padded_cell coverage document" unless data[:format] == Document::FORMAT
32
+
33
+ new(root: data[:root], criteria: data[:criteria], lost: data[:lost], shard: data[:shard],
34
+ whole: data[:whole], settings: data[:settings], unmeasured: [],
35
+ files: data[:files].transform_keys(&:to_s), groups: data[:groups].transform_keys(&:to_s))
36
+ end
37
+
38
+ # [[absolute path, name, lines, branches, methods, loaded], ...] in
39
+ # name order, for PaddedCellNative.chart_report.
40
+ def rows
41
+ files.sort.map do |name, file|
42
+ [File.join(root, name), name, file[:lines], file[:branches], file[:methods], file[:loaded]]
43
+ end
44
+ end
45
+
46
+ # [[group name, [row index, ...]], ...]
47
+ def group_rows
48
+ index = files.keys.sort.each_with_index.to_h
49
+ groups.map { |name, names| [name, names.map { index.fetch(it) }.sort] }
50
+ end
51
+
52
+ # Every shard of a whole run, and nothing lost: thresholds apply.
53
+ def complete? = whole && shard[0].zero? && shard[1] == 1 && lost.values.all?(&:zero?)
54
+
55
+ def write(dir)
56
+ FileUtils.mkdir_p(dir)
57
+ data = { format: Document::FORMAT, version: 1, **to_h.except(:unmeasured) }
58
+ File.write(File.join(dir, '.padded_cell.json'), JSON.generate(data))
59
+ end
60
+ end
61
+
62
+ Document::FORMAT = 'padded_cell-coverage'
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # What filter and group blocks receive: a file of the report.
6
+ class SourceFile
7
+ attr_reader :filename, :project_filename
8
+
9
+ def initialize(filename, project_filename)
10
+ @filename = filename
11
+ @project_filename = project_filename
12
+ end
13
+
14
+ # Its lines, as File.readlines gives them.
15
+ def src = @src ||= File.readlines(filename)
16
+ alias source src
17
+ end
18
+
19
+ # Filters and groups pick files by their path
20
+ # relative to the project root:
21
+ #
22
+ # "lib/" a directory, anywhere along the path
23
+ # "app/models" a path segment: app/models/user.rb, not app/models_old/
24
+ # "contract.rb" a file name ending
25
+ # /regexp/ matched against the relative path
26
+ # [a, b] any of them
27
+ # { |file| } a block, given a SourceFile
28
+ module Filter
29
+ module_function
30
+
31
+ # A callable taking a SourceFile.
32
+ def build(argument = nil, &block)
33
+ case argument || block
34
+ when String then string(argument)
35
+ when Regexp then ->(file) { argument.match?(file.project_filename) }
36
+ when Array
37
+ filters = argument.map { build(it) }
38
+ ->(file) { filters.any? { it.call(file) } }
39
+ when Proc then argument || block
40
+ else raise ArgumentError, "a coverage filter is a String, Regexp, Array or block, not #{argument.inspect}"
41
+ end
42
+ end
43
+
44
+ def string(argument)
45
+ normalized = argument.delete_prefix('/')
46
+ escaped = Regexp.escape(normalized)
47
+ pattern = if normalized.end_with?('/') then %r{(?:\A|/)#{escaped}}
48
+ elsif normalized.include?('.') && !normalized.include?('/') then %r{[^/]*#{escaped}(?=[/.]|\z)}
49
+ else %r{(?:\A|/)#{escaped}(?=[/.]|\z)}
50
+ end
51
+ ->(file) { pattern.match?(file.project_filename) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # --coverage-merge: one report from the documents of shards
6
+ # (--shard I/N), here, without loading the suite. Each shard stored its
7
+ # files filtered and grouped, with their settings.
8
+ module Merge
9
+ module_function
10
+
11
+ # dirs: the shards' coverage directories.
12
+ def call(dirs, root: File.realpath(Dir.pwd))
13
+ documents = dirs.map { Document.load(it) }
14
+ criteria = documents.map(&:criteria).uniq
15
+ raise ArgumentError, "the shards measured different criteria: #{criteria.join(' / ')}" if criteria.size > 1
16
+
17
+ Document.new(
18
+ root:, criteria: criteria.first, lost: lost(documents), shard: shard(documents),
19
+ whole: documents.all?(&:whole), settings: documents.first.settings, files: files(documents, root),
20
+ groups: groups(documents), unmeasured: []
21
+ )
22
+ end
23
+
24
+ def lost(documents)
25
+ documents.map(&:lost).reduce { |sum, lost| sum.merge(lost) { |_, left, right| left + right } }
26
+ end
27
+
28
+ # [0, 1] when the documents are every shard of a run, once each.
29
+ def shard(documents)
30
+ counts = documents.map { it.shard[1] }.uniq
31
+ indices = documents.map { it.shard[0] }.sort
32
+ counts.size == 1 && indices == (0...counts.first).to_a ? [0, 1] : documents.first.shard
33
+ end
34
+
35
+ def groups(documents)
36
+ documents.flat_map { it.groups.to_a }.group_by(&:first).transform_values { it.flat_map(&:last).uniq }
37
+ end
38
+
39
+ # A file loaded by any shard is summed over the shards that loaded it;
40
+ # otherwise it is the tracked, never loaded stub.
41
+ def files(documents, root)
42
+ documents.flat_map { it.files.to_a }.group_by(&:first).to_h do |name, entries|
43
+ files = entries.map(&:last)
44
+ same_source!(name, files, root)
45
+ loaded = files.select { it[:loaded] }
46
+ [name, loaded.empty? ? files.first : sum(loaded)]
47
+ end
48
+ end
49
+
50
+ def sum(files)
51
+ total = files.each_with_object({}) { |file, sum| Delta.merge!(sum, { file: Raw.entry(file) }) }
52
+ Raw.file(total[:file]).merge(digest: files.first[:digest], loaded: true)
53
+ end
54
+
55
+ def same_source!(name, files, root)
56
+ digests = files.map { it[:digest] }.uniq
57
+ return if digests == [Digest::SHA256.file(File.join(root, name)).hexdigest]
58
+
59
+ raise ArgumentError, "#{name} differs between the shards or from this checkout: merge reports of one commit"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # The measuring side, in a worker and in its cells.
6
+ #
7
+ # A fork starts with its line and branch counters at zero, so each process
8
+ # measures only itself; method counters are inherited, and cleared on
9
+ # start when methods are measured.
10
+ #
11
+ # A cell sends its delta to its worker along with its result. The worker
12
+ # adds those to its own measure and appends the sum to its frame file
13
+ # every interval, and once more before its cleanup hooks (which may stop
14
+ # Coverage). A worker killed mid-run loses at most one interval.
15
+ #
16
+ # A flush copies the coverage of every file Ruby loaded, gems included: a
17
+ # few milliseconds in a small app, a few hundred in a big Rails one. The
18
+ # interval stretches to keep flushes under 1% of a worker's time.
19
+ class Probe
20
+ INTERVAL = 10.0
21
+ BUDGET = 100
22
+
23
+ def initialize(file:, known:, keep:, methods:, interval: INTERVAL)
24
+ @file = file
25
+ @known = known
26
+ @keep = keep
27
+ @methods = methods
28
+ @interval = interval
29
+ @wait = interval
30
+ @pending = {}
31
+ @lost = 0
32
+ end
33
+
34
+ # Right after a fork, before anything runs.
35
+ def start
36
+ ::Coverage.result(stop: false, clear: true) if @methods
37
+ @flushed = Minitest.clock_time
38
+ end
39
+
40
+ # The delta of a cell, at its end.
41
+ def capture = Delta.capture(::Coverage.peek_result, @known, @keep)
42
+
43
+ # A cell's delta, in its worker.
44
+ def absorb(delta) = Delta.merge!(@pending, delta)
45
+
46
+ # A cell that died before reporting.
47
+ def lost = @lost += 1
48
+
49
+ # Between tests.
50
+ def tick
51
+ flush if Minitest.clock_time - @flushed >= @wait
52
+ end
53
+
54
+ def flush
55
+ started = Minitest.clock_time
56
+ delta = Delta.merge!(Delta.capture(::Coverage.result(stop: false, clear: true), @known, @keep), @pending)
57
+ frame = Marshal.dump({ delta:, lost: @lost })
58
+ File.open(@file, 'ab') { it.write([frame.bytesize].pack('N') + frame) }
59
+ # Dense entries brought their structure to the runner: from now on,
60
+ # the cells forked here send these files sparse.
61
+ delta.each { |path, entry| @known << path if entry[:lines].is_a?(Array) }
62
+ @pending = {}
63
+ @lost = 0
64
+ @flushed = Minitest.clock_time
65
+ # 0: after every test, whatever it costs.
66
+ @wait = @interval.zero? ? 0 : [@interval, (@flushed - started) * BUDGET].max
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # The summed coverage as plain data (coverage/.padded_cell.json): what
6
+ # the reports are made from, and what shards merge.
7
+ #
8
+ # files: {relative path => {lines:, branches:, methods:}}
9
+ # lines [hits or nil, ...] by line
10
+ # branches [[type, l1, c1, l2, c2, [[type, l1, c1, l2, c2, hits], ...]], ...]
11
+ # methods [[owner, name, l1, c1, l2, c2, hits], ...]
12
+ module Raw
13
+ module_function
14
+
15
+ def build(files, root:, criteria:, lost:)
16
+ {
17
+ format: 'padded_cell-coverage', version: 1, root:, criteria:, lost:,
18
+ files: files.sort.to_h { |path, entry| [path, file(entry)] }
19
+ }
20
+ end
21
+
22
+ # Every file reaches the runner dense at least once (the runner's own
23
+ # measure covers what it loaded, forks send the rest dense): lines is
24
+ # an Array by now. Branches keep Ruby's order, the order they were
25
+ # compiled in, which reports number them by; methods come in heap
26
+ # order, different in every process: sorted by position.
27
+ def file(entry)
28
+ {
29
+ lines: entry[:lines],
30
+ branches: entry[:branches]&.map do |condition, arms|
31
+ [*condition, arms.map { |arm, hits| [*arm, hits] }]
32
+ end,
33
+ methods: entry[:methods]&.sort_by { |(name, *span), _| [span, name] }
34
+ &.map { |(name, *span), (owner, hits)| [owner, name, *span, hits] }
35
+ }.compact
36
+ end
37
+
38
+ # file's inverse: a dense Delta entry.
39
+ def entry(file)
40
+ {
41
+ lines: file[:lines].dup,
42
+ branches: file[:branches]&.to_h { |row| [row[0, 5], row[5].to_h { |arm| [arm[0, 5], arm[5]] }] },
43
+ methods: file[:methods]&.to_h { |owner, name, *span, hits| [[name, *span], [owner, hits]] }
44
+ }.compact
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'time'
6
+
7
+ module PaddedCell
8
+ module Chart
9
+ # Writes the reports (natively), prints the summary, and reports the
10
+ # thresholds missed.
11
+ class Report
12
+ Stats = Data.define(:covered, :missed, :omitted, :hits, :percent, :strength) do
13
+ def total = covered + missed
14
+ end
15
+ CRITERIA = { lines: :line, branches: :branch, methods: :method }.freeze
16
+ WORST_FILES = 5
17
+
18
+ def initialize(document, dir:, io:)
19
+ @document = document
20
+ @settings = document.settings
21
+ @dir = dir
22
+ @io = io
23
+ end
24
+
25
+ # check: a whole, passing run: thresholds decide, and .last_run.json
26
+ # records it. False when a threshold is missed.
27
+ def call(check:)
28
+ result = PaddedCellNative.chart_report(@document.rows, @document.group_rows, options(check))
29
+ @total = stats(result[:total])
30
+ @files = result[:files].map { |name, file| [name, stats(file)] }
31
+ summary
32
+ warn_lost
33
+ return true unless check
34
+
35
+ result[:violations].each { report_violation(*it) }
36
+ result[:violations].empty?
37
+ end
38
+
39
+ private
40
+
41
+ def options(check)
42
+ now = Time.now
43
+ root = @document.root
44
+ {
45
+ dir: File.expand_path(@dir), formats: @settings[:formats], nocov_token: @settings[:nocov_token],
46
+ criteria: criteria.map(&:to_s), write_last_run: check, last_run: last_run,
47
+ minimum: limits(@settings[:minimum]), minimum_by_file: limits(@settings[:minimum_by_file]),
48
+ minimum_by_group: @settings[:minimum_by_group].map { |name, minimums| [name.to_s, limits(minimums)] },
49
+ maximum_drop: limits(@settings[:maximum_drop]),
50
+ root:, timestamp: now.iso8601(3), created_at: now.to_f, commit: commit(root),
51
+ project_name: File.basename(root).capitalize.tr('_', ' '), generator: "padded_cell #{VERSION}"
52
+ }
53
+ end
54
+
55
+ # [line, branch, method], nil where unset.
56
+ def limits(values) = %i[line branch method].map { values[it]&.to_f }
57
+
58
+ # The previous whole run's percents.
59
+ def last_run
60
+ previous = JSON.parse(File.read(File.join(@dir, '.last_run.json')), symbolize_names: true)[:result] || {}
61
+ limits({ line: previous[:line] || previous[:covered_percent], branch: previous[:branch],
62
+ method: previous[:method] })
63
+ rescue Errno::ENOENT, JSON::ParserError
64
+ [nil, nil, nil]
65
+ end
66
+
67
+ def commit(root)
68
+ out, status = Open3.capture2e('git', '-C', root, 'rev-parse', 'HEAD')
69
+ out.strip if status.success?
70
+ rescue SystemCallError
71
+ nil
72
+ end
73
+
74
+ def stats(row) = row.transform_values { Stats.new(*it) }
75
+
76
+ # The criteria measured, as stats keys.
77
+ def criteria = @document.criteria.filter_map { CRITERIA[it.to_sym] }
78
+
79
+ def summary
80
+ @io.puts "\nCoverage report generated for padded_cell to #{@dir}"
81
+ criteria.each do |criterion|
82
+ stat = @total[criterion]
83
+ next if criterion != :line && stat.total.zero?
84
+
85
+ @io.puts format('%<label>s coverage: %<covered>d / %<total>d (%<percent>.2f%%)',
86
+ label: criterion.capitalize, covered: stat.covered, total: stat.total,
87
+ percent: stat.percent.floor(2))
88
+ end
89
+ end
90
+
91
+ def warn_lost
92
+ lost = @document.lost
93
+ unmeasured = @document.unmeasured
94
+ if lost[:workers].positive? || lost[:cells].positive?
95
+ @io.puts "Coverage is partial: #{lost[:workers]} workers and #{lost[:cells]} cells died before handing " \
96
+ 'over what they measured'
97
+ end
98
+ return if unmeasured.empty?
99
+
100
+ @io.puts "#{unmeasured.size} tracked files were loaded before coverage started, and are not reported: " \
101
+ "#{unmeasured.first(3).join(', ')}#{', ...' if unmeasured.size > 3}"
102
+ end
103
+
104
+ def report_violation(scope, name, criterion, limit, actual)
105
+ label = criterion.capitalize
106
+ @io.puts case scope
107
+ when :drop
108
+ format('%<label>s coverage has dropped by %<actual>.2f%% since the last time ' \
109
+ '(maximum allowed: %<limit>.2f%%).', label:, actual:, limit:)
110
+ when :total then format('%<label>s coverage (%<actual>.2f%%) is below the expected minimum ' \
111
+ 'coverage (%<limit>.2f%%).', label:, actual:, limit:)
112
+ else format('%<label>s coverage by %<scope>s (%<actual>.2f%%) is below the expected minimum ' \
113
+ 'coverage (%<limit>.2f%%) in %<name>s.', label:, scope:, actual:, limit:, name:)
114
+ end
115
+ worst_files(criterion) if scope == :total
116
+ end
117
+
118
+ def worst_files(criterion)
119
+ worst = @files.map { |name, file| [name, file[criterion].percent] }.select { it[1] < 100 }
120
+ .min_by(WORST_FILES) { it[1] }
121
+ return if worst.empty?
122
+
123
+ @io.puts " Lowest-coverage files (#{criterion}):"
124
+ worst.each { |name, percent| @io.puts format(' %<percent>6.2f%% %<name>s', percent:, name:) }
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # The files of a report: those measured and those tracked but never
6
+ # loaded, minus the filtered ones, in path order; and their groups.
7
+ class SourceFiles
8
+ # Tracked templates are not Ruby files Coverage could stub.
9
+ TEMPLATES = /\.(?:erb|haml|slim)\z/
10
+
11
+ # Tracked files loaded before measuring started (by a Rails rake task,
12
+ # Bundler.require...): their coverage is unknown, not 0%.
13
+ attr_reader :unmeasured
14
+
15
+ # files: Raw's {relative path => {lines:, branches:, methods:}}
16
+ def initialize(files, config:, root:)
17
+ @config = config
18
+ @root = root
19
+ loaded = loaded_features
20
+ never, @unmeasured = tracked(files).partition { !loaded.include?(File.join(root, it)) }
21
+ @rows = (files.map { |name, entry| row(name, entry) } + stubs(never))
22
+ .reject { |path, name| skipped?(path, name) }
23
+ .sort_by { it[1] }
24
+ end
25
+
26
+ # [[absolute path, name, lines, branches, methods, loaded], ...], for
27
+ # PaddedCellNative.chart_report.
28
+ attr_reader :rows
29
+
30
+ # [[name, [row index, ...]], ...]: the groups with files, then
31
+ # Ungrouped. None without groups.
32
+ def groups
33
+ return [] if @config.groups.empty?
34
+
35
+ files = @rows.map { |path, name| SourceFile.new(path, name) }
36
+ grouped = @config.groups.map { |name, filter| [name, files.each_index.select { filter.call(files[it]) }] }
37
+ ungrouped = files.each_index.to_a - grouped.flat_map(&:last)
38
+ (grouped + [['Ungrouped', ungrouped]]).reject { |_, members| members.empty? }
39
+ end
40
+
41
+ private
42
+
43
+ def row(name, entry) = [File.join(@root, name), name, entry[:lines], entry[:branches], entry[:methods], true]
44
+
45
+ # Files never loaded. Ruby compiles them, without running them: while
46
+ # Coverage runs, that gives their lines and branches as if loaded, none
47
+ # covered. Their methods only exist once run: the native side finds
48
+ # them in the source. A file that does not compile has every line but
49
+ # blank and comment ones missed.
50
+ def stubs(names)
51
+ paths = names.map { File.join(@root, it) }
52
+ compiled = paths.select { compile(it) }
53
+ measured = compiled.empty? ? {} : ::Coverage.peek_result
54
+ paths.zip(names).map do |path, name|
55
+ data = measured[path]
56
+ next [path, name, File.readlines(path).map { 0 }, nil, nil, false] unless data
57
+
58
+ entry = Raw.file(Delta.dense(data))
59
+ [path, name, entry[:lines], entry[:branches], nil, false]
60
+ end
61
+ end
62
+
63
+ def compile(path)
64
+ RubyVM::InstructionSequence.compile_file(path)
65
+ rescue SyntaxError
66
+ false
67
+ end
68
+
69
+ def tracked(files)
70
+ @config.tracked.flat_map { Dir.glob(it, base: @root) }.uniq.reject do |name|
71
+ files.key?(name) || TEMPLATES.match?(name) || !File.file?(File.join(@root, name))
72
+ end
73
+ end
74
+
75
+ def skipped?(path, name)
76
+ file = SourceFile.new(path, name)
77
+ @config.filters.any? { it.call(file) }
78
+ end
79
+
80
+ # Required files, also under the root's real path.
81
+ def loaded_features
82
+ pwd = "#{Dir.pwd}/"
83
+ $LOADED_FEATURES.each_with_object(Set.new) do |feature, set|
84
+ set << feature
85
+ set << File.join(@root, feature.delete_prefix(pwd)) if feature.start_with?(pwd)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end