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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdc667523d09a211fe97edcc2175b58b341ab9cfcadaa0aed693d139d95f8319
4
- data.tar.gz: cc7047cf657da982ed4ba936c1b3cb4cba06ea11e6183b6619e61d08073cdb45
3
+ metadata.gz: 1b3054af048bc880b468b220a5fe5755ff5480a7e4c58ec4f365215b57376004
4
+ data.tar.gz: a0cdf1a94708a15ebab44407f40a32963ce09805f979d5a4e537affe2a8f96c4
5
5
  SHA512:
6
- metadata.gz: 7d7cfef25ef87d93ea0e928409a9dd8aa778fac90ad56e501a2a29de1322b02656608a6b8761902b2e801773b8c900126f65695995d464fda44d84a71cdef5e2
7
- data.tar.gz: 9ae8e0da87b1f18a54fb2b4d4a3f43875380af913694eaabd5aac78fbe2ca3d927709edb557ba9b6b9221e6f556ce37b9570d91f8359e41cfa0d99c1a880aacc
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 prints the report and prompts before writing any patch. Use this when you want the full workflow without running three separate commands.
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
- at: test/calculator_test.rb:16
142
- reason: identical coverage to CalculatorTest#test_add
143
- kept by: CalculatorTest#test_add
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
- at: test/calculator_test.rb:20
152
- reason: test body structurally identical to CalculatorTest#test_nonpositive
153
- covers: Calculator#sign (lib/calculator.rb:8)
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.0132s saved · ~85.7% of suite
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
- The tool reprints the full report, then prompts:
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
- Apply 1 HIGH-confidence, safety-verified removal(s) as a patch?
175
- (MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N]
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
- Answering `y` writes `tmp/.testprune/removal.patch`. **No files are modified yet.**
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
@@ -1,5 +1,5 @@
1
- <svg viewBox="0 0 661 668" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="testprune report — styled terminal output">
2
- <rect width="661" height="668" fill="#0d1117" rx="14"/>
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"> at: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:16</tspan></text>
12
- <text x="22" y="234"><tspan fill="#3D3D5C"> reason: </tspan><tspan fill="#9CA3AF">identical coverage to CalculatorTest#test_add</tspan></text>
13
- <text x="22" y="254"><tspan fill="#3D3D5C"> kept by: CalculatorTest#test_add</tspan></text>
14
- <text x="22" y="274"><tspan fill="#3D3D5C"> both cover: Calculator#add (lib/calculator.rb:4)</tspan></text>
15
- <text x="22" y="294"><tspan fill="#22C55E"> safeevery covered unit is retained by another test</tspan></text>
16
- <text x="22" y="334"><tspan fill="#F59E0B" font-weight="700"> ● MEDIUM confidence — review</tspan><tspan fill="#9CA3AF"> (1)</tspan></text>
17
- <text x="22" y="354"><tspan fill="#3D3D5C"> ──────────────────────────────────────────────────────────────</tspan></text>
18
- <text x="22" y="394"><tspan fill="#7D56F4"> [structural]</tspan><tspan fill="#E2E8F0"> CalculatorTest#test_positive</tspan></text>
19
- <text x="22" y="414"><tspan fill="#3D3D5C"> at: </tspan><tspan fill="#9CA3AF">test/calculator_test.rb:20</tspan></text>
20
- <text x="22" y="434"><tspan fill="#3D3D5C"> reason: </tspan><tspan fill="#9CA3AF">test body structurally identical to CalculatorTest#test_nonpositive</tspan></text>
21
- <text x="22" y="454"><tspan fill="#3D3D5C"> covers: Calculator#sign (lib/calculator.rb:8)</tspan></text>
22
- <text x="22" y="474"><tspan fill="#3D3D5C"> · review-only — not auto-applied</tspan></text>
23
- <text x="22" y="514"><tspan fill="#7D56F4">╭─────────────────────────────────────────────────────────╮</tspan></text>
24
- <text x="22" y="534"><tspan fill="#7D56F4">│ </tspan><tspan fill="#E2E8F0"> Estimated CI savings</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
25
- <text x="22" y="554"><tspan fill="#7D56F4">│ </tspan><tspan fill="#22C55E"> 1 test(s)</tspan><tspan fill="#9CA3AF"> · </tspan><tspan fill="#22C55E">0.0132s saved</tspan><tspan fill="#9CA3AF"> · </tspan><tspan fill="#22C55E">~85.7% of suite</tspan><tspan fill="#E2E8F0"> </tspan><tspan fill="#7D56F4">│</tspan></text>
26
- <text x="22" y="574"><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>
27
- <text x="22" y="594"><tspan fill="#7D56F4">╰─────────────────────────────────────────────────────────╯</tspan></text>
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
- # Styled confirmation prompt
187
- if UI.tty?($stdout)
188
- puts
189
- puts " Apply #{UI::Styles::GREEN_TEXT.render(approved.size.to_s)} HIGH-confidence removal(s) as a patch?"
190
- puts " #{UI::Styles::DIM_TEXT.render('(MEDIUM/LOW review-only candidates are NOT patched.)')}"
191
- print " #{UI::Styles::PURPLE_TEXT.render('[y/N]')} > "
192
- else
193
- print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
194
- "(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
195
- end
196
-
197
- answer = $stdin.gets&.strip&.downcase
198
- unless %w[y yes].include?(answer)
199
- msg = " Aborted no patch written."
200
- puts(UI.tty?($stdout) ? UI::Styles::DIM_TEXT.render(msg) : 'Aborted. No patch written.')
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(approved)
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
@@ -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
- lines << " #{styled('at: ', Styles::DIM_TEXT)}#{styled("#{fp.file}:#{fp.line}", Styles::META_TEXT)}" if fp.file
98
- lines << " #{styled('reason: ', Styles::DIM_TEXT)}#{styled(candidate.reason, Styles::META_TEXT)}"
99
-
100
- unless candidate.kept_by.empty?
101
- lines << " #{styled('kept by: ', Styles::DIM_TEXT)}#{styled(candidate.kept_by.join(', '), Styles::DIM_TEXT)}"
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
- def coverage_detail_lines(candidate, fp)
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
- [coverage_line('both cover', fp.units)]
149
+ [count_line("covers #{unit_word(fp.units.size)} — all retained by the keeper")]
143
150
  when :subset
144
- keeper = keeper_footprint(candidate)
145
- lines = [coverage_line('candidate covers', fp.units)]
146
- if keeper
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
- [coverage_line('covers', fp.units)]
155
+ [count_line("covers #{unit_word(fp.units.size)}")]
153
156
  end
154
157
  end
155
158
 
156
- def coverage_line(label, units)
157
- " #{styled("#{label}: ", Styles::DIM_TEXT)}#{styled(format_units(units), Styles::DIM_TEXT)}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testprune
4
- VERSION = '0.4.1'
4
+ VERSION = '0.5.0'
5
5
  end
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.1
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.3
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