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.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require_relative '../testprune'
6
+
7
+ module Testprune
8
+ # Boots the target project's suite in a subprocess, instrumented so the adapters
9
+ # capture per-test coverage. Reuses the gem's lib via RUBYOPT (-I) so the target
10
+ # project does not need testprune in its Gemfile.
11
+ class Runner
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # explicit_command: array form of a user-supplied test command (after `--`),
17
+ # or nil to autodetect.
18
+ def call(explicit_command = nil)
19
+ framework, command = resolve(explicit_command)
20
+
21
+ FileUtils.mkdir_p(@config.output_dir)
22
+ File.delete(@config.run_file) if File.exist?(@config.run_file)
23
+
24
+ warn("testprune: framework=#{framework} running: #{command.join(' ')}")
25
+ ok = system(env, *command, chdir: @config.root)
26
+
27
+ unless File.exist?(@config.run_file)
28
+ raise Error, 'suite finished but no run.json was captured — the adapter may ' \
29
+ 'not have loaded. Check the framework/command and source paths.'
30
+ end
31
+
32
+ count = begin
33
+ JSON.parse(File.read(@config.run_file)).fetch('tests', []).size
34
+ rescue JSON::ParserError
35
+ '(unreadable — suite may have been interrupted mid-write)'
36
+ end
37
+ warn("testprune: captured #{count} test(s) -> #{@config.run_file}")
38
+ warn('testprune: suite exited non-zero; coverage was still captured.') unless ok
39
+ ok
40
+ end
41
+
42
+ private
43
+
44
+ def env
45
+ gem_lib = File.expand_path('..', __dir__)
46
+ rubyopt = ["-I#{gem_lib}", '-rtestprune/autostart', ENV['RUBYOPT']].compact.reject(&:empty?).join(' ')
47
+ @config.to_env.merge('RUBYOPT' => rubyopt)
48
+ end
49
+
50
+ def resolve(explicit_command)
51
+ if explicit_command && !explicit_command.empty?
52
+ [framework_of(explicit_command.join(' ')), explicit_command]
53
+ else
54
+ autodetect
55
+ end
56
+ end
57
+
58
+ def framework_of(command_string)
59
+ command_string.include?('rspec') ? :rspec : :minitest
60
+ end
61
+
62
+ def autodetect
63
+ bundler = File.exist?(File.join(@config.root, 'Gemfile'))
64
+ prefix = bundler ? %w[bundle exec] : []
65
+
66
+ if File.directory?(File.join(@config.root, 'spec'))
67
+ [:rspec, prefix + ['rspec']]
68
+ elsif File.directory?(File.join(@config.root, 'test'))
69
+ [:minitest, prefix + ['rake', 'test']]
70
+ else
71
+ raise Error, 'could not autodetect a test suite (no spec/ or test/ dir). ' \
72
+ 'Pass an explicit command after `--`.'
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testprune
4
+ # The hard guarantee: never confirm a removal that drops any semantic unit's
5
+ # coverage to zero. Cascading-aware — evaluates candidates against the units
6
+ # still covered by the *retained* set, decrementing as removals are confirmed,
7
+ # so two individually-safe removals that are jointly unsafe can't both pass.
8
+ #
9
+ # Only non-review candidates (the auto-applicable ones) are checked; review-only
10
+ # candidates are left with `safe = nil` since they're never patched.
11
+ class SafetyCheck
12
+ # original_footprints: pre-ambient-stripping footprints. When baseline subtraction
13
+ # is active, the candidates' footprint.units are stripped — but removing a test
14
+ # also removes its ambient units from coverage. We track and decrement original
15
+ # units so ambient units are guaranteed not to drop to zero.
16
+ def initialize(footprints, original_footprints: nil)
17
+ originals = original_footprints || footprints
18
+ @cover_count = Hash.new(0)
19
+ originals.each { |fp| fp.units.each { |unit| @cover_count[unit] += 1 } }
20
+ # Map id -> original units so evaluate always decrements the right set.
21
+ @original_units = originals.each_with_object({}) { |fp, h| h[fp.id] = fp.units }
22
+ end
23
+
24
+ def apply(candidates)
25
+ removable = candidates.reject(&:review_only).sort_by { |c| c.footprint.id }
26
+ removable.each { |candidate| evaluate(candidate) }
27
+ candidates
28
+ end
29
+
30
+ private
31
+
32
+ def evaluate(candidate)
33
+ units = @original_units.fetch(candidate.footprint.id, candidate.footprint.units)
34
+ at_risk = units.reject { |unit| @cover_count[unit] >= 2 }
35
+
36
+ if at_risk.empty?
37
+ candidate.safe = true
38
+ units.each { |unit| @cover_count[unit] -= 1 }
39
+ else
40
+ candidate.safe = false
41
+ candidate.safety_note = "would leave #{at_risk.size} unit(s) with no other test"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testprune
4
+ # Estimates CI time recovered by removing the approved candidates. Reports
5
+ # aggregate test time removed — honest about the fact that parallel runners mean
6
+ # wall-clock savings are smaller.
7
+ class SavingsEstimator
8
+ def initialize(run, detector_result)
9
+ @run = run
10
+ @detector = detector_result
11
+ end
12
+
13
+ def approved_count
14
+ @detector.approved_removals.size
15
+ end
16
+
17
+ def approved_time
18
+ @detector.approved_removals.sum { |c| c.footprint.wall_time || 0.0 }
19
+ end
20
+
21
+ def total_test_time
22
+ (@run['tests'] || []).sum { |t| t['wall_time'] || 0.0 }
23
+ end
24
+
25
+ def percent_of_test_time
26
+ base = total_test_time
27
+ base.zero? ? 0.0 : (approved_time / base * 100)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Testprune
6
+ # Parses one source file with Prism and resolves Coverage locations to
7
+ # AST-aware semantic units:
8
+ # - methods (DefNode) -> "Class#method"
9
+ # - branch arms (IfNode/CaseNode/…) -> "if then-branch", labelled by the
10
+ # innermost enclosing control node
11
+ # - lines collapse to their innermost enclosing method, or a per-file
12
+ # top-level unit when outside any def — so straight-line code still
13
+ # contributes a stable unit.
14
+ class SemanticMap
15
+ Unit = Struct.new(:id, :kind, :label, :file, :line, keyword_init: true)
16
+
17
+ def self.for_file(path, relpath:)
18
+ # Read with replacement for invalid/undefined bytes so non-UTF-8 content
19
+ # (Latin-1 comments, etc.) never raises an encoding error.
20
+ source = File.read(path, encoding: 'UTF-8:UTF-8', invalid: :replace, undef: :replace)
21
+ new(path, source, relpath)
22
+ end
23
+
24
+ attr_reader :units
25
+
26
+ def initialize(path, source, relpath)
27
+ @path = path
28
+ @relpath = relpath
29
+ @units = {} # id => Unit
30
+ @methods_by_pos = {} # [start_line, start_col] => Unit
31
+ @method_intervals = [] # [start_line, end_line, Unit]
32
+ @controls = [] # [sl, sc, el, ec, type]
33
+ Visitor.new(self).visit(Prism.parse(source).value)
34
+ end
35
+
36
+ # Registration callbacks used by the Visitor.
37
+
38
+ def add_method(node, scope, sep)
39
+ loc = node.location
40
+ sl = loc.start_line
41
+ sc = loc.start_column
42
+ owner = scope.empty? ? '' : "#{scope.join('::')}#{sep}"
43
+ id = "#{@relpath}#m@#{sl}:#{sc}"
44
+ unit = Unit.new(id: id, kind: :method, label: "#{owner}#{node.name} (#{@relpath}:#{sl})",
45
+ file: @relpath, line: sl)
46
+ @units[id] = unit
47
+ @methods_by_pos[[sl, sc]] = unit
48
+ @method_intervals << [sl, loc.end_line, unit]
49
+ end
50
+
51
+ def add_control(node, type)
52
+ loc = node.location
53
+ @controls << [loc.start_line, loc.start_column, loc.end_line, loc.end_column, type]
54
+ end
55
+
56
+ # Resolution used when building footprints.
57
+
58
+ def method_unit(start_line, start_col)
59
+ @methods_by_pos[[start_line, start_col]]
60
+ end
61
+
62
+ def branch_unit(type, start_line, start_col, _end_line, _end_col)
63
+ id = "#{@relpath}#b:#{type}@#{start_line}:#{start_col}"
64
+ @units[id] ||= begin
65
+ control_type = innermost_control(start_line, start_col) || type
66
+ Unit.new(id: id, kind: :branch,
67
+ label: "#{control_type} #{type}-branch (#{@relpath}:#{start_line})",
68
+ file: @relpath, line: start_line)
69
+ end
70
+ end
71
+
72
+ def line_unit(lineno)
73
+ best = nil
74
+ @method_intervals.each do |sl, el, unit|
75
+ next unless lineno >= sl && lineno <= el
76
+
77
+ best = unit if best.nil? || sl > best.line
78
+ end
79
+ best || toplevel_unit
80
+ end
81
+
82
+ private
83
+
84
+ def toplevel_unit
85
+ id = "#{@relpath}#top"
86
+ @units[id] ||= Unit.new(id: id, kind: :toplevel, label: "#{@relpath} (top-level)",
87
+ file: @relpath, line: 0)
88
+ end
89
+
90
+ # Innermost control node containing a position; returns its type or nil.
91
+ def innermost_control(line, col)
92
+ match = nil
93
+ @controls.each do |sl, sc, el, ec, type|
94
+ next unless contains?(sl, sc, el, ec, line, col)
95
+
96
+ match = [sl, sc, type] if match.nil? || sl > match[0] || (sl == match[0] && sc > match[1])
97
+ end
98
+ match && match[2]
99
+ end
100
+
101
+ def contains?(sl, sc, el, ec, line, col)
102
+ return false if line < sl || line > el
103
+ return false if line == sl && col < sc
104
+ return false if line == el && col > ec
105
+
106
+ true
107
+ end
108
+
109
+ # Walks the AST, tracking class/module nesting, registering methods and
110
+ # control-flow nodes.
111
+ class Visitor < Prism::Visitor
112
+ def initialize(map)
113
+ @map = map
114
+ @scope = []
115
+ super()
116
+ end
117
+
118
+ def visit_class_node(node)
119
+ with_scope(node.constant_path.slice) { super }
120
+ end
121
+
122
+ def visit_module_node(node)
123
+ with_scope(node.constant_path.slice) { super }
124
+ end
125
+
126
+ def visit_def_node(node)
127
+ @map.add_method(node, @scope, node.receiver ? '.' : '#')
128
+ super
129
+ end
130
+
131
+ def visit_if_node(node)
132
+ @map.add_control(node, 'if')
133
+ super
134
+ end
135
+
136
+ def visit_unless_node(node)
137
+ @map.add_control(node, 'unless')
138
+ super
139
+ end
140
+
141
+ def visit_case_node(node)
142
+ @map.add_control(node, 'case')
143
+ super
144
+ end
145
+
146
+ def visit_case_match_node(node)
147
+ @map.add_control(node, 'case')
148
+ super
149
+ end
150
+
151
+ def visit_while_node(node)
152
+ @map.add_control(node, 'while')
153
+ super
154
+ end
155
+
156
+ def visit_until_node(node)
157
+ @map.add_control(node, 'until')
158
+ super
159
+ end
160
+
161
+ def visit_rescue_node(node)
162
+ @map.add_control(node, 'rescue')
163
+ super
164
+ end
165
+
166
+ def visit_and_node(node)
167
+ @map.add_control(node, '&&')
168
+ super
169
+ end
170
+
171
+ def visit_or_node(node)
172
+ @map.add_control(node, '||')
173
+ super
174
+ end
175
+
176
+ private
177
+
178
+ def with_scope(name)
179
+ @scope.push(name)
180
+ yield
181
+ @scope.pop
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Testprune
6
+ # Produces a structural signature for the test defined at file:line by parsing
7
+ # its body with Prism and emitting a node-type sequence that ignores literal
8
+ # values and identifiers but keeps called method names. Two tests that differ
9
+ # only in their data (e.g. `assert_equal 3, add(1,2)` vs `assert_equal 5,
10
+ # add(2,3)`) get the same signature.
11
+ module TestBody
12
+ @file_cache = {}
13
+
14
+ module_function
15
+
16
+ def signature(file, line)
17
+ return nil unless file && line && File.exist?(file)
18
+
19
+ tree = (@file_cache[file] ||= Prism.parse(File.read(file)).value)
20
+ node = locate(tree, line)
21
+ body = body_of(node)
22
+ return nil unless body
23
+
24
+ normalize(body)
25
+ end
26
+
27
+ # Innermost def or block whose definition starts on `line`.
28
+ def locate(root, line)
29
+ stack = [root]
30
+ until stack.empty?
31
+ node = stack.pop
32
+ if node.location.start_line == line &&
33
+ (node.is_a?(Prism::DefNode) || (node.is_a?(Prism::CallNode) && node.block))
34
+ return node
35
+ end
36
+
37
+ stack.concat(node.compact_child_nodes)
38
+ end
39
+ nil
40
+ end
41
+
42
+ def body_of(node)
43
+ return nil unless node
44
+
45
+ node.is_a?(Prism::DefNode) ? node.body : node.block&.body
46
+ end
47
+
48
+ def normalize(node)
49
+ tokens = []
50
+ walk(node) do |n|
51
+ tokens << (n.is_a?(Prism::CallNode) ? "call:#{n.name}" : n.type.to_s)
52
+ end
53
+ tokens.join(',')
54
+ end
55
+
56
+ def walk(node, &block)
57
+ block.call(node)
58
+ node.compact_child_nodes.each { |child| walk(child, &block) }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testprune
4
+ VERSION = '0.1.0'
5
+ end
data/lib/testprune.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'testprune/version'
4
+ require_relative 'testprune/configuration'
5
+
6
+ module Testprune
7
+ class Error < StandardError; end
8
+
9
+ class << self
10
+ def config
11
+ @config ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield config
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: testprune
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Seth MacPherson
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ description: Combines Ruby's native Coverage execution counts with Prism AST analysis
33
+ to map per-test coverage onto semantic units (methods, branches, conditions), find
34
+ redundant tests grouped by duplication type with confidence levels, and emit a removal
35
+ patch — never deleting a test that would open a coverage gap. Report + patch only;
36
+ asks for approval before any change.
37
+ email: seth.macpherson@appfolio.com
38
+ executables:
39
+ - testprune
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - LICENSE
44
+ - README.md
45
+ - assets/quickstart.svg
46
+ - exe/testprune
47
+ - lib/testprune.rb
48
+ - lib/testprune/adapters/minitest.rb
49
+ - lib/testprune/adapters/rspec.rb
50
+ - lib/testprune/analysis.rb
51
+ - lib/testprune/autostart.rb
52
+ - lib/testprune/baseline.rb
53
+ - lib/testprune/cli.rb
54
+ - lib/testprune/configuration.rb
55
+ - lib/testprune/coverage_delta.rb
56
+ - lib/testprune/duplication_detector.rb
57
+ - lib/testprune/footprint.rb
58
+ - lib/testprune/patch_writer.rb
59
+ - lib/testprune/recorder.rb
60
+ - lib/testprune/report.rb
61
+ - lib/testprune/runner.rb
62
+ - lib/testprune/safety_check.rb
63
+ - lib/testprune/savings_estimator.rb
64
+ - lib/testprune/semantic_map.rb
65
+ - lib/testprune/test_body.rb
66
+ - lib/testprune/version.rb
67
+ homepage: https://github.com/seth-macpherson/testprune
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.2.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.6.9
87
+ specification_version: 4
88
+ summary: Audits a Ruby test suite for duplicate/redundant coverage using Prism AST
89
+ + Coverage data
90
+ test_files: []