From b55fbbb1e36af2024695aab4777e7086e4ff0993 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 06:44:51 +0000 Subject: [PATCH 1/7] feat: cache the mutation gate through turbo The cargo-mutants verdict is now a turbo task keyed on exactly the files that determine mutant survival (crate sources, tests, workspace and toolchain manifests). An unchanged tree replays the cached verdict in milliseconds instead of re-running the 3-minute mutant loop; any change to the classifier inputs re-executes. cacheDir is pinned to .turbo/cache so git worktrees stop silently sharing one cache with the main checkout. --- npm/packages/comment-checker/package.json | 3 ++- package.json | 5 +++-- turbo.json | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/npm/packages/comment-checker/package.json b/npm/packages/comment-checker/package.json index 8a1d7ab..c57ec53 100644 --- a/npm/packages/comment-checker/package.json +++ b/npm/packages/comment-checker/package.json @@ -20,7 +20,8 @@ "scripts": { "build": "tsdown", "typecheck": "tsc -b", - "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --format=${f:-default}" + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint . --format=${f:-default}", + "mutants": "cd ../.. && cargo mutants --file crates/comment-checker/src/classify.rs --timeout 90" }, "devDependencies": { "@effect/platform-node": "4.0.0-rc.108", diff --git a/package.json b/package.json index 3cbaa13..6977ace 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "turbo build", "typecheck": "turbo typecheck", - "lint": "turbo lint" + "lint": "turbo lint", + "mutants": "turbo mutants" }, "devDependencies": { "@effect/tsgo": "latest", @@ -16,4 +17,4 @@ "turbo": "^2.10.5", "typescript": "latest" } -} +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 5af97e3..3f297e8 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://v2-10-1.turborepo.dev/schema.json", + "cacheDir": ".turbo/cache", "tasks": { "build": { "outputLogs": "new-only", @@ -45,6 +46,23 @@ "GITHUB_ACTIONS" ], "cache": true + }, + "mutants": { + "outputLogs": "new-only", + "inputs": [ + "$TURBO_ROOT$/package.json", + "$TURBO_ROOT$/Cargo.toml", + "$TURBO_ROOT$/Cargo.lock", + "$TURBO_ROOT$/.cargo/config.toml", + "$TURBO_ROOT$/crates/comment-checker/Cargo.toml", + "$TURBO_ROOT$/crates/comment-checker/src/**", + "$TURBO_ROOT$/crates/comment-checker/tests/**" + ], + "outputs": [], + "env": [ + "CARGO_BUILD_JOBS" + ], + "cache": true } } } \ No newline at end of file From 1eea3989971c20fb7928581b879a9a7a55469392 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 06:44:59 +0000 Subject: [PATCH 2/7] feat: provision toolchain via nix and cache CI mutation verdicts flake.nix locks the entire toolchain (cargo, clippy, rustfmt, cargo-mutants, gcc, node, pnpm pinned to packageManager) behind one `nix develop`. CI's gate and mutation jobs provision from the same flake instead of dtolnay/rust-toolchain plus a per-run `cargo install cargo-mutants`, and the mutation job runs the gate through turbo with .turbo/cache persisted by actions/cache, so re-runs and same-input PR builds replay the cached verdict instead of re-executing mutants. --- .github/workflows/ci.yml | 41 ++++++++++++++++++++-------------------- flake.lock | 27 ++++++++++++++++++++++++++ flake.nix | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df5277d..707b55f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,11 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy + # Toolchain comes from the repo flake (nix develop): cargo, clippy, + # rustfmt, gcc — same versions as every local dev shell. + - uses: DeterminateSystems/nix-installer-action@v22 - - name: Cache cargo + - name: Cache cargo build uses: actions/cache@v4 with: path: | @@ -23,15 +23,16 @@ jobs: ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- - name: Format check - run: cargo fmt --check + run: nix develop -c bash -lc 'CARGO_BUILD_JOBS=4 cargo fmt --check' - name: Clippy (deny warnings) - run: cargo clippy --all-targets -- -D warnings + run: nix develop -c bash -lc 'CARGO_BUILD_JOBS=4 cargo clippy --all-targets -- -D warnings' - name: Test (unit + property + composition + F1) - run: cargo test --all-targets + run: nix develop -c bash -lc 'cargo test --all-targets -- --test-threads=4' npm: runs-on: ubuntu-latest @@ -68,20 +69,20 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # Toolchain + cargo-mutants from the repo flake — no cargo install. + - uses: DeterminateSystems/nix-installer-action@v22 - - name: Cache cargo + # Turbo verdict cache: content-addressed entries keyed by task inputs. + # Restore the newest previous save, then save this run's state under a + # fresh sha key. Unchanged inputs -> turbo replays the cached verdict + # (FULL TURBO) instead of re-running the mutant loop. + - name: Cache turbo verdicts uses: actions/cache@v4 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - ~/.cargo/bin - key: ${{ runner.os }}-mutants-${{ hashFiles('Cargo.lock') }} - - - name: Install cargo-mutants - run: command -v cargo-mutants || cargo install cargo-mutants --locked + path: .turbo/cache + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}- - - name: Mutation gate (core classifier, 100%) - run: cargo mutants --file crates/comment-checker/src/classify.rs --timeout 90 + - name: Mutation gate via turbo (core classifier, 100%) + run: nix develop -c bash -lc 'pnpm install --frozen-lockfile && pnpm mutants' diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0a9c59d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1787172299, + "narHash": "sha256-PShzS87awOlE5XWkxUGBd/58/F+AtE2ZMgFffKj4r8s=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "07e1d92cdc0ed416cfa11ff3ca40d17e61cfba7a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..613dc34 --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "comment-checker dev shell: Rust + JS toolchain, no ad-hoc installs"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: + nixpkgs.lib.genAttrs systems + (system: f nixpkgs.legacyPackages.${system}); + in { + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + # Rust toolchain (rustc 1.97 — satisfies rust-version = 1.85). + # clippy/rustfmt/cargo are the same toolchain family; cargo-mutants + # drives `cargo` from PATH. + cargo + clippy + rustfmt + cargo-mutants + + # Linker + C compiler: the cc crate compiles tree-sitter grammars. + gcc + + # JS toolchain: nodejs 24 (CI major), pnpm pinned by nixpkgs to + # 11.21.0 — identical to packageManager, so no corepack shim. + nodejs + pnpm + ]; + }; + }); + }; +} From afa8e351030507af1561eefc3d8b356a1550d0fa Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 07:13:29 +0000 Subject: [PATCH 3/7] fix: key mutation cache on gate inputs and cache the pnpm store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actions/cache key now hashes the same input set the mutants task hashes (sources, tests, manifests, flake.lock) instead of the commit sha, so unchanged inputs restore an exact key, skip the post-job save, and stop growing the cache store on every commit. flake.lock joins the task inputs: nixpkgs pins cargo-mutants and rustc, so a toolchain bump regenerates a different mutant set and must invalidate the cached verdict. The pnpm store is cached too — pnpm install runs before turbo can replay anything and was a cold fetch on every run. --- .github/workflows/ci.yml | 23 ++++++++++++++++++----- turbo.json | 1 + 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 707b55f..4d191a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,17 +72,30 @@ jobs: # Toolchain + cargo-mutants from the repo flake — no cargo install. - uses: DeterminateSystems/nix-installer-action@v22 - # Turbo verdict cache: content-addressed entries keyed by task inputs. - # Restore the newest previous save, then save this run's state under a - # fresh sha key. Unchanged inputs -> turbo replays the cached verdict - # (FULL TURBO) instead of re-running the mutant loop. + # Turbo verdict cache. The path must mirror turbo.json's `cacheDir`. + # Keyed on the mutation gate's input set (the same files the mutants + # task hashes): unchanged inputs restore an exact key, which also + # suppresses the post-job save; turbo then replays the cached verdict + # (FULL TURBO) instead of re-running the mutant loop. The restore-key + # prefix picks up the newest previous bag after any input change. - name: Cache turbo verdicts uses: actions/cache@v4 with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-${{ hashFiles('package.json', 'Cargo.toml', 'Cargo.lock', 'flake.lock', '.cargo/config.toml', 'crates/comment-checker/Cargo.toml', 'crates/comment-checker/src/**', 'crates/comment-checker/tests/**') }} restore-keys: | turbo-${{ runner.os }}- + # pnpm store: `pnpm install` runs before turbo can replay anything + # (turbo itself comes from node_modules), so the store is cached to + # keep that install a fast relink instead of a cold fetch. + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + - name: Mutation gate via turbo (core classifier, 100%) run: nix develop -c bash -lc 'pnpm install --frozen-lockfile && pnpm mutants' diff --git a/turbo.json b/turbo.json index 3f297e8..f379c09 100644 --- a/turbo.json +++ b/turbo.json @@ -53,6 +53,7 @@ "$TURBO_ROOT$/package.json", "$TURBO_ROOT$/Cargo.toml", "$TURBO_ROOT$/Cargo.lock", + "$TURBO_ROOT$/flake.lock", "$TURBO_ROOT$/.cargo/config.toml", "$TURBO_ROOT$/crates/comment-checker/Cargo.toml", "$TURBO_ROOT$/crates/comment-checker/src/**", From cae08e3bd0ec64eefcfeb75193cf5e8aa3c28b2f Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 08:11:59 +0000 Subject: [PATCH 4/7] fix: key mutation cache on flake.nix and restore cargo build cache Review fixes (ce-code-review, validated): - turbo.json: add $TURBO_ROOT$/flake.nix to mutants inputs. flake.nix defines the toolchain; without it in the key a flake.nix-only edit replayed the previous verdict verbatim (probe: hash b956bf4d... stayed put, Cached (Local) = true). With it: miss + full re-execution. - ci.yml: add flake.nix, turbo.json and the npm package.json (which carries the gate script) to the turbo-bag hashFiles key. turbo self-hashes the latter two, so an exact-key restore was suppressing the post-job save on config-only edits: fresh verdicts were re-run every time and never persisted. - ci.yml: restore cargo build-state caching in the mutation job (origin/master had it; the turbo rewrite dropped it) keyed on Cargo.lock + flake.lock, sharing the gate job's cargo- restore prefix. - ci.yml: set CARGO_BUILD_JOBS=4 on the mutation run line for parity with the gate job and deterministic env hashing. Verified: mutants loop miss(117 green, 2m36s) -> hit(10ms) -> flake.nix edit miss(2m36s) -> revert hit(8ms); one-shot gate green (fmt, clippy -D warnings, 90 tests). --- .github/workflows/ci.yml | 23 +++++++++++++++++++---- turbo.json | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d191a6..7a54dc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,9 @@ jobs: - uses: DeterminateSystems/nix-installer-action@v22 # Turbo verdict cache. The path must mirror turbo.json's `cacheDir`. - # Keyed on the mutation gate's input set (the same files the mutants - # task hashes): unchanged inputs restore an exact key, which also + # Keyed on the mutation gate's input set plus the files turbo + # self-hashes (turbo.json, the npm package.json carrying the gate + # script): unchanged inputs restore an exact key, which also # suppresses the post-job save; turbo then replays the cached verdict # (FULL TURBO) instead of re-running the mutant loop. The restore-key # prefix picks up the newest previous bag after any input change. @@ -82,10 +83,24 @@ jobs: uses: actions/cache@v4 with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ hashFiles('package.json', 'Cargo.toml', 'Cargo.lock', 'flake.lock', '.cargo/config.toml', 'crates/comment-checker/Cargo.toml', 'crates/comment-checker/src/**', 'crates/comment-checker/tests/**') }} + key: turbo-${{ runner.os }}-${{ hashFiles('package.json', 'Cargo.toml', 'Cargo.lock', 'flake.lock', 'flake.nix', '.cargo/config.toml', 'crates/comment-checker/Cargo.toml', 'crates/comment-checker/src/**', 'crates/comment-checker/tests/**', 'turbo.json', 'npm/packages/comment-checker/package.json') }} restore-keys: | turbo-${{ runner.os }}- + # Cargo build state for the turbo-miss path (any input change + # recompiles). Keyed on Cargo.lock + flake.lock (deps + toolchain); + # the shared `cargo-` restore prefix also picks up bags the gate + # job saved under the same namespace. + - name: Cache cargo build (mutants miss path) + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-mut-${{ hashFiles('Cargo.lock', 'flake.lock') }} + restore-keys: ${{ runner.os }}-cargo- + # pnpm store: `pnpm install` runs before turbo can replay anything # (turbo itself comes from node_modules), so the store is cached to # keep that install a fast relink instead of a cold fetch. @@ -98,4 +113,4 @@ jobs: pnpm-store-${{ runner.os }}- - name: Mutation gate via turbo (core classifier, 100%) - run: nix develop -c bash -lc 'pnpm install --frozen-lockfile && pnpm mutants' + run: nix develop -c bash -lc 'pnpm install --frozen-lockfile && CARGO_BUILD_JOBS=4 pnpm mutants' diff --git a/turbo.json b/turbo.json index f379c09..1b3f1a9 100644 --- a/turbo.json +++ b/turbo.json @@ -54,6 +54,7 @@ "$TURBO_ROOT$/Cargo.toml", "$TURBO_ROOT$/Cargo.lock", "$TURBO_ROOT$/flake.lock", + "$TURBO_ROOT$/flake.nix", "$TURBO_ROOT$/.cargo/config.toml", "$TURBO_ROOT$/crates/comment-checker/Cargo.toml", "$TURBO_ROOT$/crates/comment-checker/src/**", From 9eb007f23b6c44d1859b7305c71e9fee7e25e0ea Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 08:43:24 +0000 Subject: [PATCH 5/7] docs: solution doc for mutation-cache hash coverage + verdict-cache concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compound of this session's learning: the turbo-cached mutation gate hashed the toolchain pin (flake.lock) but not the toolchain definition (flake.nix), so flake.nix-only edits replayed stale verdicts. Doc captures the fix, the two falsified adversarial hypotheses (script-body and env staleness — both busted by hash-movement probes), the transport-superset invariant, and the probe protocol. CONCEPTS.md gains the Verdict cache entry (two hash surfaces). --- CONCEPTS.md | 15 +++ .../turbo-mutants-flake-nix-cache-key.md | 114 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 docs/solutions/integration-issues/turbo-mutants-flake-nix-cache-key.md diff --git a/CONCEPTS.md b/CONCEPTS.md index c3e19a2..d4a932a 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -66,6 +66,21 @@ A matrix row in the release workflow: one platform/arch build, gate, smoke, and publish run on its native runner. Platforms publish before the launcher, and the release cannot proceed if any lane fails. +## Mutation gate + +### Verdict cache +The turbo task cache that stores the mutation gate's result keyed on the +gate's input set; unchanged inputs replay the stored verdict instead of +re-running the mutant loop. + +Two hash surfaces govern it. The task hash is the gate's own key: every +file that can change the verdict must be an explicit task input — automatic +coverage extends only to the script body and declared env values. The +transport key (the CI cache step's key) must be a superset of the task hash +surface, because an exact-key restore suppresses the post-run save: a +narrower transport key does not replay stale verdicts, it discards fresh +ones. + ## Flagged ambiguities - "context" had been used for both the language (scope/position) and the diff --git a/docs/solutions/integration-issues/turbo-mutants-flake-nix-cache-key.md b/docs/solutions/integration-issues/turbo-mutants-flake-nix-cache-key.md new file mode 100644 index 0000000..3d3f747 --- /dev/null +++ b/docs/solutions/integration-issues/turbo-mutants-flake-nix-cache-key.md @@ -0,0 +1,114 @@ +--- +title: Turbo-cached mutation gate replays stale verdicts when the toolchain definition misses the cache key +date: 2026-08-21 +category: integration-issues +module: cargo-mutants mutation gate (turbo mutants task + CI mutation job cache) +problem_type: integration_issue +component: tooling +severity: high +symptoms: + - "A flake.nix-only edit replayed the previous cargo-mutants verdict verbatim: the turbo task hash did not move and the run reported a local cache hit" + - "Changing the dev-shell toolchain (the cargo-mutants version lives in the flake's devShells.default package list) did not invalidate the cached verdict, so a stale green could replay instead of a fresh run" + - "Inverse CI-side symptom: config-only edits moved turbo's own hash but not the actions/cache bag key, so the gate re-executed on every CI run and the fresh verdict was never persisted (exact-key restore suppresses the post-job save — documented actions/cache behavior, per the CI workflow's own cache-step comment)" +root_cause: config_error +resolution_type: config_change +related_components: + - development_workflow + - testing_framework +tags: [turbo, cargo-mutants, mutation-testing, cache-key, flake, actions-cache, stale-cache, hash-coverage] +--- + +# Mutation gate cache hashed the toolchain pin but not the toolchain definition + +## Problem + +The turbo `mutants` task — the cached cargo-mutants gate over the core classifier — hashed the nix lockfile as an input but not the flake that defines the dev-shell toolchain, including the cargo-mutants version. An edit confined to the toolchain definition therefore left the task hash untouched and turbo replayed the previous verdict without running the mutant loop. The same class of gap existed one level up: the CI mutation job's actions/cache key did not cover the files turbo hashes beyond the task's declared inputs, so config-only edits re-executed the gate every run while the restored exact key suppressed the save — the fresh verdict never persisted. + +## Symptoms + +- Editing the toolchain definition did not invalidate the mutation gate; turbo replayed the previous verdict verbatim (task hash unchanged, local cache hit reported). +- Config-only edits (task options, the npm launcher's gate script) moved turbo's internal hash but not the CI bag key; the gate re-executed every CI run and nothing was saved back. +- No error, no warning: a stale replay is indistinguishable in output from a correct one. Only the absence of the expected multi-minute runtime exposes it. + +## What Didn't Work + +- **Trusting the task's input list as proof of coverage.** The list looked complete — lockfile, workspace config, source globs. An input covers only the file it names; the toolchain definition was simply absent. A reading pass cannot prove hash coverage; only a hash-movement probe can. +- **Treating the CI cache key as an independent ledger.** The transport cache (actions/cache) is a bag labeled by its key, not a second opinion. When the bag key is a strict subset of the engine's hash surface, unchanged inputs restore an exact key, and exact-key restore suppresses the post-run save — so any edit outside the bag key re-executes forever without persisting. +- **Two adversarial hypotheses, falsified by probe — do not re-argue them:** + - *False: "editing the gate script body replays stale verdicts."* Turbo self-hashes the task's package script body. Editing the launcher's `scripts.mutants` entry moved the hash and forced a miss. Script bodies are covered automatically; they need no explicit input. + - *False: "env or option changes replay stale verdicts."* Turbo hashes declared env values and its own task options. A probe setting a divergent `CARGO_BUILD_JOBS` value moved the hash and missed. Divergent environment busts the cache; it never replays. + +## Mechanism + +A cached verdict is sound if and only if every determinant of the verdict is in the key: + +``` +stale replay possible <=> Determinants(verdict) \ Inputs(hash) != {} +``` + +Determinants here: mutation-target sources and tests, workspace manifests, dependency lockfiles, toolchain definition + toolchain pin, build config, and the gate command itself. The bug was a single-element set difference: the toolchain definition file. Turbo's automatic hashing covers the script body and declared env values — never arbitrary repository files outside the package — so any file whose bytes change the verdict must appear in the task's explicit input list. + +The transport layer adds a second condition. The CI bag key must be a superset of the engine's full hash surface: + +``` +bag key ⊇ turbo hash surface (else: exact-key restore suppresses save) +``` + +A violation does not replay stale verdicts — turbo's own hash still gates replay — but it discards every fresh verdict produced under a hash the bag key cannot distinguish, turning the cache into a permanent re-execute. + +## Solution + +Fix on branch `turbo-rust` (PR opening on that branch), pending merge as of +this writing. + +1. Add the toolchain definition to the task's explicit inputs, beside the toolchain pin that was already there: + +``` +"inputs": [ + "$TURBO_ROOT$/package.json", + "$TURBO_ROOT$/Cargo.toml", + "$TURBO_ROOT$/Cargo.lock", + "$TURBO_ROOT$/flake.lock", + "$TURBO_ROOT$/flake.nix", + "$TURBO_ROOT$/.cargo/config.toml", + "$TURBO_ROOT$/crates/comment-checker/Cargo.toml", + "$TURBO_ROOT$/crates/comment-checker/src/**", + "$TURBO_ROOT$/crates/comment-checker/tests/**" +] +``` + +2. Widen the CI mutation job's actions/cache `hashFiles` key to a superset: the task input set above, plus the two descriptors turbo self-hashes (the turbo config and the npm launcher package manifest carrying the gate script). A narrower key silently discards fresh verdicts. +3. Restore build-state caching for the miss path (registry, git checkouts, target dir), keyed on the dependency lockfile + toolchain pin, sharing a common restore-key prefix with the gate job's build cache. +4. Set the build-jobs env var explicitly on the mutation run line, matching the gate job — declared env values are hash inputs, so an unset-or-varies value makes local and CI keys diverge for no reason. + +## Architectural Invariants + +- **Hash-coverage completeness:** every file that can change the verdict must be in the task's input list. Automatic coverage extends only to the script body and declared env values. Corollary: when moving a gate behind a cache, enumerate its determinants first, then make the input list equal that enumeration. +- **Transport-key superset:** a transport cache wrapped around a content-addressed engine must key on a superset of the engine's own hash surface, because exact-key restore suppresses the save. Subset keys do not corrupt verdicts; they discard deposits. +- **Probe, don't trust:** membership in the hash is an empirical property of the engine version in use. Establish it by editing the file alone and observing hash movement, not by documentation or intuition. +- **A stale verdict is observationally identical to a fresh one.** Never disable caching to fix staleness; fix the key. The only honest signal of a replay is missing execution time. + +## Proof protocol + +Probe with the engine's dry-run hash report (`turbo run mutants --dry=json` or equivalent), editing one file at a time: + +| Edit under test | Expected hash | Expected cache event | +| --- | --- | --- | +| toolchain definition (post-fix) | changes | miss + re-execution | +| gate script body | changes | miss (self-hashed) | +| declared env value | changes | miss (env-hashed) | +| no change / reverted file | unchanged | hit (full replay) | + +Session verification (2026-08-21, four runs): input-set change -> miss, 117 mutants, 113 caught, 4 unviable, 0 survived, ~2m36s; unchanged -> hit in 10 ms; toolchain-definition edit -> miss, full re-execution; revert -> hit in 8 ms. The pre-fix behavior — toolchain edit leaving the hash unchanged and replaying — was the defect this loop reproduced and closed. + +## Prevention + +- When a file must invalidate a cached gate, add it to the task's explicit inputs. Gate: the probe above — edit the file alone, the dry-run hash must change; revert, it must not. +- Keep the transport cache key a superset of the engine hash surface. Gate: whenever an input is added to the task, diff the transport key's file list against the task inputs plus self-hashed descriptors; every engine-side input must appear. +- Do not put a value in declared task env that must not be a hash input; every local variance busts the team cache. Gate: the env-value probe row above. +- Record falsified hypotheses (script-body staleness, env staleness — both false) so the coverage boundary is not re-litigated from intuition. Gate: this document. + +## Related + +- docs/solutions/design-patterns/evidence-gated-context-aware-classification.md — the classifier gate whose verdict this cache transports; its enforcement section is the upstream consumer. +- docs/solutions/architecture-patterns/rust-cli-npm-distribution.md — shares the CI surface and the gate-the-derived-surface discipline. From 7dce7b7e0a7b6091086a578f4c6394179d9f655f Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 08:52:08 +0000 Subject: [PATCH 6/7] fix: drop login shell from nix develop CI commands First Actions run failed in both gate and mutation jobs: 'bash -lc' inside nix develop -c re-sources the runner profiles, which re-prepend ~/.cargo/bin ahead of the dev shell PATH. The rustup shim then owned the toolchain: it synced the stable channel mid-build, removed the previous rust-std/rustc components, and every compile died with E0463 (can't find crate for std). cargo-mutants hit the identical failure in its scratch tree. 'bash -c' keeps the dev shell's PATH first; nix develop -c already exports the full toolchain. Comment added at the first use so the flag isn't reintroduced. Verified locally in the exact new form: one-shot gate green (90 tests), mutants cold miss (117 mutants, 113 caught, 4 unviable, 0 survived, 2m36s) then FULL TURBO hit in 9ms with CARGO_BUILD_JOBS=4 matching CI. --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a54dc6..819dab5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,18 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- + # No `bash -l`: login shells re-source runner profiles which re-prepend + # ~/.cargo/bin ahead of the dev shell, and the rustup shim then hijacks + # the toolchain (observed: rustup updated stable mid-build, removed + # rust-std, E0463). `nix develop -c` already exports the full PATH. - name: Format check - run: nix develop -c bash -lc 'CARGO_BUILD_JOBS=4 cargo fmt --check' + run: nix develop -c bash -c 'CARGO_BUILD_JOBS=4 cargo fmt --check' - name: Clippy (deny warnings) - run: nix develop -c bash -lc 'CARGO_BUILD_JOBS=4 cargo clippy --all-targets -- -D warnings' + run: nix develop -c bash -c 'CARGO_BUILD_JOBS=4 cargo clippy --all-targets -- -D warnings' - name: Test (unit + property + composition + F1) - run: nix develop -c bash -lc 'cargo test --all-targets -- --test-threads=4' + run: nix develop -c bash -c 'cargo test --all-targets -- --test-threads=4' npm: runs-on: ubuntu-latest @@ -113,4 +117,4 @@ jobs: pnpm-store-${{ runner.os }}- - name: Mutation gate via turbo (core classifier, 100%) - run: nix develop -c bash -lc 'pnpm install --frozen-lockfile && CARGO_BUILD_JOBS=4 pnpm mutants' + run: nix develop -c bash -c 'pnpm install --frozen-lockfile && CARGO_BUILD_JOBS=4 pnpm mutants' From 2f32cceed88220bc5c212a0f5f8826d1f9243b9a Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 09:00:01 +0000 Subject: [PATCH 7/7] fix: provide rustc explicitly in the dev shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 2 failed differently from run 1: nix's cargo resolved rustc via PATH, the dev shell never shipped one, and on Actions runners the rustup proxy won the lookup — its stable toolchain then failed to link (std rlibs not applicable/missing). Locally the same shell only worked because ambient PATH satisfied the lookup by accident; 'type -P rustc' inside a clean 'nix develop -c bash -c' proved MISSING. cargo resolves rustc from PATH; a toolchain-provisioning flake that lists cargo without rustc is incomplete. Add rustc beside cargo with a comment naming the failure mode. Verified in a runner simulation (env -i, PATH=nix:/usr/bin:/bin, CI=true, cold pnpm store, no rustup on any path): pnpm install 2.9s; mutants cache miss -> full re-execution, 117 mutants green, 2m37s, exit 0; replay hit. The flake.nix edit itself flipped the turbo input hash, so the gate re-verified under the new toolchain set rather than replaying. --- flake.nix | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 613dc34..d6f3d72 100644 --- a/flake.nix +++ b/flake.nix @@ -14,9 +14,11 @@ default = pkgs.mkShell { packages = with pkgs; [ # Rust toolchain (rustc 1.97 — satisfies rust-version = 1.85). - # clippy/rustfmt/cargo are the same toolchain family; cargo-mutants - # drives `cargo` from PATH. + # rustc MUST be explicit: cargo resolves rustc via PATH, and a + # dev shell without it silently borrows whatever rustc the host + # happens to expose (on CI runners: the rustup proxy). cargo + rustc clippy rustfmt cargo-mutants