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,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