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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Impact
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []