testprune 0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +552 -0
- data/assets/quickstart.svg +70 -0
- data/exe/testprune +6 -0
- data/lib/testprune/adapters/minitest.rb +42 -0
- data/lib/testprune/adapters/rspec.rb +31 -0
- data/lib/testprune/analysis.rb +53 -0
- data/lib/testprune/autostart.rb +40 -0
- data/lib/testprune/baseline.rb +23 -0
- data/lib/testprune/cli.rb +136 -0
- data/lib/testprune/configuration.rb +86 -0
- data/lib/testprune/coverage_delta.rb +82 -0
- data/lib/testprune/duplication_detector.rb +203 -0
- data/lib/testprune/footprint.rb +87 -0
- data/lib/testprune/patch_writer.rb +117 -0
- data/lib/testprune/recorder.rb +102 -0
- data/lib/testprune/report.rb +127 -0
- data/lib/testprune/runner.rb +76 -0
- data/lib/testprune/safety_check.rb +45 -0
- data/lib/testprune/savings_estimator.rb +30 -0
- data/lib/testprune/semantic_map.rb +185 -0
- data/lib/testprune/test_body.rb +61 -0
- data/lib/testprune/version.rb +5 -0
- data/lib/testprune.rb +18 -0
- metadata +90 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'footprint'
|
|
5
|
+
require_relative 'test_body'
|
|
6
|
+
require_relative 'safety_check'
|
|
7
|
+
require_relative 'baseline'
|
|
8
|
+
|
|
9
|
+
module Testprune
|
|
10
|
+
# One test proposed for removal, with why and (after the safety pass) whether
|
|
11
|
+
# it's safe. `review_only` candidates (structural/overlap) are reported but
|
|
12
|
+
# never auto-patched.
|
|
13
|
+
Candidate = Struct.new(
|
|
14
|
+
:footprint, :confidence, :group, :reason, :kept_by,
|
|
15
|
+
:review_only, :safe, :safety_note,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Holds the detection outcome and exposes the set that `apply` may patch.
|
|
20
|
+
# `ambient_units`/`setup_only` record what baseline subtraction removed, so the
|
|
21
|
+
# report can disclose it rather than silently dropping tests.
|
|
22
|
+
class DetectorResult
|
|
23
|
+
attr_reader :footprints, :candidates, :ambient_units, :setup_only
|
|
24
|
+
|
|
25
|
+
def initialize(footprints, candidates, ambient_units: 0, setup_only: 0)
|
|
26
|
+
@footprints = footprints
|
|
27
|
+
@candidates = candidates
|
|
28
|
+
@ambient_units = ambient_units
|
|
29
|
+
@setup_only = setup_only
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Only HIGH-confidence, non-review candidates that passed the safety check are
|
|
33
|
+
# auto-applicable. Structural (MEDIUM) and overlap (LOW) are review-only.
|
|
34
|
+
def approved_removals
|
|
35
|
+
@candidates.select { |c| c.confidence == :high && !c.review_only && c.safe }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Detects duplicate/redundant tests from semantic footprints. Each removable
|
|
40
|
+
# candidate is justified by a *retained* keeper whose coverage is a superset
|
|
41
|
+
# (or equal), which the SafetyCheck then re-verifies against cascading gaps.
|
|
42
|
+
# Above this many post-identical/subset footprints, skip O(n²) overlap detection
|
|
43
|
+
# and log a warning. ~500 yields ~125k pairs which is acceptable; 2000+ becomes
|
|
44
|
+
# unusably slow on large suites.
|
|
45
|
+
OVERLAP_SIZE_LIMIT = 500
|
|
46
|
+
|
|
47
|
+
class DuplicationDetector
|
|
48
|
+
def initialize(footprints, overlap_threshold: 0.9, baseline_fraction: nil)
|
|
49
|
+
@original_footprints = footprints # preserved for SafetyCheck ambient-unit guarantee
|
|
50
|
+
ambient = Baseline.ambient_units(footprints, baseline_fraction)
|
|
51
|
+
had_coverage = footprints.reject(&:empty?).size
|
|
52
|
+
@footprints = strip_ambient(footprints, ambient).reject(&:empty?)
|
|
53
|
+
@ambient = ambient.size
|
|
54
|
+
@setup_only = had_coverage - @footprints.size # lost all signal to baseline
|
|
55
|
+
@threshold = overlap_threshold
|
|
56
|
+
@candidates = []
|
|
57
|
+
@seen = Set.new # ids already proposed for removal
|
|
58
|
+
@protected = Set.new # ids chosen as keepers — never propose these
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call
|
|
62
|
+
detect_identical
|
|
63
|
+
detect_subset
|
|
64
|
+
detect_structural
|
|
65
|
+
detect_overlap
|
|
66
|
+
SafetyCheck.new(@footprints, original_footprints: @original_footprints).apply(@candidates)
|
|
67
|
+
DetectorResult.new(@footprints, @candidates,
|
|
68
|
+
ambient_units: @ambient, setup_only: @setup_only)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Returns copies of the footprints with ambient (shared-setup) units removed,
|
|
74
|
+
# so detection compares only each test's *distinctive* coverage. A test whose
|
|
75
|
+
# footprint was entirely ambient becomes empty and is dropped — we can't tell
|
|
76
|
+
# what it uniquely exercises, so it must never be proposed for removal.
|
|
77
|
+
def strip_ambient(footprints, ambient)
|
|
78
|
+
return footprints if ambient.empty?
|
|
79
|
+
|
|
80
|
+
footprints.map do |fp|
|
|
81
|
+
fp.dup.tap { |copy| copy.units = fp.units - ambient }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def available
|
|
86
|
+
@footprints.reject { |fp| @seen.include?(fp.id) || @protected.include?(fp.id) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def detect_identical
|
|
90
|
+
@footprints.group_by(&:units).each_value do |members|
|
|
91
|
+
next if members.size < 2
|
|
92
|
+
|
|
93
|
+
members = members.sort_by(&:id)
|
|
94
|
+
keeper = members.first
|
|
95
|
+
@protected << keeper.id
|
|
96
|
+
members.drop(1).each do |fp|
|
|
97
|
+
propose_local(fp, keeper, group: :identical,
|
|
98
|
+
high_reason: "identical coverage to #{keeper.id}",
|
|
99
|
+
low_reason: "identical coverage to #{keeper.id}, but in a different " \
|
|
100
|
+
'test file (likely a shared code path, not a redundant test)')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def detect_subset
|
|
106
|
+
available.sort_by(&:id).each do |candidate|
|
|
107
|
+
# Search for a keeper among all footprints not yet proposed for removal.
|
|
108
|
+
# Crucially, we include @protected footprints (already-designated keepers):
|
|
109
|
+
# excluding them caused false negatives in coverage chains where A ⊊ B ⊊ C —
|
|
110
|
+
# after B is protected as A's keeper, C must still be findable as B's keeper.
|
|
111
|
+
keeper = @footprints
|
|
112
|
+
.reject { |fp| @seen.include?(fp.id) || fp.id == candidate.id }
|
|
113
|
+
.find { |other| candidate.units.proper_subset?(other.units) }
|
|
114
|
+
next unless keeper
|
|
115
|
+
|
|
116
|
+
@protected << keeper.id
|
|
117
|
+
propose_local(candidate, keeper, group: :subset,
|
|
118
|
+
high_reason: "coverage is a strict subset of #{keeper.id}",
|
|
119
|
+
low_reason: "coverage is a strict subset of #{keeper.id}, but in a different " \
|
|
120
|
+
'test file (likely a shared code path, not a redundant test)')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Coverage-equivalence only earns HIGH (auto-removable) confidence when both
|
|
125
|
+
# tests live in the same file — i.e. variations of one scenario. Equivalence
|
|
126
|
+
# across files usually means the tests merely share a guard/middleware path
|
|
127
|
+
# while asserting different things, so it's demoted to LOW review-only.
|
|
128
|
+
def propose_local(footprint, keeper, group:, high_reason:, low_reason:)
|
|
129
|
+
if same_file?(footprint, keeper)
|
|
130
|
+
propose(footprint, confidence: :high, group: group, reason: high_reason, keeper: keeper)
|
|
131
|
+
else
|
|
132
|
+
propose(footprint, confidence: :low, group: group, review_only: true,
|
|
133
|
+
reason: low_reason, keeper: keeper)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def same_file?(a, b)
|
|
138
|
+
!a.file.nil? && a.file == b.file
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def detect_structural
|
|
142
|
+
by_signature = {}
|
|
143
|
+
available.each do |fp|
|
|
144
|
+
sig = TestBody.signature(fp.file, fp.line)
|
|
145
|
+
(by_signature[sig] ||= []) << fp if sig
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
by_signature.each_value do |members|
|
|
149
|
+
next if members.size < 2
|
|
150
|
+
|
|
151
|
+
members = members.sort_by(&:id)
|
|
152
|
+
keeper = members.first
|
|
153
|
+
@protected << keeper.id
|
|
154
|
+
members.drop(1).each do |fp|
|
|
155
|
+
next if (fp.units & keeper.units).empty? # require real coverage overlap
|
|
156
|
+
|
|
157
|
+
propose(fp, confidence: :medium, group: :structural, review_only: true,
|
|
158
|
+
reason: "test body structurally identical to #{keeper.id}", keeper: keeper)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def detect_overlap
|
|
164
|
+
pool = available
|
|
165
|
+
if pool.size > OVERLAP_SIZE_LIMIT
|
|
166
|
+
warn "testprune: #{pool.size} candidates after identical/subset detection; " \
|
|
167
|
+
"overlap (LOW-confidence) detection skipped to avoid O(n²) cost at this " \
|
|
168
|
+
"suite size. HIGH/MEDIUM results are unaffected."
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
pool.combination(2).each do |a, b|
|
|
173
|
+
next if @seen.include?(a.id) || @seen.include?(b.id)
|
|
174
|
+
|
|
175
|
+
score = jaccard(a.units, b.units)
|
|
176
|
+
next if score < @threshold
|
|
177
|
+
|
|
178
|
+
smaller, larger = [a, b].sort_by { |fp| [fp.units.size, fp.id] }
|
|
179
|
+
next if @protected.include?(smaller.id)
|
|
180
|
+
|
|
181
|
+
@protected << larger.id
|
|
182
|
+
propose(smaller, confidence: :low, group: :overlap, review_only: true,
|
|
183
|
+
reason: "#{(score * 100).round}% coverage overlap with #{larger.id}",
|
|
184
|
+
keeper: larger)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def propose(footprint, confidence:, group:, reason:, keeper:, review_only: false)
|
|
189
|
+
@candidates << Candidate.new(
|
|
190
|
+
footprint: footprint, confidence: confidence, group: group, reason: reason,
|
|
191
|
+
kept_by: [keeper.id], review_only: review_only
|
|
192
|
+
)
|
|
193
|
+
@seen << footprint.id
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Avoids materializing the union Set: uses inclusion-exclusion arithmetic instead.
|
|
197
|
+
def jaccard(a, b)
|
|
198
|
+
inter = (a & b).size
|
|
199
|
+
union = a.size + b.size - inter
|
|
200
|
+
union.zero? ? 0.0 : inter.to_f / union
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative 'semantic_map'
|
|
5
|
+
|
|
6
|
+
module Testprune
|
|
7
|
+
# A single test's semantic coverage: the set of AST-aware unit IDs it executed,
|
|
8
|
+
# plus the metadata needed to report and patch it.
|
|
9
|
+
Footprint = Struct.new(:id, :description, :file, :line, :wall_time, :units, keyword_init: true) do
|
|
10
|
+
def empty?
|
|
11
|
+
units.empty?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Turns recorded per-test coverage (run.json) into Footprints by mapping
|
|
16
|
+
# Coverage locations onto Prism semantic units. Caches one SemanticMap per file.
|
|
17
|
+
class SemanticIndex
|
|
18
|
+
def initialize(root)
|
|
19
|
+
@root = root
|
|
20
|
+
@maps = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_footprints(tests)
|
|
24
|
+
tests.map { |test| footprint(test) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def footprint(test)
|
|
28
|
+
units = Set.new
|
|
29
|
+
(test['coverage'] || {}).each do |file, data|
|
|
30
|
+
next unless File.exist?(file)
|
|
31
|
+
|
|
32
|
+
map = map_for(file)
|
|
33
|
+
collect_methods(units, map, data['methods'])
|
|
34
|
+
collect_branches(units, map, data['branches'])
|
|
35
|
+
collect_lines(units, map, data['lines'])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Footprint.new(
|
|
39
|
+
id: test['id'], description: test['description'], file: test['file'],
|
|
40
|
+
line: test['line'], wall_time: test['wall_time'] || 0.0, units: units
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def label_for(id)
|
|
45
|
+
@maps.each_value do |map|
|
|
46
|
+
unit = map.units[id]
|
|
47
|
+
return unit.label if unit
|
|
48
|
+
end
|
|
49
|
+
id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def collect_methods(units, map, entries)
|
|
55
|
+
(entries || []).each do |entry|
|
|
56
|
+
_name, sl, sc, = entry
|
|
57
|
+
unit = map.method_unit(sl, sc)
|
|
58
|
+
units << unit.id if unit
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def collect_branches(units, map, entries)
|
|
63
|
+
(entries || []).each do |entry|
|
|
64
|
+
type, sl, sc, el, ec = entry
|
|
65
|
+
units << map.branch_unit(type, sl, sc, el, ec).id
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def collect_lines(units, map, lines)
|
|
70
|
+
(lines || []).each do |lineno|
|
|
71
|
+
unit = map.line_unit(lineno)
|
|
72
|
+
units << unit.id if unit
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def map_for(file)
|
|
77
|
+
@maps[file] ||= SemanticMap.for_file(file, relpath: relativize(file))
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
warn "testprune: could not parse #{file} (#{e.class}: #{e.message}) — skipping"
|
|
80
|
+
@maps[file] = SemanticMap.new(file, '', relativize(file))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def relativize(file)
|
|
84
|
+
file.start_with?("#{@root}/") ? file[(@root.length + 1)..] : file
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative '../testprune'
|
|
7
|
+
require_relative 'test_body'
|
|
8
|
+
|
|
9
|
+
module Testprune
|
|
10
|
+
# Emits a git-applyable patch that comments out the approved tests. Locates each
|
|
11
|
+
# test's exact AST block (def or `it`/`describe` block) with Prism, comments
|
|
12
|
+
# those lines, and diffs against the original via `git diff --no-index`. Writes
|
|
13
|
+
# only the .patch file — never mutates the target source.
|
|
14
|
+
class PatchWriter
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(candidates)
|
|
20
|
+
FileUtils.mkdir_p(@config.output_dir)
|
|
21
|
+
patch = candidates.group_by { |c| c.footprint.file }
|
|
22
|
+
.map { |file, group| file_patch(file, group) }
|
|
23
|
+
.compact.join
|
|
24
|
+
File.write(@config.patch_file, patch)
|
|
25
|
+
@config.patch_file
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def file_patch(file, candidates)
|
|
31
|
+
unless file && File.exist?(file)
|
|
32
|
+
ids = candidates.map { |c| c.footprint.id }
|
|
33
|
+
warn "testprune: #{ids.size} candidate(s) skipped — #{file || '(no file)'} not found:\n" \
|
|
34
|
+
" #{ids.join(', ')}"
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
original = File.read(file, encoding: 'UTF-8:UTF-8', invalid: :replace, undef: :replace)
|
|
39
|
+
tree = begin
|
|
40
|
+
Prism.parse(original).value
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
warn "testprune: Prism parse failed for #{file} (#{e.message}) — skipping"
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
ranges = candidates.filter_map { |c| block_range(tree, c) }
|
|
46
|
+
return nil if ranges.empty?
|
|
47
|
+
|
|
48
|
+
modified = comment_out(original, ranges)
|
|
49
|
+
diff(file, modified)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# [start_line, end_line, reason] for the test block at the candidate's line.
|
|
53
|
+
def block_range(tree, candidate)
|
|
54
|
+
node = TestBody.locate(tree, candidate.footprint.line)
|
|
55
|
+
return nil unless node
|
|
56
|
+
|
|
57
|
+
loc = node.location
|
|
58
|
+
[loc.start_line, loc.end_line, candidate.reason]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def comment_out(original, ranges)
|
|
62
|
+
lines = original.lines
|
|
63
|
+
commented = lines.dup
|
|
64
|
+
annotations = {} # 0-based insert index => annotation text
|
|
65
|
+
|
|
66
|
+
ranges.each do |start_line, end_line, reason|
|
|
67
|
+
annotations[start_line - 1] = "# testprune: removed redundant test — #{reason}\n"
|
|
68
|
+
(start_line..end_line).each do |lineno|
|
|
69
|
+
idx = lineno - 1
|
|
70
|
+
commented[idx] = "# #{commented[idx]}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Insert annotations from the bottom up so earlier indexes stay valid.
|
|
75
|
+
annotations.sort_by { |idx, _| -idx }.each do |idx, text|
|
|
76
|
+
indent = lines[idx][/\A\s*/]
|
|
77
|
+
commented.insert(idx, "#{indent}#{text}")
|
|
78
|
+
end
|
|
79
|
+
commented.join
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Unified diff via git, with headers rewritten to the repo-relative path so
|
|
83
|
+
# the patch applies cleanly from the project root.
|
|
84
|
+
def diff(file, modified)
|
|
85
|
+
relpath = relativize(file)
|
|
86
|
+
Tempfile.create(['testprune', '.rb']) do |tmp|
|
|
87
|
+
tmp.write(modified)
|
|
88
|
+
tmp.flush
|
|
89
|
+
out, _err, status = Open3.capture3(
|
|
90
|
+
'git', 'diff', '--no-index', '--no-color', file, tmp.path
|
|
91
|
+
)
|
|
92
|
+
raise Error, 'git diff failed while building patch' if status.exitstatus > 1
|
|
93
|
+
|
|
94
|
+
rewrite_headers(out, relpath)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rewrite_headers(diff, relpath)
|
|
99
|
+
diff.lines.map do |line|
|
|
100
|
+
if line.start_with?('diff --git ')
|
|
101
|
+
"diff --git a/#{relpath} b/#{relpath}\n"
|
|
102
|
+
elsif line.start_with?('--- ')
|
|
103
|
+
"--- a/#{relpath}\n"
|
|
104
|
+
elsif line.start_with?('+++ ')
|
|
105
|
+
"+++ b/#{relpath}\n"
|
|
106
|
+
else
|
|
107
|
+
line
|
|
108
|
+
end
|
|
109
|
+
end.join
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def relativize(file)
|
|
113
|
+
root = @config.root
|
|
114
|
+
file.start_with?("#{root}/") ? file[(root.length + 1)..] : file
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative '../testprune'
|
|
7
|
+
require_relative 'coverage_delta'
|
|
8
|
+
|
|
9
|
+
module Testprune
|
|
10
|
+
# Process-wide singleton that the framework adapters drive. It starts Coverage,
|
|
11
|
+
# snapshots peek_result around each test, and writes run.json when the suite ends.
|
|
12
|
+
class Recorder
|
|
13
|
+
def self.instance
|
|
14
|
+
@instance ||= new(Configuration.from_env)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Used in testprune's own test suite to prevent Recorder state from leaking
|
|
18
|
+
# between test runs (the singleton is normally process-scoped by design).
|
|
19
|
+
def self.reset!
|
|
20
|
+
@instance = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(config)
|
|
24
|
+
@config = config
|
|
25
|
+
@tests = []
|
|
26
|
+
@framework = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_accessor :framework
|
|
30
|
+
|
|
31
|
+
# Start Coverage with all required options and keep it running for the
|
|
32
|
+
# entire suite. Staying always-on means `Coverage.running?` is true before
|
|
33
|
+
# SimpleCov (or any other coverage tool) loads, so they see it already
|
|
34
|
+
# running and skip their own `Coverage.start` call — no guard needed in
|
|
35
|
+
# test_helper.rb. Per-test footprints are captured by diffing `peek_result`
|
|
36
|
+
# snapshots before and after each test; `peek_result` is non-destructive so
|
|
37
|
+
# SimpleCov's final `Coverage.result` call at suite end still gets the full
|
|
38
|
+
# aggregate unaffected.
|
|
39
|
+
def start_coverage
|
|
40
|
+
return if @started
|
|
41
|
+
|
|
42
|
+
Coverage.setup(lines: true, branches: true, methods: true)
|
|
43
|
+
Coverage.resume
|
|
44
|
+
@started = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Bracket a single test (RSpec around(:each)). Minitest uses start_test/
|
|
48
|
+
# finish_test directly from lifecycle hooks instead, to avoid wrapping #run.
|
|
49
|
+
def around(id:, file:, line:, description: nil)
|
|
50
|
+
start_test
|
|
51
|
+
begin
|
|
52
|
+
yield
|
|
53
|
+
ensure
|
|
54
|
+
finish_test(id: id, file: file, line: line, description: description)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Snapshot coverage counts before the test begins.
|
|
59
|
+
def start_test
|
|
60
|
+
@before = Coverage.peek_result
|
|
61
|
+
@test_started = monotonic
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Record this test's coverage delta + wall time. Coverage keeps running
|
|
65
|
+
# between tests — the peek_result diff is accurate for serial suites because
|
|
66
|
+
# nothing else executes between start_test and finish_test.
|
|
67
|
+
def finish_test(id:, file:, line:, description: nil)
|
|
68
|
+
wall = monotonic - @test_started
|
|
69
|
+
delta = CoverageDelta.compute(@before, Coverage.peek_result, @config)
|
|
70
|
+
@tests << {
|
|
71
|
+
'id' => id,
|
|
72
|
+
'description' => description || id,
|
|
73
|
+
'file' => file && File.expand_path(file),
|
|
74
|
+
'line' => line,
|
|
75
|
+
'wall_time' => wall,
|
|
76
|
+
'coverage' => delta
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dump
|
|
81
|
+
return if @dumped
|
|
82
|
+
|
|
83
|
+
@dumped = true
|
|
84
|
+
FileUtils.mkdir_p(@config.output_dir)
|
|
85
|
+
File.write(@config.run_file, JSON.generate(payload))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def payload
|
|
91
|
+
{
|
|
92
|
+
'root' => @config.root,
|
|
93
|
+
'framework' => @framework,
|
|
94
|
+
'tests' => @tests
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def monotonic
|
|
99
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
# Renders the analysis as a grouped, human-readable report (or JSON). Candidates
|
|
7
|
+
# are grouped by confidence; HIGH shows safety status, MEDIUM/LOW are marked
|
|
8
|
+
# review-only.
|
|
9
|
+
class Report
|
|
10
|
+
GROUP_TITLES = {
|
|
11
|
+
identical: 'Identical coverage',
|
|
12
|
+
subset: 'Subset / subsumed coverage',
|
|
13
|
+
structural: 'Structurally duplicated test body',
|
|
14
|
+
overlap: 'High coverage overlap'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(result, json: false)
|
|
18
|
+
@result = result
|
|
19
|
+
@json = json
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render
|
|
23
|
+
@json ? render_json : render_text
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render_text
|
|
29
|
+
lines = []
|
|
30
|
+
lines << 'testprune — test coverage redundancy report'
|
|
31
|
+
lines << "Suite: #{test_count} test(s), framework=#{@result.run['framework']}"
|
|
32
|
+
if @result.ambient_units.positive?
|
|
33
|
+
lines << "Baseline: subtracted #{@result.ambient_units} shared-setup unit(s); " \
|
|
34
|
+
"#{@result.setup_only} test(s) had no distinctive coverage and were set aside."
|
|
35
|
+
end
|
|
36
|
+
lines << ''
|
|
37
|
+
|
|
38
|
+
lines.concat(section('HIGH confidence — safe to remove', high_candidates))
|
|
39
|
+
lines.concat(section('MEDIUM confidence — review (structural duplicates)', medium_candidates))
|
|
40
|
+
lines.concat(section('LOW confidence — review (overlapping coverage)', low_candidates))
|
|
41
|
+
|
|
42
|
+
lines.concat(savings_section)
|
|
43
|
+
lines << ''
|
|
44
|
+
lines << if @result.approved_removals.empty?
|
|
45
|
+
'No auto-removable candidates. Nothing to apply.'
|
|
46
|
+
else
|
|
47
|
+
'Run `testprune apply` to review and emit a removal patch.'
|
|
48
|
+
end
|
|
49
|
+
lines.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def section(title, candidates)
|
|
53
|
+
return [] if candidates.empty?
|
|
54
|
+
|
|
55
|
+
out = ["#{title}: #{candidates.size}"]
|
|
56
|
+
candidates.each { |c| out.concat(candidate_lines(c)) }
|
|
57
|
+
out << ''
|
|
58
|
+
out
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def candidate_lines(candidate)
|
|
62
|
+
fp = candidate.footprint
|
|
63
|
+
out = []
|
|
64
|
+
out << " [#{candidate.group}] #{fp.id}"
|
|
65
|
+
out << " at: #{fp.file}:#{fp.line}" if fp.file
|
|
66
|
+
out << " reason: #{candidate.reason}"
|
|
67
|
+
out << " kept by: #{candidate.kept_by.join(', ')}" unless candidate.kept_by.empty?
|
|
68
|
+
out << " covers: #{covered_labels(fp)}"
|
|
69
|
+
out << " #{safety_line(candidate)}"
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def covered_labels(footprint)
|
|
74
|
+
labels = footprint.units.map { |id| @result.label_for(id) }.sort
|
|
75
|
+
labels.size <= 4 ? labels.join('; ') : "#{labels.first(4).join('; ')} (+#{labels.size - 4} more)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def safety_line(candidate)
|
|
79
|
+
case candidate.safe
|
|
80
|
+
when true then '✓ safe — every covered unit remains covered by a retained test'
|
|
81
|
+
when false then "✗ NOT safe — #{candidate.safety_note} (kept)"
|
|
82
|
+
else '· review-only — not auto-applied'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def savings_section
|
|
87
|
+
s = @result.savings
|
|
88
|
+
[
|
|
89
|
+
'Estimated CI savings:',
|
|
90
|
+
" #{s.approved_count} test(s), #{format('%.4f', s.approved_time)}s " \
|
|
91
|
+
"(~#{format('%.1f', s.percent_of_test_time)}% of #{format('%.4f', s.total_test_time)}s test time)",
|
|
92
|
+
' Note: under parallel CI runners, wall-clock savings will be lower.'
|
|
93
|
+
]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def high_candidates = @result.candidates.select { |c| c.confidence == :high }
|
|
97
|
+
def medium_candidates = @result.candidates.select { |c| c.confidence == :medium }
|
|
98
|
+
def low_candidates = @result.candidates.select { |c| c.confidence == :low }
|
|
99
|
+
def test_count = (@result.run['tests'] || []).size
|
|
100
|
+
|
|
101
|
+
def render_json
|
|
102
|
+
JSON.pretty_generate(
|
|
103
|
+
framework: @result.run['framework'],
|
|
104
|
+
test_count: test_count,
|
|
105
|
+
savings: {
|
|
106
|
+
approved_count: @result.savings.approved_count,
|
|
107
|
+
approved_time: @result.savings.approved_time,
|
|
108
|
+
total_test_time: @result.savings.total_test_time,
|
|
109
|
+
percent_of_test_time: @result.savings.percent_of_test_time
|
|
110
|
+
},
|
|
111
|
+
candidates: @result.candidates.map { |c| candidate_json(c) }
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def candidate_json(candidate)
|
|
116
|
+
fp = candidate.footprint
|
|
117
|
+
{
|
|
118
|
+
id: fp.id, file: fp.file, line: fp.line,
|
|
119
|
+
confidence: candidate.confidence, group: candidate.group,
|
|
120
|
+
reason: candidate.reason, kept_by: candidate.kept_by,
|
|
121
|
+
review_only: candidate.review_only, safe: candidate.safe,
|
|
122
|
+
safety_note: candidate.safety_note,
|
|
123
|
+
covers: fp.units.map { |id| @result.label_for(id) }.sort
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|