test_impact 0.2.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 +541 -0
- data/exe/test-impact +6 -0
- data/lib/test_impact/cli.rb +156 -0
- data/lib/test_impact/collector/coverage_backend.rb +47 -0
- data/lib/test_impact/collector/ddcov_backend.rb +103 -0
- data/lib/test_impact/collector/null_backend.rb +22 -0
- data/lib/test_impact/config.rb +62 -0
- data/lib/test_impact/frameworks/rspec.rb +28 -0
- data/lib/test_impact/git.rb +125 -0
- data/lib/test_impact/map.rb +124 -0
- data/lib/test_impact/map_serializer.rb +88 -0
- data/lib/test_impact/path_matcher.rb +35 -0
- data/lib/test_impact/paths.rb +39 -0
- data/lib/test_impact/plan_result.rb +36 -0
- data/lib/test_impact/planner.rb +224 -0
- data/lib/test_impact/recorder.rb +112 -0
- data/lib/test_impact/version.rb +5 -0
- data/lib/test_impact.rb +28 -0
- data/skills/impacted-specs/SKILL.md +80 -0
- data/test_impact.gemspec +34 -0
- metadata +98 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module TestImpact
|
|
9
|
+
# Reads and writes Map as gzip-compressed JSON. Unreadable payloads are
|
|
10
|
+
# raised as MapFormatError / SchemaVersionError so callers can fall back.
|
|
11
|
+
module MapSerializer
|
|
12
|
+
class << self
|
|
13
|
+
def dump(map, io_or_path)
|
|
14
|
+
bytes = dump_bytes(map)
|
|
15
|
+
if io_or_path.is_a?(String)
|
|
16
|
+
File.binwrite(io_or_path, bytes)
|
|
17
|
+
else
|
|
18
|
+
io_or_path.write(bytes)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load(io_or_path)
|
|
23
|
+
bytes =
|
|
24
|
+
if io_or_path.is_a?(String)
|
|
25
|
+
File.binread(io_or_path)
|
|
26
|
+
else
|
|
27
|
+
io_or_path.read
|
|
28
|
+
end
|
|
29
|
+
load_bytes(bytes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dump_bytes(map)
|
|
33
|
+
payload = {
|
|
34
|
+
'schema_version' => map.schema_version,
|
|
35
|
+
'generated_at' => to_iso8601(map.generated_at),
|
|
36
|
+
'commit_sha' => map.commit_sha,
|
|
37
|
+
'branch' => map.branch,
|
|
38
|
+
'collector' => map.collector,
|
|
39
|
+
'known_spec_files' => map.known_spec_files.to_a.sort,
|
|
40
|
+
'index' => map.index.transform_values { |specs| specs.to_a.sort },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
io = StringIO.new
|
|
44
|
+
io.set_encoding(Encoding::BINARY)
|
|
45
|
+
gz = Zlib::GzipWriter.new(io)
|
|
46
|
+
gz.write(JSON.generate(payload))
|
|
47
|
+
gz.close
|
|
48
|
+
io.string
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load_bytes(bytes)
|
|
52
|
+
io = StringIO.new(bytes)
|
|
53
|
+
gz = Zlib::GzipReader.new(io)
|
|
54
|
+
json = gz.read
|
|
55
|
+
gz.close
|
|
56
|
+
|
|
57
|
+
build_map(JSON.parse(json))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_map(data)
|
|
63
|
+
if data['schema_version'] != Map::SCHEMA_VERSION
|
|
64
|
+
raise SchemaVersionError, "unsupported schema_version: #{data['schema_version'].inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Map.new(
|
|
68
|
+
schema_version: data['schema_version'],
|
|
69
|
+
generated_at: Time.parse(data['generated_at']),
|
|
70
|
+
commit_sha: data['commit_sha'],
|
|
71
|
+
branch: data['branch'],
|
|
72
|
+
collector: data['collector'],
|
|
73
|
+
known_spec_files: data['known_spec_files'],
|
|
74
|
+
index: data['index']
|
|
75
|
+
)
|
|
76
|
+
rescue TypeError, NoMethodError, ArgumentError => e
|
|
77
|
+
# Missing or malformed fields (nil generated_at, non-hash index, ...)
|
|
78
|
+
raise MapFormatError, "malformed map payload: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_iso8601(generated_at)
|
|
82
|
+
time = generated_at.is_a?(String) ? Time.parse(generated_at) : generated_at
|
|
83
|
+
# getutc (not utc/gmtime) so the caller's Time object is not mutated
|
|
84
|
+
time.getutc.iso8601
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestImpact
|
|
4
|
+
# The single source of pattern matching semantics for the config keys that
|
|
5
|
+
# take patterns. global_files, always_run and ignore are pure globs;
|
|
6
|
+
# collector.ignored_paths also accepts a plain path prefix (see
|
|
7
|
+
# prefix_or_glob_match?).
|
|
8
|
+
module PathMatcher
|
|
9
|
+
# FNM_PATHNAME is required for "**" to match across directory levels.
|
|
10
|
+
# FNM_DOTMATCH lets "**/*.yml" pick up dotfiles such as ".rubocop.yml".
|
|
11
|
+
FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
|
|
12
|
+
|
|
13
|
+
# Glob metacharacters; a pattern without any of them is a plain literal.
|
|
14
|
+
GLOB_CHARS = /[*?\[\]{}]/
|
|
15
|
+
|
|
16
|
+
def self.match?(pattern, path)
|
|
17
|
+
File.fnmatch?(pattern, path, FLAGS)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.any_match?(patterns, path)
|
|
21
|
+
Array(patterns).any? { |pattern| match?(pattern, path) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.glob?(pattern)
|
|
25
|
+
GLOB_CHARS.match?(pattern)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# collector.ignored_paths predates glob support, so a pattern without
|
|
29
|
+
# metacharacters keeps its original "path prefix" meaning: the default
|
|
30
|
+
# 'vendor/' would match nothing as a glob.
|
|
31
|
+
def self.prefix_or_glob_match?(pattern, path)
|
|
32
|
+
glob?(pattern) ? match?(pattern, path) : path.start_with?(pattern)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module TestImpact
|
|
7
|
+
# Resolves the repository root and converts between absolute paths and the
|
|
8
|
+
# repo-relative paths the map is keyed by.
|
|
9
|
+
module Paths
|
|
10
|
+
class << self
|
|
11
|
+
def repo_root
|
|
12
|
+
@repo_root ||=
|
|
13
|
+
begin
|
|
14
|
+
stdout, status = Open3.capture2('git', 'rev-parse', '--show-toplevel')
|
|
15
|
+
status.success? ? stdout.strip : Dir.pwd
|
|
16
|
+
rescue StandardError
|
|
17
|
+
Dir.pwd
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def relative(abs_path)
|
|
22
|
+
Pathname.new(abs_path).relative_path_from(root_pathname).to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def absolute(rel_path)
|
|
26
|
+
File.expand_path(rel_path, repo_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Cached per repo_root value so stubs and re-memoization stay consistent.
|
|
32
|
+
def root_pathname
|
|
33
|
+
root = repo_root
|
|
34
|
+
@root_pathname = Pathname.new(root) if @root_pathname.nil? || @root_pathname.to_s != root
|
|
35
|
+
@root_pathname
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestImpact
|
|
4
|
+
# Outcome of planning: run everything (:all, with a reason), a selected set
|
|
5
|
+
# of specs (:partial), or nothing at all (:none).
|
|
6
|
+
class PlanResult
|
|
7
|
+
attr_reader :mode, :spec_files, :reason
|
|
8
|
+
|
|
9
|
+
def self.all(reason)
|
|
10
|
+
new(mode: :all, spec_files: [], reason:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.partial(spec_files)
|
|
14
|
+
spec_files = spec_files.to_a.uniq.sort
|
|
15
|
+
new(mode: spec_files.empty? ? :none : :partial, spec_files:, reason: nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(mode:, spec_files:, reason:)
|
|
19
|
+
@mode = mode
|
|
20
|
+
@spec_files = spec_files
|
|
21
|
+
@reason = reason
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ==(other)
|
|
25
|
+
other.is_a?(PlanResult) &&
|
|
26
|
+
mode == other.mode &&
|
|
27
|
+
spec_files == other.spec_files &&
|
|
28
|
+
reason == other.reason
|
|
29
|
+
end
|
|
30
|
+
alias eql? ==
|
|
31
|
+
|
|
32
|
+
def hash
|
|
33
|
+
[mode, spec_files, reason].hash
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestImpact
|
|
4
|
+
# Classifies the files changed since the merge-base and turns them into a
|
|
5
|
+
# PlanResult, degrading to a full run whenever the map cannot be trusted.
|
|
6
|
+
# The classify_* methods all serve the single job of deciding what a changed
|
|
7
|
+
# file implies, so splitting them out would scatter one decision across classes.
|
|
8
|
+
class Planner # rubocop:disable Metrics/ClassLength
|
|
9
|
+
IGNORABLE_EXTENSIONS = ['.md', '.txt', '.adoc'].freeze
|
|
10
|
+
# Ruby sources and view templates. ActionView compiles templates under
|
|
11
|
+
# their absolute path, so DDCov records them like any other source file.
|
|
12
|
+
TRACKED_EXTENSIONS = ['.rb', '.erb', '.haml', '.slim', '.jbuilder'].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(map:, config:, git: Git.new)
|
|
15
|
+
@map = map
|
|
16
|
+
@config = config
|
|
17
|
+
@git = git
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def plan(base: nil, include_uncommitted: false)
|
|
21
|
+
changed = changed_files_or_reason(base || config.base, include_uncommitted:)
|
|
22
|
+
return PlanResult.all(changed) if changed.is_a?(String)
|
|
23
|
+
|
|
24
|
+
spec_files = Set.new
|
|
25
|
+
all_reason = nil
|
|
26
|
+
|
|
27
|
+
changed.each do |change|
|
|
28
|
+
all_reason = classify(change, spec_files)
|
|
29
|
+
break if all_reason
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return PlanResult.all(all_reason) if all_reason
|
|
33
|
+
|
|
34
|
+
apply_always_run(spec_files)
|
|
35
|
+
spec_files.select! { |path| File.exist?(Paths.absolute(path)) }
|
|
36
|
+
|
|
37
|
+
PlanResult.partial(spec_files)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :map, :config, :git
|
|
43
|
+
|
|
44
|
+
# Returns the changed files for base_ref, or a String reason when the diff
|
|
45
|
+
# cannot be trusted and the caller must degrade to a full run.
|
|
46
|
+
def changed_files_or_reason(base_ref, include_uncommitted:)
|
|
47
|
+
return 'no map available or backend invalid' if invalid_map?
|
|
48
|
+
|
|
49
|
+
stale_reason = staleness_reason(base_ref)
|
|
50
|
+
return stale_reason if stale_reason
|
|
51
|
+
|
|
52
|
+
merge_base_sha = git.merge_base(base_ref)
|
|
53
|
+
return 'could not compute merge-base (shallow clone? try fetch-depth: 0 in checkout)' unless merge_base_sha
|
|
54
|
+
|
|
55
|
+
git.changed_files(merge_base_sha, include_uncommitted:) ||
|
|
56
|
+
"could not compute git diff against #{merge_base_sha}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invalid_map?
|
|
60
|
+
map.nil? || map.empty? || !map.valid_backend?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def staleness_reason(base_ref)
|
|
64
|
+
if map.generated_at < (Time.now - (config.max_age_days * 86_400))
|
|
65
|
+
return "map is older than max_age_days (#{config.max_age_days})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
unless git.in_history?(map.commit_sha, base_ref)
|
|
69
|
+
return "map commit_sha #{map.commit_sha} not found in #{base_ref} history"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns an "all" reason string if this change forces a full run, otherwise nil
|
|
76
|
+
# (and mutates spec_files as a side effect).
|
|
77
|
+
def classify(change, spec_files)
|
|
78
|
+
# ignore runs first so it can subtract from global_files (carving
|
|
79
|
+
# config/locales/** out of the default config/**/*). Suppressing the
|
|
80
|
+
# "unknown file type" fallback alone would only need it in
|
|
81
|
+
# classify_other; winning over global_files is what buys the top slot,
|
|
82
|
+
# and what makes the rename rule below necessary.
|
|
83
|
+
return nil if ignored_change?(change)
|
|
84
|
+
|
|
85
|
+
path = change[:path]
|
|
86
|
+
|
|
87
|
+
global_reason = global_change_reason(change)
|
|
88
|
+
return global_reason if global_reason
|
|
89
|
+
|
|
90
|
+
return classify_as_spec(change, spec_files) if spec_file?(path)
|
|
91
|
+
|
|
92
|
+
# A file renamed away from a tracked path still carries its old
|
|
93
|
+
# coverage, so the old extension counts too — its dependent specs must
|
|
94
|
+
# not be silently dropped.
|
|
95
|
+
if tracked_file?(path) || (change[:old_path] && tracked_file?(change[:old_path]))
|
|
96
|
+
return classify_tracked(change, spec_files)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
classify_other(change)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Unlike global_change_reason, which reacts to either side of a rename,
|
|
103
|
+
# a rename is only ignored when BOTH sides match. Ignoring
|
|
104
|
+
# "R lib/foo.rb -> docs/foo.rb" on a docs/**/* pattern would silently drop
|
|
105
|
+
# the specs the old path still covers: ignore asserts that a file has no
|
|
106
|
+
# impact, not that its history had none.
|
|
107
|
+
def ignored_change?(change)
|
|
108
|
+
return false unless ignored_file?(change[:path])
|
|
109
|
+
|
|
110
|
+
old_path = change[:old_path]
|
|
111
|
+
old_path.nil? || ignored_file?(old_path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A rename away from a global location is still a change to that
|
|
115
|
+
# global file — it must not slip past the safeguard.
|
|
116
|
+
def global_change_reason(change)
|
|
117
|
+
return "global file changed: #{change[:path]}" if global_file?(change[:path])
|
|
118
|
+
return "global file changed: #{change[:old_path]}" if change[:old_path] && global_file?(change[:old_path])
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A source file renamed into a spec path still carries its old coverage —
|
|
124
|
+
# pull its dependents (or fall back) in addition to scheduling the new
|
|
125
|
+
# spec itself.
|
|
126
|
+
def classify_as_spec(change, spec_files)
|
|
127
|
+
classify_spec(change, spec_files)
|
|
128
|
+
|
|
129
|
+
old_path = change[:old_path]
|
|
130
|
+
return nil unless old_path && tracked_file?(old_path) && !spec_file?(old_path)
|
|
131
|
+
|
|
132
|
+
classify_renamed_tracked(change, spec_files)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Spec files are never indexed as coverage sources (Recorder skips spec/),
|
|
136
|
+
# so a changed spec only schedules itself. Non-spec helpers under spec/
|
|
137
|
+
# (support files etc.) fall through to the uncovered-file fallback instead.
|
|
138
|
+
def classify_spec(change, spec_files)
|
|
139
|
+
case change[:status]
|
|
140
|
+
when 'A', 'M', 'R'
|
|
141
|
+
spec_files << change[:path]
|
|
142
|
+
when 'D'
|
|
143
|
+
# excluded: do nothing
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def classify_tracked(change, spec_files)
|
|
150
|
+
case change[:status]
|
|
151
|
+
when 'R'
|
|
152
|
+
classify_renamed_tracked(change, spec_files)
|
|
153
|
+
else # "A", "M", "D" — an uncovered file always forces a full run
|
|
154
|
+
pull_covered_specs_or_fallback(change[:path], spec_files)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The map predates the rename, so the old path carries the known
|
|
159
|
+
# dependents; a covered old path must not force a full run just because
|
|
160
|
+
# the new name is absent from the map.
|
|
161
|
+
def classify_renamed_tracked(change, spec_files)
|
|
162
|
+
covered_old = map&.covered?(change[:old_path])
|
|
163
|
+
spec_files.merge(map.specs_for(change[:old_path])) if covered_old
|
|
164
|
+
|
|
165
|
+
if map&.covered?(change[:path])
|
|
166
|
+
spec_files.merge(map.specs_for(change[:path]))
|
|
167
|
+
nil
|
|
168
|
+
elsif covered_old
|
|
169
|
+
nil
|
|
170
|
+
else
|
|
171
|
+
"uncovered file changed: #{change[:path]}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def pull_covered_specs_or_fallback(path, spec_files)
|
|
176
|
+
if map&.covered?(path)
|
|
177
|
+
spec_files.merge(map.specs_for(path))
|
|
178
|
+
nil
|
|
179
|
+
else
|
|
180
|
+
"uncovered file changed: #{path}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def classify_other(change)
|
|
185
|
+
path = change[:path]
|
|
186
|
+
ext = File.extname(path)
|
|
187
|
+
|
|
188
|
+
return nil if IGNORABLE_EXTENSIONS.include?(ext)
|
|
189
|
+
|
|
190
|
+
"unknown file type changed: #{path}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def apply_always_run(spec_files)
|
|
194
|
+
return if config.always_run.empty?
|
|
195
|
+
|
|
196
|
+
candidates = Set.new(spec_files)
|
|
197
|
+
candidates.merge(map.known_spec_files) if map
|
|
198
|
+
|
|
199
|
+
candidates.each do |spec_path|
|
|
200
|
+
next unless PathMatcher.any_match?(config.always_run, spec_path)
|
|
201
|
+
next unless File.exist?(Paths.absolute(spec_path))
|
|
202
|
+
|
|
203
|
+
spec_files << spec_path
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def global_file?(path)
|
|
208
|
+
PathMatcher.any_match?(config.global_files, path)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def ignored_file?(path)
|
|
212
|
+
PathMatcher.any_match?(config.ignore, path)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def spec_file?(path)
|
|
216
|
+
path.end_with?('_spec.rb') && path.start_with?('spec/')
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A file that can appear as a key in the coverage map.
|
|
220
|
+
def tracked_file?(path)
|
|
221
|
+
TRACKED_EXTENSIONS.include?(File.extname(path))
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'socket'
|
|
6
|
+
|
|
7
|
+
require 'test_impact/paths'
|
|
8
|
+
require 'test_impact/path_matcher'
|
|
9
|
+
require 'test_impact/git'
|
|
10
|
+
require 'test_impact/map'
|
|
11
|
+
require 'test_impact/map_serializer'
|
|
12
|
+
|
|
13
|
+
module TestImpact
|
|
14
|
+
# Drives the coverage backend around each example and writes this process's
|
|
15
|
+
# results to a part-*.json.gz file for `test-impact merge` to combine.
|
|
16
|
+
class Recorder
|
|
17
|
+
DEFAULT_PART_DIR = 'tmp/test_impact'
|
|
18
|
+
|
|
19
|
+
attr_reader :backend, :config, :index, :known_spec_files
|
|
20
|
+
|
|
21
|
+
def initialize(backend:, config:)
|
|
22
|
+
@backend = backend
|
|
23
|
+
@config = config
|
|
24
|
+
@index = {}
|
|
25
|
+
@known_spec_files = Set.new
|
|
26
|
+
@written = false
|
|
27
|
+
@ignored_paths = Array(config.collector['ignored_paths'])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def start_example
|
|
31
|
+
backend.start
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finish_example(spec_abs_path)
|
|
35
|
+
covered = backend.stop || {}
|
|
36
|
+
spec_rel = spec_abs_path && Paths.relative(spec_abs_path)
|
|
37
|
+
return unless inside_repo?(spec_rel)
|
|
38
|
+
|
|
39
|
+
covered.each_key { |abs| record_covered(abs, spec_rel) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record_known_spec_files(paths)
|
|
43
|
+
Array(paths).each do |path|
|
|
44
|
+
rel = Paths.relative(path)
|
|
45
|
+
next unless inside_repo?(rel)
|
|
46
|
+
|
|
47
|
+
@known_spec_files << rel
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_part(dir = ENV.fetch('TEST_IMPACT_PART_DIR', DEFAULT_PART_DIR))
|
|
52
|
+
return if @written
|
|
53
|
+
|
|
54
|
+
FileUtils.mkdir_p(dir)
|
|
55
|
+
|
|
56
|
+
# commit_sha is a hard requirement for the plan side: Planner treats a
|
|
57
|
+
# map whose commit is no longer reachable from the base ref as stale
|
|
58
|
+
# (force-push detection). branch is informational (info command).
|
|
59
|
+
git = Git.new
|
|
60
|
+
map = Map.build(
|
|
61
|
+
commit_sha: git.head_sha,
|
|
62
|
+
branch: git.head_branch,
|
|
63
|
+
collector: collector_metadata,
|
|
64
|
+
known_spec_files: @known_spec_files,
|
|
65
|
+
index: @index
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
path = File.join(dir, part_filename)
|
|
69
|
+
MapSerializer.dump(map, path)
|
|
70
|
+
# Only flip after a successful write so the at_exit fallback can retry
|
|
71
|
+
# when the after(:suite) attempt fails midway.
|
|
72
|
+
@written = true
|
|
73
|
+
path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def record_covered(abs, spec_rel)
|
|
79
|
+
rel = Paths.relative(abs)
|
|
80
|
+
return unless inside_repo?(rel)
|
|
81
|
+
return if spec_file?(rel, spec_rel)
|
|
82
|
+
return if ignored?(rel)
|
|
83
|
+
|
|
84
|
+
(@index[rel] ||= Set.new) << spec_rel
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collector_metadata
|
|
88
|
+
{
|
|
89
|
+
'backend' => backend.name,
|
|
90
|
+
'allocation_tracing' => config.collector['allocation_tracing'] ? true : false,
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def part_filename
|
|
95
|
+
"part-#{Process.pid}-#{Socket.gethostname}-#{SecureRandom.hex(4)}.json.gz"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inside_repo?(rel)
|
|
99
|
+
!rel.nil? && !rel.start_with?('..')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ignored?(rel)
|
|
103
|
+
@ignored_paths.any? { |pattern| PathMatcher.prefix_or_glob_match?(pattern, rel) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# spec/ files are intentionally never indexed as sources; Planner relies on
|
|
107
|
+
# this contract (changed specs run themselves, other spec/ files fall back).
|
|
108
|
+
def spec_file?(rel, spec_rel)
|
|
109
|
+
rel == spec_rel || rel.start_with?('spec/')
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/test_impact.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_impact/version'
|
|
4
|
+
require 'test_impact/paths'
|
|
5
|
+
require 'test_impact/path_matcher'
|
|
6
|
+
require 'test_impact/config'
|
|
7
|
+
require 'test_impact/map'
|
|
8
|
+
require 'test_impact/map_serializer'
|
|
9
|
+
|
|
10
|
+
# Test Impact Analysis for Ruby: records which source files each spec touches,
|
|
11
|
+
# then plans a minimal spec set from a git diff.
|
|
12
|
+
module TestImpact
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class SchemaVersionError < Error; end
|
|
15
|
+
class MapFormatError < Error; end
|
|
16
|
+
class CoverageUnavailableError < Error; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_accessor :recorder
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require 'test_impact/collector/coverage_backend'
|
|
24
|
+
require 'test_impact/recorder'
|
|
25
|
+
require 'test_impact/git'
|
|
26
|
+
require 'test_impact/plan_result'
|
|
27
|
+
require 'test_impact/planner'
|
|
28
|
+
require 'test_impact/cli'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: impacted-specs
|
|
3
|
+
description: Use when you want to find which RSpec specs to run for locally changed code, including uncommitted changes. Refreshes the coverage map produced by the test_impact gem's GitHub Actions collect workflow, then runs `test-impact plan` to narrow the run down to the impacted specs. Triggers on requests like "run only the tests related to my changes", "which specs does this diff affect", "figure out the impacted specs", or "what should I run for the current changes".
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# impacted-specs
|
|
8
|
+
|
|
9
|
+
This skill runs inside a **project that consumes the test_impact gem**. It determines which specs
|
|
10
|
+
to run from local changes, including uncommitted ones.
|
|
11
|
+
|
|
12
|
+
Prerequisites:
|
|
13
|
+
- `gh` CLI is authenticated
|
|
14
|
+
- The consuming project has the `test_impact` gem in its `Gemfile`
|
|
15
|
+
- That project runs a collect workflow in GitHub Actions that uploads the coverage map as an
|
|
16
|
+
artifact named `test-impact-map`
|
|
17
|
+
|
|
18
|
+
## Steps
|
|
19
|
+
|
|
20
|
+
### 1. Refresh the base
|
|
21
|
+
|
|
22
|
+
Check `base` in `.test_impact.yml` (defaults to `origin/main`) and fetch the corresponding remote
|
|
23
|
+
branch:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git fetch origin main
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Why: if the local `origin/main` is stale, the map's `commit_sha` fails the history reachability
|
|
30
|
+
check and the plan degrades to a full run (`all`).
|
|
31
|
+
|
|
32
|
+
### 2. Download the map (every time, auto-discovered)
|
|
33
|
+
|
|
34
|
+
Do not judge freshness even if a local map exists — **always download the latest artifact and
|
|
35
|
+
overwrite**. Do not depend on a specific workflow name:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
run_id=$(gh api 'repos/{owner}/{repo}/actions/artifacts?name=test-impact-map&per_page=1' \
|
|
39
|
+
--jq '.artifacts[] | select(.expired == false) | .workflow_run.id' | head -1)
|
|
40
|
+
rm -f .test_impact/map.json.gz
|
|
41
|
+
gh run download "$run_id" -n test-impact-map -D .test_impact
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Notes:
|
|
45
|
+
- Test `expired` with an explicit `== false` comparison (`//` treats `false` as falsy and falls
|
|
46
|
+
back to the right-hand side, so an `enabled // true` style check misjudges it).
|
|
47
|
+
- `gh run download` fails when a file of the same name already exists in the destination, so
|
|
48
|
+
remove it first with `rm -f`.
|
|
49
|
+
- If `run_id` comes back empty (no artifact found), report to the user that the collect workflow
|
|
50
|
+
is not set up and stop here.
|
|
51
|
+
|
|
52
|
+
### 3. Run plan
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
bundle exec test-impact plan --format json --include-uncommitted > /tmp/plan.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--format json` always exits 0. stdout is a single line of JSON:
|
|
59
|
+
`{"mode":"all|partial|none","spec_files":[...],"reason":null|"..."}`.
|
|
60
|
+
Diagnostics (mode / reason / spec_files count, etc.) go to stderr.
|
|
61
|
+
|
|
62
|
+
### 4. Interpret the result
|
|
63
|
+
|
|
64
|
+
- `mode: "partial"` → run only the selected specs:
|
|
65
|
+
```sh
|
|
66
|
+
bundle exec rspec $(jq -r '.spec_files[]' /tmp/plan.json)
|
|
67
|
+
```
|
|
68
|
+
- `mode: "none"` → report to the user that there are no specs to run.
|
|
69
|
+
- `mode: "all"` → **do not run the full suite on your own.** Report that a full run is required
|
|
70
|
+
along with `reason`, and ask the user how to proceed. Common `reason` values:
|
|
71
|
+
- `uncovered file changed: <path>` — a new or uncollected file was changed
|
|
72
|
+
- `no map available or backend invalid` — the map is missing or unusable
|
|
73
|
+
|
|
74
|
+
## Caveats
|
|
75
|
+
|
|
76
|
+
- If `.test_impact/` is not in the consuming project's `.gitignore`, the map itself is treated as
|
|
77
|
+
an untracked new file and triggers a full run.
|
|
78
|
+
- In a shallow clone, merge-base computation fails and the plan degrades to a full run.
|
|
79
|
+
- `--include-uncommitted` includes staged / unstaged / untracked changes in the diff, but never
|
|
80
|
+
modifies repository state (read-only).
|
data/test_impact.gemspec
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/test_impact/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'test_impact'
|
|
7
|
+
spec.version = TestImpact::VERSION
|
|
8
|
+
spec.authors = ['aki77']
|
|
9
|
+
|
|
10
|
+
spec.summary = 'Test Impact Analysis for Ruby without Datadog backend'
|
|
11
|
+
spec.description = "Collects per-test coverage via datadog-ci's native extension " \
|
|
12
|
+
'and selects impacted specs from git diff.'
|
|
13
|
+
spec.homepage = 'https://github.com/aki77/test_impact_analysis'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.3'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
20
|
+
|
|
21
|
+
spec.files =
|
|
22
|
+
Dir.chdir(__dir__) do
|
|
23
|
+
# The `-ja` skill variants are for repo contributors only; the gem ships the English ones.
|
|
24
|
+
`git ls-files -z -- lib exe skills README.md LICENSE.txt test_impact.gemspec`
|
|
25
|
+
.split("\x0")
|
|
26
|
+
.grep_v(%r{\Askills/.*-ja\.md\z})
|
|
27
|
+
end
|
|
28
|
+
spec.bindir = 'exe'
|
|
29
|
+
spec.executables = ['test-impact']
|
|
30
|
+
spec.require_paths = ['lib']
|
|
31
|
+
|
|
32
|
+
spec.add_dependency 'datadog-ci', '>= 1.20', '< 2.0'
|
|
33
|
+
spec.add_dependency 'thor', '~> 1.3'
|
|
34
|
+
end
|