Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9604313
feat: mutation gate (make mutate-check, Quality / Mutation) on uncleb…
ThomasK33 Oct 21, 2026
7971f19
fix: mutation runner review fixes (baseline worker queue, deadline, C…
ThomasK33 Oct 21, 2026
93f248f
fix: one crashing mutant costs one error row, not the mutation report
ThomasK33 Oct 22, 2026
83d6601
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
1011048
fix: mutation gate review round 1 (no-coverage fallback, capped tests…
ThomasK33 Oct 22, 2026
2579f4d
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
b575414
fix: mutation gate review round 2 (parsed imports, no-coverage tests,…
ThomasK33 Oct 22, 2026
dc94cbe
fix: mutation gate review round 3 (POSIX only, error rows in the CI s…
ThomasK33 Oct 22, 2026
991dfe6
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
5f4e8d9
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
72be907
fix: a retired mutation worker leaves the remaining sites to healthy …
ThomasK33 Oct 22, 2026
c5c0621
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
6998e5d
fix: the mutation import graph reads every tsconfig alias and Bun ?qu…
ThomasK33 Oct 22, 2026
31bc4f7
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
e3f7626
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
132597a
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
3273eb5
Merge branch 'gauntlet/dry-gate' into gauntlet/mutation-gate
ThomasK33 Oct 22, 2026
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
63 changes: 63 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,69 @@ jobs:
fi
bun scripts/dry/ci.ts

mutation:
name: Quality / Mutation
# Mutants in the PR's changed functions that the unit tests do not kill
# (scripts/mutate/). Informational only: scripts/mutate/ci.ts always exits
# 0, every step is soft, and Required does not list this job. It waits for
# Test / Unit to reuse its coverage (crap-lcov-unit-*): a site on a line
# no unit test ran is skipped. ci.ts stops at 300 mutants or 15 minutes.
needs: [changes, test-unit]
if: ${{ !cancelled() && github.event_name == 'pull_request' && needs.changes.outputs.tests == 'true' }}
continue-on-error: true
# Above the step timeouts (34 minutes) so a slow setup still leaves a summary.
timeout-minutes: 35
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
continue-on-error: true
timeout-minutes: 3
with:
persist-credentials: false
# The merge commit's first parent, as in Quality / CRAP above.
- name: Fetch the merge base
id: base
continue-on-error: true
timeout-minutes: 2
run: |
set -euo pipefail
sha=$(git cat-file -p HEAD | sed -n 's/^parent //p' | head -n 1)
git fetch --no-tags --depth=1 origin "$sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# The mutants run the unit tests, which need node_modules.
- uses: ./.github/actions/setup-xum
continue-on-error: true
timeout-minutes: 6
with:
skip-build-caches: "true"
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
continue-on-error: true
timeout-minutes: 2
with:
enable-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
timeout-minutes: 3
with:
pattern: crap-lcov-unit-*
path: coverage-shards
- name: Mutation report
continue-on-error: true
timeout-minutes: 18
env:
BASE_REF: ${{ steps.base.outputs.sha }}
LCOV_DIR: coverage-shards
# Shard count of the test-unit matrix above.
EXPECTED_UNIT: "6"
run: |
if ! command -v bun >/dev/null; then
echo "::warning title=Mutation::bun setup failed, so no mutation report"
exit 0
fi
bun scripts/mutate/ci.ts

test-storybook:
name: Test / Storybook
needs: [changes]
Expand Down
44 changes: 43 additions & 1 deletion .xum/skills/gauntlet/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: gauntlet
description: Read and fix DRY gate findings (make dry-check, Quality / DRY). Use when a dry-check report names a new near-duplicate, or before you copy a function and edit the copy.
description: Read and fix DRY and mutation gate findings (make dry-check, make mutate-check, Quality / DRY, Quality / Mutation). Use when a report names a new near-duplicate or a surviving mutant, or before you copy a function and edit the copy.
---

# Gauntlet gates
Expand Down Expand Up @@ -43,3 +43,45 @@ warning new-duplicate score 1.00 src/x.ts:41-46 feeBucket

Never merge two functions only to clear a DRY finding. A wrong abstraction
costs more than a copy.

## Mutation gate

`make mutate-check` (or `bun scripts/mutate/check.ts --base <ref>`) makes
small changes ("mutants") in the functions you changed, one at a time: `===`
to `!==`, `>` to `>=`, `&&` to `||`, `??` to `||`, `true` to `false`, `0` to
`1`, and more ([mutator](https://github.com/unclebob/mutator)'s rules). For
each mutant it runs the Bun unit tests that import the file. A mutant that no
test fails on "survived": no test checks that behaviour. It runs on Linux and
macOS only (it kills each test's process group); on Windows it is a tool error.

```
mutate-check: 2 survived, 7 killed, 0 uncovered, 0 without a test, 0 not run
src/x.ts (tests: src/x.test.ts, src/y.test.ts)
survived :61 === -> !== parseCap
```

- Tests: the colocated test first, then the nearest other unit tests that
import the file directly, at most 10. Each test file runs in its own
`bun test` process, in a private copy of the tracked files. The first
failing file kills the mutant. A timeout (10x the file's own time, at
least 2 s) also kills it.
- Exit 0 no survivors, 1 survivors, 2 tool error, 3 baseline failure: a test
file failed without a mutant, so the run dropped it. Fix that test first.
- `uncovered`: with `--coverage <lcov>` (CI uses the Test / Unit reports), a
site on a line that no test ran does not run. `no-test`: no passing unit
test imports the file. `not-run`: the run stopped at its limit (CI: 300
mutants or 15 minutes). `error`: the runner crashed on that one mutant; the
report gives the reason, and the other mutants still count.
- Locally it runs on 4 workers at most (`--workers N`). Start with a small
change: every site in a touched function runs.

### Repair

1. Write a test that fails for the mutant. Assert the behaviour that the
mutant breaks (the boundary, the other branch), not the line.
2. If no input can tell the mutant from the original, it is equivalent. For
example, `m[2] ?? ""` and `m[2] || ""` are the same when `m[2]` is never
`""`. Say so in the PR and move on.

Never change production code only to kill a mutant, and never add a test
without a real assertion. The CRAP gate's anti-gaming rules apply here too.
18 changes: 16 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ include fmt.mk
.PHONY: all build dev start clean help
.PHONY: build-renderer version build-icons build-static build-docker-runtime verify-docker-runtime-artifacts
.PHONY: lint lint-eslint lint-full lint-fix check-oxlint-config typecheck static-check static-check-full
.PHONY: crap crap-check test-crap-scripts tree-sitter-grammars dry-check test-dry-scripts
.PHONY: crap crap-check test-crap-scripts tree-sitter-grammars dry-check test-dry-scripts mutate-check test-mutate-scripts
.PHONY: test-bugbash-repros test-bugbash-known-failures bugbash-sandbox-key bugbash-sandbox-image bugbash-sandbox-publish bug-bash-sandbox-recover bug-bash-sandbox-check test-bugbash-repros-sandbox
.PHONY: test test-unit test-unit-ci test-integration test-watch test-coverage test-e2e test-e2e-perf perf-tape-replay smoke-test
.PHONY: dist dist-mac dist-win dist-linux install-mac-arm64 ensure-mac-sharp-runtime-deps check-appimage-icons check-mac-attach-file-runtime
Expand Down Expand Up @@ -543,7 +543,7 @@ STATIC_CHECKS := typecheck fmt-check check-startup-imports check-react-compiler

static-check: lint $(STATIC_CHECKS) ## Run fast local static checks

static-check-full: lint-full $(STATIC_CHECKS) check-bench-agent test-bench-scripts test-crap-scripts test-dry-scripts check-docs-links ## Run the full CI static check suite
static-check-full: lint-full $(STATIC_CHECKS) check-bench-agent test-bench-scripts test-crap-scripts test-dry-scripts test-mutate-scripts check-docs-links ## Run the full CI static check suite

# Harbor version for benchmark-terminal and the adapter tests below, so CI tests the
# adapter against the same Harbor API the scheduled benchmark runs.
Expand Down Expand Up @@ -599,6 +599,20 @@ test-dry-scripts: tree-sitter-grammars ## Test the dryer adapter and dry-check e
@$(DRY_PYTHON) -m pytest -q -p no:cacheprovider scripts/dry/dryer_report_test.py
@bun test ./scripts/dry/check.test.ts

# Mutation gate (scripts/mutate/): mutants in the functions changed since BASE
# that the unit tests do not kill. It only warns; see .xum/skills/gauntlet.
# The script exits 0 no survivors, 1 survivors, 2 tool error, 3 a test failed
# without a mutant; CI calls it through scripts/mutate/ci.ts.
mutate-check: ## Mutation gate on functions changed since BASE (default: merge-base with origin/main) [COVERAGE="<lcov>..."] [MUTATE_CHECK_ARGS=--json]
@bun scripts/mutate/check.ts --base "$(or $(BASE),$$(git merge-base origin/main HEAD))" \
$(foreach lcov,$(COVERAGE),--coverage $(lcov)) $(MUTATE_CHECK_ARGS)

MUTATE_PYTHON := PYTHONDONTWRITEBYTECODE=1 uv run --quiet --no-project --with pytest --with-requirements scripts/mutate/mutate_report.py python
Comment thread
ThomasK33 marked this conversation as resolved.

test-mutate-scripts: tree-sitter-grammars ## Test the mutation runner and mutate-check end to end (needs uv)
@$(MUTATE_PYTHON) -m pytest -q -p no:cacheprovider scripts/mutate/mutate_report_test.py
@bun test ./scripts/mutate/check.test.ts

check-test-routing: node_modules/.installed ## Fail when a *.test.ts(x) file is run by no CI lane (or by two)
@./scripts/check-test-routing.sh

Expand Down
2 changes: 1 addition & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ description: Agent instructions for AI assistants working on the Xum codebase
- Xum sources `.xum/tool_env` before every `bash` tool call; in other harnesses, `source .xum/tool_env` first. Use `run_and_report <step_name> <command...>` when running multiple validation steps in one call.
- Do not pipe/redirect/wrap `run_and_report` output; keep helper markers intact so Xum can show clean step status.
- CRAP gate: `make crap-check` scores the functions you changed. When it (or the report after an agent `git commit`/`git push`) names a function, read the `crap-gate` skill (`.xum/skills/crap-gate/SKILL.md`) before fixing it. In block mode, `Quality / CRAP` violations fail `Required`. Agents must not add the `crap-debt` label or a `Crap-Debt:` trailer without the maintainer's explicit approval for that PR: the label skips every CI block (the trailer skips only local checks: the commit hook and `make crap-check`), and agent PRs run as the maintainer's account, so GitHub cannot tell who added them.
- DRY gate: `make dry-check` reports near-duplicate functions that your change makes. It only warns. Before you fix a finding, read the `gauntlet` skill (`.xum/skills/gauntlet/SKILL.md`).
- DRY and mutation gates: `make dry-check` reports near-duplicate functions that your change makes, and `make mutate-check` reports mutants in the functions you changed that the unit tests do not kill. Both only warn. Before you fix a finding, read the `gauntlet` skill (`.xum/skills/gauntlet/SKILL.md`).
- `./scripts/wait_pr_ready.sh <pr_number>` is the preferred tail-end helper after local validation and after you've exhausted useful local work.
- `./scripts/wait_pr_checks.sh <pr_number>` is the checks watcher; `wait_pr_ready.sh` must execute `wait_pr_checks.sh --once` on each loop iteration.
- `./scripts/wait_pr_codex.sh <pr_number>` is the Codex gate used by `wait_pr_ready.sh`.
Expand Down
1 change: 1 addition & 0 deletions scripts/check-test-routing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ other_routes=(
$'scripts/crap/check.test.ts\tmake test-crap-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'scripts/crap/agentGate.test.ts\tmake test-crap-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'scripts/dry/check.test.ts\tmake test-dry-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'scripts/mutate/check.test.ts\tmake test-mutate-scripts (static-check-full): needs uv, which Test / Unit does not install'
$'tests/ui/domIsolation.radixOrder.child.test.tsx\tspawned in a fresh process by tests/ui/domIsolation.test.ts'
$'vscode/src/api/orpcConnection.integration.test.ts\tnot in CI: needs a live xum server; run with TEST_INTEGRATION=1 bun test ./vscode/src/api/'
)
Expand Down
124 changes: 124 additions & 0 deletions scripts/mutate/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// End-to-end tests of `scripts/mutate/check.ts` in throwaway git repos, with
// real `bun test` processes. They need uv (the runner goes through `uv run`),
// which the Test / Unit runners do not install, so `make test-mutate-scripts`
// (static-check-full) runs this file and scripts/test-unit-ci.sh skips it.
import { afterEach, beforeEach, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { MutateReport } from "./check";

const CHECK = join(import.meta.dir, "check.ts");
// Hermetic git: CI has no identity, and host config (hooks, signing) must not leak in.
const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };

let repo: string;

function git(...args: string[]): void {
const result = spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], {
cwd: repo,
env: GIT_ENV,
});
expect(result.status).toBe(0);
}

function write(path: string, text: string): void {
mkdirSync(dirname(join(repo, path)), { recursive: true });
writeFileSync(join(repo, path), text);
}

function check(): { status: number | null; report: MutateReport | null } {
const result = spawnSync(process.execPath, [CHECK, "--base", "HEAD", "--json"], {
cwd: repo,
env: GIT_ENV,
encoding: "utf8",
});
return { status: result.status, report: result.status === 2 ? null : JSON.parse(result.stdout) };
}

function outcomes(report: MutateReport | null): string[] {
return (report?.mutants ?? []).map((m) => `${m.original}>${m.mutant}:${m.outcome}`).sort();
}

// Each test starts real bun processes (baseline plus mutants), so allow more
// than bun's 5 s default on a loaded host.
const SLOW = 60_000;

const AGE = "export const isAdult = (age: number) => age >= 18;\n";

beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), "mutate-check-test-"));
git("init", "-q", "-b", "main");
// The real script lists the Bun unit lane; this one lists the fixture's tests,
// all with coverage.
write(
"scripts/test-unit-ci.sh",
"[ \"$1\" = --list-no-coverage ] && exit 0\ngit ls-files -- '*.test.ts' | sed 's#^#./#'\n"
);
write("src/age.ts", AGE);
write(
"src/age.test.ts",
'import { expect, test } from "bun:test";\nimport { isAdult } from "./age";\ntest("adult", () => expect(isAdult(30)).toBe(true));\n'
);
git("add", ".");
git("commit", "-q", "-m", "base");
});

afterEach(() => {
rmSync(repo, { recursive: true, force: true });
});

test(
"a mutant at an untested boundary survives",
() => {
write("src/age.ts", AGE.replace("age >= 18", "age >= 18 /* touched */"));

const { status, report } = check();

expect(status).toBe(1);
expect(outcomes(report)).toEqual([">=>>:survived"]);
expect(report?.tests["src/age.ts"]).toEqual(["src/age.test.ts"]);
},
SLOW
);

test(
"a test at the boundary kills it, also through an @/ import",
() => {
write("src/age.ts", AGE.replace("age >= 18", "age >= 18 /* touched */"));
write(
"src/edge.test.ts",
'import { expect, test } from "bun:test";\nimport { isAdult } from "@/age";\ntest("18", () => expect(isAdult(18)).toBe(true));\n'
);
write("tsconfig.json", '{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }\n');
git("add", ".");
git("commit", "-q", "-m", "edge test");
write("src/age.ts", AGE.replace("age >= 18", "age >= 18 /* again */"));

const { status, report } = check();

expect(status).toBe(0);
expect(outcomes(report)).toEqual([">=>>:killed"]);
expect(report?.tests["src/age.ts"]).toEqual(["src/age.test.ts", "src/edge.test.ts"]);
},
SLOW
);

test(
"a test that fails without a mutant is a baseline failure, not a pass",
() => {
write("src/age.ts", AGE.replace("age >= 18", "age >= 18 /* touched */"));
write(
"src/age.test.ts",
'import { expect, test } from "bun:test";\ntest("broken", () => expect(1).toBe(2));\nimport "./age";\n'
);

const { status, report } = check();

expect(status).toBe(3);
expect(report?.droppedTests.map((d) => d.path)).toEqual(["src/age.test.ts"]);
expect(outcomes(report)).toEqual([">=>>:no-test"]);
},
SLOW
);
Loading
Loading