testprune 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +552 -0
- data/assets/quickstart.svg +70 -0
- data/exe/testprune +6 -0
- data/lib/testprune/adapters/minitest.rb +42 -0
- data/lib/testprune/adapters/rspec.rb +31 -0
- data/lib/testprune/analysis.rb +53 -0
- data/lib/testprune/autostart.rb +40 -0
- data/lib/testprune/baseline.rb +23 -0
- data/lib/testprune/cli.rb +136 -0
- data/lib/testprune/configuration.rb +86 -0
- data/lib/testprune/coverage_delta.rb +82 -0
- data/lib/testprune/duplication_detector.rb +203 -0
- data/lib/testprune/footprint.rb +87 -0
- data/lib/testprune/patch_writer.rb +117 -0
- data/lib/testprune/recorder.rb +102 -0
- data/lib/testprune/report.rb +127 -0
- data/lib/testprune/runner.rb +76 -0
- data/lib/testprune/safety_check.rb +45 -0
- data/lib/testprune/savings_estimator.rb +30 -0
- data/lib/testprune/semantic_map.rb +185 -0
- data/lib/testprune/test_body.rb +61 -0
- data/lib/testprune/version.rb +5 -0
- data/lib/testprune.rb +18 -0
- metadata +90 -0
|
@@ -0,0 +1,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
|