padded_cell 0.1.0-x86_64-linux-gnu → 0.2.0-x86_64-linux-gnu
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +148 -7
- data/lib/padded_cell/4.0/padded_cell_native.so +0 -0
- data/lib/padded_cell/asylum.rb +91 -0
- data/lib/padded_cell/cell.rb +14 -8
- data/lib/padded_cell/chart/accumulator.rb +103 -0
- data/lib/padded_cell/chart/config.rb +129 -0
- data/lib/padded_cell/chart/delta.rb +148 -0
- data/lib/padded_cell/chart/document.rb +64 -0
- data/lib/padded_cell/chart/filter.rb +55 -0
- data/lib/padded_cell/chart/merge.rb +63 -0
- data/lib/padded_cell/chart/probe.rb +70 -0
- data/lib/padded_cell/chart/raw.rb +48 -0
- data/lib/padded_cell/chart/report.rb +128 -0
- data/lib/padded_cell/chart/source_files.rb +90 -0
- data/lib/padded_cell/chart.rb +38 -0
- data/lib/padded_cell/cli.rb +59 -4
- data/lib/padded_cell/loader.rb +5 -1
- data/lib/padded_cell/pool.rb +19 -42
- data/lib/padded_cell/reporters.rb +20 -3
- data/lib/padded_cell/results.rb +13 -0
- data/lib/padded_cell/runner.rb +125 -22
- data/lib/padded_cell/version.rb +1 -1
- data/lib/padded_cell/worker.rb +63 -0
- data/lib/padded_cell.rb +8 -0
- metadata +14 -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
|