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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a457c5238b9e9123a01e2c38014782ea26abf3e2fba51d382777c8bc0289641c
|
|
4
|
+
data.tar.gz: 60976ee502955466ef52f3f858f4cdea1c7dccfcff9f8c5f35635e058efc296e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ec3bda5b23df69c5a2b3c66bc64c51ba65f380eec4ed58d4f43bf4cc2c9910c6522944451c4334444f113170f083944b597ecbd569c654b19de4ff70572e66cd
|
|
7
|
+
data.tar.gz: 470373a12c82f937d4352e3c2905b5b7ea42d8ed2dc8637181306021dbdb54fe9c5bc605c3fef9605b206b249a2eeb17d74f67d0420af6fe1a800504a5d1b9cf
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aki77
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
# test_impact
|
|
2
|
+
|
|
3
|
+
Test Impact Analysis for Ruby — without a Datadog backend.
|
|
4
|
+
|
|
5
|
+
`test_impact` runs only the RSpec files affected by your change, using a
|
|
6
|
+
per-test coverage map built from real coverage data (not heuristics). It is
|
|
7
|
+
inspired by [Datadog CI Visibility's Test Impact
|
|
8
|
+
Analysis](https://docs.datadoghq.com/tests/test_impact_analysis/), but stores
|
|
9
|
+
the coverage map in your own infrastructure — a GitHub Actions Artifact — so
|
|
10
|
+
it works without a Datadog subscription.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
13
|
+
|
|
14
|
+
Coverage is collected using [datadog-ci](https://github.com/DataDog/datadog-ci-rb)'s
|
|
15
|
+
native `DDCov` extension, used standalone (no `Datadog.configure`, no agent,
|
|
16
|
+
no Datadog account required). For each example, `test_impact` records which
|
|
17
|
+
source files were exercised and aggregates that into a **file-level**
|
|
18
|
+
coverage map: source file → set of spec files that cover it.
|
|
19
|
+
|
|
20
|
+
The system has two layers:
|
|
21
|
+
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart TB
|
|
24
|
+
subgraph collect["Collect (main, full suite)"]
|
|
25
|
+
direction TB
|
|
26
|
+
A["push to main"] --> B["RSpec + DDCov<br/>TEST_IMPACT_COLLECT=1"]
|
|
27
|
+
B --> C["part-*.json.gz"]
|
|
28
|
+
C -->|"test-impact merge"| D["map.json.gz"]
|
|
29
|
+
end
|
|
30
|
+
D -->|"upload-artifact"| E(["Artifact"])
|
|
31
|
+
E -->|"download-artifact"| F
|
|
32
|
+
subgraph select["Select (pull request)"]
|
|
33
|
+
direction TB
|
|
34
|
+
F["map.json.gz"] --> G["test-impact plan"]
|
|
35
|
+
G --> H["impacted spec files"]
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- **Collect**: on every full run on `main` (or a merge queue), RSpec runs with
|
|
40
|
+
coverage collection turned on. Each CI node writes a partial map
|
|
41
|
+
(`part-*.json.gz`); a merge job combines them into a single map and saves
|
|
42
|
+
it as a GitHub Actions Artifact.
|
|
43
|
+
- **Select**: on a pull request, the CI downloads the latest map artifact,
|
|
44
|
+
diffs the PR branch against its merge-base, and asks `test_impact` which
|
|
45
|
+
spec files are impacted. Only those specs run.
|
|
46
|
+
|
|
47
|
+
Because collection only happens on the already-required full run on `main`,
|
|
48
|
+
PR builds pay **zero extra overhead** for coverage collection — they only pay
|
|
49
|
+
the cost of `test-impact plan`, which is a git diff plus a hash lookup.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Ruby >= 3.3
|
|
54
|
+
- RSpec
|
|
55
|
+
- git
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
Add to the `:test` group of your `Gemfile`:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
group :test do
|
|
63
|
+
gem "test_impact"
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
bundle install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Setup
|
|
74
|
+
|
|
75
|
+
Add one line to `spec/spec_helper.rb` (before `RSpec.configure` is fine, order
|
|
76
|
+
relative to other requires doesn't matter):
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require "test_impact/frameworks/rspec"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This line is a **complete no-op** unless `TEST_IMPACT_COLLECT=1` is set in the
|
|
83
|
+
environment. When it's not set, the file returns immediately and does not
|
|
84
|
+
register any RSpec hooks, so there is no overhead and no behavior change for
|
|
85
|
+
local development or PR test runs.
|
|
86
|
+
|
|
87
|
+
When `TEST_IMPACT_COLLECT=1` **is** set (typically only in the "collect on
|
|
88
|
+
main" CI job), it wires up:
|
|
89
|
+
|
|
90
|
+
- `prepend_before(:each)` / `append_after(:each)` hooks that start/stop DDCov
|
|
91
|
+
around every example (including `let` evaluation and `before` blocks, since
|
|
92
|
+
`prepend_before`/`append_after` wrap the whole example, not just the body)
|
|
93
|
+
- an `after(:suite)` hook that records `RSpec.configuration.files_to_run` and
|
|
94
|
+
writes the partial coverage map
|
|
95
|
+
- an `at_exit` fallback that writes the map if `after(:suite)` didn't run (the
|
|
96
|
+
write is idempotent/guarded so it never writes twice)
|
|
97
|
+
|
|
98
|
+
By default, if the `DDCov` coverage backend can't be set up, collection fails
|
|
99
|
+
loudly instead of silently producing a useless map — see "Coverage backend
|
|
100
|
+
unavailable" under [Accuracy & Safety](#accuracy--safety) below.
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
All commands are available via the `test-impact` executable.
|
|
105
|
+
|
|
106
|
+
### `test-impact merge`
|
|
107
|
+
|
|
108
|
+
Merges `part-*.json.gz` files (written by the RSpec integration during
|
|
109
|
+
collection) into a single map. Merging is a pure set union and is idempotent
|
|
110
|
+
— merging the same part twice does not change the result.
|
|
111
|
+
|
|
112
|
+
| Option | Default | Description |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `--input` | `tmp/test_impact` | Directory containing `part-*.json.gz` files |
|
|
115
|
+
| `--output` | `.test_impact/map.json.gz` | Output path for the merged map |
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
test-impact merge --input tmp/test_impact --output .test_impact/map.json.gz
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `test-impact info`
|
|
122
|
+
|
|
123
|
+
Prints summary information about a map file (schema version, commit, branch,
|
|
124
|
+
generated_at, backend, file/spec counts) to stdout. Useful for debugging.
|
|
125
|
+
|
|
126
|
+
| Option | Default | Description |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `--map` | `.test_impact/map.json.gz` | Path to the map file |
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
test-impact info --map .test_impact/map.json.gz
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `test-impact plan`
|
|
135
|
+
|
|
136
|
+
Prints the spec files impacted by the current diff. **All diagnostics go to
|
|
137
|
+
stderr; stdout carries only the result**, so `$(test-impact plan)` can be
|
|
138
|
+
passed directly to `rspec` or to `split-test`/`parallel_tests`.
|
|
139
|
+
|
|
140
|
+
| Option | Default | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `--map` | `.test_impact/map.json.gz` | Path to the map file |
|
|
143
|
+
| `--base` | (see below) | Base ref to diff against |
|
|
144
|
+
| `--format` | `lines` | `lines` or `json` |
|
|
145
|
+
| `--fallback-to-all-exit-code` | `10` | Exit code used in `lines` format when the plan says "run everything" |
|
|
146
|
+
| `--include-uncommitted` | `false` | Also consider staged / unstaged / untracked working tree changes |
|
|
147
|
+
|
|
148
|
+
Base ref resolution order: `--base` > `GITHUB_BASE_REF` (prefixed with
|
|
149
|
+
`origin/`) > `.test_impact.yml`'s `base` (default `origin/main`).
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
# lines format (default) — feed straight into rspec
|
|
153
|
+
test-impact plan
|
|
154
|
+
rspec $(test-impact plan)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
When running `plan` against code being edited locally (including AI-agent
|
|
158
|
+
edits) that isn't committed yet, pass `--include-uncommitted`. This makes the
|
|
159
|
+
diff span merge-base through the working tree, and untracked files reported
|
|
160
|
+
by `git ls-files --others --exclude-standard` are treated as newly added. It
|
|
161
|
+
does not modify repository state (read-only). CI should typically not use
|
|
162
|
+
this flag, since it should only consider committed diffs. Also note: if
|
|
163
|
+
`.test_impact/` isn't gitignored, the map file itself becomes untracked and
|
|
164
|
+
would trigger a full run — `.test_impact/` must be in `.gitignore` for this
|
|
165
|
+
flag to be useful.
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
test-impact plan --include-uncommitted
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### Exit code contract
|
|
172
|
+
|
|
173
|
+
| Exit code | Meaning | `lines` stdout |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `0` | Partial run, or nothing impacted | spec files, one per line (empty if none) |
|
|
176
|
+
| `10` | Run everything (no map / stale map / unknown file changed) | *(empty)* |
|
|
177
|
+
| `1` | Unexpected error (invalid arguments, unreadable git state, ...) | *(empty)* |
|
|
178
|
+
|
|
179
|
+
**This is important**: in `lines` format, both "run zero specs" (exit 0,
|
|
180
|
+
because the diff genuinely impacts nothing) and "run every spec" (exit 10)
|
|
181
|
+
print an empty line to stdout. You must branch on the exit code — you cannot
|
|
182
|
+
tell the two apart from stdout alone. `set -e` is safe with this convention
|
|
183
|
+
because `10` is a normal, expected outcome (unlike `1`, which signals a real
|
|
184
|
+
error).
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
if test-impact plan > /tmp/specs.txt; then
|
|
188
|
+
if [ -s /tmp/specs.txt ]; then
|
|
189
|
+
bundle exec rspec $(cat /tmp/specs.txt)
|
|
190
|
+
fi
|
|
191
|
+
# empty file = exit 0 with nothing impacted: run nothing
|
|
192
|
+
elif [ $? -eq 10 ]; then
|
|
193
|
+
bundle exec rspec
|
|
194
|
+
else
|
|
195
|
+
exit 1
|
|
196
|
+
fi
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
For scripting, `--format json` plus `jq` is the recommended, unambiguous way
|
|
200
|
+
to consume the result — it always distinguishes `all` / `partial` / `none`
|
|
201
|
+
regardless of exit code:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
test-impact plan --format json > /tmp/plan.json
|
|
205
|
+
mode=$(jq -r '.mode' /tmp/plan.json)
|
|
206
|
+
|
|
207
|
+
if [ "$mode" = "all" ]; then
|
|
208
|
+
bundle exec rspec
|
|
209
|
+
else
|
|
210
|
+
mapfile -t specs < <(jq -r '.spec_files[]' /tmp/plan.json)
|
|
211
|
+
[ "${#specs[@]}" -gt 0 ] && bundle exec rspec "${specs[@]}"
|
|
212
|
+
fi
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Configuration
|
|
216
|
+
|
|
217
|
+
`.test_impact.yml` at the repository root. All keys are optional.
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
base: origin/main
|
|
221
|
+
max_age_days: 7
|
|
222
|
+
always_run:
|
|
223
|
+
- spec/smoke/**/*_spec.rb
|
|
224
|
+
global_files:
|
|
225
|
+
- Gemfile
|
|
226
|
+
- Gemfile.lock
|
|
227
|
+
- "*.gemspec"
|
|
228
|
+
- .ruby-version
|
|
229
|
+
- Dockerfile
|
|
230
|
+
- config/**/*
|
|
231
|
+
- db/schema.rb
|
|
232
|
+
- db/structure.sql
|
|
233
|
+
- spec/spec_helper.rb
|
|
234
|
+
- spec/rails_helper.rb
|
|
235
|
+
- spec/factories/**/*
|
|
236
|
+
- spec/fixtures/**/*
|
|
237
|
+
ignore:
|
|
238
|
+
- .rubocop.yml
|
|
239
|
+
- .github/**/*
|
|
240
|
+
- "LICENSE*"
|
|
241
|
+
collector:
|
|
242
|
+
allocation_tracing: true
|
|
243
|
+
ignored_paths:
|
|
244
|
+
- vendor/
|
|
245
|
+
- tmp/
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| Key | Default | Description |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `base` | `"origin/main"` | Default base ref for `test-impact plan` |
|
|
251
|
+
| `max_age_days` | `7` | A map older than this (by `generated_at`) is treated as stale → run everything |
|
|
252
|
+
| `always_run` | `[]` | Glob patterns (see below); any known or impacted spec file matching these is always included |
|
|
253
|
+
| `global_files` | see above | Glob patterns (see below); a change to any matching file forces a full run |
|
|
254
|
+
| `ignore` | `[]` | Glob patterns (see below); matching changed files are treated as having no impact at all — see [Accuracy & Safety](#accuracy--safety) |
|
|
255
|
+
| `collector.allocation_tracing` | `true` | Passed to DDCov; catches coverage that pure line coverage misses (e.g. bare constant references), at some collection-time cost |
|
|
256
|
+
| `collector.ignored_paths` | `["vendor/", "tmp/"]` | Dual-mode (see below): a pattern containing glob metacharacters (`* ? [ ] { }`) is matched as a glob; otherwise it's a plain path prefix (relative to repo root). The defaults are prefixes, matched with `start_with?` |
|
|
257
|
+
|
|
258
|
+
`always_run`, `global_files`, and `ignore` all share the same glob semantics,
|
|
259
|
+
matched with `File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)`:
|
|
260
|
+
|
|
261
|
+
- `FNM_PATHNAME` means `*` does not cross a `/` — `*.yml` matches
|
|
262
|
+
`database.yml` but not `config/database.yml`; use `**/*.yml` to match at any
|
|
263
|
+
depth.
|
|
264
|
+
- `FNM_EXTGLOB` enables `{a,b}`-style alternation.
|
|
265
|
+
- `FNM_DOTMATCH` makes `**/*.yml` match dotfiles too, e.g. `.rubocop.yml` —
|
|
266
|
+
without it, a pattern like that would silently skip every dotfile. The same
|
|
267
|
+
flag also lets `**` descend into dot-*directories*, so `**/*.yml` reaches
|
|
268
|
+
`.github/workflows/deploy.yml` and `.circleci/config.yml` as well. The two
|
|
269
|
+
cannot be separated. This matters most for `ignore`, where matching too much
|
|
270
|
+
means skipping specs you wanted to run: to take only the dotfiles at the
|
|
271
|
+
repo root, write `*.yml` (no `**` — `FNM_PATHNAME` stops it at `/`), or
|
|
272
|
+
list the directories you mean explicitly.
|
|
273
|
+
|
|
274
|
+
`collector.ignored_paths` shares these same flags, but only for entries that
|
|
275
|
+
are actually globs — a plain prefix like the default `vendor/` is matched
|
|
276
|
+
with `start_with?` instead and never goes through `File.fnmatch?` at all.
|
|
277
|
+
|
|
278
|
+
`ignore` and `collector.ignored_paths` act in different phases and do not
|
|
279
|
+
imply one another. If you add a path with a tracked extension (`.rb`, `.erb`,
|
|
280
|
+
…) to `collector.ignored_paths`, it stops being indexed as a coverage source,
|
|
281
|
+
so changing it later reads as an *uncovered* file and falls back to a full
|
|
282
|
+
run. To keep such a path out of both phases, list it in `ignore` as well.
|
|
283
|
+
|
|
284
|
+
Setting `always_run` or `global_files` **replaces the default entirely**; it
|
|
285
|
+
does not add to it. If you set `global_files` and still want the built-in
|
|
286
|
+
defaults (Gemfile, `config/**/*`, factories, fixtures, etc.), copy the list
|
|
287
|
+
above into your own config and add to it. `ignore` has no default to
|
|
288
|
+
preserve, since it starts empty. `collector` is the only nested key that's
|
|
289
|
+
merged rather than replaced: an explicit `collector:` in your config is
|
|
290
|
+
shallow-merged over `DEFAULT_COLLECTOR`, and a key you omit (or leave blank,
|
|
291
|
+
e.g. a bare `ignored_paths:`) falls back to its default rather than
|
|
292
|
+
disappearing.
|
|
293
|
+
|
|
294
|
+
Note that `FNM_DOTMATCH` also means `global_files` patterns now match
|
|
295
|
+
hidden files they previously didn't — e.g. `config/**/*` matches
|
|
296
|
+
`config/.keep`. This only makes more changes trigger a full run, never fewer,
|
|
297
|
+
so it's a safe-direction behavior change.
|
|
298
|
+
|
|
299
|
+
## GitHub Actions
|
|
300
|
+
|
|
301
|
+
### Collect on `main` (matrix + merge)
|
|
302
|
+
|
|
303
|
+
```yaml
|
|
304
|
+
name: test-impact-collect
|
|
305
|
+
on:
|
|
306
|
+
push:
|
|
307
|
+
branches: [main]
|
|
308
|
+
|
|
309
|
+
jobs:
|
|
310
|
+
collect:
|
|
311
|
+
runs-on: ubuntu-latest
|
|
312
|
+
strategy:
|
|
313
|
+
matrix:
|
|
314
|
+
ci_node: [0, 1, 2, 3]
|
|
315
|
+
env:
|
|
316
|
+
TEST_IMPACT_COLLECT: "1"
|
|
317
|
+
steps:
|
|
318
|
+
- uses: actions/checkout@v4
|
|
319
|
+
- uses: ruby/setup-ruby@v1
|
|
320
|
+
with:
|
|
321
|
+
ruby-version: "3.4"
|
|
322
|
+
bundler-cache: true
|
|
323
|
+
- run: bundle exec rspec --format progress
|
|
324
|
+
env:
|
|
325
|
+
# split however your test-splitting tool of choice expects
|
|
326
|
+
CI_NODE_INDEX: ${{ matrix.ci_node }}
|
|
327
|
+
CI_NODE_TOTAL: 4
|
|
328
|
+
- uses: actions/upload-artifact@v4
|
|
329
|
+
with:
|
|
330
|
+
name: test-impact-parts-${{ matrix.ci_node }}
|
|
331
|
+
path: tmp/test_impact/part-*.json.gz
|
|
332
|
+
retention-days: 1
|
|
333
|
+
|
|
334
|
+
merge:
|
|
335
|
+
needs: collect
|
|
336
|
+
runs-on: ubuntu-latest
|
|
337
|
+
steps:
|
|
338
|
+
- uses: actions/checkout@v4
|
|
339
|
+
- uses: ruby/setup-ruby@v1
|
|
340
|
+
with:
|
|
341
|
+
ruby-version: "3.4"
|
|
342
|
+
bundler-cache: true
|
|
343
|
+
- uses: actions/download-artifact@v4
|
|
344
|
+
with:
|
|
345
|
+
pattern: test-impact-parts-*
|
|
346
|
+
path: tmp/test_impact
|
|
347
|
+
merge-multiple: true
|
|
348
|
+
- run: bundle exec test-impact merge
|
|
349
|
+
# `name` here must match the `name` used by actions/download-artifact
|
|
350
|
+
# in the select workflow below.
|
|
351
|
+
- uses: actions/upload-artifact@v4
|
|
352
|
+
with:
|
|
353
|
+
name: test-impact-map
|
|
354
|
+
path: .test_impact/map.json.gz
|
|
355
|
+
retention-days: 30
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Select on pull requests
|
|
359
|
+
|
|
360
|
+
```yaml
|
|
361
|
+
name: test-impact-select
|
|
362
|
+
on:
|
|
363
|
+
pull_request:
|
|
364
|
+
|
|
365
|
+
permissions:
|
|
366
|
+
contents: read
|
|
367
|
+
actions: read # required to read the collect workflow's artifact
|
|
368
|
+
|
|
369
|
+
jobs:
|
|
370
|
+
select:
|
|
371
|
+
runs-on: ubuntu-latest
|
|
372
|
+
steps:
|
|
373
|
+
- uses: actions/checkout@v4
|
|
374
|
+
with:
|
|
375
|
+
fetch-depth: 0 # merge-base needs full history
|
|
376
|
+
- uses: ruby/setup-ruby@v1
|
|
377
|
+
with:
|
|
378
|
+
ruby-version: "3.4"
|
|
379
|
+
bundler-cache: true
|
|
380
|
+
|
|
381
|
+
# Find the most recent successful collect run on main
|
|
382
|
+
- id: collect_run
|
|
383
|
+
run: |
|
|
384
|
+
run_id=$(gh run list --workflow=test-impact-collect.yml \
|
|
385
|
+
--branch=main --status=success --limit=1 \
|
|
386
|
+
--json databaseId --jq '.[0].databaseId')
|
|
387
|
+
echo "id=$run_id" >> "$GITHUB_OUTPUT"
|
|
388
|
+
env:
|
|
389
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
390
|
+
|
|
391
|
+
# No map (expired, or no collect run yet) is fine: plan falls back to
|
|
392
|
+
# running everything.
|
|
393
|
+
- uses: actions/download-artifact@v4
|
|
394
|
+
continue-on-error: true
|
|
395
|
+
with:
|
|
396
|
+
name: test-impact-map
|
|
397
|
+
path: .test_impact
|
|
398
|
+
run-id: ${{ steps.collect_run.outputs.id }}
|
|
399
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
400
|
+
|
|
401
|
+
- id: plan
|
|
402
|
+
run: |
|
|
403
|
+
set +e
|
|
404
|
+
bundle exec test-impact plan > /tmp/specs.txt
|
|
405
|
+
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
|
|
406
|
+
- if: steps.plan.outputs.exit_code == '0'
|
|
407
|
+
run: |
|
|
408
|
+
if [ -s /tmp/specs.txt ]; then
|
|
409
|
+
bundle exec rspec $(cat /tmp/specs.txt)
|
|
410
|
+
else
|
|
411
|
+
echo "no impacted specs"
|
|
412
|
+
fi
|
|
413
|
+
- if: steps.plan.outputs.exit_code == '10'
|
|
414
|
+
run: bundle exec rspec
|
|
415
|
+
- if: steps.plan.outputs.exit_code != '0' && steps.plan.outputs.exit_code != '10'
|
|
416
|
+
run: exit 1
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`actions/download-artifact` extracts to `.test_impact/map.json.gz`, which
|
|
420
|
+
matches the default value of `test-impact plan`'s `--map` option, so no
|
|
421
|
+
extra configuration is needed.
|
|
422
|
+
|
|
423
|
+
The `continue-on-error: true` on the download step is intentional: it keeps
|
|
424
|
+
the job from failing when no map is available, so `plan` safely falls back
|
|
425
|
+
to "run everything" (exit code `10`). If you'd rather treat a missing map as
|
|
426
|
+
a hard failure, drop that line.
|
|
427
|
+
|
|
428
|
+
## Accuracy & Safety
|
|
429
|
+
|
|
430
|
+
The design principle throughout is: **when in doubt, run everything.** A
|
|
431
|
+
false "skip" (silently not running a spec that should have run) is far worse
|
|
432
|
+
than an unnecessary full run. `test_impact` is deliberately biased toward
|
|
433
|
+
falling back to a full run (exit code `10` / mode `all`) whenever it cannot
|
|
434
|
+
be confident:
|
|
435
|
+
|
|
436
|
+
- **No map, or map fails to parse / has an unsupported `schema_version`** →
|
|
437
|
+
run everything.
|
|
438
|
+
- **Stale map** — older than `max_age_days` (default 7 days), or its
|
|
439
|
+
`commit_sha` is not reachable from current history → run everything.
|
|
440
|
+
- **Unknown/uncovered file changed** — a `.rb` file that changed but isn't a
|
|
441
|
+
key in the map's `index` → run everything (it's either genuinely new, or
|
|
442
|
+
the map is out of date), unless the path matches `ignore` (see below).
|
|
443
|
+
- **`global_files`** — changes to files like `Gemfile.lock`, anything under
|
|
444
|
+
`config/**`, `spec/spec_helper.rb`, factories, fixtures, etc. always force
|
|
445
|
+
a full run, since these can affect behavior in ways per-file coverage
|
|
446
|
+
can't express.
|
|
447
|
+
- **View templates** — `.erb`/`.haml`/`.slim`/`.jbuilder` files are tracked
|
|
448
|
+
like any other source file: DDCov records the file identifier a template
|
|
449
|
+
was compiled under, and Rails compiles templates with their absolute path,
|
|
450
|
+
so a template actually rendered during a spec shows up as an ordinary map
|
|
451
|
+
key and only its dependent specs run. A template that isn't in the map
|
|
452
|
+
falls back to a full run (safe). The legitimate reasons a template never
|
|
453
|
+
appears in the map: controller specs without `render_views` never execute
|
|
454
|
+
it; it was compiled under a non-absolute/virtual identifier (in-memory
|
|
455
|
+
templates, some custom resolvers), which falls outside the repo root and is
|
|
456
|
+
filtered out by DDCov; or it is rendered via a direct in-process eval (e.g.
|
|
457
|
+
plain `ERB#result` called inside an ordinary method), which DDCov
|
|
458
|
+
attributes to the caller instead — Rails' compiled-template mechanism is
|
|
459
|
+
unaffected by this.
|
|
460
|
+
- **`always_run`** — glob patterns for specs that should run unconditionally
|
|
461
|
+
regardless of what the diff/map say (e.g. smoke tests).
|
|
462
|
+
- **`ignore` is the one deliberate exception to "when in doubt, run
|
|
463
|
+
everything."** A change matching `ignore` is treated as having no impact at
|
|
464
|
+
all — not "run everything," not "run the specs coverage says depend on it,"
|
|
465
|
+
nothing. This is checked before any other classification, including
|
|
466
|
+
`global_files` and the spec-file check, so:
|
|
467
|
+
- Writing a `.rb` or `.erb` pattern into `ignore` can suppress specs that
|
|
468
|
+
coverage says genuinely depend on it. This is intentional: `ignore` is an
|
|
469
|
+
explicit assertion from the user that a path has no test-relevant impact,
|
|
470
|
+
and it is meant to override coverage when you know better.
|
|
471
|
+
- If a changed `_spec.rb` file itself matches `ignore`, that spec is not
|
|
472
|
+
scheduled either — "a changed spec always runs itself" is a default, not
|
|
473
|
+
a guarantee `ignore` respects.
|
|
474
|
+
- `always_run` is still checked afterwards and wins over `ignore`: a spec
|
|
475
|
+
matching both `ignore` and `always_run` still runs, because `always_run`
|
|
476
|
+
is applied after classification, scanning known/impacted spec files
|
|
477
|
+
regardless of how they were classified. This keeps the one bias-breaking
|
|
478
|
+
setting from stacking with itself in the unsafe direction.
|
|
479
|
+
- This is unrelated to the built-in `.md`/`.txt`/`.adoc` fallback in
|
|
480
|
+
`classify_other` (no user config needed): that check runs last, after
|
|
481
|
+
`global_files`, so e.g. `spec/fixtures/README.md` still forces a full
|
|
482
|
+
run via `global_files` despite its extension. `ignore`, by contrast, is
|
|
483
|
+
checked *before* `global_files` and wins.
|
|
484
|
+
- **Coverage backend unavailable** — if `DDCov` fails to load or its
|
|
485
|
+
behavior can't be verified at startup, collection **raises by default**,
|
|
486
|
+
failing the collection job. The error message includes the class and
|
|
487
|
+
message of the underlying exception, so the root cause (e.g. a
|
|
488
|
+
Ruby-version/platform mismatch, or a `datadog-ci` upgrade that changed its
|
|
489
|
+
internals) is visible directly in the job log. Set
|
|
490
|
+
`TEST_IMPACT_REQUIRE_COVERAGE=0` to opt out of this: it makes collection
|
|
491
|
+
fall back to a `NullBackend` instead, logging a one-line warning to stderr.
|
|
492
|
+
(`false`, `no`, and `off` are accepted too — a misspelled opt-out would
|
|
493
|
+
otherwise fail the job on the day the backend actually breaks.)
|
|
494
|
+
A map produced this way is tagged `backend: "null"`, and the planner treats
|
|
495
|
+
any map with a null backend as invalid and runs everything.
|
|
496
|
+
|
|
497
|
+
Even with all of this, per-test **coverage-based** impact analysis has an
|
|
498
|
+
inherent blind spot: coverage tells you which lines *ran*, not everything a
|
|
499
|
+
test's correctness *depends on*. As documented in
|
|
500
|
+
[Raksul's write-up on adopting a similar system](https://user-first.ikyu.co.jp/entry/2024/tia),
|
|
501
|
+
even line coverage combined with allocation tracing can miss dependencies
|
|
502
|
+
that never execute a traceable line — for example, behavior gated by
|
|
503
|
+
environment variables, external service contracts, or timing/ordering that
|
|
504
|
+
only manifests under specific data. Treat `test_impact` as a strong,
|
|
505
|
+
practical heuristic for day-to-day PR feedback, not a formal guarantee — keep
|
|
506
|
+
a scheduled/periodic full run of the suite (e.g., nightly, or on `main`) as a
|
|
507
|
+
backstop.
|
|
508
|
+
|
|
509
|
+
## Limitations
|
|
510
|
+
|
|
511
|
+
- Depends on `datadog-ci`'s internal, undocumented native extension API
|
|
512
|
+
(`Datadog::CI::TestImpactAnalysis::Coverage::DDCov`, loaded via a
|
|
513
|
+
Ruby-version/platform-specific require path). This is not a public,
|
|
514
|
+
stable API — a `datadog-ci` upgrade could change or remove it. The
|
|
515
|
+
`DdcovBackend` startup probe exists specifically to detect this and report
|
|
516
|
+
it as a clear, actionable failure (naming the underlying exception) rather
|
|
517
|
+
than silently collecting nothing.
|
|
518
|
+
- Licensing: `datadog-ci` is published under BSD-3-Clause, which permits
|
|
519
|
+
using the gem (including DDCov) without Datadog's services. Should a
|
|
520
|
+
future version change its license, the dependency constraint can be pinned
|
|
521
|
+
to the last BSD release, and the `Collector::CoverageBackend` seam is
|
|
522
|
+
designed so DDCov can be swapped for another per-test coverage collector
|
|
523
|
+
without touching the planner or map layers.
|
|
524
|
+
- GitHub Actions Artifacts expire (`retention-days`, 30 in the example
|
|
525
|
+
above, capped by the repo's overall retention setting). If the artifact
|
|
526
|
+
expires before the next `main` collection run, the map won't be available
|
|
527
|
+
and `test_impact` will fall back to running everything — correct, but
|
|
528
|
+
slower.
|
|
529
|
+
|
|
530
|
+
## Agent skill
|
|
531
|
+
|
|
532
|
+
The gem bundles an agent skill at `skills/impacted-specs`, which gives coding
|
|
533
|
+
agents (e.g. Claude Code) a step-by-step procedure for detecting which specs
|
|
534
|
+
to run against local, uncommitted changes — fetching the latest
|
|
535
|
+
`test-impact-map` artifact and running `test-impact plan`. Symlink it from
|
|
536
|
+
your project (e.g. `.claude/skills/`) to use it, following the same
|
|
537
|
+
distribution convention as sgcop.
|
|
538
|
+
|
|
539
|
+
## License
|
|
540
|
+
|
|
541
|
+
[MIT](LICENSE.txt)
|