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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Minitest
|
|
8
|
+
module Impact
|
|
9
|
+
module Jev
|
|
10
|
+
# A minimal client for TypeSafe's System One endpoint (docs.typesafe.ai/api): one POST with a
|
|
11
|
+
# state and named questions, answers under the same names. The `jev` gem (0.2.0) was
|
|
12
|
+
# considered first; it always sends "jev-latest" and one fixed URL, and a threshold tuned on
|
|
13
|
+
# one version needs that version pinned (docs.typesafe.ai/models).
|
|
14
|
+
class Client
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.typesafe.ai"
|
|
16
|
+
# Pinned: the thresholds in Questions were set against this version.
|
|
17
|
+
DEFAULT_MODEL = "jev-1.13.0"
|
|
18
|
+
RETRY_STATUSES = [429, 529].freeze
|
|
19
|
+
ATTEMPTS = 3
|
|
20
|
+
TIMEOUT = 15
|
|
21
|
+
|
|
22
|
+
Response = Struct.new(:answers, :model, :input_tokens, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
class Failure < Error; end
|
|
25
|
+
|
|
26
|
+
def self.from_env(env = ENV)
|
|
27
|
+
key = env["TYPESAFE_API_KEY"].to_s
|
|
28
|
+
return nil if key.empty?
|
|
29
|
+
|
|
30
|
+
new(api_key: key, base_url: env.fetch("TYPESAFE_BASE_URL", DEFAULT_BASE_URL),
|
|
31
|
+
model: env.fetch("MINITEST_IMPACT_JEV_MODEL", DEFAULT_MODEL))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :model
|
|
35
|
+
|
|
36
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, model: DEFAULT_MODEL, sleeper: ->(seconds) { sleep(seconds) })
|
|
37
|
+
@api_key = api_key
|
|
38
|
+
@uri = URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", "v1/systemone")
|
|
39
|
+
@model = model
|
|
40
|
+
@sleeper = sleeper
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ask(state:, questions:)
|
|
44
|
+
attempt = 0
|
|
45
|
+
begin
|
|
46
|
+
attempt += 1
|
|
47
|
+
response = post(JSON.generate(model: model, state: state, questions: questions))
|
|
48
|
+
if RETRY_STATUSES.include?(response.code.to_i) && attempt < ATTEMPTS
|
|
49
|
+
@sleeper.call(retry_after(response, attempt))
|
|
50
|
+
raise RetryableStatus
|
|
51
|
+
end
|
|
52
|
+
parse(response)
|
|
53
|
+
rescue RetryableStatus
|
|
54
|
+
retry
|
|
55
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError => error
|
|
56
|
+
raise Failure, "Jev unreachable: #{error.class}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
class RetryableStatus < StandardError; end
|
|
63
|
+
|
|
64
|
+
def post(body)
|
|
65
|
+
request = Net::HTTP::Post.new(@uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}")
|
|
66
|
+
request.body = body
|
|
67
|
+
Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
68
|
+
http.request(request)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def retry_after(response, attempt)
|
|
73
|
+
header = response["retry-after"].to_s
|
|
74
|
+
header.match?(/\A\d+(\.\d+)?\z/) ? header.to_f : 0.5 * (2**attempt)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse(response)
|
|
78
|
+
raise Failure, "Jev answered #{response.code}: #{response.body.to_s[0, 200]}" unless response.is_a?(Net::HTTPSuccess)
|
|
79
|
+
|
|
80
|
+
body = JSON.parse(response.body)
|
|
81
|
+
Response.new(answers: body.fetch("answers"), model: body.fetch("model"), input_tokens: body.dig("usage", "input_tokens").to_i)
|
|
82
|
+
rescue JSON::ParserError, KeyError => error
|
|
83
|
+
raise Failure, "Jev answered something unreadable: #{error.class}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
module Jev
|
|
6
|
+
# Every question and threshold sent to Jev, in one file so they can be reviewed together
|
|
7
|
+
# (TypeSafe's agent skill). One narrow judgment per question, all asked over one state in one
|
|
8
|
+
# request. Rules (which files need the whole suite, what a test file is) stay in Rules.
|
|
9
|
+
module Questions
|
|
10
|
+
# Candidates per request: 48 Nouls and a 49-option Choice keep the state and the longest
|
|
11
|
+
# question well under jev-1.13's 32k-token bound.
|
|
12
|
+
MAX_CANDIDATES = 48
|
|
13
|
+
MAX_DIFF_CHARS = 12_000
|
|
14
|
+
MAX_TEST_NAMES = 8
|
|
15
|
+
|
|
16
|
+
# UNTUNED until `minitest-impact eval --jev` runs on labelled cases; see README, "Tuning".
|
|
17
|
+
KEEP = 0.5
|
|
18
|
+
MOST_DIRECT = 0.5
|
|
19
|
+
WHOLE_SUITE = 0.8
|
|
20
|
+
|
|
21
|
+
NONE = "none"
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def exercises(index)
|
|
26
|
+
{
|
|
27
|
+
type: "noul",
|
|
28
|
+
instructions: "Do the tests in `candidates[#{index}]` call, render or assert on something that `change` adds, edits or removes?",
|
|
29
|
+
criteria: {
|
|
30
|
+
true: "They exercise code, pages, routes, copy or data that the change modifies.",
|
|
31
|
+
false: "They test other features. A shared word in the file names is not enough."
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def most_direct(count)
|
|
37
|
+
{
|
|
38
|
+
type: "choice",
|
|
39
|
+
instructions: "Which candidate test file was written to check the behaviour that `change` modifies?",
|
|
40
|
+
criteria: Array.new(count) { |index| ["t#{index}", "The test file `candidates[#{index}]`."] }.to_h
|
|
41
|
+
.merge(NONE => "None of the candidates checks this behaviour.")
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def whole_suite
|
|
46
|
+
{
|
|
47
|
+
type: "noul",
|
|
48
|
+
instructions: "Does `change` modify something that every test depends on, such as test setup, gem versions or how the application boots?",
|
|
49
|
+
criteria: {
|
|
50
|
+
true: "Any test could fail because of this change.",
|
|
51
|
+
false: "Only tests of particular features could fail."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
module Jev
|
|
6
|
+
# Re-ranks and extends a map selection with Jev, for what the map cannot see: new files,
|
|
7
|
+
# views, config, copy, and selections too large to run in a loop. A fast word-overlap search
|
|
8
|
+
# over test paths and test names builds a shortlist; Jev reads the change once and answers one
|
|
9
|
+
# question per candidate, in one request (docs.typesafe.ai/cookbooks/rerank_typesafe).
|
|
10
|
+
#
|
|
11
|
+
# Exact map hits are never dropped: the map saw those tests run the changed code.
|
|
12
|
+
class Ranker
|
|
13
|
+
EXACT = 0.9
|
|
14
|
+
|
|
15
|
+
def initialize(client:)
|
|
16
|
+
@client = client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(picks:, changes:, intent:, repo:, map:, head:)
|
|
20
|
+
candidates = shortlist(picks, changes, intent, repo, map, head)
|
|
21
|
+
return [picks, nil] if candidates.empty?
|
|
22
|
+
|
|
23
|
+
response = @client.ask(state: state(candidates, changes, intent, repo, head), questions: questions(candidates.size))
|
|
24
|
+
[combine(picks, candidates, response.answers, map), report(response, candidates)]
|
|
25
|
+
rescue Client::Failure => error
|
|
26
|
+
[picks, { "error" => error.message }]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def shortlist(picks, changes, intent, repo, map, head)
|
|
32
|
+
chosen = picks.first(Questions::MAX_CANDIDATES).map(&:test)
|
|
33
|
+
room = Questions::MAX_CANDIDATES - chosen.size
|
|
34
|
+
return chosen if room <= 0
|
|
35
|
+
|
|
36
|
+
words = query_words(changes, intent)
|
|
37
|
+
pool = (map.test_files + repo.files(head).select { |file| Rules.test_file?(file) }).uniq - chosen
|
|
38
|
+
scored = pool.filter_map do |test|
|
|
39
|
+
overlap = (words & test_words(test, repo, head)).size
|
|
40
|
+
[test, overlap] if overlap.positive?
|
|
41
|
+
end
|
|
42
|
+
chosen + scored.sort_by { |test, overlap| [-overlap, test] }.first(room).map(&:first)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def query_words(changes, intent)
|
|
46
|
+
text = changes.map { |change| "#{change.path} #{change.added_text} #{change.removed_text}" }.join(" ") + " #{intent}"
|
|
47
|
+
words(text)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def test_words(test, repo, head)
|
|
51
|
+
words("#{test} #{test_names(test, repo, head).join(" ")}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
STOP = %w[test tests rb app the and for with that this should when from into def end do].freeze
|
|
55
|
+
|
|
56
|
+
def words(text)
|
|
57
|
+
text.downcase.scan(/[a-z][a-z0-9]{2,}/).map { |word| Rules.singularize(word) }.uniq - STOP
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_names(test, repo, head)
|
|
61
|
+
source = repo.read(test, head) || ""
|
|
62
|
+
source.scan(/^\s*test\s+["'](.+?)["']|^\s*def\s+(test_\w+)/).map { |a, b| a || b.tr("_", " ") }.first(Questions::MAX_TEST_NAMES)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def state(candidates, changes, intent, repo, head)
|
|
66
|
+
budget = Questions::MAX_DIFF_CHARS
|
|
67
|
+
change = changes.map do |c|
|
|
68
|
+
excerpt = [c.removed_text.lines.map { |l| "-#{l}" }, c.added_text.lines.map { |l| "+#{l}" }].flatten.join
|
|
69
|
+
excerpt = excerpt[0, [budget, 0].max]
|
|
70
|
+
budget -= excerpt.size
|
|
71
|
+
{ path: c.path, status: c.status.to_s, diff: excerpt }
|
|
72
|
+
end
|
|
73
|
+
{
|
|
74
|
+
intent: intent.to_s,
|
|
75
|
+
change: change,
|
|
76
|
+
candidates: candidates.map { |test| { path: test, tests: test_names(test, repo, head) } }
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def questions(count)
|
|
81
|
+
asked = Array.new(count) { |index| ["t#{index}", Questions.exercises(index)] }.to_h
|
|
82
|
+
asked.merge("most_direct" => Questions.most_direct(count), "whole_suite" => Questions.whole_suite)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def combine(picks, candidates, answers, map)
|
|
86
|
+
by_test = picks.to_h { |pick| [pick.test, pick] }
|
|
87
|
+
best = answers["most_direct"]
|
|
88
|
+
direct = best && best["choice"] != Questions::NONE && best["confidence"].to_f >= Questions::MOST_DIRECT ? best["choice"] : nil
|
|
89
|
+
|
|
90
|
+
candidates.each_with_index do |test, index|
|
|
91
|
+
noul = answers.dig("t#{index}", "noul")
|
|
92
|
+
next if noul.nil?
|
|
93
|
+
|
|
94
|
+
pick = by_test[test]
|
|
95
|
+
if pick.nil?
|
|
96
|
+
next if noul < Questions::KEEP
|
|
97
|
+
|
|
98
|
+
by_test[test] = Pick.new(test: test, score: noul * 0.8, reasons: ["Jev: exercises the change (#{noul.round(2)})"], seconds: map.seconds(test))
|
|
99
|
+
elsif pick.score < EXACT && noul < Questions::KEEP
|
|
100
|
+
by_test.delete(test)
|
|
101
|
+
else
|
|
102
|
+
pick.score = pick.score >= EXACT ? pick.score : (pick.score + noul) / 2
|
|
103
|
+
pick.reasons += ["Jev: #{noul.round(2)}"]
|
|
104
|
+
end
|
|
105
|
+
by_test[test].score = 1.0 + (by_test[test].score / 10) if by_test[test] && direct == "t#{index}"
|
|
106
|
+
end
|
|
107
|
+
by_test.values.sort_by { |pick| [-pick.score, pick.seconds || Float::INFINITY, pick.test] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def report(response, candidates)
|
|
111
|
+
{
|
|
112
|
+
"model" => response.model,
|
|
113
|
+
"input_tokens" => response.input_tokens,
|
|
114
|
+
"candidates" => candidates.size,
|
|
115
|
+
"whole_suite" => response.answers.dig("whole_suite", "noul").to_f >= Questions::WHOLE_SUITE
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module Impact
|
|
7
|
+
# The full i18n keys ("sessions.new.title", without the locale) whose lines a change touched.
|
|
8
|
+
module LocaleKeys
|
|
9
|
+
def self.at(source, lines)
|
|
10
|
+
return [] if source.nil? || lines.empty?
|
|
11
|
+
|
|
12
|
+
wanted = lines.to_set
|
|
13
|
+
keys = []
|
|
14
|
+
document = Psych.parse(source)
|
|
15
|
+
walk(document&.root, [], keys, wanted)
|
|
16
|
+
keys.uniq
|
|
17
|
+
rescue Psych::SyntaxError
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.walk(node, path, keys, wanted)
|
|
22
|
+
return unless node.is_a?(Psych::Nodes::Mapping)
|
|
23
|
+
|
|
24
|
+
node.children.each_slice(2) do |key, value|
|
|
25
|
+
next unless key.is_a?(Psych::Nodes::Scalar)
|
|
26
|
+
|
|
27
|
+
full = path + [key.value]
|
|
28
|
+
if value.is_a?(Psych::Nodes::Mapping)
|
|
29
|
+
walk(value, full, keys, wanted)
|
|
30
|
+
else
|
|
31
|
+
span = (key.start_line + 1)..(value.end_line + 1)
|
|
32
|
+
keys << full.drop(1).join(".") if span.any? { |line| wanted.include?(line) } && full.size > 1
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_class_method :walk
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Minitest
|
|
9
|
+
module Impact
|
|
10
|
+
# The coverage map: for each test file, the project lines its tests executed, plus how long it
|
|
11
|
+
# took. Stored as JSON with line numbers folded into ranges ("3-7,12"), so a Rails app's map
|
|
12
|
+
# stays a few hundred kilobytes.
|
|
13
|
+
class Map
|
|
14
|
+
FORMAT = 1
|
|
15
|
+
DEFAULT_PATH = "tmp/minitest-impact/map.json"
|
|
16
|
+
|
|
17
|
+
TestEntry = Struct.new(:seconds, :count, :files, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
attr_reader :commit, :recorded_at, :tests
|
|
20
|
+
|
|
21
|
+
def self.load(path)
|
|
22
|
+
data = JSON.parse(File.read(path))
|
|
23
|
+
raise Error, "#{path} is map format #{data["format"]}, this version reads #{FORMAT}" unless data["format"] == FORMAT
|
|
24
|
+
|
|
25
|
+
tests = data.fetch("tests").to_h do |test, entry|
|
|
26
|
+
files = entry.fetch("files").transform_values { |ranges| Ranges.parse(ranges) }
|
|
27
|
+
[test, TestEntry.new(seconds: entry["seconds"].to_f, count: entry["count"].to_i, files: files)]
|
|
28
|
+
end
|
|
29
|
+
new(commit: data["commit"], recorded_at: data["recorded_at"], tests: tests)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Folds the per-process parts a recording wrote into one map.
|
|
33
|
+
def self.merge(parts_dir, commit:)
|
|
34
|
+
tests = Hash.new { |hash, key| hash[key] = TestEntry.new(seconds: 0.0, count: 0, files: Hash.new { |h, k| h[k] = Set.new }) }
|
|
35
|
+
Dir[File.join(parts_dir, "*.ndjson")].sort.each do |part|
|
|
36
|
+
File.foreach(part) do |line|
|
|
37
|
+
record = JSON.parse(line)
|
|
38
|
+
entry = tests[record.fetch("test")]
|
|
39
|
+
entry.seconds += record["seconds"].to_f
|
|
40
|
+
entry.count += 1
|
|
41
|
+
record.fetch("lines").each { |file, lines| entry.files[file].merge(lines) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
new(commit: commit, recorded_at: Time.now.utc.iso8601, tests: tests.to_h { |test, entry| [test, freeze_entry(entry)] })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.freeze_entry(entry)
|
|
48
|
+
TestEntry.new(seconds: entry.seconds.round(3), count: entry.count, files: entry.files.to_h { |f, l| [f, l.to_a.sort] })
|
|
49
|
+
end
|
|
50
|
+
private_class_method :freeze_entry
|
|
51
|
+
|
|
52
|
+
def initialize(commit:, recorded_at:, tests:)
|
|
53
|
+
@commit = commit
|
|
54
|
+
@recorded_at = recorded_at
|
|
55
|
+
@tests = tests
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def save(path)
|
|
59
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
60
|
+
File.write(path, JSON.pretty_generate(to_h))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
{
|
|
65
|
+
"format" => FORMAT,
|
|
66
|
+
"commit" => commit,
|
|
67
|
+
"recorded_at" => recorded_at,
|
|
68
|
+
"tests" => tests.sort.to_h do |test, entry|
|
|
69
|
+
[test, { "seconds" => entry.seconds, "count" => entry.count,
|
|
70
|
+
"files" => entry.files.sort.to_h { |file, lines| [file, Ranges.dump(lines)] } }]
|
|
71
|
+
end
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_files = tests.keys
|
|
76
|
+
|
|
77
|
+
def known_file?(path) = by_file.key?(path)
|
|
78
|
+
|
|
79
|
+
# test file => executed lines of +path+
|
|
80
|
+
def tests_for(path) = by_file.fetch(path, {})
|
|
81
|
+
|
|
82
|
+
# Share of all test files that touch +path+: near 1 for a base class every test loads, near 0
|
|
83
|
+
# for a file only its own tests reach.
|
|
84
|
+
def spread(path)
|
|
85
|
+
return 0.0 if tests.empty?
|
|
86
|
+
|
|
87
|
+
tests_for(path).size.to_f / tests.size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def seconds(test) = tests[test]&.seconds
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def by_file
|
|
95
|
+
@by_file ||= tests.each_with_object(Hash.new { |h, k| h[k] = {} }) do |(test, entry), index|
|
|
96
|
+
entry.files.each { |file, lines| index[file][test] = lines }
|
|
97
|
+
end.tap { |index| index.default_proc = nil }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "1-3,7" <-> [1, 2, 3, 7]
|
|
102
|
+
module Ranges
|
|
103
|
+
def self.dump(lines)
|
|
104
|
+
lines.sort.slice_when { |a, b| b != a + 1 }.map { |run| run.size == 1 ? run.first.to_s : "#{run.first}-#{run.last}" }.join(",")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.parse(text)
|
|
108
|
+
text.split(",").flat_map do |part|
|
|
109
|
+
first, last = part.split("-").map(&:to_i)
|
|
110
|
+
last ? (first..last).to_a : [first]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Minitest
|
|
6
|
+
module Impact
|
|
7
|
+
# The methods of a Ruby file and the lines each spans, named with their namespace
|
|
8
|
+
# ("Billing::Invoice#total", "Billing::Invoice.build"). Naming a method lets a changed line in
|
|
9
|
+
# one version of a file find the same method in the version the map was recorded against,
|
|
10
|
+
# even after the lines around it moved.
|
|
11
|
+
class Methods
|
|
12
|
+
Definition = Struct.new(:name, :first_line, :last_line) do
|
|
13
|
+
def lines = (first_line..last_line)
|
|
14
|
+
# The body, without the `def` and `end` lines: those run when the file loads, not when a
|
|
15
|
+
# test calls the method. A one-line method runs on its only line.
|
|
16
|
+
def body = last_line - first_line >= 2 ? ((first_line + 1)..(last_line - 1)) : lines
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.of(source)
|
|
20
|
+
return new([]) if source.nil?
|
|
21
|
+
|
|
22
|
+
result = Prism.parse(source)
|
|
23
|
+
definitions = []
|
|
24
|
+
walk(result.value, [], definitions)
|
|
25
|
+
new(definitions)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.walk(node, namespace, out)
|
|
29
|
+
case node
|
|
30
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
31
|
+
inner = namespace + [node.constant_path.slice]
|
|
32
|
+
node.body && walk(node.body, inner, out)
|
|
33
|
+
return
|
|
34
|
+
when Prism::SingletonClassNode
|
|
35
|
+
node.body && walk(node.body, namespace + ["<<self"], out)
|
|
36
|
+
return
|
|
37
|
+
when Prism::DefNode
|
|
38
|
+
separator = node.receiver ? "." : "#"
|
|
39
|
+
separator = "." if namespace.last == "<<self"
|
|
40
|
+
owner = namespace.reject { |part| part == "<<self" }.join("::")
|
|
41
|
+
out << Definition.new("#{owner}#{separator}#{node.name}", node.location.start_line, node.location.end_line)
|
|
42
|
+
end
|
|
43
|
+
node.compact_child_nodes.each { |child| walk(child, namespace, out) }
|
|
44
|
+
end
|
|
45
|
+
private_class_method :walk
|
|
46
|
+
|
|
47
|
+
attr_reader :definitions
|
|
48
|
+
|
|
49
|
+
def initialize(definitions)
|
|
50
|
+
@definitions = definitions
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The innermost method containing +line+, or nil for class-body code.
|
|
54
|
+
def enclosing(line)
|
|
55
|
+
definitions.select { |definition| definition.lines.cover?(line) }.min_by { |definition| definition.lines.size }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def named(name) = definitions.find { |definition| definition.name == name }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
# Adds `bin/rails test:impact` to a Rails app that has the gem in its Gemfile.
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
require_relative "rake_task"
|
|
9
|
+
RakeTask.new
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require_relative "../impact"
|
|
5
|
+
require_relative "cli"
|
|
6
|
+
|
|
7
|
+
module Minitest
|
|
8
|
+
module Impact
|
|
9
|
+
# Defines `rake test:impact` (and so `bin/rails test:impact`): runs the tests the current
|
|
10
|
+
# change selects. SINCE=REV sets the base (default HEAD), INTENT="..." describes the change.
|
|
11
|
+
# When the change needs the whole suite, it runs `test` instead.
|
|
12
|
+
class RakeTask
|
|
13
|
+
include Rake::DSL
|
|
14
|
+
|
|
15
|
+
def initialize(name = "test:impact")
|
|
16
|
+
desc "Run the tests the current change is most likely to break (minitest-impact)"
|
|
17
|
+
task(name) do
|
|
18
|
+
args = ["run", "--since", ENV.fetch("SINCE", "HEAD")]
|
|
19
|
+
args += ["--intent", ENV["INTENT"]] if ENV["INTENT"]
|
|
20
|
+
status = CLI.start(args)
|
|
21
|
+
if status == CLI::WHOLE_SUITE_EXIT
|
|
22
|
+
Rake::Task["test"].invoke
|
|
23
|
+
elsif !status.zero?
|
|
24
|
+
abort
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Required through RUBYOPT by `minitest-impact record`, before the application or Bundler load, so
|
|
4
|
+
# coverage sees every project file from its first line. Minitest 6 no longer loads plugins on its
|
|
5
|
+
# own, so a TracePoint waits for Minitest::Test to be defined and wraps every test from there.
|
|
6
|
+
require_relative "recorder"
|
|
7
|
+
|
|
8
|
+
if (dir = ENV["MINITEST_IMPACT_RECORD"])
|
|
9
|
+
Minitest::Impact::Recorder.start(dir: dir, root: ENV.fetch("MINITEST_IMPACT_ROOT", Dir.pwd))
|
|
10
|
+
|
|
11
|
+
trace = TracePoint.new(:class) do |event|
|
|
12
|
+
next unless event.self.name == "Minitest::Test"
|
|
13
|
+
|
|
14
|
+
trace.disable
|
|
15
|
+
require_relative "recording_hooks"
|
|
16
|
+
event.self.prepend(Minitest::Impact::RecordingHooks)
|
|
17
|
+
end
|
|
18
|
+
trace.enable
|
|
19
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Minitest
|
|
7
|
+
module Impact
|
|
8
|
+
# Records which project lines each test executes. Loaded through RUBYOPT before the
|
|
9
|
+
# application boots (see `minitest-impact record`), so it depends on the standard library only.
|
|
10
|
+
#
|
|
11
|
+
# Each test appends one JSON line to a file named after its process, so forked parallel
|
|
12
|
+
# workers never share a file handle; `Map.merge` folds the parts into one map afterwards.
|
|
13
|
+
module Recorder
|
|
14
|
+
PARTS = "parts"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_reader :root, :dir, :mode
|
|
18
|
+
|
|
19
|
+
def start(dir:, root: Dir.pwd)
|
|
20
|
+
return if @started
|
|
21
|
+
|
|
22
|
+
@root = File.expand_path(root)
|
|
23
|
+
@dir = File.expand_path(dir)
|
|
24
|
+
Dir.mkdir(File.join(@dir, PARTS)) unless Dir.exist?(File.join(@dir, PARTS))
|
|
25
|
+
@mode = begin_coverage
|
|
26
|
+
@started = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def started? = @started == true
|
|
30
|
+
|
|
31
|
+
def before_test
|
|
32
|
+
if mode == :clear
|
|
33
|
+
Coverage.result(stop: false, clear: true)
|
|
34
|
+
else
|
|
35
|
+
@baseline = Coverage.peek_result
|
|
36
|
+
end
|
|
37
|
+
@clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def after_test(test_file:, name:)
|
|
41
|
+
seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @clock
|
|
42
|
+
lines = executed_lines
|
|
43
|
+
write(test: relative(test_file), name: name, seconds: seconds.round(4), lines: lines)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Judged on the path inside the project: a project that itself lives under a `tmp`
|
|
47
|
+
# directory (Linux's temporary directories, some CI workspaces) must still be recorded.
|
|
48
|
+
def project_file?(path)
|
|
49
|
+
path.start_with?("#{root}/") &&
|
|
50
|
+
!"/#{relative(path)}".match?(%r{/(vendor|tmp|node_modules)/})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def relative(path) = path.delete_prefix("#{root}/")
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Coverage can be set up once per process. When another tool (SimpleCov) got there first,
|
|
58
|
+
# clearing its counters would corrupt its report, so each test is measured as the
|
|
59
|
+
# difference between two snapshots instead: slower, but harmless to the other tool.
|
|
60
|
+
def begin_coverage
|
|
61
|
+
if Coverage.running?
|
|
62
|
+
:peek
|
|
63
|
+
else
|
|
64
|
+
Coverage.setup(lines: true, eval: true)
|
|
65
|
+
Coverage.resume
|
|
66
|
+
:clear
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def executed_lines
|
|
71
|
+
now = mode == :clear ? Coverage.result(stop: false, clear: true) : Coverage.peek_result
|
|
72
|
+
now.each_with_object({}) do |(path, data), acc|
|
|
73
|
+
next unless project_file?(path)
|
|
74
|
+
|
|
75
|
+
counts = data.is_a?(Hash) ? data[:lines] : data
|
|
76
|
+
next unless counts
|
|
77
|
+
|
|
78
|
+
before = mode == :peek ? line_counts(@baseline[path]) : nil
|
|
79
|
+
hit = []
|
|
80
|
+
counts.each_with_index do |count, index|
|
|
81
|
+
next unless count&.positive?
|
|
82
|
+
next if before && before[index].to_i >= count
|
|
83
|
+
|
|
84
|
+
hit << index + 1
|
|
85
|
+
end
|
|
86
|
+
acc[relative(path)] = hit unless hit.empty?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def line_counts(data)
|
|
91
|
+
return [] unless data
|
|
92
|
+
|
|
93
|
+
data.is_a?(Hash) ? (data[:lines] || []) : data
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def write(record)
|
|
97
|
+
File.open(File.join(dir, PARTS, "#{Process.pid}.ndjson"), "a") do |file|
|
|
98
|
+
file.puts(JSON.generate(record))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Impact
|
|
5
|
+
# Prepended to Minitest::Test while recording, so the measurement starts before every other
|
|
6
|
+
# before_setup (fixtures included) and ends after every other after_teardown.
|
|
7
|
+
module RecordingHooks
|
|
8
|
+
def before_setup
|
|
9
|
+
Recorder.before_test
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def after_teardown
|
|
14
|
+
super
|
|
15
|
+
ensure
|
|
16
|
+
file = self.class.instance_method(name).source_location&.first
|
|
17
|
+
Recorder.after_test(test_file: file, name: "#{self.class.name}##{name}") if file
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|