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
57 changes: 36 additions & 21 deletions docs/quality/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,12 @@ Capability: `None`.
### Test selection (`Audit:TestSelection`)

`csharp:test-pass` can narrow `dotnet test` to the tests a change may affect.
Narrowing ships **advisory/shadow only**: the selector computes the subset it
*WOULD* run, the **full suite still runs**, and per-run telemetry plus a shadow
record capture whether any deselected test failed. No ticket in this sequence
skips a test.
New selectors first ship **advisory/shadow only** (`coverage-shadow`): the
selector computes the subset it *WOULD* run, the **full suite still runs**,
and per-run telemetry plus a shadow record capture whether any deselected
test failed. Real skipping is gated on the soundness gate below reporting
`readyForEnforcement`, after which operators may opt into the enforcing
`project-graph` or `coverage` modes.

**Selection scopes (`all` | `project-graph` | `coverage`)** β€” the three layers,
innermost first:
Expand All @@ -285,22 +287,30 @@ innermost first:
- `project-graph` (`ProjectGraphTestSelector`): maps each changed file to its
owning MSBuild project and selects the baseline's precomputed affected tests,
plus tests defined in the changed files.
- `coverage` (`CoverageTestSelector`): refines the project-graph superset by
per-test coverage intersection β€” and ALWAYS also selects tests defined in
changed files, tests with NO coverage record (new/uninstrumented), and
everything the project-graph layer picks. Coverage only refines WITHIN that
superset: the result is a union, never less.
- `coverage` (`CoverageTestSelector`): intersects the changed lines with
per-test recorded coverage, NESTED INSIDE the project-graph superset β€” the
executed set only shrinks that superset, never grows beyond it β€” and ALWAYS
keeps (within the superset) tests defined in changed files and tests with NO
coverage record (new/uninstrumented). A change no recorded coverage
intersects descends the fallback ladder to the project-graph rung.

The live config knob is `Audit:TestSelection:Mode` (`all` |
`coverage-shadow`, case-insensitive, hot-reloaded via `IOptionsMonitor`; an
unrecognised value fails fast at load). `coverage-shadow` runs the
`project-graph` + `coverage` layers advisorially and records the verdict; `all`
runs neither. `csharp:test-pass` consults the configured `ITestSelector` only
in `coverage-shadow` mode, and only on paper: it computes the advisory
selection, still runs the FULL suite (the narrowed `--filter` argv is recorded,
never executed), parses the full run's failed tests, and emits a shadow record
(`safe-for-this-run` / `unsafe-skips-observed` / `full-suite` /
`unverifiable`).
`coverage-shadow` | `project-graph` | `coverage`, case-insensitive,
hot-reloaded via `IOptionsMonitor`; an unrecognised value fails fast at load).
`coverage-shadow` runs the `project-graph` + `coverage` layers advisorially
and records the verdict; `project-graph` enforces the superset rung;
`coverage` enforces the nested coverage subset with fallback down the
coverage β†’ project-graph β†’ all ladder; `all` runs neither. Enable an enforcing
mode only after the soundness gate below reports `readyForEnforcement: true`
for that selector. `csharp:test-pass` consults the configured `ITestSelector`
only in these selection modes, and only for per-item audit runs: the
merge/release verification path (`IRequiredBuildVerifier` /
`process:required-build`) takes no selector dependency and always runs the
full surface. In `coverage-shadow` mode the advisory selection is computed on
paper only: the FULL suite still runs (the narrowed `--filter` argv is
recorded, never executed), the full run's failed tests are parsed, and a
shadow record is emitted (`safe-for-this-run` / `unsafe-skips-observed` /
`full-suite` / `unverifiable`).

**Fallback ladder** β€” running MORE tests is always safe, so ANY uncertainty
resolves to the full suite, in this order: unknown/empty changeset β†’ no
Expand All @@ -309,13 +319,18 @@ than `MaxBaselineAge`) β†’ global-target touch (`Directory.Build.*`,
`Directory.Packages.props`, `global.json`, `NuGet.Config`, `CodeyBox.slnx`,
`.github/workflows/`) β†’ whole-file change (no line granularity) β†’ changed test
file (may define unrecorded tests) β†’ changed file no record references β†’
project-graph superset already full β†’ selector error. Each rung records its
reason in the telemetry `fallbacks` list and the shadow record detail.
change no recorded coverage intersects (coverage rung only β€” descends to the
project-graph rung rather than the full suite) β†’ project-graph superset
already full β†’ selector error. In `coverage` mode the ladder is structural:
the coverage rung is attempted first, then the project-graph rung, then the
full suite. Each rung records its reason in the telemetry `fallbacks` list
and the shadow record detail.

**Full-suite-on-main soundness invariant** β€” the merge/release path
(`IRequiredBuildVerifier` / `process:required-build`) takes NO dependency on
the `ITestSelector` seam and always runs the full build/test surface,
regardless of mode. Selectors are advisory for the audit loop only; the gate
regardless of mode. Selectors narrow per-item audit runs only (advisory in
`coverage-shadow`, enforcing in `project-graph`/`coverage`); the gate
that certifies `main` cannot narrow. Enforced structurally in code (see
`TestSelectorTests`), not by config.

Expand Down
26 changes: 16 additions & 10 deletions docs/quality/test-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ may affect. New selectors first ship **advisory/shadow only**
**full suite still runs**, and a shadow record captures whether any deselected
test failed. Real skipping is gated on accumulated shadow data showing zero
unsafe skips over the calibration window (the soundness gate,
`readyForEnforcement`). The `project-graph` mode below is the first ENFORCING
mode, enabled by operators only after that gate reports ready.
`readyForEnforcement`). The `project-graph` and `coverage` modes below are the
ENFORCING modes, each enabled by operators only after that gate reports ready
for the matching selector.

## Modes (`Audit:TestSelection:Mode`)

Expand All @@ -16,6 +17,7 @@ mode, enabled by operators only after that gate reports ready.
| `all` (default) | Full suite; the emitted command is byte-identical to the legacy path. Instant kill-switch: hot-reloading back to `all` disables all selection. |
| `coverage-shadow` | Coverage selector computes the would-be subset; full suite still runs; one structured `test-selection shadow` log line is emitted per run. |
| `project-graph` | ENFORCING: the project-graph selector's subset is executed via `--filter`; any selector error, missing/stale data, global-target touch, or ambiguous result falls back to the full suite (fail-safe). Opt in only after the soundness gate reports zero unsafe skips. |
| `coverage` | ENFORCING: the coverage selector's subset β€” nested inside the project-graph superset (coverage can only shrink it, never grow beyond it) β€” is executed via `--filter`. Fallback ladder: coverage rung β†’ project-graph rung (the superset) β†’ full suite, on missing/stale coverage data, selector error, or global-target touch. Opt in only after the soundness gate reports zero unsafe skips for the `coverage` selector. |

The value is case-insensitive (`coverage_shadow` also parses) and hot-reloads
via `IOptionsMonitor`. An unrecognised value fails fast at load.
Expand All @@ -28,10 +30,13 @@ via `IOptionsMonitor`. An unrecognised value fails fast at load.
precomputed affected tests, plus tests defined in the changed files.
A change owned by an ALWAYS-FULL project forces the full suite (see below).
- **Coverage** (`CoverageTestSelector`): selects tests whose recorded per-test
coverage intersects the changed lines, and ALWAYS also selects tests defined
in changed files, tests with NO coverage record (new/uninstrumented), and
everything the project-graph selector picks. Coverage only refines WITHIN
that superset β€” the result is a union, never less.
coverage intersects the changed lines, NESTED INSIDE the project-graph
superset β€” every candidate (coverage hit, test defined in a changed file,
test with NO coverage record) is kept only when the superset already
contains it. Coverage only shrinks that superset, never grows beyond it
(defense in depth against a poisoned/stale coverage map). A change no
recorded coverage intersects carries no signal, so the ladder descends to
the project-graph rung instead of narrowing.

Both fall back to the full suite on ANY uncertainty: no/unknown changeset, no
baseline, stale baseline, global targets (`Directory.Build.*`,
Expand Down Expand Up @@ -90,8 +95,9 @@ suite. Size caps (`MaxBaselineBytes`, `MaxBaselineTests`,
- **SHADOW-BEFORE-ENFORCE** β€” `DotnetTestAuditor` executes
`BuildInvocation(TestSelection.All, …)` on every shadow run; the narrowed
`--filter` argv is computed for the shadow record only, never executed.
The enforcing `project-graph` mode executes the narrowed argv only after the
soundness gate reported zero unsafe skips over the calibration window.
The enforcing `project-graph` and `coverage` modes execute the narrowed argv
only after the soundness gate reported zero unsafe skips over the calibration
window (for the matching selector).
- **FULL-SUITE-ON-MAIN** β€” the merge/release path (`IRequiredBuildVerifier` /
`process:required-build`) takes no `ITestSelector` dependency and always
runs everything, enforced in code (see `TestSelectorTests`), not config.
Expand Down Expand Up @@ -125,13 +131,13 @@ Timeline dashboard pages):

| Field | Meaning |
|-------|---------|
| `mode` | Live selection mode (`All`, `CoverageShadow`, `ProjectGraph`). |
| `mode` | Live selection mode (`All`, `CoverageShadow`, `ProjectGraph`, `Coverage`). |
| `selector` | Selector that decided (`coverage`, `project-graph`, or `none` when neither shadow nor enforcement was active). |
| `layers` | Layers consulted, innermost first (`["project-graph","coverage"]` for the coverage selector, which refines the project-graph superset; `["project-graph"]` for enforcing project-graph runs). |
| `selectedCount` / `totalCount` | WOULD-BE subset / known universe size. `0/0` means the universe was unknown (no baseline) β€” the dashboard shows "full suite". For a full-suite fallback with a known universe, both equal the universe size. For enforcing runs, the EXECUTED subset / universe size. |
| `estimatedSavedFraction` | Proportional estimate: deselected / total in [0,1]. The dashboard multiplies it by the run's `durationMs` (`est. saved 62.5% (~75s)`). Zero for full-suite runs. For shadow runs this is an estimate, not a measurement β€” the full suite always ran. |
| `assessment` | Shadow verdict (`safe-for-this-run` \| `unsafe-skips-observed` \| `full-suite` \| `unverifiable`), plus `enforced-subset` for enforcing runs that executed a narrowed subset (deselected tests were skipped, so no safe/unsafe claim is made; the soundness gate ignores these runs). |
| `fallbacks` | Which fallback-ladder rungs fired (e.g. `no per-test coverage baseline is available`). Empty when the selector narrowed without falling back. |
| `fallbacks` | Which fallback-ladder rungs fired (e.g. `no per-test coverage baseline is available`, or the coverage β†’ project-graph descent marker `project-graph rung:`). Empty when the selector narrowed without falling back. |
| `detail` | Operator-facing selection detail (capped at 4000 chars). |

See `tests/CodeyBox.Tests/CoverageTestSelectionTests.cs` and
Expand Down
25 changes: 17 additions & 8 deletions src/CodeyBox.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ void ConfigureResource(ResourceBuilder r)
.Bind(builder.Configuration.GetSection(TestSelectionOptions.SectionName))
.Validate(
static opts => TestSelectionModeParser.TryParse(opts.Mode, out _),
$"{TestSelectionOptions.SectionName}:Mode must be one of: all, coverage-shadow, project-graph");
$"{TestSelectionOptions.SectionName}:Mode must be one of: all, coverage-shadow, project-graph, coverage");
// Coverage-guided selection knobs (Audit:TestSelection:Coverage). Bound through
// AddOptions so IOptionsMonitor<CoverageTestSelectionOptions> hot-reloads the
// baseline location, age/size caps, and global targets without a restart, with
Expand Down Expand Up @@ -2617,13 +2617,19 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
// from IOptionsMonitor on every call (hot-reload) and dispatches to the selector
// registered for that mode; the default 'all' maps to RunAllTestSelector, whose
// TestSelection.All keeps the emitted dotnet-test command byte-identical to the
// legacy path. 'coverage-shadow' maps to CoverageTestSelector, which refines the
// legacy path. 'coverage-shadow' maps to CoverageTestSelector, which narrows the
// project-graph superset by per-test coverage β€” ADVISORY ONLY: the per-item
// csharp:test-pass runner computes the decision, still runs the full suite, and
// emits a shadow record (SHADOW-BEFORE-ENFORCE). 'project-graph' maps to
// ProjectGraphTestSelector and is ENFORCING: the per-item csharp:test-pass runner
// executes only the selected subset (fail-safe fallback to the full suite on any
// error or ambiguous result). The merge/release verification
// error or ambiguous result). 'coverage' maps to the same CoverageTestSelector
// instance and is ENFORCING: the runner executes only the coverage-narrowed
// subset nested inside the project-graph superset, descending the
// coverage β†’ project-graph β†’ all fallback ladder on any error or ambiguous
// result. Opt into either enforcing mode only after the soundness gate
// (GET /audit/test-selection/soundness, selector=project-graph or coverage)
// reports readyForEnforcement. The merge/release verification
// path (IRequiredBuildVerifier / process:required-build) deliberately takes NO
// dependency on this seam: it always verifies the full build/test surface
// regardless of Mode.
Expand All @@ -2632,14 +2638,16 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
var modeMonitor = sp.GetRequiredService<IOptionsMonitor<TestSelectionOptions>>();
var coverageOptionsMonitor = sp.GetRequiredService<IOptionsMonitor<CoverageTestSelectionOptions>>();
var projectGraph = new ProjectGraphTestSelector(() => coverageOptionsMonitor.CurrentValue, TimeProvider.System);
var coverage = new CoverageTestSelector(
projectGraph,
() => coverageOptionsMonitor.CurrentValue,
TimeProvider.System);
var selectorsByMode = new Dictionary<TestSelectionMode, ITestSelector>
{
[TestSelectionMode.All] = new RunAllTestSelector(),
[TestSelectionMode.CoverageShadow] = new CoverageTestSelector(
projectGraph,
() => coverageOptionsMonitor.CurrentValue,
TimeProvider.System),
[TestSelectionMode.CoverageShadow] = coverage,
[TestSelectionMode.ProjectGraph] = projectGraph,
[TestSelectionMode.Coverage] = coverage,
};
return new ConfiguredTestSelector(
() => TestSelectionModeParser.Parse(modeMonitor.CurrentValue.Mode),
Expand All @@ -2649,7 +2657,8 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
// threaded into every csharp:test-pass runner the preset catalogs build.
// Mode=all (the default) is an instant kill-switch: the runner checks the live
// mode on every run and runs the full suite for anything but coverage-shadow
// (advisory shadow) or project-graph (enforcing subset).
// (advisory shadow), project-graph (enforcing subset), or coverage (enforcing
// coverage-nested subset).
builder.Services.AddSingleton<ITestSelectionShadowSink, LoggerTestSelectionShadowSink>();
builder.Services.AddSingleton<TestSelectionShadowConfig>(sp => new TestSelectionShadowConfig
{
Expand Down
Loading
Loading