minitest-impact 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.txt +21 -0
- data/README.md +205 -0
- data/eval/ci_failures.rb +72 -0
- data/exe/minitest-impact +7 -0
- data/lib/minitest/impact/cli.rb +190 -0
- data/lib/minitest/impact/diff.rb +47 -0
- data/lib/minitest/impact/evaluation.rb +80 -0
- data/lib/minitest/impact/jev/client.rb +88 -0
- data/lib/minitest/impact/jev/questions.rb +58 -0
- data/lib/minitest/impact/jev/ranker.rb +121 -0
- data/lib/minitest/impact/locale_keys.rb +39 -0
- data/lib/minitest/impact/map.rb +115 -0
- data/lib/minitest/impact/methods.rb +61 -0
- data/lib/minitest/impact/railtie.rb +13 -0
- data/lib/minitest/impact/rake_task.rb +30 -0
- data/lib/minitest/impact/record_bootstrap.rb +19 -0
- data/lib/minitest/impact/recorder.rb +104 -0
- data/lib/minitest/impact/recording_hooks.rb +21 -0
- data/lib/minitest/impact/repo.rb +80 -0
- data/lib/minitest/impact/rules.rb +127 -0
- data/lib/minitest/impact/selector.rb +337 -0
- data/lib/minitest/impact/version.rb +7 -0
- data/lib/minitest/impact.rb +22 -0
- metadata +71 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module Impact
|
|
7
|
+
# Reads a git repository either as its working tree (rev nil) or as it was at a commit, so the
|
|
8
|
+
# same selection runs on an agent's uncommitted change and on a commit from history.
|
|
9
|
+
class Repo
|
|
10
|
+
attr_reader :root
|
|
11
|
+
|
|
12
|
+
def initialize(root = Dir.pwd)
|
|
13
|
+
@root = File.expand_path(root)
|
|
14
|
+
@cache = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def git(*args, allow_failure: false)
|
|
18
|
+
out, err, status = Open3.capture3("git", "-C", root, *args)
|
|
19
|
+
raise Error, "git #{args.join(" ")} failed: #{err.strip}" unless status.success? || allow_failure
|
|
20
|
+
|
|
21
|
+
status.success? ? out : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def head = git("rev-parse", "HEAD").strip
|
|
25
|
+
|
|
26
|
+
def commit?(rev) = !git("cat-file", "-e", "#{rev}^{commit}", allow_failure: true).nil?
|
|
27
|
+
|
|
28
|
+
def resolve(rev) = git("rev-parse", rev).strip
|
|
29
|
+
|
|
30
|
+
def merge_base(a, b) = git("merge-base", a, b, allow_failure: true)&.strip
|
|
31
|
+
|
|
32
|
+
# Contents of +path+ at +rev+, or in the working tree when rev is nil. nil when absent.
|
|
33
|
+
def read(path, rev = nil)
|
|
34
|
+
@cache.fetch([path, rev]) do
|
|
35
|
+
@cache[[path, rev]] =
|
|
36
|
+
if rev
|
|
37
|
+
git("show", "#{rev}:#{path}", allow_failure: true)
|
|
38
|
+
else
|
|
39
|
+
full = File.join(root, path)
|
|
40
|
+
File.file?(full) ? File.read(full) : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def files(rev = nil)
|
|
46
|
+
@cache.fetch([:files, rev]) do
|
|
47
|
+
@cache[[:files, rev]] =
|
|
48
|
+
if rev
|
|
49
|
+
git("ls-tree", "-r", "--name-only", rev).split("\n")
|
|
50
|
+
else
|
|
51
|
+
git("ls-files", "--cached", "--others", "--exclude-standard").split("\n").select { |f| File.file?(File.join(root, f)) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Files under +paths+ whose text contains the fixed string +needle+.
|
|
57
|
+
def grep(needle, paths:, rev: nil)
|
|
58
|
+
return [] if needle.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
args = ["grep", "-l", "-F", "-e", needle]
|
|
61
|
+
args << "--untracked" unless rev
|
|
62
|
+
args << rev if rev
|
|
63
|
+
args += ["--", *paths]
|
|
64
|
+
out = git(*args, allow_failure: true).to_s
|
|
65
|
+
out.split("\n").map { |line| rev ? line.delete_prefix("#{rev}:") : line }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Unified diff with no context between +base+ and +head+ (working tree when head is nil).
|
|
69
|
+
# Untracked files are listed as additions too, since an agent's new file is part of its change.
|
|
70
|
+
def diff(base, head = nil)
|
|
71
|
+
range = head ? [base, head] : [base]
|
|
72
|
+
text = git("diff", "--no-color", "--no-ext-diff", "-M", "--unified=0", *range)
|
|
73
|
+
return text if head
|
|
74
|
+
|
|
75
|
+
untracked = git("ls-files", "--others", "--exclude-standard").split("\n")
|
|
76
|
+
text + untracked.map { |path| "diff --git a/#{path} b/#{path}\nnew file mode 100644\n--- /dev/null\n+++ b/#{path}\n" }.join
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
# Every rule that is a fact about Rails and Minitest layouts, in one place, so a reader can
|
|
6
|
+
# check them without reading the selector. Nothing here asks a model.
|
|
7
|
+
module Rules
|
|
8
|
+
TEST_ROOT = "test/"
|
|
9
|
+
TEST_FILE = %r{\Atest/.*_test\.rb\z}
|
|
10
|
+
FIXTURE = %r{\Atest/fixtures/(.+)\.yml\z}
|
|
11
|
+
|
|
12
|
+
# A change here can break any test, so the full suite is the only honest selection.
|
|
13
|
+
WHOLE_SUITE = %w[
|
|
14
|
+
Gemfile Gemfile.lock gems.rb gems.locked .ruby-version .tool-versions mise.toml
|
|
15
|
+
test/test_helper.rb config/application.rb config/boot.rb config/environment.rb
|
|
16
|
+
config/environments/test.rb config/database.yml
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# A change to a base test class reaches every test under a directory.
|
|
20
|
+
BASE_CLASSES = {
|
|
21
|
+
"test/application_system_test_case.rb" => "test/system/"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Files no test runs. Selected only when a test reads the file by path.
|
|
25
|
+
QUIET = [%r{\A(?:docs|tmp|log)/}, /\.md\z/, %r{\A\.github/}, /\ALICENSE/, /\.txt\z/, %r{\A\.claude/}, /\A\.gitignore\z/].freeze
|
|
26
|
+
|
|
27
|
+
ROUTES = "config/routes.rb"
|
|
28
|
+
LOCALE = %r{\Aconfig/locales/.+\.ya?ml\z}
|
|
29
|
+
MIGRATION = %r{\Adb/(migrate/.+\.rb|schema\.rb|.+_schema\.rb)\z}
|
|
30
|
+
VIEW = %r{\Aapp/views/(.+?)/(_?)([^/]+?)\.[^/]+\z}
|
|
31
|
+
STIMULUS = %r{\Aapp/javascript/controllers/(.+)_controller\.[jt]s\z}
|
|
32
|
+
AUTOLOADED = %r{\A(?:app/[^/]+|lib)/(.+)\.rb\z}
|
|
33
|
+
|
|
34
|
+
# Weights: how sure a reason is that a test file exercises the change (0..1).
|
|
35
|
+
WEIGHT = {
|
|
36
|
+
changed_test: 1.0,
|
|
37
|
+
method: 1.0,
|
|
38
|
+
deleted_file: 0.95,
|
|
39
|
+
conventional_test: 0.9,
|
|
40
|
+
reads_path: 0.85,
|
|
41
|
+
asserts_key: 0.85,
|
|
42
|
+
view: 0.85,
|
|
43
|
+
mentions_constant: 0.7,
|
|
44
|
+
file: 0.7,
|
|
45
|
+
via_file: 0.6,
|
|
46
|
+
untested_method: 0.3
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def test_file?(path) = TEST_FILE.match?(path)
|
|
52
|
+
def whole_suite?(path) = WHOLE_SUITE.include?(path)
|
|
53
|
+
def quiet?(path) = QUIET.any? { |pattern| pattern.match?(path) }
|
|
54
|
+
def ruby?(path) = path.end_with?(".rb")
|
|
55
|
+
|
|
56
|
+
# app/models/billing/invoice.rb => test/models/billing/invoice_test.rb
|
|
57
|
+
# lib/tasks/x.rb => test/lib/tasks/x_test.rb, test/tasks/x_test.rb
|
|
58
|
+
def conventional_tests(path)
|
|
59
|
+
case path
|
|
60
|
+
when %r{\Aapp/(.+)\.rb\z} then ["test/#{Regexp.last_match(1)}_test.rb"]
|
|
61
|
+
when %r{\Alib/(.+)\.rb\z} then ["test/lib/#{Regexp.last_match(1)}_test.rb", "test/#{Regexp.last_match(1)}_test.rb"]
|
|
62
|
+
when VIEW
|
|
63
|
+
["test/controllers/#{Regexp.last_match(1)}_controller_test.rb"]
|
|
64
|
+
else []
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Zeitwerk's naming: app/models/billing/invoice.rb => Billing::Invoice
|
|
69
|
+
def constant_for(path)
|
|
70
|
+
match = AUTOLOADED.match(path)
|
|
71
|
+
return unless match
|
|
72
|
+
|
|
73
|
+
rel = match[1].delete_prefix("concerns/")
|
|
74
|
+
rel.split("/").map { |part| camelize(part) }.join("::")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def camelize(word) = word.split("_").map(&:capitalize).join
|
|
78
|
+
|
|
79
|
+
def singularize(word)
|
|
80
|
+
case word
|
|
81
|
+
when /ies\z/ then word.sub(/ies\z/, "y")
|
|
82
|
+
when /(ss|us)\z/ then word
|
|
83
|
+
when /(s|x|z|ch|sh)es\z/ then word.sub(/es\z/, "")
|
|
84
|
+
when /s\z/ then word.chomp("s")
|
|
85
|
+
else word
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Tables a migration or schema diff touches.
|
|
90
|
+
def tables_in(text)
|
|
91
|
+
text.scan(/(?:create_table|drop_table|add_column|remove_column|change_column|rename_column|add_index|remove_index|add_reference|remove_reference|change_table|add_foreign_key|change_column_default|change_column_null)\s*\(?\s*[:"'](\w+)/).flatten.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The create_table block of schema.rb that holds +line+.
|
|
95
|
+
def schema_table_at(source, line)
|
|
96
|
+
source.lines.first(line).reverse_each do |text|
|
|
97
|
+
return Regexp.last_match(1) if text =~ /create_table\s+"(\w+)"/
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Controllers a routes diff names.
|
|
103
|
+
def controllers_in_routes(text)
|
|
104
|
+
names = text.scan(/\bresources?\s+:(\w+)/).flatten.map { |name| name }
|
|
105
|
+
names += text.scan(/to:\s*["']([\w\/]+)#/).flatten
|
|
106
|
+
names += text.scan(/controller:\s*[:"']([\w\/]+)/).flatten
|
|
107
|
+
names.uniq.map { |name| "app/controllers/#{name}_controller.rb" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Candidate view files for a lazy key such as "sessions.new.title".
|
|
111
|
+
def views_for_key(key, files)
|
|
112
|
+
parts = key.split(".")
|
|
113
|
+
return [] if parts.size < 2
|
|
114
|
+
|
|
115
|
+
dir = parts[0..-3].join("/")
|
|
116
|
+
template = parts[-2]
|
|
117
|
+
files.select do |file|
|
|
118
|
+
file.start_with?("app/views/#{dir}/") &&
|
|
119
|
+
File.basename(file).split(".").first.delete_prefix("_") == template &&
|
|
120
|
+
File.dirname(file) == "app/views/#{dir}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stimulus_identifier(name) = name.tr("_", "-").gsub("/", "--")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module Impact
|
|
7
|
+
# One test file in a selection, with every reason it was picked.
|
|
8
|
+
Pick = Struct.new(:test, :score, :reasons, :seconds, keyword_init: true) do
|
|
9
|
+
def to_h = { "test" => test, "score" => score.round(3), "reasons" => reasons, "seconds" => seconds }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# What a selection decided about one changed file.
|
|
13
|
+
Resolution = Struct.new(:path, :how, :note, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
Selection = Struct.new(:picks, :whole_suite, :resolutions, :jev, :warnings, keyword_init: true) do
|
|
16
|
+
def tests = picks.map(&:test)
|
|
17
|
+
|
|
18
|
+
def unresolved = resolutions.select { |resolution| resolution.how == :unresolved }
|
|
19
|
+
|
|
20
|
+
# high: every changed file was traced exactly (a changed test, a changed method the map saw
|
|
21
|
+
# run, or a file no test reads). medium: some file was traced by file or by convention. low:
|
|
22
|
+
# some file could not be traced, or the change needs the whole suite.
|
|
23
|
+
def confidence
|
|
24
|
+
return :low if whole_suite || resolutions.any? { |r| r.how == :unresolved }
|
|
25
|
+
return :medium if resolutions.any? { |r| %i[file convention].include?(r.how) }
|
|
26
|
+
|
|
27
|
+
:high
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
"confidence" => confidence.to_s,
|
|
33
|
+
"whole_suite" => whole_suite,
|
|
34
|
+
"tests" => picks.map(&:to_h),
|
|
35
|
+
"files" => resolutions.map { |r| { "path" => r.path, "how" => r.how.to_s, "note" => r.note } },
|
|
36
|
+
"jev" => jev,
|
|
37
|
+
"warnings" => warnings
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Picks the test files a change is most likely to break or that verify it. Everything here is
|
|
43
|
+
# deterministic: the coverage map, method names, and the layout rules in Rules. A Jev ranker,
|
|
44
|
+
# when one is given, then re-ranks and extends the result (see Jev::Ranker).
|
|
45
|
+
class Selector
|
|
46
|
+
# granularity: :method (default) traces changed lines to methods; :file selects every test
|
|
47
|
+
# that loaded a changed file, as Crystalball and Ekstazi do. Kept to measure one against the other.
|
|
48
|
+
def initialize(repo:, map:, base:, head: nil, intent: nil, ranker: nil, exclude: nil, granularity: :method)
|
|
49
|
+
@repo = repo
|
|
50
|
+
@map = map
|
|
51
|
+
@base = base
|
|
52
|
+
@head = head
|
|
53
|
+
@intent = intent
|
|
54
|
+
@ranker = ranker
|
|
55
|
+
@exclude = exclude
|
|
56
|
+
@granularity = granularity
|
|
57
|
+
@hits = Hash.new { |hash, key| hash[key] = [] }
|
|
58
|
+
@resolutions = []
|
|
59
|
+
@warnings = []
|
|
60
|
+
@whole_suite = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def changes
|
|
64
|
+
@changes ||= Diff.parse(@repo.diff(@base, @head)).reject { |change| @exclude&.call(change.path) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call
|
|
68
|
+
warn_if_stale
|
|
69
|
+
changes.each { |change| resolve(change) }
|
|
70
|
+
picks = rank
|
|
71
|
+
jev = nil
|
|
72
|
+
if @ranker && needs_ranker?(picks)
|
|
73
|
+
picks, jev = @ranker.call(picks: picks, changes: changes, intent: @intent, repo: @repo, map: @map, head: @head)
|
|
74
|
+
@whole_suite ||= jev&.dig("whole_suite") == true
|
|
75
|
+
end
|
|
76
|
+
Selection.new(picks: picks, whole_suite: @whole_suite, resolutions: @resolutions, jev: jev, warnings: @warnings)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def needs_ranker?(picks)
|
|
82
|
+
@resolutions.any? { |r| %i[unresolved file convention].include?(r.how) } || picks.size > Jev::Questions::MAX_CANDIDATES
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def warn_if_stale
|
|
86
|
+
return if @map.commit.nil? || @repo.commit?(@map.commit)
|
|
87
|
+
|
|
88
|
+
@warnings << "the map was recorded at #{@map.commit}, which this repository does not have; matching by file only"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def hit(test, kind, reason)
|
|
92
|
+
return unless @map.tests.key?(test) || head_has?(test)
|
|
93
|
+
|
|
94
|
+
@hits[test] << [Rules::WEIGHT.fetch(kind), reason]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def head_has?(path) = head_files.include?(path)
|
|
98
|
+
|
|
99
|
+
def head_files = @head_files ||= @repo.files(@head).to_set
|
|
100
|
+
|
|
101
|
+
def resolved(change, how, note = nil)
|
|
102
|
+
@resolutions << Resolution.new(path: change.path, how: how, note: note)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Several independent reasons make a test more likely, never less: 1 - Π(1 - w).
|
|
106
|
+
def rank
|
|
107
|
+
@hits.select { |test, _| head_has?(test) }.map do |test, reasons|
|
|
108
|
+
score = 1 - reasons.map(&:first).reduce(1.0) { |acc, weight| acc * (1 - weight) }
|
|
109
|
+
Pick.new(test: test, score: score, reasons: reasons.map(&:last).uniq, seconds: @map.seconds(test))
|
|
110
|
+
end.sort_by { |pick| [-pick.score, pick.seconds || Float::INFINITY, pick.test] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve(change)
|
|
114
|
+
path = change.path
|
|
115
|
+
return resolve_whole_suite(change) if Rules.whole_suite?(path)
|
|
116
|
+
return resolve_base_class(change) if Rules::BASE_CLASSES.key?(path)
|
|
117
|
+
return resolve_test(change) if Rules.test_file?(path)
|
|
118
|
+
|
|
119
|
+
case path
|
|
120
|
+
when Rules::FIXTURE then resolve_fixture(change, Regexp.last_match(1))
|
|
121
|
+
when Rules::LOCALE then resolve_locale(change)
|
|
122
|
+
when Rules::MIGRATION then resolve_migration(change)
|
|
123
|
+
when Rules::STIMULUS then resolve_stimulus(change, Regexp.last_match(1))
|
|
124
|
+
when Rules::ROUTES then resolve_routes(change)
|
|
125
|
+
else resolve_file(change)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_whole_suite(change)
|
|
130
|
+
@whole_suite = true
|
|
131
|
+
resolved(change, :whole_suite, "every test depends on it")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resolve_base_class(change)
|
|
135
|
+
dir = Rules::BASE_CLASSES.fetch(change.path)
|
|
136
|
+
tests = (@map.test_files + head_files.to_a).uniq.select { |test| test.start_with?(dir) && Rules.test_file?(test) }
|
|
137
|
+
tests.each { |test| hit(test, :file, "inherits #{change.path}") }
|
|
138
|
+
resolved(change, :file, "every test under #{dir}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def resolve_test(change)
|
|
142
|
+
return resolved(change, :quiet, "deleted test") if change.deleted?
|
|
143
|
+
|
|
144
|
+
hit(change.path, :changed_test, "the change edits this test")
|
|
145
|
+
resolved(change, :exact)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolve_fixture(change, name)
|
|
149
|
+
found = tests_mentioning("#{name}(:")
|
|
150
|
+
found.each { |test| hit(test, :reads_path, "uses the #{name} fixtures") }
|
|
151
|
+
model = "app/models/#{Rules.singularize(name.split("/").last)}.rb"
|
|
152
|
+
via = map_tests(model, :via_file, "tests #{model}, whose fixtures changed")
|
|
153
|
+
resolved(change, found.any? || via ? :convention : :unresolved, "fixtures #{name}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def resolve_locale(change)
|
|
157
|
+
keys = LocaleKeys.at(@repo.read(change.path, @head), change.new_lines) |
|
|
158
|
+
LocaleKeys.at(@repo.read(change.old_path || change.path, @base), change.deleted? || change.status == :modified ? change.old_lines : [])
|
|
159
|
+
found = false
|
|
160
|
+
keys.each do |key|
|
|
161
|
+
tests_mentioning(key).each do |test|
|
|
162
|
+
hit(test, :asserts_key, "uses the key #{key}")
|
|
163
|
+
found = true
|
|
164
|
+
end
|
|
165
|
+
@repo.grep(key, paths: %w[app lib], rev: @head).each do |file|
|
|
166
|
+
found = true if map_tests(file, :via_file, "renders #{key} through #{file}")
|
|
167
|
+
end
|
|
168
|
+
Rules.views_for_key(key, head_files.to_a).each do |view|
|
|
169
|
+
found = true if resolve_view_like(view, "renders #{key} (lazy lookup) in #{view}")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
resolved(change, found ? :convention : :unresolved, keys.empty? ? "no key found in the change" : "keys: #{keys.first(5).join(", ")}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_migration(change)
|
|
176
|
+
source = @repo.read(change.path, @head) || ""
|
|
177
|
+
tables = Rules.tables_in(change.text)
|
|
178
|
+
if change.path.end_with?("schema.rb")
|
|
179
|
+
tables |= change.new_lines.filter_map { |line| Rules.schema_table_at(source, line) }
|
|
180
|
+
end
|
|
181
|
+
found = tables.any? do |table|
|
|
182
|
+
model = "app/models/#{Rules.singularize(table)}.rb"
|
|
183
|
+
by_map = map_tests(model, :via_file, "tests #{model}, whose table #{table} changed")
|
|
184
|
+
by_name = conventional(model)
|
|
185
|
+
by_map || by_name
|
|
186
|
+
end
|
|
187
|
+
resolved(change, found ? :convention : :unresolved, tables.empty? ? "no table found" : "tables: #{tables.join(", ")}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resolve_stimulus(change, name)
|
|
191
|
+
identifier = Rules.stimulus_identifier(name)
|
|
192
|
+
views = @repo.grep(identifier, paths: %w[app/views app/components app/helpers], rev: @head)
|
|
193
|
+
found = views.any? { |view| resolve_view_like(view, "uses the #{identifier} Stimulus controller through #{view}") }
|
|
194
|
+
resolved(change, found ? :convention : :unresolved, "Stimulus #{identifier}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def resolve_routes(change)
|
|
198
|
+
found = Rules.controllers_in_routes(change.text).any? do |controller|
|
|
199
|
+
map_tests(controller, :via_file, "reaches #{controller}, whose routes changed") | conventional(controller)
|
|
200
|
+
end
|
|
201
|
+
resolved(change, found ? :convention : :unresolved, "routes")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def resolve_file(change)
|
|
205
|
+
reads = tests_mentioning(change.path)
|
|
206
|
+
reads.each { |test| hit(test, :reads_path, "reads #{change.path}") }
|
|
207
|
+
lookup = change.old_path || change.path
|
|
208
|
+
|
|
209
|
+
# A file the change adds cannot be in a map recorded before it; if the map knows it anyway
|
|
210
|
+
# (a map newer than the change, as when replaying history), using it would be hindsight.
|
|
211
|
+
if @map.known_file?(lookup) && !change.added?
|
|
212
|
+
resolve_mapped(change, lookup)
|
|
213
|
+
elsif change.path.start_with?("app/views/")
|
|
214
|
+
found = resolve_view_like(change.path, nil)
|
|
215
|
+
resolved(change, found || reads.any? ? :convention : :unresolved)
|
|
216
|
+
elsif Rules.ruby?(change.path) && Rules.constant_for(change.path)
|
|
217
|
+
resolve_by_name(change, reads.any?)
|
|
218
|
+
elsif reads.any?
|
|
219
|
+
resolved(change, :exact, "tests read it by path")
|
|
220
|
+
elsif Rules.quiet?(change.path)
|
|
221
|
+
resolved(change, :quiet, "no test reads it")
|
|
222
|
+
else
|
|
223
|
+
resolved(change, :unresolved, "not in the map and no rule matches")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A file the map saw run. Changed lines inside methods select the tests that ran those
|
|
228
|
+
# methods; lines outside any method (class bodies, macros, constants) select every test that
|
|
229
|
+
# loaded the file.
|
|
230
|
+
def resolve_mapped(change, lookup)
|
|
231
|
+
if @granularity == :file && !change.deleted? && Rules.ruby?(lookup)
|
|
232
|
+
map_tests(lookup, :file, "loads #{lookup}")
|
|
233
|
+
conventional(lookup)
|
|
234
|
+
return resolved(change, :file)
|
|
235
|
+
end
|
|
236
|
+
if change.deleted? || !Rules.ruby?(lookup)
|
|
237
|
+
map_tests(lookup, change.deleted? ? :deleted_file : :view, change.deleted? ? "loaded #{lookup}, now deleted" : "rendered #{lookup}")
|
|
238
|
+
conventional(lookup)
|
|
239
|
+
return resolved(change, change.deleted? ? :exact : :file)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
old_methods = Methods.of(@repo.read(lookup, @base))
|
|
243
|
+
map_version = map_source(lookup)
|
|
244
|
+
map_methods = map_version ? Methods.of(map_version) : nil
|
|
245
|
+
outside = false
|
|
246
|
+
untested = []
|
|
247
|
+
names = change.old_lines.map { |line| old_methods.enclosing(line)&.name }
|
|
248
|
+
outside = true if names.include?(nil) || change.old_lines.empty?
|
|
249
|
+
names.compact.uniq.each do |name|
|
|
250
|
+
definition = map_methods&.named(name)
|
|
251
|
+
next outside = true unless definition
|
|
252
|
+
|
|
253
|
+
body = definition.body.to_set
|
|
254
|
+
tests = @map.tests_for(lookup).select { |_test, lines| lines.any? { |line| body.include?(line) } }
|
|
255
|
+
untested << name if tests.empty?
|
|
256
|
+
tests.each_key { |test| hit(test, :method, "runs #{name}") }
|
|
257
|
+
end
|
|
258
|
+
conventional(lookup)
|
|
259
|
+
if outside
|
|
260
|
+
map_tests(lookup, :file, "loads #{lookup}")
|
|
261
|
+
resolved(change, :file, "changed outside a method the map knows")
|
|
262
|
+
elsif untested.any?
|
|
263
|
+
map_tests(lookup, :untested_method, "loads #{lookup}; no test runs #{untested.join(", ")}")
|
|
264
|
+
resolved(change, :file, "no test runs #{untested.join(", ")}")
|
|
265
|
+
else
|
|
266
|
+
resolved(change, :exact)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def resolve_by_name(change, found)
|
|
271
|
+
constant = Rules.constant_for(change.path)
|
|
272
|
+
found |= conventional(change.path)
|
|
273
|
+
tests_mentioning(constant).each do |test|
|
|
274
|
+
hit(test, :mentions_constant, "mentions #{constant}")
|
|
275
|
+
found = true
|
|
276
|
+
end
|
|
277
|
+
@repo.grep(constant, paths: %w[app lib], rev: @head).each do |file|
|
|
278
|
+
next if file == change.path
|
|
279
|
+
|
|
280
|
+
found = true if map_tests(file, :via_file, "reaches #{constant} through #{file}")
|
|
281
|
+
end
|
|
282
|
+
resolved(change, found ? :convention : :unresolved, constant)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def resolve_view_like(view, reason)
|
|
286
|
+
return map_tests(view, :view, reason || "renders #{view}") if @map.known_file?(view)
|
|
287
|
+
|
|
288
|
+
match = Rules::VIEW.match(view)
|
|
289
|
+
return false unless match
|
|
290
|
+
|
|
291
|
+
found = conventional(view)
|
|
292
|
+
dir = match[1]
|
|
293
|
+
controller = dir.end_with?("_mailer") ? "app/mailers/#{dir}.rb" : "app/controllers/#{dir}_controller.rb"
|
|
294
|
+
found |= map_tests(controller, :via_file, reason || "renders #{view} through #{controller}")
|
|
295
|
+
partial = "#{dir}/#{match[3]}"
|
|
296
|
+
if match[2] == "_"
|
|
297
|
+
@repo.grep(partial, paths: %w[app], rev: @head).each do |file|
|
|
298
|
+
found = true if map_tests(file, :via_file, reason || "renders the partial #{partial} through #{file}")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
found
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Selects every test the map says loaded +file+; true when there was any. A file every test
|
|
305
|
+
# loads (a base class) tells little, so its weight shrinks with how widely it is loaded.
|
|
306
|
+
def map_tests(file, kind, reason)
|
|
307
|
+
tests = @map.tests_for(file)
|
|
308
|
+
return false if tests.empty?
|
|
309
|
+
|
|
310
|
+
weight = Rules::WEIGHT.fetch(kind) * (1 - (0.5 * @map.spread(file)))
|
|
311
|
+
tests.each_key { |test| @hits[test] << [weight, reason] }
|
|
312
|
+
true
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def conventional(path)
|
|
316
|
+
found = false
|
|
317
|
+
Rules.conventional_tests(path).each do |test|
|
|
318
|
+
next unless @map.tests.key?(test) || head_has?(test)
|
|
319
|
+
|
|
320
|
+
hit(test, :conventional_test, "is the test for #{path}")
|
|
321
|
+
found = true
|
|
322
|
+
end
|
|
323
|
+
found
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def tests_mentioning(needle)
|
|
327
|
+
@repo.grep(needle, paths: [Rules::TEST_ROOT], rev: @head).select { |file| Rules.test_file?(file) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def map_source(path)
|
|
331
|
+
return nil if @map.commit.nil? || !@repo.commit?(@map.commit)
|
|
332
|
+
|
|
333
|
+
@repo.read(path, @map.commit)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "impact/version"
|
|
10
|
+
require_relative "impact/recorder"
|
|
11
|
+
require_relative "impact/map"
|
|
12
|
+
require_relative "impact/repo"
|
|
13
|
+
require_relative "impact/diff"
|
|
14
|
+
require_relative "impact/methods"
|
|
15
|
+
require_relative "impact/rules"
|
|
16
|
+
require_relative "impact/locale_keys"
|
|
17
|
+
require_relative "impact/jev/client"
|
|
18
|
+
require_relative "impact/jev/questions"
|
|
19
|
+
require_relative "impact/jev/ranker"
|
|
20
|
+
require_relative "impact/selector"
|
|
21
|
+
require_relative "impact/evaluation"
|
|
22
|
+
require_relative "impact/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: minitest-impact
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Bruno Costanzo
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Records which lines each Minitest test file executes, then, given a git diff, selects the test
|
|
14
|
+
files that ran the changed methods, plus the ones Rails conventions point to for views, copy,
|
|
15
|
+
routes and migrations. With a TypeSafe key, Jev re-ranks the selection and covers what the map
|
|
16
|
+
cannot see. Made for coding agents that should run a few tests in their loop and leave the full
|
|
17
|
+
suite to the final gate.
|
|
18
|
+
executables:
|
|
19
|
+
- minitest-impact
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- eval/ci_failures.rb
|
|
26
|
+
- exe/minitest-impact
|
|
27
|
+
- lib/minitest/impact.rb
|
|
28
|
+
- lib/minitest/impact/cli.rb
|
|
29
|
+
- lib/minitest/impact/diff.rb
|
|
30
|
+
- lib/minitest/impact/evaluation.rb
|
|
31
|
+
- lib/minitest/impact/jev/client.rb
|
|
32
|
+
- lib/minitest/impact/jev/questions.rb
|
|
33
|
+
- lib/minitest/impact/jev/ranker.rb
|
|
34
|
+
- lib/minitest/impact/locale_keys.rb
|
|
35
|
+
- lib/minitest/impact/map.rb
|
|
36
|
+
- lib/minitest/impact/methods.rb
|
|
37
|
+
- lib/minitest/impact/railtie.rb
|
|
38
|
+
- lib/minitest/impact/rake_task.rb
|
|
39
|
+
- lib/minitest/impact/record_bootstrap.rb
|
|
40
|
+
- lib/minitest/impact/recorder.rb
|
|
41
|
+
- lib/minitest/impact/recording_hooks.rb
|
|
42
|
+
- lib/minitest/impact/repo.rb
|
|
43
|
+
- lib/minitest/impact/rules.rb
|
|
44
|
+
- lib/minitest/impact/selector.rb
|
|
45
|
+
- lib/minitest/impact/version.rb
|
|
46
|
+
homepage: https://github.com/bruno-costanzo/minitest-impact
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://github.com/bruno-costanzo/minitest-impact
|
|
51
|
+
changelog_uri: https://github.com/bruno-costanzo/minitest-impact/releases
|
|
52
|
+
rubygems_mfa_required: 'true'
|
|
53
|
+
rdoc_options: []
|
|
54
|
+
require_paths:
|
|
55
|
+
- lib
|
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.3'
|
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0'
|
|
66
|
+
requirements: []
|
|
67
|
+
rubygems_version: 4.0.16
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: Pick the Minitest tests a change is most likely to break, from a coverage
|
|
70
|
+
map and, optionally, Jev.
|
|
71
|
+
test_files: []
|