testprune 0.4.1 → 0.5.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 +4 -4
- data/README.md +33 -13
- data/assets/report-example.svg +19 -20
- data/lib/testprune/cli.rb +36 -22
- data/lib/testprune/report.rb +0 -66
- data/lib/testprune/review_plan.rb +92 -0
- data/lib/testprune/ui/report_renderer.rb +30 -18
- data/lib/testprune/ui/reviewer.rb +192 -0
- data/lib/testprune/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b3054af048bc880b468b220a5fe5755ff5480a7e4c58ec4f365215b57376004
|
|
4
|
+
data.tar.gz: a0cdf1a94708a15ebab44407f40a32963ce09805f979d5a4e537affe2a8f96c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e03f881713e69ad483d978d28699651872efede8f815db3fe113b06059c7fad3bd2a3fee86a0ff12513a8fbd573bbdbf5fe5810d66c62c465a92ac8fc96aa5f9
|
|
7
|
+
data.tar.gz: ebfab54ff0ed0e6b8a2a0424d59c10ba444f10e1ac42c09f537e79b96873fc3d1ca9f5f3202be773bc9f3486fd318c1f0d3a9e9132ac3a743c09863c15f98f47
|
data/README.md
CHANGED
|
@@ -112,7 +112,7 @@ testprune prune test/models test/jobs -s app -s lib
|
|
|
112
112
|
testprune prune -- bundle exec rails test test/models/
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
`prune` runs `scan` to capture coverage data, then immediately runs `apply` — which
|
|
115
|
+
`prune` runs `scan` to capture coverage data, then immediately runs `apply` — which opens the guided reviewer (or, non-interactively, prompts) before writing any patch. Use this when you want the full workflow without running three separate commands.
|
|
116
116
|
|
|
117
117
|
### Step 2 — Report
|
|
118
118
|
|
|
@@ -138,23 +138,22 @@ testprune report -s app -s lib
|
|
|
138
138
|
──────────────────────────────────────────────────────────────
|
|
139
139
|
|
|
140
140
|
[identical] CalculatorTest#test_add_again
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
both cover: Calculator#add (lib/calculator.rb:4)
|
|
141
|
+
remove: test/calculator_test.rb:16
|
|
142
|
+
keep: test/calculator_test.rb:11 CalculatorTest#test_add
|
|
143
|
+
covers 1 unit — all retained by the keeper
|
|
145
144
|
✓ safe — every covered unit is retained by another test
|
|
146
145
|
|
|
147
146
|
● MEDIUM confidence — review (1)
|
|
148
147
|
──────────────────────────────────────────────────────────────
|
|
149
148
|
|
|
150
149
|
[structural] CalculatorTest#test_positive
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
covers
|
|
150
|
+
remove: test/calculator_test.rb:20
|
|
151
|
+
keep: test/calculator_test.rb:25 CalculatorTest#test_nonpositive
|
|
152
|
+
covers 2 units
|
|
154
153
|
· review-only — not auto-applied
|
|
155
154
|
|
|
156
155
|
Estimated CI savings
|
|
157
|
-
1 test(s) · 0.
|
|
156
|
+
1 test(s) · 0.0000s saved · ~20.3% of suite
|
|
158
157
|
Note: wall-clock savings lower on parallel CI runners
|
|
159
158
|
|
|
160
159
|
Run testprune apply to review and emit a removal patch.
|
|
@@ -168,14 +167,35 @@ testprune report -s app -s lib
|
|
|
168
167
|
testprune apply
|
|
169
168
|
```
|
|
170
169
|
|
|
171
|
-
|
|
170
|
+
In an interactive terminal, `apply` opens a **guided reviewer** rather than dumping the whole report. It walks the safely-removable tests one *cluster* at a time — every test that duplicates a single keeper is grouped into one decision — starting with the highest-confidence `identical` matches:
|
|
172
171
|
|
|
173
172
|
```
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
Identical coverage cluster 1 / 1 0 accepted
|
|
174
|
+
████████████████████████████ 1/1
|
|
175
|
+
|
|
176
|
+
KEEP test/calculator_test.rb:11
|
|
177
|
+
#test_add
|
|
178
|
+
|
|
179
|
+
REMOVE 1 test with identical coverage — all 1 unit retained by the keeper
|
|
180
|
+
:16 #test_add_again
|
|
181
|
+
|
|
182
|
+
✓ safe — every covered unit remains covered by the kept test
|
|
183
|
+
|
|
184
|
+
[a] accept & remove [s] skip [d] diff vs keeper [q] quit & write
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- **`a`** accept the whole cluster · **`s`** skip it · **`d`** view a side-by-side diff of the two test bodies · **`q`** stop and write a patch for whatever you've accepted so far.
|
|
188
|
+
- Review-only candidates (structural / overlap) are never auto-patched; they're summarized at the end — inspect them with `testprune report`.
|
|
189
|
+
|
|
190
|
+
For non-interactive use (CI, pipes):
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
testprune apply --yes # accept all safe removals without prompting
|
|
176
194
|
```
|
|
177
195
|
|
|
178
|
-
|
|
196
|
+
When stdout isn't a TTY and `--yes` isn't given, `apply` falls back to printing the report and a single `[y/N]` prompt.
|
|
197
|
+
|
|
198
|
+
Accepted removals are written to `tmp/.testprune/removal.patch`. **No files are modified yet.**
|
|
179
199
|
|
|
180
200
|
```sh
|
|
181
201
|
git apply --check tmp/.testprune/removal.patch # dry-run first
|
data/assets/report-example.svg
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<svg viewBox="0 0
|
|
2
|
-
<rect width="
|
|
1
|
+
<svg viewBox="0 0 598 648" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="testprune report — styled terminal output">
|
|
2
|
+
<rect width="598" height="648" fill="#0d1117" rx="14"/>
|
|
3
3
|
<g font-family="ui-monospace,'SF Mono',Menlo,Consolas,monospace" font-size="13" xml:space="preserve">
|
|
4
4
|
<text x="22" y="34"><tspan fill="#7D56F4">╭──────────────────────────────────────────╮</tspan></text>
|
|
5
5
|
<text x="22" y="54"><tspan fill="#7D56F4">│ </tspan><tspan fill="#E2E8F0"> testprune — coverage redundancy report</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
|
|
@@ -8,23 +8,22 @@
|
|
|
8
8
|
<text x="22" y="134"><tspan fill="#22C55E" font-weight="700"> ● HIGH confidence — safe to remove</tspan><tspan fill="#9CA3AF"> (1)</tspan></text>
|
|
9
9
|
<text x="22" y="154"><tspan fill="#3D3D5C"> ──────────────────────────────────────────────────────────────</tspan></text>
|
|
10
10
|
<text x="22" y="194"><tspan fill="#7D56F4"> [identical]</tspan><tspan fill="#E2E8F0"> CalculatorTest#test_add_again</tspan></text>
|
|
11
|
-
<text x="22" y="214"><tspan fill="#3D3D5C">
|
|
12
|
-
<text x="22" y="234"><tspan fill="#3D3D5C">
|
|
13
|
-
<text x="22" y="254"><tspan fill="#3D3D5C">
|
|
14
|
-
<text x="22" y="274"><tspan fill="#
|
|
15
|
-
<text x="22" y="
|
|
16
|
-
<text x="22" y="334"><tspan fill="#
|
|
17
|
-
<text x="22" y="
|
|
18
|
-
<text x="22" y="394"><tspan fill="#
|
|
19
|
-
<text x="22" y="414"><tspan fill="#3D3D5C">
|
|
20
|
-
<text x="22" y="434"><tspan fill="#3D3D5C">
|
|
21
|
-
<text x="22" y="454"><tspan fill="#3D3D5C">
|
|
22
|
-
<text x="22" y="
|
|
23
|
-
<text x="22" y="514"><tspan fill="#7D56F4"
|
|
24
|
-
<text x="22" y="534"><tspan fill="#7D56F4">│ </tspan><tspan fill="#
|
|
25
|
-
<text x="22" y="554"><tspan fill="#7D56F4">│ </tspan><tspan fill="#
|
|
26
|
-
<text x="22" y="574"><tspan fill="#7D56F4"
|
|
27
|
-
<text x="22" y="
|
|
28
|
-
<text x="22" y="634"><tspan fill="#9CA3AF"> Run </tspan><tspan fill="#7D56F4">testprune apply</tspan><tspan fill="#9CA3AF"> to review and emit a removal patch.</tspan></text>
|
|
11
|
+
<text x="22" y="214"><tspan fill="#3D3D5C"> remove: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:16</tspan></text>
|
|
12
|
+
<text x="22" y="234"><tspan fill="#3D3D5C"> keep: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:11</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#3D3D5C">CalculatorTest#test_add</tspan></text>
|
|
13
|
+
<text x="22" y="254"><tspan fill="#3D3D5C"> covers 1 unit — all retained by the keeper</tspan></text>
|
|
14
|
+
<text x="22" y="274"><tspan fill="#22C55E"> ✓ safe — every covered unit is retained by another test</tspan></text>
|
|
15
|
+
<text x="22" y="314"><tspan fill="#F59E0B" font-weight="700"> ● MEDIUM confidence — review</tspan><tspan fill="#9CA3AF"> (1)</tspan></text>
|
|
16
|
+
<text x="22" y="334"><tspan fill="#3D3D5C"> ──────────────────────────────────────────────────────────────</tspan></text>
|
|
17
|
+
<text x="22" y="374"><tspan fill="#7D56F4"> [structural]</tspan><tspan fill="#E2E8F0"> CalculatorTest#test_positive</tspan></text>
|
|
18
|
+
<text x="22" y="394"><tspan fill="#3D3D5C"> remove: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:20</tspan></text>
|
|
19
|
+
<text x="22" y="414"><tspan fill="#3D3D5C"> keep: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:25</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#3D3D5C">CalculatorTest#test_nonpositive</tspan></text>
|
|
20
|
+
<text x="22" y="434"><tspan fill="#3D3D5C"> covers 2 units</tspan></text>
|
|
21
|
+
<text x="22" y="454"><tspan fill="#3D3D5C"> · review-only — not auto-applied</tspan></text>
|
|
22
|
+
<text x="22" y="494"><tspan fill="#7D56F4">╭─────────────────────────────────────────────────────────╮</tspan></text>
|
|
23
|
+
<text x="22" y="514"><tspan fill="#7D56F4">│ </tspan><tspan fill="#E2E8F0"> Estimated CI savings</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
|
|
24
|
+
<text x="22" y="534"><tspan fill="#7D56F4">│ </tspan><tspan fill="#22C55E"> 1 test(s)</tspan><tspan fill="#9CA3AF"> · </tspan><tspan fill="#22C55E">0.0000s saved</tspan><tspan fill="#9CA3AF"> · </tspan><tspan fill="#22C55E">~20.3% of suite</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
|
|
25
|
+
<text x="22" y="554"><tspan fill="#7D56F4">│ </tspan><tspan fill="#9CA3AF"> Note: wall-clock savings lower on parallel CI runners</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
|
|
26
|
+
<text x="22" y="574"><tspan fill="#7D56F4">╰─────────────────────────────────────────────────────────╯</tspan></text>
|
|
27
|
+
<text x="22" y="614"><tspan fill="#9CA3AF"> Run </tspan><tspan fill="#7D56F4">testprune apply</tspan><tspan fill="#9CA3AF"> to review and emit a removal patch.</tspan></text>
|
|
29
28
|
</g>
|
|
30
29
|
</svg>
|
data/lib/testprune/cli.rb
CHANGED
|
@@ -34,6 +34,7 @@ module Testprune
|
|
|
34
34
|
noise and subtract them (0..1; default 0.5; 0 to disable)
|
|
35
35
|
--json Emit machine-readable JSON (report only)
|
|
36
36
|
-V, --verbose Show raw test output during scan (disables progress display)
|
|
37
|
+
-y, --yes Skip interactive review; accept all safe removals (apply)
|
|
37
38
|
-h, --help Show this help
|
|
38
39
|
-v, --version Show version
|
|
39
40
|
TXT
|
|
@@ -81,6 +82,7 @@ module Testprune
|
|
|
81
82
|
o.on('--baseline FRAC', Float) { |v| opts[:baseline] = v }
|
|
82
83
|
o.on('--json') { opts[:json] = true }
|
|
83
84
|
o.on('-V', '--verbose') { opts[:verbose] = true }
|
|
85
|
+
o.on('-y', '--yes') { opts[:yes] = true }
|
|
84
86
|
o.on('-h', '--help') { puts(BANNER); exit(0) }
|
|
85
87
|
end
|
|
86
88
|
rest = parser.parse(argv)
|
|
@@ -173,40 +175,54 @@ module Testprune
|
|
|
173
175
|
apply_config(opts)
|
|
174
176
|
require_relative 'analysis'
|
|
175
177
|
result = Analysis.new(Testprune.config).call
|
|
176
|
-
require_relative 'report'
|
|
177
|
-
puts(Report.new(result).render)
|
|
178
178
|
|
|
179
179
|
approved = result.approved_removals
|
|
180
180
|
if approved.empty?
|
|
181
|
+
require_relative 'report'
|
|
182
|
+
puts(Report.new(result).render)
|
|
181
183
|
msg = " Nothing safe to remove. No patch written."
|
|
182
184
|
puts(UI.tty?($stdout) ? UI::Styles::SUCCESS_BOX.render(msg) : "\n#{msg.strip}")
|
|
183
185
|
return nil
|
|
184
186
|
end
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return false
|
|
188
|
+
accepted =
|
|
189
|
+
if opts[:yes]
|
|
190
|
+
approved
|
|
191
|
+
elsif UI.tty?($stdout)
|
|
192
|
+
require_relative 'ui/reviewer'
|
|
193
|
+
UI::Reviewer.new(result).run
|
|
194
|
+
else
|
|
195
|
+
noninteractive_confirm(result, approved)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return false if accepted == false # explicit abort at the non-interactive prompt
|
|
199
|
+
if accepted.nil? || accepted.empty?
|
|
200
|
+
msg = " Nothing accepted — no patch written."
|
|
201
|
+
puts(UI.tty?($stdout) ? UI::Styles::DIM_TEXT.render(msg) : msg.strip)
|
|
202
|
+
return nil
|
|
202
203
|
end
|
|
203
204
|
|
|
204
205
|
require_relative 'patch_writer'
|
|
205
|
-
path = PatchWriter.new(Testprune.config).write(
|
|
206
|
+
path = PatchWriter.new(Testprune.config).write(accepted)
|
|
207
|
+
print_patch_written(path, accepted.size)
|
|
208
|
+
path
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Non-TTY (piped/CI without --yes): show the full report and ask once.
|
|
212
|
+
# Returns the approved set on yes, or false on abort.
|
|
213
|
+
def noninteractive_confirm(result, approved)
|
|
214
|
+
require_relative 'report'
|
|
215
|
+
puts(Report.new(result).render)
|
|
216
|
+
print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
|
|
217
|
+
"(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
|
|
218
|
+
answer = $stdin.gets&.strip&.downcase
|
|
219
|
+
%w[y yes].include?(answer) ? approved : false
|
|
220
|
+
end
|
|
206
221
|
|
|
222
|
+
def print_patch_written(path, count)
|
|
207
223
|
if UI.tty?($stdout)
|
|
208
224
|
box = [
|
|
209
|
-
" #{UI::Styles::GREEN_TEXT.render('✓')} Patch written",
|
|
225
|
+
" #{UI::Styles::GREEN_TEXT.render('✓')} Patch written — #{count} test(s)",
|
|
210
226
|
" #{path}",
|
|
211
227
|
" #{UI::Styles::DIM_TEXT.render('Apply with:')} " \
|
|
212
228
|
"#{UI::Styles::PURPLE_TEXT.render("git apply #{path}")}"
|
|
@@ -216,8 +232,6 @@ module Testprune
|
|
|
216
232
|
puts("Wrote #{path}")
|
|
217
233
|
puts("Review it, then apply with: git apply #{path}")
|
|
218
234
|
end
|
|
219
|
-
|
|
220
|
-
path
|
|
221
235
|
end
|
|
222
236
|
end
|
|
223
237
|
end
|
data/lib/testprune/report.rb
CHANGED
|
@@ -30,77 +30,11 @@ module Testprune
|
|
|
30
30
|
UI::ReportRenderer.new(@result).render
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def section(title, candidates)
|
|
34
|
-
return [] if candidates.empty?
|
|
35
|
-
|
|
36
|
-
out = ["#{title}: #{candidates.size}"]
|
|
37
|
-
candidates.each { |c| out.concat(candidate_lines(c)) }
|
|
38
|
-
out << ''
|
|
39
|
-
out
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def candidate_lines(candidate)
|
|
43
|
-
fp = candidate.footprint
|
|
44
|
-
out = []
|
|
45
|
-
out << " [#{candidate.group}] #{fp.id}"
|
|
46
|
-
out << " at: #{fp.file}:#{fp.line}" if fp.file
|
|
47
|
-
out << " reason: #{candidate.reason}"
|
|
48
|
-
out << " kept by: #{candidate.kept_by.join(', ')}" unless candidate.kept_by.empty?
|
|
49
|
-
out.concat(coverage_text_lines(candidate, fp))
|
|
50
|
-
out << " #{safety_line(candidate)}"
|
|
51
|
-
out
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def coverage_text_lines(candidate, fp)
|
|
55
|
-
case candidate.group
|
|
56
|
-
when :identical
|
|
57
|
-
[" both cover: #{covered_labels(fp.units)}"]
|
|
58
|
-
when :subset
|
|
59
|
-
keeper = find_keeper(candidate)
|
|
60
|
-
lines = [" candidate covers: #{covered_labels(fp.units)}"]
|
|
61
|
-
if keeper
|
|
62
|
-
extra = keeper.units - fp.units
|
|
63
|
-
lines << " keeper adds: #{covered_labels(extra)}" unless extra.empty?
|
|
64
|
-
end
|
|
65
|
-
lines
|
|
66
|
-
else
|
|
67
|
-
[" covers: #{covered_labels(fp.units)}"]
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def find_keeper(candidate)
|
|
72
|
-
return nil if candidate.kept_by.empty?
|
|
73
|
-
return nil unless @result.respond_to?(:detector_result)
|
|
74
|
-
|
|
75
|
-
@result.detector_result.footprints.find { |f| f.id == candidate.kept_by.first }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
33
|
def covered_labels(units)
|
|
79
34
|
labels = units.map { |id| @result.label_for(id) }.sort
|
|
80
35
|
labels.size <= 4 ? labels.join('; ') : "#{labels.first(4).join('; ')} (+#{labels.size - 4} more)"
|
|
81
36
|
end
|
|
82
37
|
|
|
83
|
-
def safety_line(candidate)
|
|
84
|
-
case candidate.safe
|
|
85
|
-
when true then '✓ safe — every covered unit remains covered by a retained test'
|
|
86
|
-
when false then "✗ NOT safe — #{candidate.safety_note} (kept)"
|
|
87
|
-
else '· review-only — not auto-applied'
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def savings_section
|
|
92
|
-
s = @result.savings
|
|
93
|
-
[
|
|
94
|
-
'Estimated CI savings:',
|
|
95
|
-
" #{s.approved_count} test(s), #{format('%.4f', s.approved_time)}s " \
|
|
96
|
-
"(~#{format('%.1f', s.percent_of_test_time)}% of #{format('%.4f', s.total_test_time)}s test time)",
|
|
97
|
-
' Note: under parallel CI runners, wall-clock savings will be lower.'
|
|
98
|
-
]
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def high_candidates = @result.candidates.select { |c| c.confidence == :high }
|
|
102
|
-
def medium_candidates = @result.candidates.select { |c| c.confidence == :medium }
|
|
103
|
-
def low_candidates = @result.candidates.select { |c| c.confidence == :low }
|
|
104
38
|
def test_count = (@result.run['tests'] || []).size
|
|
105
39
|
|
|
106
40
|
def render_json
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Testprune
|
|
6
|
+
# Pure (IO-free) transformation of an Analysis::Result into an ordered, clustered
|
|
7
|
+
# review plan. Candidates are ordered by tier — identical first — and within each
|
|
8
|
+
# tier grouped by their *keeper*, so a reviewer can approve a whole cluster of
|
|
9
|
+
# redundant tests in one decision instead of N. File paths are relativized to the
|
|
10
|
+
# scan root here so every consumer renders short, consistent locations.
|
|
11
|
+
module ReviewPlan
|
|
12
|
+
# Review order. `actionable` tiers contain candidates that can actually be
|
|
13
|
+
# patched (HIGH-confidence, safety-verified); the rest are review-only.
|
|
14
|
+
TIERS = [
|
|
15
|
+
{ tier: :identical, title: 'Identical coverage', actionable: true },
|
|
16
|
+
{ tier: :subset, title: 'Subset / subsumed coverage', actionable: true },
|
|
17
|
+
{ tier: :structural, title: 'Structurally duplicated body', actionable: false },
|
|
18
|
+
{ tier: :overlap, title: 'High coverage overlap', actionable: false }
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
Loc = Struct.new(:id, :method, :file, :line, :unit_count, keyword_init: true)
|
|
22
|
+
Member = Struct.new(:candidate, :loc, :safe, keyword_init: true)
|
|
23
|
+
Cluster = Struct.new(:keeper, :members, keyword_init: true) do
|
|
24
|
+
def size = members.size
|
|
25
|
+
end
|
|
26
|
+
TierPlan = Struct.new(:tier, :title, :actionable, :clusters, keyword_init: true) do
|
|
27
|
+
def count = clusters.sum(&:size)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Returns an array of TierPlan in review order. Empty tiers are omitted.
|
|
33
|
+
# When actionable_only: true, only safety-verified removable candidates are
|
|
34
|
+
# included (what the interactive reviewer turns into a patch).
|
|
35
|
+
def build(result, actionable_only: false)
|
|
36
|
+
root = result.run['root']
|
|
37
|
+
keepers = index_footprints(result)
|
|
38
|
+
approved = result.approved_removals.to_set
|
|
39
|
+
|
|
40
|
+
TIERS.filter_map do |spec|
|
|
41
|
+
members = result.candidates.select { |c| c.group == spec[:tier] }
|
|
42
|
+
members = members.select { |c| approved.include?(c) } if actionable_only
|
|
43
|
+
next if members.empty?
|
|
44
|
+
|
|
45
|
+
clusters = members
|
|
46
|
+
.group_by { |c| c.kept_by.first }
|
|
47
|
+
.map { |keeper_id, group| cluster_for(keeper_id, group, keepers, approved, root) }
|
|
48
|
+
.sort_by { |cl| [-cl.size, cl.keeper&.id || ''] }
|
|
49
|
+
|
|
50
|
+
TierPlan.new(tier: spec[:tier], title: spec[:title],
|
|
51
|
+
actionable: spec[:actionable], clusters: clusters)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cluster_for(keeper_id, group, keepers, approved, root)
|
|
56
|
+
keeper_fp = keepers[keeper_id]
|
|
57
|
+
keeper_loc = keeper_fp && loc_for(keeper_id, keeper_fp.file, keeper_fp.line,
|
|
58
|
+
keeper_fp.units.size, root)
|
|
59
|
+
members = group
|
|
60
|
+
.sort_by { |c| [c.footprint.file.to_s, c.footprint.line.to_i, c.footprint.id] }
|
|
61
|
+
.map do |c|
|
|
62
|
+
fp = c.footprint
|
|
63
|
+
Member.new(candidate: c, safe: approved.include?(c),
|
|
64
|
+
loc: loc_for(fp.id, fp.file, fp.line, fp.units.size, root))
|
|
65
|
+
end
|
|
66
|
+
Cluster.new(keeper: keeper_loc, members: members)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def loc_for(id, file, line, unit_count, root)
|
|
70
|
+
Loc.new(id: id, method: short_method(id), file: relpath(file, root),
|
|
71
|
+
line: line, unit_count: unit_count)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `ClassName#method` -> `#method`; leaves bare ids untouched.
|
|
75
|
+
def short_method(id)
|
|
76
|
+
idx = id.index('#')
|
|
77
|
+
idx ? id[idx..] : id
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def relpath(file, root)
|
|
81
|
+
return file unless file && root && file.start_with?("#{root}/")
|
|
82
|
+
|
|
83
|
+
file[(root.length + 1)..]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def index_footprints(result)
|
|
87
|
+
return {} unless result.respond_to?(:detector_result)
|
|
88
|
+
|
|
89
|
+
result.detector_result.footprints.each_with_object({}) { |fp, h| h[fp.id] = fp }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -90,18 +90,19 @@ module Testprune
|
|
|
90
90
|
|
|
91
91
|
def candidate_lines(candidate)
|
|
92
92
|
fp = candidate.footprint
|
|
93
|
+
keeper = keeper_footprint(candidate)
|
|
93
94
|
lines = []
|
|
94
95
|
|
|
95
96
|
group_badge = styled("[#{GROUP_LABELS[candidate.group] || candidate.group}]", Styles::PURPLE_TEXT)
|
|
96
97
|
lines << " #{group_badge} #{fp.id}"
|
|
97
|
-
|
|
98
|
-
lines << " #{styled('
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
# Compare block: the two tests under review, both as relative file:line.
|
|
99
|
+
lines << " #{styled('remove:', Styles::DIM_TEXT)} #{styled(location(fp), Styles::META_TEXT)}" if fp.file
|
|
100
|
+
if keeper
|
|
101
|
+
lines << " #{styled('keep: ', Styles::DIM_TEXT)} #{styled(location(keeper), Styles::META_TEXT)}" \
|
|
102
|
+
" #{styled(keeper.id, Styles::DIM_TEXT)}"
|
|
102
103
|
end
|
|
103
104
|
|
|
104
|
-
lines.concat(coverage_detail_lines(candidate, fp))
|
|
105
|
+
lines.concat(coverage_detail_lines(candidate, fp, keeper))
|
|
105
106
|
|
|
106
107
|
safety = case candidate.safe
|
|
107
108
|
when true then styled(' ✓ safe — every covered unit is retained by another test', Styles::SAFE_LINE)
|
|
@@ -136,25 +137,36 @@ module Testprune
|
|
|
136
137
|
tty? ? Styles::SUCCESS_BOX.render(msg) : msg
|
|
137
138
|
end
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
# The full per-unit coverage list (a 60+ item dump on large controller tests)
|
|
141
|
+
# carries no decision value for identical matches — both tests cover exactly
|
|
142
|
+
# the same units by construction, and the safety check guarantees the keeper
|
|
143
|
+
# retains them. So we summarize as a count. Only the subset *delta* (what the
|
|
144
|
+
# keeper covers beyond the candidate) earns a labeled list, since that's the
|
|
145
|
+
# reason the keeper is worth keeping.
|
|
146
|
+
def coverage_detail_lines(candidate, fp, keeper)
|
|
140
147
|
case candidate.group
|
|
141
148
|
when :identical
|
|
142
|
-
[
|
|
149
|
+
[count_line("covers #{unit_word(fp.units.size)} — all retained by the keeper")]
|
|
143
150
|
when :subset
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
extra = keeper.units - fp.units
|
|
148
|
-
lines << coverage_line('keeper adds', extra) unless extra.empty?
|
|
149
|
-
end
|
|
150
|
-
lines
|
|
151
|
+
extra = keeper ? (keeper.units - fp.units) : []
|
|
152
|
+
extra.empty? ? [count_line("covers #{unit_word(fp.units.size)}")]
|
|
153
|
+
: [" #{styled('keeper adds: ', Styles::DIM_TEXT)}#{styled(format_units(extra), Styles::DIM_TEXT)}"]
|
|
151
154
|
else
|
|
152
|
-
[
|
|
155
|
+
[count_line("covers #{unit_word(fp.units.size)}")]
|
|
153
156
|
end
|
|
154
157
|
end
|
|
155
158
|
|
|
156
|
-
def
|
|
157
|
-
|
|
159
|
+
def count_line(text) = " #{styled(text, Styles::DIM_TEXT)}"
|
|
160
|
+
|
|
161
|
+
def unit_word(n) = "#{n} unit#{'s' if n != 1}"
|
|
162
|
+
|
|
163
|
+
def location(fp) = "#{relpath(fp.file)}:#{fp.line}"
|
|
164
|
+
|
|
165
|
+
def relpath(file)
|
|
166
|
+
root = @result.run['root']
|
|
167
|
+
return file unless file && root && file.start_with?("#{root}/")
|
|
168
|
+
|
|
169
|
+
file[(root.length + 1)..]
|
|
158
170
|
end
|
|
159
171
|
|
|
160
172
|
def keeper_footprint(candidate)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'styles'
|
|
5
|
+
require_relative '../review_plan'
|
|
6
|
+
require_relative '../test_body'
|
|
7
|
+
|
|
8
|
+
module Testprune
|
|
9
|
+
module UI
|
|
10
|
+
# Interactive, cluster-at-a-time reviewer for `apply`. Walks the actionable
|
|
11
|
+
# removals in review order (identical first), grouping redundant tests under a
|
|
12
|
+
# shared keeper so one keystroke accepts a whole cluster. Returns the list of
|
|
13
|
+
# accepted candidates for the patch writer.
|
|
14
|
+
#
|
|
15
|
+
# Input is read one keystroke at a time. The key reader is injectable so the
|
|
16
|
+
# flow is testable without a real TTY (see ReviewerTest).
|
|
17
|
+
class Reviewer
|
|
18
|
+
KEYS = " [a] accept & remove [s] skip [d] diff vs keeper [q] quit & write"
|
|
19
|
+
|
|
20
|
+
def initialize(result, input: $stdin, output: $stdout, color: nil, read_key: nil)
|
|
21
|
+
@result = result
|
|
22
|
+
@in = input
|
|
23
|
+
@out = output
|
|
24
|
+
@color = color.nil? ? UI.tty?(output) : color
|
|
25
|
+
@read_key = read_key || method(:default_read_key)
|
|
26
|
+
@fp_by_id = ReviewPlan.index_footprints(result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns an Array of accepted Candidate objects (subset of approved_removals).
|
|
30
|
+
def run
|
|
31
|
+
clusters = ReviewPlan.build(@result, actionable_only: true)
|
|
32
|
+
.flat_map { |tier| tier.clusters.map { |c| [tier, c] } }
|
|
33
|
+
return finish([]) if clusters.empty?
|
|
34
|
+
|
|
35
|
+
accepted = []
|
|
36
|
+
clusters.each_with_index do |(tier, cluster), i|
|
|
37
|
+
@diff_idx = 0
|
|
38
|
+
loop do
|
|
39
|
+
render_cluster(tier, cluster, i + 1, clusters.size, accepted.size)
|
|
40
|
+
case @read_key.call
|
|
41
|
+
when 'a', ' ', "\r", "\n"
|
|
42
|
+
accepted.concat(cluster.members.select(&:safe).map(&:candidate))
|
|
43
|
+
break
|
|
44
|
+
when 's' then break
|
|
45
|
+
when 'd' then show_diff(cluster)
|
|
46
|
+
when 'q', "", nil then return finish(accepted)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
finish(accepted)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# ── Rendering ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def render_cluster(tier, cluster, idx, total, accepted_count)
|
|
58
|
+
clear
|
|
59
|
+
line " #{styled(tier.title, Styles::PURPLE_TEXT)} " \
|
|
60
|
+
"#{styled("cluster #{idx} / #{total}", Styles::META_TEXT)} " \
|
|
61
|
+
"#{styled("#{accepted_count} accepted", Styles::GREEN_TEXT)}"
|
|
62
|
+
line " #{progress_bar(idx, total)}"
|
|
63
|
+
line ''
|
|
64
|
+
|
|
65
|
+
keeper = cluster.keeper
|
|
66
|
+
line " #{badge('KEEP', Styles::GREEN_TEXT)} #{loc(keeper)}"
|
|
67
|
+
line " #{styled(keeper.method, Styles::META_TEXT)}" if keeper && keeper.method != keeper.id
|
|
68
|
+
|
|
69
|
+
n = cluster.size
|
|
70
|
+
line ''
|
|
71
|
+
units = keeper&.unit_count
|
|
72
|
+
line " #{badge('REMOVE', Styles::AMBER_TEXT)} " \
|
|
73
|
+
"#{n} test#{'s' if n > 1} with identical coverage — " \
|
|
74
|
+
"#{styled("all #{units} unit#{'s' if units != 1} retained by the keeper", Styles::DIM_TEXT)}"
|
|
75
|
+
cluster.members.each do |m|
|
|
76
|
+
where = m.loc.file == keeper&.file ? " :#{m.loc.line}" : " #{m.loc.file}:#{m.loc.line}"
|
|
77
|
+
line " #{styled(where, Styles::DIM_TEXT)} #{styled(m.loc.method, Styles::META_TEXT)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
line ''
|
|
81
|
+
line " #{styled('✓ safe', Styles::GREEN_TEXT)}#{styled(' — every covered unit remains covered by the kept test', Styles::DIM_TEXT)}"
|
|
82
|
+
line ''
|
|
83
|
+
line styled(KEYS, Styles::META_TEXT)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def show_diff(cluster)
|
|
87
|
+
member = cluster.members[@diff_idx % cluster.size]
|
|
88
|
+
@diff_idx += 1
|
|
89
|
+
keeper_fp = cluster.keeper && @fp_by_id[cluster.keeper.id]
|
|
90
|
+
|
|
91
|
+
clear
|
|
92
|
+
kb = keeper_fp ? body_lines(keeper_fp.file, keeper_fp.line) : []
|
|
93
|
+
mb = body_lines(member.candidate.footprint.file, member.candidate.footprint.line)
|
|
94
|
+
render_diff(cluster.keeper&.method || '(keeper)', kb, member.loc.method, mb)
|
|
95
|
+
more = cluster.size > 1 ? ' · [d] next member' : ''
|
|
96
|
+
line ''
|
|
97
|
+
line styled(" press any key to return#{more}", Styles::DIM_TEXT)
|
|
98
|
+
@read_key.call
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
COL = 46
|
|
102
|
+
|
|
103
|
+
def render_diff(keep_name, keep_lines, rm_name, rm_lines)
|
|
104
|
+
line " #{badge('KEEP', Styles::GREEN_TEXT)} #{styled(keep_name, Styles::META_TEXT)}" \
|
|
105
|
+
"#{' ' * [COL - keep_name.length - 7, 1].max}#{badge('REMOVE', Styles::AMBER_TEXT)} #{styled(rm_name, Styles::META_TEXT)}"
|
|
106
|
+
line " #{styled('─' * COL, Styles::DIM_TEXT)} #{styled('─' * COL, Styles::DIM_TEXT)}"
|
|
107
|
+
[keep_lines.size, rm_lines.size].max.times do |i|
|
|
108
|
+
left = fmt_code(keep_lines[i])
|
|
109
|
+
right = fmt_code(rm_lines[i])
|
|
110
|
+
line " #{left} #{right}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fmt_code(entry)
|
|
115
|
+
return ' ' * COL unless entry
|
|
116
|
+
|
|
117
|
+
num, text = entry
|
|
118
|
+
s = format('%4d %s', num, text)
|
|
119
|
+
clip(s, COL)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def progress_bar(idx, total)
|
|
123
|
+
width = 28
|
|
124
|
+
filled = total.zero? ? width : (idx.to_f / total * width).round
|
|
125
|
+
bar = ('█' * filled) + ('░' * (width - filled))
|
|
126
|
+
"#{styled(bar, Styles::PURPLE_TEXT)} #{styled("#{idx}/#{total}", Styles::META_TEXT)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def finish(accepted)
|
|
130
|
+
clear
|
|
131
|
+
if accepted.empty?
|
|
132
|
+
line " #{styled('No removals accepted', Styles::DIM_TEXT)} — no patch will be written."
|
|
133
|
+
else
|
|
134
|
+
line " #{styled('✓', Styles::GREEN_TEXT)} Accepted #{styled(accepted.size.to_s, Styles::GREEN_TEXT)} removal(s)."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
review_only = ReviewPlan.build(@result).reject(&:actionable)
|
|
138
|
+
unless review_only.empty?
|
|
139
|
+
counts = review_only.map { |t| "#{t.count} #{t.title.downcase}" }.join(', ')
|
|
140
|
+
line " #{styled("Review-only candidates not shown here (never auto-patched): #{counts}.", Styles::DIM_TEXT)}"
|
|
141
|
+
line " #{styled('See them with:', Styles::DIM_TEXT)} testprune report"
|
|
142
|
+
end
|
|
143
|
+
accepted
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── Helpers ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def loc(l)
|
|
149
|
+
return styled('(keeper not found)', Styles::DIM_TEXT) unless l
|
|
150
|
+
|
|
151
|
+
"#{styled("#{l.file}:#{l.line}", Styles::META_TEXT)}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def badge(text, style) = styled(" #{text} ", style)
|
|
155
|
+
|
|
156
|
+
def styled(text, style) = @color ? style.render(text) : text
|
|
157
|
+
|
|
158
|
+
def line(text = '') = @out.puts(text)
|
|
159
|
+
|
|
160
|
+
def clear = (@out.print("\e[2J\e[H") if @color)
|
|
161
|
+
|
|
162
|
+
def clip(str, width)
|
|
163
|
+
return str.ljust(width) if str.length <= width
|
|
164
|
+
|
|
165
|
+
"#{str[0, width - 1]}…"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def body_lines(file, start_line)
|
|
169
|
+
return [] unless file && File.exist?(file)
|
|
170
|
+
|
|
171
|
+
src = File.read(file).lines
|
|
172
|
+
node = TestBody.locate(Prism.parse(File.read(file)).value, start_line)
|
|
173
|
+
return [] unless node
|
|
174
|
+
|
|
175
|
+
(node.location.start_line..node.location.end_line).map do |n|
|
|
176
|
+
[n, (src[n - 1] || '').chomp]
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError
|
|
179
|
+
[]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def default_read_key
|
|
183
|
+
require 'io/console'
|
|
184
|
+
if @color && @in.respond_to?(:getch)
|
|
185
|
+
@in.getch
|
|
186
|
+
else
|
|
187
|
+
(@in.gets || 'q')[0]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/testprune/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: testprune
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seth MacPherson
|
|
@@ -73,6 +73,7 @@ files:
|
|
|
73
73
|
- lib/testprune/patch_writer.rb
|
|
74
74
|
- lib/testprune/recorder.rb
|
|
75
75
|
- lib/testprune/report.rb
|
|
76
|
+
- lib/testprune/review_plan.rb
|
|
76
77
|
- lib/testprune/runner.rb
|
|
77
78
|
- lib/testprune/safety_check.rb
|
|
78
79
|
- lib/testprune/savings_estimator.rb
|
|
@@ -82,6 +83,7 @@ files:
|
|
|
82
83
|
- lib/testprune/ui/error_toggle.rb
|
|
83
84
|
- lib/testprune/ui/progress.rb
|
|
84
85
|
- lib/testprune/ui/report_renderer.rb
|
|
86
|
+
- lib/testprune/ui/reviewer.rb
|
|
85
87
|
- lib/testprune/ui/styles.rb
|
|
86
88
|
- lib/testprune/version.rb
|
|
87
89
|
homepage: https://github.com/seth-macpherson/testprune
|
|
@@ -103,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
103
105
|
- !ruby/object:Gem::Version
|
|
104
106
|
version: '0'
|
|
105
107
|
requirements: []
|
|
106
|
-
rubygems_version: 4.0.
|
|
108
|
+
rubygems_version: 4.0.10
|
|
107
109
|
specification_version: 4
|
|
108
110
|
summary: Audits a Ruby test suite for duplicate/redundant coverage using Prism AST
|
|
109
111
|
+ Coverage data
|