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 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)
data/exe/test-impact ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'test_impact'
5
+
6
+ TestImpact::CLI.start(ARGV)