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,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
|
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: []
|