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
55 changes: 23 additions & 32 deletions .github/workflows/mutation-affected.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,33 @@ name: Mutation Affected

# PR-side half of the decision-kernel mutation lane (issue #1415).
#
# Graduation, not a flag day: this job runs on every PR that touches a kernel
# module, but `scripts/mutation/run.ts` only exits non-zero once the committed
# baseline reports `gating: true` — earned by two consecutive stable weekly
# sweeps (mutation-weekly.yml). Until then it is a report with the same numbers,
# so the gate's first failing day is not also its first running day.
# Advisory only — the lane reports and never gates (#1457). `scripts/mutation/run.ts`
# exits non-zero on a harness failure, never on a score.
#
# Cost control: before graduation an affected run is a report nobody acts on, so
# `select` returns an empty matrix and no mutants run — except when the diff
# touches the lane's own tooling, the one case where a pre-graduation run buys
# something (the gate has to be proven before it can bite).
# Cost control: the kernel report that pays is the weekly sweep, so this lane
# spends mutants on one thing only — proving the harness still runs end to end
# when the harness itself changes. `select` therefore returns an empty matrix
# unless the diff touches the lane's own sources. Selecting on derived kernel
# ownership instead would run the full ten-shard sweep on 24 of the last 40
# merged PRs.
#
# Scope is the AFFECTED modules only, and affectedness is DERIVED from the import
# graph (scripts/mutation/ownership.ts): a kernel source, or any test that reaches
# one. Reaching a kernel is a superset of killing its mutants, so the `select` job
# frequently returns several modules — sharded like the weekly sweep so the PR's
# wall clock is one module, not their sum. The weekly run stays the full sweep.
# On a harness diff the matrix is the canary (`LANE_CANARY`, the registry's
# cheapest real sweep — lane sources own no kernel, so derivation alone would
# select nothing) plus any kernel that same diff reaches through the import
# graph (scripts/mutation/ownership.ts): a kernel source, or a test that reaches
# one. Selected modules are sharded like the weekly sweep, so the PR's wall
# clock is one module rather than their sum.

on:
pull_request:
paths:
# Kernel sources, every src/package test (ownership is derived, so any
# test may own a kernel — `select` decides, not this filter), and the
# lane's own tooling. scripts/mutation/workflow.test.ts asserts this
# covers the registry.
- 'packages/kernel/src/errors.ts'
- 'src/daemon/ref-frame.ts'
- 'src/commands/interaction/runtime/settle.ts'
- 'src/utils/scroll-edge-state.ts'
- 'packages/selectors/src/**'
- 'packages/ad-script/src/internal/target-annotation-serde.ts'
- 'src/snapshot/snapshot-occlusion.ts'
- 'src/**/*.test.ts'
- 'packages/*/src/**/*.test.ts'
# Exactly `LANE_TOOLING` in scripts/mutation/run.ts plus this file — the
# only diffs that can produce a non-empty matrix, asserted both ways by
# scripts/mutation/workflow.test.ts. A kernel or test path here would only
# start a job that selects nothing.
- 'scripts/mutation/**'
- 'scripts/lib/**'
- 'stryker.config.json'
- 'mutation-baselines/**'
- '.github/workflows/mutation-affected.yml'

permissions:
Expand All @@ -64,7 +54,7 @@ jobs:
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Ratchet self-test
- name: Harness self-test
uses: ./.github/actions/run-gate
with: { gate: mutation-model }

Expand Down Expand Up @@ -138,7 +128,7 @@ jobs:
.tmp/mutation/lane-envelope.json
if-no-files-found: warn

ratchet:
report:
name: Affected decision-kernel mutants
needs: [select, mutants]
if: always() && needs.select.result == 'success'
Expand All @@ -162,7 +152,8 @@ jobs:

# No shards means no affected kernel: run.ts reports "nothing to mutate"
# and still writes the envelope, so the lane is never silently absent.
- name: Ratchet the affected modules
# Either invocation writes the score table to $GITHUB_STEP_SUMMARY.
- name: Score the affected modules
run: |
if [ -d .tmp/mutation/shards ]; then
expected=$(echo '${{ needs.select.outputs.modules }}' | jq length)
Expand All @@ -177,7 +168,7 @@ jobs:
if: failure()
run: |
pnpm gate mutation --affected --fail-envelope \
"affected ratchet failed before producing a verdict (run ${{ github.run_id }})" || true
"affected report failed before producing a score table (run ${{ github.run_id }})" || true

- name: Upload mutation report
if: always()
Expand Down
37 changes: 16 additions & 21 deletions .github/workflows/mutation-weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ name: Mutation Weekly
# Sharded one job per module: the whole sweep is ~2,150 mutants, and the selector
# module alone is ~1,280 of them, so a single job would sit near the 30-minute
# acceptance budget on an ubuntu runner. The shards' JSON reports are merged into
# one verdict by the ratchet job (`--report-dir`), so the ratchet still sees a
# full sweep.
# one score table by the report job (`--report-dir`).
#
# The lane reports; it does not commit. The proposed baseline rides in the
# artifact and applying it is a reviewed `pnpm mutation:baseline` commit — a score
# can never lower itself. Gating (after two consecutive stable weekly runs) is
# enforced on PRs by mutation-affected.yml.
# The lane reports and never gates (#1457): the per-kernel table lands in the job
# summary and the artifact, and a low score is an input for a human-authored
# test-strengthening PR (#1474, #1475 were written that way).

on:
schedule:
Expand Down Expand Up @@ -90,8 +88,8 @@ jobs:
.tmp/mutation/lane-envelope.json
if-no-files-found: warn

ratchet:
name: Mutation ratchet
report:
name: Mutation score report
runs-on: ubuntu-latest
needs: shard
if: always()
Expand All @@ -103,7 +101,7 @@ jobs:
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Ratchet self-test
- name: Harness self-test
uses: ./.github/actions/run-gate
with: { gate: mutation-model }

Expand All @@ -113,9 +111,13 @@ jobs:
pattern: mutation-shard-*
path: .tmp/mutation/shards

# run.ts writes the markdown verdict to $GITHUB_STEP_SUMMARY when the
# run.ts writes the per-kernel score table to $GITHUB_STEP_SUMMARY when the
# runner exports it, so the summary and the artifact carry the same numbers.
- name: Ratchet the merged sweep and propose the next baseline
# It publishes the table before judging the shard set, so even the one
# failure it can raise — an incomplete shard set — still reports the kernels
# that completed. A red lane here always means the sweep did not happen,
# never a low score.
- name: Score the merged sweep
uses: ./.github/actions/run-gate
with:
gate: mutation-check
Expand All @@ -124,20 +126,14 @@ jobs:
.tmp/mutation/shards
--expect-shards
10
--update

- name: Stage the proposed baseline artifact
run: |
cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json
git checkout -- mutation-baselines/decision-kernels.json

# The self-test and the artifact download both run before the ratchet, so a
# failure there would otherwise leave the aggregate lane with no envelope.
# The self-test and the artifact download both run before the scoring step,
# so a failure there would otherwise leave the aggregate lane with no envelope.
- name: Record a failed lane envelope
if: failure()
run: |
pnpm gate mutation --fail-envelope \
"weekly ratchet job failed before producing a verdict (run ${{ github.run_id }})" || true
"weekly report job failed before producing a score table (run ${{ github.run_id }})" || true

# Freshness/drift telemetry (#1430): the envelope states commit, Stryker
# version, config hash, duration and result, so a lane going dark or a tool
Expand Down Expand Up @@ -167,5 +163,4 @@ jobs:
path: |
.tmp/mutation/shards
.tmp/mutation/lane-envelope.json
.tmp/mutation/proposed-baseline.json
if-no-files-found: warn
3 changes: 1 addition & 2 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"node_modules/**",
"**/*.md",
"scripts/maestro-conformance/corpus/**",
"fallow-baselines/**",
"mutation-baselines/**"
"fallow-baselines/**"
]
}
63 changes: 32 additions & 31 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The mapping it encodes, for when you need to run a gate directly or reason about
| Daemon RPC wire surface (the declarations listed in `test/wire-compat/surface.ts` — JSON-RPC envelope, request/response/error/artifact/progress framing, `/health` payload, HTTP auth headers) | `pnpm exec vitest run --project unit-core test/wire-compat` holds the ledger to its source and prints the digest to paste; `pnpm check:daemon-wire-compat` compares it against the last released tag and requires a `DAEMON_RPC_PROTOCOL_VERSION` bump or a `compatibleChanges` ack for the drift. Read ADR 0006 to decide which; `test/wire-compat/README.md` walks both |
| Anything in `src/`, `test/` | `pnpm format` (`skills/` is Markdown-only guidance: oxfmt ignores `**/*.md`, and the affected-check selector classifies it docs-only) |
| Workspace package source (`packages/*/src/**`) | Root format/lint/typecheck plus layering (R11 package-boundaries); Vitest resolves affected tests through the module graph; package manifests/tsconfigs fail open to the full set |
| A decision kernel or its tests (`packages/kernel/src/errors.ts`, `src/daemon/ref-frame.ts`, `src/commands/interaction/runtime/settle.ts`, `src/utils/scroll-edge-state.ts`, `packages/selectors/src/`) | `pnpm mutation:affected --base origin/main` (minutes; GitHub runs it per PR — see the mutation ratchet section) |
| A decision kernel or its tests (`packages/kernel/src/errors.ts`, `src/daemon/ref-frame.ts`, `src/commands/interaction/runtime/settle.ts`, `src/utils/scroll-edge-state.ts`, `packages/selectors/src/`) | `pnpm mutation:run --modules <kernel>` (minutes; optional — the lane reports, it never gates, see the mutation section) |

Two traps worth naming:

Expand Down Expand Up @@ -243,7 +243,7 @@ The three facts the manifest cannot derive live together in `scripts/gate/declar
coverage wrapper, one reporting-only `test:*` script, and the Android replay owner hidden inside a
third-party action's `script:` input.

## Mutation ratchet over decision kernels
## Mutation report over decision kernels

Mutation score is the mechanical answer to "is this test load-bearing or decorative". A full-suite
sweep is unaffordable, so the scope is an enumerated list of pure decision kernels — modules where a
Expand All @@ -252,46 +252,47 @@ surviving mutant means a silently wrong agent-facing decision. The registry
are asserted against it, and PR-affected selection maps changed files through it. Modules that spawn
subprocesses or wait real time stay out by construction.

Mutation runs **report-only** on the seven decision kernels — a weekly full sweep plus a per-PR
affected sweep. It never gates: `scripts/mutation/run.ts` exits non-zero on a harness failure (a
missing report, an incomplete shard set, a bad argument) and never on a score. A low score is an
input for a human-authored test-strengthening PR, which is exactly how #1474 and #1475 were written.
The ratchet, baseline file and graduation rule this lane used to carry were deleted in #1457: in
three weeks nobody applied a baseline, so the gate half never operated while the report half was
paying.

```sh
pnpm mutation:test # ratchet self-test (fast, no Stryker)
pnpm mutation:test # harness self-test (fast, no Stryker)
pnpm mutation:run --modules selectors # one module locally (~7 min for selectors)
pnpm mutation:check # ratchet an existing .tmp/mutation/mutation.json
pnpm mutation:baseline # full sweep, then record it (reviewed commit)
pnpm mutation:check # score an existing .tmp/mutation/mutation.json
```

- **Weekly full sweep** (`.github/workflows/mutation-weekly.yml`) runs `shardMatrix()` from the
registry: one job per module, except modules that declare a `shards` count and are sliced with
`--shard i/n` (selectors is ~1,280 mutants, well past the 30-minute budget in one job). The ratchet
merges the shard reports (`--report-dir`) for one verdict and requires the full set
(`--expect-shards`), so a dead shard fails the lane instead of scoring its module as 0. Results are
reported as a job summary plus an artifact. It never commits: the proposed baseline rides in the
artifact, and applying it is a reviewed `pnpm mutation:baseline` commit, so a score cannot lower
itself.
- **PR lane** (`.github/workflows/mutation-affected.yml`) derives the affected shard matrix
(`--list-affected`) and merges the shards into one verdict. Before graduation the matrix is empty —
a report nobody acts on is not worth the runner minutes — unless the diff touches the lane's own
tooling, the one pre-graduation run that buys something: the gate has to be proven before it bites.
Lane sources own no kernel, so that exception adds `LANE_CANARY` (`kernel-errors`, the registry's
cheapest real sweep) to whatever the diff derives; otherwise it would select zero mutants and prove
nothing. `scripts/mutation/selection.test.ts` drives both halves of the rule through the real CLI
against a throwaway worktree commit.
- **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the
high-water score per module plus the Stryker version and config content hash that produced it, so a
score change caused by a tool/config change is reported as provenance drift, never as a
test-strength regression.
- **Graduation, not a flag day**: gating is off until two consecutive comparable weekly sweeps pass
(`stableRuns`/`requiredStableRuns` in the baseline); the PR job starts selecting modules — and
failing on them — once the committed baseline says `gating: true`. A regression or provenance drift
resets the counter.
`--shard i/n` (selectors is ~1,280 mutants, well past the 30-minute budget in one job). The report
job merges the shard reports (`--report-dir`) into one per-kernel table — kernel, score, killed,
survived, total, timeouts, plus the surviving mutants — and requires the full set
(`--expect-shards`), so a dead shard fails the lane instead of publishing its module as 0%. The
table lands in the job summary and the artifact.
- **PR lane** (`.github/workflows/mutation-affected.yml`) exists to prove the harness still runs end
to end when the harness changes, so its matrix (`--list-affected`) is empty unless the diff touches
the lane's own sources: the weekly sweep is the kernel report, and selecting on derived kernel
ownership would run the full ten-shard sweep on 24 of the last 40 merged PRs for a report nobody
gates on. The workflow triggers on exactly those sources (`LANE_TOOLING` in `run.ts`, asserted both
ways by `workflow.test.ts`), so a PR that could only select `[]` never starts the job. Lane sources
own no kernel, so a harness diff adds `LANE_CANARY` (`kernel-errors`, the registry's cheapest real
sweep) to whatever kernels that same diff derives; otherwise it would select zero mutants and prove
nothing. It reports the same table. `scripts/mutation/selection.test.ts` drives both halves of the
rule through the real CLI against a throwaway worktree commit.
- **Provenance**: every report and lane envelope carries the Stryker version and the config content
hash that produced it, so scores measured across a tool or config change are not read as
test-strength change.
- **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the
same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three
groups it drops and why dropping them cannot hide a surviving mutant.
- **Test ownership is derived, never listed** (`scripts/mutation/ownership.ts`): a test owns every
kernel its imports reach, so `src/__tests__/daemon-error.test.ts` selects `kernel-errors` through
`src/daemon.ts` without naming it. A listed set of test files would silently omit exactly those
indirect tests and rot as tests are added — weakening one would skip the ratchet. Reaching a kernel
is a superset of killing its mutants, so the PR lane over-selects on purpose and shards the
selected modules; a false positive costs runner minutes, a false negative costs the gate. Non-kernel
`src/daemon.ts` without naming it. Reaching a kernel is a superset of killing its mutants, so the
derivation over-selects on purpose; it applies to the modules a lane-tooling diff selects. Non-kernel
*sources* are not owned: they can only move a score through those tests, and the weekly sweep
re-measures the whole surface.
- **Lane envelope** (`scripts/lib/lane-envelope.ts`, issue #1430): every run writes
Expand Down
71 changes: 0 additions & 71 deletions mutation-baselines/decision-kernels.json

This file was deleted.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@
"size": "node scripts/size-report.mjs",
"perf": "node --experimental-strip-types scripts/perf/run.ts",
"mutation:run": "node --experimental-strip-types scripts/mutation/run.ts",
"mutation:baseline": "node --experimental-strip-types scripts/mutation/run.ts --update",
"mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run",
"mutation:affected": "node --experimental-strip-types scripts/mutation/run.ts --affected",
"mutation:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/mutation/*.test.ts",
Expand Down
Loading
Loading