padded_cell 0.1.0 → 0.3.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/Cargo.lock +16 -2
  4. data/Cargo.toml +2 -0
  5. data/README.md +170 -8
  6. data/ext/padded_cell_native/core/Cargo.toml +2 -1
  7. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  8. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  9. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  10. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  11. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  13. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  14. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  15. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  16. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  17. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  18. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  19. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  20. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  21. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  22. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  23. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  24. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  25. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  27. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  28. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  29. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  30. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  31. data/ext/padded_cell_native/core/src/error.rs +24 -0
  32. data/ext/padded_cell_native/core/src/lib.rs +10 -3
  33. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  34. data/ext/padded_cell_native/core/src/restarts/tests.rs +29 -0
  35. data/ext/padded_cell_native/core/src/restarts.rs +64 -0
  36. data/ext/padded_cell_native/ffi/Cargo.toml +2 -1
  37. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  38. data/ext/padded_cell_native/ffi/src/lib.rs +5 -0
  39. data/ext/padded_cell_native/ffi/src/limits.rs +83 -0
  40. data/lib/padded_cell/asylum.rb +93 -0
  41. data/lib/padded_cell/cell.rb +23 -14
  42. data/lib/padded_cell/chart/accumulator.rb +103 -0
  43. data/lib/padded_cell/chart/config.rb +129 -0
  44. data/lib/padded_cell/chart/delta.rb +148 -0
  45. data/lib/padded_cell/chart/document.rb +64 -0
  46. data/lib/padded_cell/chart/filter.rb +55 -0
  47. data/lib/padded_cell/chart/merge.rb +63 -0
  48. data/lib/padded_cell/chart/probe.rb +70 -0
  49. data/lib/padded_cell/chart/raw.rb +48 -0
  50. data/lib/padded_cell/chart/report.rb +128 -0
  51. data/lib/padded_cell/chart/source_files.rb +90 -0
  52. data/lib/padded_cell/chart.rb +38 -0
  53. data/lib/padded_cell/cli.rb +70 -4
  54. data/lib/padded_cell/limits.rb +45 -0
  55. data/lib/padded_cell/loader.rb +5 -1
  56. data/lib/padded_cell/pool.rb +82 -58
  57. data/lib/padded_cell/reporters.rb +20 -3
  58. data/lib/padded_cell/results.rb +13 -0
  59. data/lib/padded_cell/runner.rb +150 -22
  60. data/lib/padded_cell/version.rb +1 -1
  61. data/lib/padded_cell/worker.rb +63 -0
  62. data/lib/padded_cell.rb +8 -0
  63. metadata +43 -1
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddedCell
4
+ module Chart
5
+ # A PaddedCell.coverage block, in the test helper:
6
+ #
7
+ # PaddedCell.coverage(:rails) do
8
+ # track "{app,lib}/**/*.rb"
9
+ # skip "app/legacy/"
10
+ # group "Operations", "app/concepts"
11
+ # minimum line: 90, branch: 80
12
+ # minimum_by_file line: 50
13
+ # minimum_by_group "Models", line: 95
14
+ # maximum_drop line: 1
15
+ # formats :markdown, :lcov, :json
16
+ # end
17
+ #
18
+ # Filters and groups match paths (see Filter). What is
19
+ # measured is decided before the suite loads, on the command line:
20
+ # --coverage=lines,branches,methods.
21
+ class Config
22
+ FORMATS = %i[markdown lcov json].freeze
23
+ # Left out unless cleared: bundled gems, hidden files, test files.
24
+ DEFAULT_FILTERS = ['/vendor/bundle/', /\A\..*/, %r{\A(test|features|spec|autotest)/}].freeze
25
+ RAILS_GROUPS = {
26
+ 'Channels' => 'app/channels', 'Controllers' => 'app/controllers', 'Helpers' => 'app/helpers',
27
+ 'Jobs' => %w[app/jobs app/workers], 'Libraries' => 'lib/', 'Mailers' => 'app/mailers',
28
+ 'Models' => 'app/models', 'Views' => 'app/views'
29
+ }.freeze
30
+
31
+ attr_reader :filters, :groups, :tracked, :output_formats, :directory, :token, :interval, :minimums,
32
+ :file_minimums, :group_minimums, :maximum_drops
33
+
34
+ def initialize
35
+ @filters = DEFAULT_FILTERS.map { Filter.build(it) }
36
+ @groups = {}
37
+ @tracked = []
38
+ @minimums = {}
39
+ @file_minimums = {}
40
+ @group_minimums = {}
41
+ @maximum_drops = {}
42
+ @output_formats = %i[markdown lcov json]
43
+ @directory = 'coverage'
44
+ @token = 'nocov'
45
+ @interval = Probe::INTERVAL
46
+ end
47
+
48
+ # :rails skips config/ and db/, groups the
49
+ # app's directories, tracks {app,lib}/**/*.rb.
50
+ def profile(name)
51
+ raise ArgumentError, "unknown coverage profile #{name.inspect} (known: :rails)" unless name.to_s == 'rails'
52
+
53
+ skip(%r{\Aconfig/}, %r{\Adb/})
54
+ RAILS_GROUPS.each { |group_name, filter| group(group_name, filter) }
55
+ track('{app,lib}/**/*.rb')
56
+ end
57
+
58
+ # Files to report even when the suite never loads them, at 0%.
59
+ def track(*globs) = @tracked.concat(globs)
60
+
61
+ # Files to leave out of the report.
62
+ def skip(*filters, &block)
63
+ filters.each { @filters << Filter.build(it) }
64
+ @filters << Filter.build(&block) if block
65
+ end
66
+ alias filter skip
67
+
68
+ # Removes every filter, the defaults included.
69
+ def clear_filters = @filters.clear
70
+
71
+ # A file can be in several groups; files in none are Ungrouped.
72
+ # Defining a group again replaces it.
73
+ def group(name, filter = nil, &) = @groups[name.to_s] = Filter.build(filter, &)
74
+
75
+ # Thresholds compare percents floored to 2 decimals; a whole, passing
76
+ # run that misses one exits 3.
77
+ def minimum(line: nil, branch: nil, method: nil) = @minimums.merge!({ line:, branch:, method: }.compact)
78
+
79
+ # For every file.
80
+ def minimum_by_file(line: nil, branch: nil, method: nil)
81
+ @file_minimums.merge!({ line:, branch:, method: }.compact)
82
+ end
83
+
84
+ def minimum_by_group(name, line: nil, branch: nil, method: nil)
85
+ (@group_minimums[name.to_s] ||= {}).merge!({ line:, branch:, method: }.compact)
86
+ end
87
+
88
+ # How many points coverage may lose since the previous whole run.
89
+ def maximum_drop(line: nil, branch: nil, method: nil)
90
+ @maximum_drops.merge!({ line:, branch:, method: }.compact)
91
+ end
92
+
93
+ def formats(*names)
94
+ unknown = names.map(&:to_sym) - FORMATS
95
+ unless unknown.empty?
96
+ raise ArgumentError, "unknown coverage formats: #{unknown.join(', ')} (known: #{FORMATS.join(', ')})"
97
+ end
98
+
99
+ @output_formats = names.map(&:to_sym)
100
+ end
101
+
102
+ # Where reports go (default: coverage). --coverage-dir wins.
103
+ def dir(path) = @directory = path
104
+
105
+ # `# :nocov:` opens and closes blocks of lines coverage ignores.
106
+ def nocov_token(token) = @token = token
107
+
108
+ # How often, at least, a worker hands over what it measured (seconds,
109
+ # default 10, stretched to keep it under 1% of the worker's time; 0:
110
+ # after every test). A worker that dies loses what it measured since.
111
+ def flush_interval(seconds) = @interval = seconds
112
+
113
+ # What a report needs once files are filtered and grouped, as stored
114
+ # in its Document.
115
+ def settings
116
+ {
117
+ formats: @output_formats.map(&:to_s), nocov_token: @token, minimum: @minimums,
118
+ minimum_by_file: @file_minimums, minimum_by_group: @group_minimums, maximum_drop: @maximum_drops
119
+ }
120
+ end
121
+
122
+ # Criteria come from the command line, before the suite (and this
123
+ # block) loads.
124
+ def enable_coverage(*)
125
+ raise ArgumentError, 'coverage criteria come from --coverage=lines,branches,methods, not the test helper'
126
+ end
127
+ end
128
+ end
129
+ end
@@ -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