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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../recorder'
4
+
5
+ module Testprune
6
+ module Adapters
7
+ # Minitest integration. Brackets each test via the before_setup/after_teardown
8
+ # lifecycle hooks rather than wrapping #run. Wrapping #run is unsafe here:
9
+ # minitest-reporters does `alias_method :run_without_hooks, :run` after we'd
10
+ # prepend, capturing our method into its alias and causing infinite recursion.
11
+ # The lifecycle hooks are called exactly once per test and are not aliased.
12
+ module Minitest
13
+ module Hook
14
+ def before_setup
15
+ Testprune::Recorder.instance.start_test
16
+ super
17
+ end
18
+
19
+ def after_teardown
20
+ super
21
+ ensure
22
+ recorder = Testprune::Recorder.instance
23
+ id = "#{self.class}##{name}"
24
+ file, line = location
25
+ recorder.finish_test(id: id, description: id, file: file, line: line)
26
+ end
27
+
28
+ private
29
+
30
+ def location
31
+ method(name).source_location
32
+ rescue NameError
33
+ [nil, nil]
34
+ end
35
+ end
36
+
37
+ def self.install
38
+ ::Minitest::Test.prepend(Hook)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../recorder'
4
+
5
+ module Testprune
6
+ module Adapters
7
+ # RSpec integration. Installed by autostart once ::RSpec is defined. Wraps each
8
+ # example to capture its coverage delta + timing, and dumps run.json after the
9
+ # suite.
10
+ module RSpec
11
+ def self.install
12
+ recorder = Testprune::Recorder.instance
13
+ recorder.framework = 'rspec'
14
+
15
+ ::RSpec.configure do |config|
16
+ config.around(:each) do |example|
17
+ md = example.metadata
18
+ Testprune::Recorder.instance.around(
19
+ id: example.id,
20
+ description: example.full_description,
21
+ file: md[:file_path],
22
+ line: md[:line_number]
23
+ ) { example.run }
24
+ end
25
+
26
+ config.after(:suite) { Testprune::Recorder.instance.dump }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../testprune'
5
+ require_relative 'footprint'
6
+ require_relative 'duplication_detector'
7
+ require_relative 'savings_estimator'
8
+
9
+ module Testprune
10
+ # Loads run.json, builds semantic footprints, runs detection + safety, and
11
+ # bundles everything the report and patch writer need.
12
+ class Analysis
13
+ Result = Struct.new(:detector_result, :index, :savings, :run, keyword_init: true) do
14
+ def candidates = detector_result.candidates
15
+ def approved_removals = detector_result.approved_removals
16
+ def label_for(id) = index.label_for(id)
17
+ def ambient_units = detector_result.ambient_units
18
+ def setup_only = detector_result.setup_only
19
+ end
20
+
21
+ def initialize(config)
22
+ @config = config
23
+ end
24
+
25
+ def call
26
+ unless File.directory?(@config.root)
27
+ raise Error, "root directory #{@config.root.inspect} does not exist. " \
28
+ "Check TESTPRUNE_ROOT or --root."
29
+ end
30
+
31
+ unless File.exist?(@config.run_file)
32
+ raise Error, "no captured data at #{@config.run_file}. Run `testprune run` first."
33
+ end
34
+
35
+ run = begin
36
+ JSON.parse(File.read(@config.run_file))
37
+ rescue JSON::ParserError => e
38
+ raise Error, "run.json is not valid JSON (#{e.message}) — it may be truncated. " \
39
+ "Re-run 'testprune run'."
40
+ end
41
+ index = SemanticIndex.new(run['root'] || @config.root)
42
+ footprints = index.build_footprints(run['tests'] || [])
43
+ detector = DuplicationDetector.new(
44
+ footprints,
45
+ overlap_threshold: @config.overlap_threshold,
46
+ baseline_fraction: @config.baseline_fraction
47
+ ).call
48
+
49
+ Result.new(detector_result: detector, index: index,
50
+ savings: SavingsEstimator.new(run, detector), run: run)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded via `RUBYOPT=-r testprune/autostart` in the instrumented subprocess.
4
+ # Starts Coverage (lines + branches + methods) immediately and keeps it running
5
+ # for the entire suite. Starting early means every file compiled after this point
6
+ # is measurable. Keeping Coverage always-on means `Coverage.running?` is true
7
+ # before SimpleCov or any other coverage tool loads, so they skip their own
8
+ # Coverage.start and no guard is needed in test_helper.rb. Then watches for the
9
+ # test framework to be defined and installs the matching adapter.
10
+ require_relative 'recorder'
11
+
12
+ warn "[testprune-debug] autostart loaded in pid #{Process.pid}" if ENV['TESTPRUNE_DEBUG']
13
+
14
+ Testprune::Recorder.instance.start_coverage
15
+
16
+ # Fallback: ensure run.json is written even if the suite crashes before the
17
+ # framework's after-suite hook fires (unhandled exception, SIGTERM, etc.).
18
+ # The @dumped flag in Recorder prevents double-writes on clean exits.
19
+ at_exit { Testprune::Recorder.instance.dump }
20
+
21
+ installed = false
22
+ tracepoint = TracePoint.new(:end) do
23
+ next if installed
24
+
25
+ if defined?(::Minitest::Test)
26
+ installed = true
27
+ require_relative 'adapters/minitest'
28
+ recorder = Testprune::Recorder.instance
29
+ recorder.framework = 'minitest'
30
+ Testprune::Adapters::Minitest.install
31
+ ::Minitest.after_run { Testprune::Recorder.instance.dump }
32
+ elsif defined?(::RSpec) && ::RSpec.respond_to?(:configure)
33
+ installed = true
34
+ require_relative 'adapters/rspec'
35
+ Testprune::Adapters::RSpec.install
36
+ end
37
+
38
+ tracepoint.disable if installed
39
+ end
40
+ tracepoint.enable
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Testprune
6
+ # Identifies "ambient" coverage units — ones executed by so many tests that they
7
+ # carry no signal about what a given test is *for*. In real suites these come
8
+ # from shared `setup`/fixture code (e.g. creating a User fires the same callbacks
9
+ # in hundreds of tests). Left in, they make unrelated tests look identical and
10
+ # produce false "redundant" clusters, so the detector subtracts them first.
11
+ module Baseline
12
+ # A unit is ambient if it appears in >= fraction of all tests. fraction nil,
13
+ # <= 0, or >= 1.0 disables subtraction entirely.
14
+ def self.ambient_units(footprints, fraction)
15
+ return Set.new if fraction.nil? || fraction <= 0.0 || fraction >= 1.0 || footprints.empty?
16
+
17
+ threshold = (footprints.size * fraction).ceil
18
+ counts = Hash.new(0)
19
+ footprints.each { |fp| fp.units.each { |unit| counts[unit] += 1 } }
20
+ counts.select { |_unit, count| count >= threshold }.keys.to_set
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative '../testprune'
5
+
6
+ module Testprune
7
+ # Command-line front end. Three real commands:
8
+ # run boots the target suite under coverage instrumentation -> run.json
9
+ # report analyzes run.json and prints grouped candidates (read-only)
10
+ # apply prompts for approval, then writes a removal patch (never edits in place)
11
+ class CLI
12
+ BANNER = <<~TXT
13
+ testprune — audit a Ruby test suite for redundant coverage
14
+
15
+ Usage:
16
+ testprune run [options] [-- <test command>]
17
+ testprune report [options]
18
+ testprune apply [options]
19
+
20
+ Commands:
21
+ run Run the target suite instrumented; capture per-test coverage + timing
22
+ report Analyze captured data and print removal candidates (read-only)
23
+ apply Review candidates, ask for approval, emit a git-applyable patch
24
+
25
+ Options:
26
+ -s, --source PATH Source dir to analyze (repeatable; default: app, lib)
27
+ -o, --output DIR Output dir for captured data (default: .testprune)
28
+ --baseline FRAC Treat units run by >= FRAC of tests as shared-setup
29
+ noise and subtract them (0..1; default 0.5; 0 to disable)
30
+ --json Emit machine-readable JSON (report only)
31
+ -h, --help Show this help
32
+ -v, --version Show version
33
+ TXT
34
+
35
+ def self.start(argv)
36
+ new.run(argv)
37
+ end
38
+
39
+ def run(argv)
40
+ argv = argv.dup
41
+ command = argv.shift
42
+ case command
43
+ when 'run' then cmd_run(argv)
44
+ when 'report' then cmd_report(argv)
45
+ when 'apply' then cmd_apply(argv)
46
+ when '-v', '--version' then puts(Testprune::VERSION)
47
+ when nil, '-h', '--help' then puts(BANNER)
48
+ else
49
+ warn("testprune: unknown command #{command.inspect}\n\n#{BANNER}")
50
+ return 1
51
+ end
52
+ 0
53
+ rescue Testprune::Error => e
54
+ warn("testprune: #{e.message}")
55
+ 1
56
+ end
57
+
58
+ private
59
+
60
+ # Splits argv at a literal `--`; everything after is the user's test command.
61
+ def split_test_command(argv)
62
+ idx = argv.index('--')
63
+ return [argv, nil] unless idx
64
+
65
+ [argv[0...idx], argv[(idx + 1)..]]
66
+ end
67
+
68
+ def parse_options(argv)
69
+ sources = []
70
+ opts = { json: false }
71
+ parser = OptionParser.new do |o|
72
+ o.on('-s', '--source PATH') { |v| sources << v }
73
+ o.on('-o', '--output DIR') { |v| opts[:output] = v }
74
+ o.on('--baseline FRAC', Float) { |v| opts[:baseline] = v }
75
+ o.on('--json') { opts[:json] = true }
76
+ o.on('-h', '--help') { puts(BANNER); exit(0) }
77
+ end
78
+ rest = parser.parse(argv)
79
+ opts[:sources] = sources unless sources.empty?
80
+ [opts, rest]
81
+ end
82
+
83
+ def apply_config(opts)
84
+ Testprune.configure do |c|
85
+ c.source_paths = opts[:sources] if opts[:sources]
86
+ c.output_dir = File.expand_path(opts[:output], c.root) if opts[:output]
87
+ c.baseline_fraction = (opts[:baseline]).positive? ? opts[:baseline] : nil if opts.key?(:baseline)
88
+ end
89
+ end
90
+
91
+ def cmd_run(argv)
92
+ cmd_argv, test_command = split_test_command(argv)
93
+ opts, = parse_options(cmd_argv)
94
+ apply_config(opts)
95
+ require_relative 'runner'
96
+ Runner.new(Testprune.config).call(test_command)
97
+ end
98
+
99
+ def cmd_report(argv)
100
+ opts, = parse_options(argv)
101
+ apply_config(opts)
102
+ require_relative 'analysis'
103
+ result = Analysis.new(Testprune.config).call
104
+ require_relative 'report'
105
+ puts(Report.new(result, json: opts[:json]).render)
106
+ end
107
+
108
+ def cmd_apply(argv)
109
+ opts, = parse_options(argv)
110
+ apply_config(opts)
111
+ require_relative 'analysis'
112
+ result = Analysis.new(Testprune.config).call
113
+ require_relative 'report'
114
+ puts(Report.new(result).render)
115
+
116
+ approved = result.approved_removals
117
+ if approved.empty?
118
+ puts("\nNothing safe to remove. No patch written.")
119
+ return
120
+ end
121
+
122
+ print("\nApply #{approved.size} HIGH-confidence, safety-verified removal(s) as a patch?\n" \
123
+ "(MEDIUM/LOW review-only candidates are NOT patched automatically.) [y/N] ")
124
+ answer = $stdin.gets&.strip&.downcase
125
+ unless %w[y yes].include?(answer)
126
+ puts('Aborted. No patch written.')
127
+ return
128
+ end
129
+
130
+ require_relative 'patch_writer'
131
+ path = PatchWriter.new(Testprune.config).write(approved)
132
+ puts("Wrote #{path}")
133
+ puts("Review it, then apply with: git apply #{path}")
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testprune
4
+ # Holds run/analysis settings. `source_paths` define which files count as the
5
+ # system-under-test — coverage in any other file (test helpers, vendored gems,
6
+ # the bundle) is ignored so footprints stay precise and cheap to diff.
7
+ class Configuration
8
+ attr_reader :source_paths, :exclude_globs, :output_dir,
9
+ :overlap_threshold, :baseline_fraction, :root
10
+ attr_writer :output_dir, :overlap_threshold, :baseline_fraction
11
+
12
+ def source_paths=(val)
13
+ @source_paths = val
14
+ @source_roots = nil
15
+ end
16
+
17
+ def exclude_globs=(val)
18
+ @exclude_globs = val
19
+ @source_roots = nil
20
+ end
21
+
22
+ def root=(val)
23
+ @root = File.expand_path(val)
24
+ @source_roots = nil
25
+ end
26
+
27
+ def initialize(root: Dir.pwd)
28
+ @root = File.expand_path(root)
29
+ @source_paths = %w[app lib]
30
+ @exclude_globs = %w[**/vendor/** **/node_modules/** **/db/** **/config/**]
31
+ @output_dir = File.join(@root, 'tmp', '.testprune')
32
+ @overlap_threshold = 0.9 # Jaccard cutoff for LOW-confidence overlap pairs
33
+ # Units executed by >= this fraction of tests are treated as ambient
34
+ # shared-setup noise and subtracted before detection. nil disables it.
35
+ @baseline_fraction = 0.5
36
+ end
37
+
38
+ # Rebuild config inside the instrumented subprocess from env vars set by Runner.
39
+ def self.from_env(env = ENV)
40
+ cfg = new(root: env.fetch('TESTPRUNE_ROOT', Dir.pwd))
41
+ cfg.source_paths = split_env(env['TESTPRUNE_SOURCE_PATHS']) || cfg.source_paths
42
+ cfg.exclude_globs = split_env(env['TESTPRUNE_EXCLUDE']) || cfg.exclude_globs
43
+ cfg.output_dir = env['TESTPRUNE_OUTPUT_DIR'] || cfg.output_dir
44
+ cfg
45
+ end
46
+
47
+ def self.split_env(value)
48
+ return nil if value.nil? || value.empty?
49
+
50
+ value.split(File::PATH_SEPARATOR)
51
+ end
52
+
53
+ # Env vars the Runner must export so the subprocess reconstructs this config.
54
+ def to_env
55
+ {
56
+ 'TESTPRUNE_ROOT' => @root,
57
+ 'TESTPRUNE_SOURCE_PATHS' => @source_paths.join(File::PATH_SEPARATOR),
58
+ 'TESTPRUNE_EXCLUDE' => @exclude_globs.join(File::PATH_SEPARATOR),
59
+ 'TESTPRUNE_OUTPUT_DIR' => @output_dir
60
+ }
61
+ end
62
+
63
+ # Absolute, existing source roots under which coverage is considered in-scope.
64
+ # Memoized: re-allocating this array on every source_file? call is O(tests * files).
65
+ def source_roots
66
+ @source_roots ||= @source_paths.map { |p| File.expand_path(p, @root) }
67
+ .select { |p| File.directory?(p) }
68
+ end
69
+
70
+ # True when an absolute file path is part of the system-under-test.
71
+ def source_file?(path)
72
+ abs = File.expand_path(path)
73
+ return false unless source_roots.any? { |root| abs.start_with?("#{root}/") }
74
+
75
+ @exclude_globs.none? { |glob| File.fnmatch?(glob, abs, File::FNM_PATHNAME) }
76
+ end
77
+
78
+ def run_file
79
+ File.join(@output_dir, 'run.json')
80
+ end
81
+
82
+ def patch_file
83
+ File.join(@output_dir, 'removal.patch')
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testprune
4
+ # Computes what a single test executed by diffing two `Coverage.peek_result`
5
+ # snapshots. A unit (line / branch arm / method) belongs to the test iff its
6
+ # execution count *increased* between the snapshots — this is order-independent,
7
+ # so coverage shared with earlier tests is still attributed correctly.
8
+ #
9
+ # Output (per in-scope source file):
10
+ # { "lines" => [Integer, ...],
11
+ # "branches" => [[type, sl, sc, el, ec], ...],
12
+ # "methods" => [[name, sl, sc, el, ec], ...] }
13
+ # Locations come straight from Coverage's keys so the analysis phase can map
14
+ # them onto Prism AST nodes without re-deriving positions.
15
+ module CoverageDelta
16
+ module_function
17
+
18
+ def compute(before, after, config)
19
+ result = {}
20
+ after.each do |file, aft|
21
+ next unless config.source_file?(file)
22
+
23
+ bef = before[file]
24
+ lines = delta_lines(bef && bef[:lines], aft[:lines])
25
+ branches = delta_branches(bef && bef[:branches], aft[:branches])
26
+ methods = delta_methods(bef && bef[:methods], aft[:methods])
27
+ next if lines.empty? && branches.empty? && methods.empty?
28
+
29
+ result[file] = { 'lines' => lines, 'branches' => branches, 'methods' => methods }
30
+ end
31
+ result
32
+ end
33
+
34
+ def delta_lines(before, after)
35
+ return [] unless after
36
+
37
+ newly = []
38
+ after.each_with_index do |after_count, idx|
39
+ next if after_count.nil? # non-executable line
40
+
41
+ before_count = before && before[idx] ? before[idx] : 0
42
+ newly << (idx + 1) if after_count > before_count
43
+ end
44
+ newly
45
+ end
46
+
47
+ # Coverage branch shape: { node_key => { branch_key => count } }.
48
+ # We record each *branch arm* (then/else/when/...) that newly executed,
49
+ # keyed by the arm's own location + type.
50
+ def delta_branches(before, after)
51
+ return [] unless after
52
+
53
+ newly = []
54
+ after.each do |node_key, arms|
55
+ before_arms = before && before[node_key]
56
+ arms.each do |arm_key, after_count|
57
+ before_count = before_arms && before_arms[arm_key] ? before_arms[arm_key] : 0
58
+ next unless after_count > before_count
59
+
60
+ type, _id, sl, sc, el, ec = arm_key
61
+ newly << [type.to_s, sl, sc, el, ec]
62
+ end
63
+ end
64
+ newly
65
+ end
66
+
67
+ # Coverage method shape: { [class, name, sl, sc, el, ec] => count }.
68
+ def delta_methods(before, after)
69
+ return [] unless after
70
+
71
+ newly = []
72
+ after.each do |method_key, after_count|
73
+ before_count = before && before[method_key] ? before[method_key] : 0
74
+ next unless after_count > before_count
75
+
76
+ _klass, name, sl, sc, el, ec = method_key
77
+ newly << [name.to_s, sl, sc, el, ec]
78
+ end
79
+ newly
80
+ end
81
+ end
82
+ end