Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ the functional test suite.
- `collect.rs` — Coverage pipeline: `cargo nextest list` → instrumented `nextest run` via the runner shim → SQLite write. Extraction is **inlined into the shim** (see `shim.rs`): each test's shim merges/exports/parses its own profraw and deletes the bundle before exiting, so peak disk usage is bounded by nextest's own concurrency (O(test-threads × per-test size) instead of O(whole-suite size)) with no external watcher or completion heuristic. After `nextest run` exits, `collect` reads the per-test `TestResult` files the shims wrote under `CARGO_AFFECTED_RESULTS_DIR`, folds in each test's crate-root sentinels, and writes the rows. `--diff` reuses the same pipeline, restricting the run to the affected + new test set. Also owns the test-selection plumbing shared with `run.rs`: `nextest_filter_expr` builds the filterset, `write_nextest_config` hands it to nextest as a `default-filter` in a generated config file (passed via `--config-file`) so an arbitrarily large selection never overflows the OS command-line limit.
- `shim.rs` — Hidden `runner-shim` invoked per test by nextest. Sets a per-test `LLVM_PROFILE_FILE`, spawns and waits for the real binary, then extracts its coverage (`llvm-profdata`/`llvm-cov`), writes a `TestResult` JSON file, and deletes the per-test profraw dir. Waiting (rather than `execvp`) is what lets extraction run in-process; nextest signals each test's process group on cancellation, so the spawned child is reaped without the shim forwarding signals.
- `coverage.rs` — Parses `llvm-cov export` JSON into `(file, line_start, line_end)` ranges per hit function.
- `fingerprint.rs` — SHA-256 of `Cargo.lock`, workspace `Cargo.toml`s, `rustc -vV`, `RUSTFLAGS`, `CARGO_BUILD_TARGET`. Queries scoped to the current fingerprint naturally miss when any tracked input changes — no explicit invalidation path.
- `fingerprint.rs` — SHA-256 of `Cargo.lock`, workspace `Cargo.toml`s, `rustc -vV`, `RUSTFLAGS`, `CARGO_BUILD_TARGET`. Queries scoped to the current fingerprint naturally miss when any tracked input changes — no explicit invalidation path. `[*.metadata]` tables are stripped from each manifest before hashing (cargo ignores them for builds), so editing `[workspace.metadata.affected]` rules is cache-neutral; metadata-free manifests hash by raw bytes so there's no churn on upgrade.
- `db.rs` — SQLite at `target/affected/coverage.db`. `test_regions` rows carry a per-row `collect_sha` so `--diff` can leave unaffected tests anchored at their original sha while re-anchoring rerun tests at the new HEAD; multiple shas can coexist for one fingerprint. Diverged-sha rows linger until `cargo affected clean`. Crate roots ride the same table with sentinel `(1, i64::MAX)` ranges (the structural-edit backstop). Legacy schemas drop on open.
- `selection.rs` — Shared between `run`, `status`, and `collect --diff`. Owns reachability classification (`check_shas_reachable`), per-sha diff collection (`changed_ranges_per_sha`), the divergence notice, and the affected + new + listed selection itself.
- `selection.rs` — Shared between `run`, `status`, and `collect --diff`. Owns reachability classification (`check_shas_reachable`), per-sha diff collection (`changed_ranges_per_sha`), the divergence notice, and the selection itself: affected + config + new + stranded, where config hits (from `config.rs`) are a disjoint category that never inflates the coverage-overlap counts.
- `config.rs` — Declarative input→test rules from `[workspace.metadata.affected]` (or `[package.metadata.affected]` for single-crate projects). Each `[[rule]]` pairs input globs with a nextest filterset; when a changed path matches, `cargo nextest list -E` resolves the filterset and those tests are force-selected. Closes the blind spot where a test reads a non-Rust file at runtime (an insta `.snap`, a doc `.md`) that has no coverage row, so a change to it would otherwise select no test. No rules means no extra `nextest list` call.
- `run.rs` — `collect_shas` → reachability → per-sha `git diff -U0` → selection → `nextest run` against the generated filter config. Widens to all tests only when every sha is diverged.
- `status.rs` — Dry-run variant of `run`.

Expand Down
50 changes: 50 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ path = "src/main.rs"
anyhow = "1"
camino = { version = "1", features = ["serde1"] }
clap = { version = "4", features = ["derive"] }
globset = "0.4"
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ CI should still run the full suite.
### False negatives (tests skipped that should have run)

- **Non-Rust sources.** `include_str!` / `include_bytes!` targets, SQL
files, migrations, assets, and templates aren't seen by llvm-cov.
files, migrations, assets, snapshots, and templates aren't seen by
llvm-cov — a change confined to one selects no test. [Input
rules](#input-rules) close this for inputs you can name.
- **Build-time inputs not in the fingerprint.** The fingerprint covers
`Cargo.lock`, workspace `Cargo.toml`s, `rustc -vV`, `RUSTFLAGS`, and
`CARGO_BUILD_TARGET`. Changes to `build.rs`, `rust-toolchain.toml`, or
Expand All @@ -128,6 +130,46 @@ CI should still run the full suite.
When in doubt, `cargo affected collect` to refresh coverage, or skip
cargo-affected and run the full suite.

## Input rules

Coverage can't link a test to a non-Rust input it reads at runtime — an insta
`.snap`, a doc a sync-test compares against, an `include_str!` target — so a
change confined to that input selects no test (see [false
negatives](#false-negatives-tests-skipped-that-should-have-run)). Optional
`[[workspace.metadata.affected.rule]]` tables in `Cargo.toml` close the gap by
mapping input globs to the tests that depend on them (use
`[[package.metadata.affected.rule]]` in a single-crate project):

```toml
# Any `.snap` edit re-runs the integration suite that owns the snapshots.
[[workspace.metadata.affected.rule]]
globs = ["**/*.snap"]
filterset = "binary_id(=mycrate::integration)"

# Doc-sync tests read these inputs at runtime; run that module when any change.
[[workspace.metadata.affected.rule]]
globs = ["README.md", "docs/**/*.md"]
filterset = "test(/readme_sync/)"
```

Each rule pairs `globs` (matched against changed paths) with a nextest
`filterset` (the full [filter-expression
language](https://nexte.st/docs/filtersets/)). When a changed path matches, the
filterset is resolved with `cargo nextest list -E` and its tests are
force-selected — reported under a `config` category distinct from coverage-driven
selection. A Rust-only diff matches no globs and takes the exact prior path, so
the speedup is preserved; the extra `nextest list` runs only on diffs that touch
a configured input. A rule that matches a path but resolves to no tests warns
rather than failing silently. No rules → no change in behavior.

The rules live in `[*.metadata]`, which cargo ignores for the build — so
cargo-affected excludes it from the coverage fingerprint. Editing a rule is
cache-neutral: it doesn't force a re-collect, so you can iterate on rules freely.

Rules are a remedy of last resort, not a substitute for coverage: prefer letting
`collect` map Rust changes. Reach for a rule only for inputs llvm-cov
structurally cannot see, and keep periodic full runs for everything else.

## Comparison with similar tools

The biggest design choice is *how* a tool decides what changed. The headline
Expand Down
27 changes: 17 additions & 10 deletions docs/report-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ and its `differing_labels` answers "which input changed?".
{
"selected": 2577,
"affected": 2569,
"config": 0,
"new": 6,
"stranded": 2,
"skipped": 924,
Expand All @@ -136,11 +137,12 @@ and its `differing_labels` answers "which input changed?".
| Field | Notes |
|---|---|
| `mode` | `"selection"` or `"full-suite-no-listing"`. |
| `selected` | Union of affected + new + stranded. `null` on full-suite paths. |
| `selected` | Union of affected + config + new + stranded. `null` on full-suite paths. |
| `affected` | In DB at a reachable sha AND a hunk overlapped a stored row. |
| `config` | Reachable-known and force-selected by a `[workspace.metadata.affected]` input rule (would otherwise have been skipped). Disjoint from `affected`. |
| `new` | Listed in nextest but not in the DB at all under this fingerprint. |
| `stranded` | In DB but only at currently-missing collect_shas. |
| `skipped` | `total_reachable_known - affected` (saturating). |
| `skipped` | `total_reachable_known - affected - config` (saturating). |
| `total_reachable_known` | Distinct test count under the fingerprint at reachable shas. |

### `selection.changed_files[]`
Expand All @@ -156,20 +158,24 @@ Sorted `(tests_pulled_total desc, path asc)`.
"tests_pulled_by_reason": {
"line_overlap": 180,
"structural_backstop": 0,
"crate_root_sentinel": 1060
"crate_root_sentinel": 1060,
"config_rule": 0
}
}
```

`tracked_by_coverage` is `true` iff the file has at least one stored
`test_regions` row at a reachable sha. Non-Rust files (`.snap`, configs)
read `false`.
read `false` — and are exactly where `config_rule` selections show up.

`tests_pulled_by_reason` is deduplicated by **strongest reason** per
test per file: a test pulled in by both line overlap and a sentinel
counts once, classified by the strongest reason
(`line_overlap` > `structural_backstop` > `crate_root_sentinel`). The
three counters sum to `tests_pulled_total`.
(`line_overlap` > `structural_backstop` > `config_rule` >
`crate_root_sentinel`). `config_rule` counts tests force-selected by a
`[workspace.metadata.affected]` rule matching this path (non-zero only for the
non-Rust inputs such rules target). The four counters sum to
`tests_pulled_total`.

### `selection.selected_tests[]`

Expand All @@ -195,10 +201,11 @@ Sorted `(binary_id, test_name)`.

| Field | Notes |
|---|---|
| `kind` | `"affected"`, `"new"`, or `"stranded"`. |
| `reasons` | Empty for `"new"` and `"stranded"`. Sorted `(file, kind, collect_sha)`. |
| `reasons[].kind` | `"line_overlap"`, `"structural_backstop"`, or `"crate_root_sentinel"`. |
| `reasons[].stored_range` | `null` for `structural_backstop` (no row matched by definition). |
| `kind` | `"affected"`, `"config_rule"`, `"new"`, or `"stranded"`. |
| `reasons` | Empty for `"new"` and `"stranded"`. For `"config_rule"`, names the triggering input path(s). Sorted `(file, kind, collect_sha)`. |
| `reasons[].kind` | `"line_overlap"`, `"structural_backstop"`, `"crate_root_sentinel"`, or `"config_rule"`. |
| `reasons[].stored_range` | `null` for `structural_backstop` and `config_rule` (no coverage row matched). |
| `reasons[].collect_sha` / `matched_hunk` | Empty / `[0, 0]` for `config_rule` (not anchored to a coverage hunk). |

## Stderr summary line

Expand Down
9 changes: 9 additions & 0 deletions src/collect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ pub fn collect(
Some(&rustflags),
Some(&build_dir),
&cargo_build_args(nextest_args),
None,
)?;
eprintln!(
"found {} tests across {} binaries",
Expand Down Expand Up @@ -917,11 +918,16 @@ pub(crate) struct BinaryEntry {
/// the build config of the subsequent `cargo nextest run`, or the listing
/// enumerates a different test set than the run builds and new-test
/// detection ("listed minus DB") becomes unsound.
///
/// `filter_expr`, when set, passes `-E <expr>` so the listing is restricted to
/// tests matching a nextest filterset — used to resolve `[workspace.metadata.affected]`
/// rules to concrete tests. Leave `None` for a full listing.
pub(crate) fn nextest_list(
project_root: &Path,
rustflags_override: Option<&str>,
build_dir: Option<&Path>,
build_args: &[String],
filter_expr: Option<&str>,
) -> Result<Listing> {
let mut cmd = Command::new("cargo");
cmd.arg("nextest")
Expand All @@ -941,6 +947,9 @@ pub(crate) fn nextest_list(
for a in build_args {
cmd.arg(a);
}
if let Some(expr) = filter_expr {
cmd.arg("-E").arg(expr);
}
let output = cmd
.spawn()
.context("failed to spawn cargo nextest list")?
Expand Down
Loading
Loading