chore: run each client's test suite once in local:gate, not twice (#2341)

`local:gate` ran every client's unit suite bare inside `validate` and
again instrumented inside `coverage` — ~80s of a ~370s run re-executing
exactly the files the coverage pass runs a few minutes later. CI pays
nothing for that duplication because its two jobs run in parallel; the
gate runs serially, so it paid all of it.

The gate's first stage is now `local:validate`: the same `validate:guards`
and `validate:core`, then each client's new `check` script (`format:check`
+ `lint` + `typecheck` + `build`) instead of its `validate`, which is now
literally `check && test`. `npm run validate` and both CI jobs are
unchanged; `local:validate` sits in the `local:` namespace so the
workflow guard keeps it out of CI by construction.

The "strict superset" claim in AGENTS.md is reworded to what is now
true — every CI *check*, not every CI *invocation* — with the reasoning:
`@vitest/coverage-v8` reads V8's precise-coverage profiler and rewrites
no source, so the instrumented pass runs byte-identical code and is only
slower, the stricter direction for the failure class this repo sees.

Measured on the same worktree: 370s -> 262s wall (EXIT=0 both), the
four removed legs summing to 78.5s in the baseline run's own profile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-12 19:53:08 -04:00
commit fe8b039030
2 changed files with 163 additions and 31 deletions

View file

@ -7,9 +7,11 @@
* two verify gates, `smoke` and the Storybook tests. `npm run smoke` covers
* `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web` and
* `smoke:web:chromium` — all of which BELONG there.
* - **`npm run local:gate`**, the pre-push gate, is a strict superset: it adds
* the **Firefox** engine pass, and `smoke:tui` really runs there rather than
* self-skipping. WebKit is on demand and belongs to neither tier.
* - **`npm run local:gate`**, the pre-push gate, runs every check CI runs and
* adds the **Firefox** engine pass, and `smoke:tui` really runs there rather
* than self-skipping. WebKit is on demand and belongs to neither tier. It
* runs each client's unit suite once (instrumented, under `coverage`) via
* `local:validate`, where CI's two parallel jobs run it twice (#2341).
*
* The narrow thing that must never reach CI is a **non-Chromium engine pass**.
* `smoke:web:firefox` was placed in the local gate on purpose (#2086,

View file

@ -23,6 +23,7 @@ import {
findWorkflowViolations,
formatWorkflowViolations,
} from "./workflow-gate.mjs";
import { reachableScripts } from "./npm-scripts.mjs";
const repoRoot = join(import.meta.dirname, "..", "..");
const workflowDir = join(repoRoot, ".github", "workflows");
@ -609,6 +610,106 @@ describe("the gate's name", () => {
assert.match(scripts["local:gate"], /local:storybook/);
});
describe("runs each client's test suite once, not twice (#2341)", () => {
// CI runs each client's unit suite bare (inside `validate`) and again
// instrumented (inside `coverage`) on two PARALLEL runners, so the second
// pass is free there. The gate runs serially, where the bare pass was ~80s
// of a ~370s run re-executing exactly the files `coverage` runs a few
// minutes later. So the gate's first stage is `local:validate` — `validate`
// minus every client's `test` leg — and these pin the three properties
// that keep it honest: the gate no longer reaches a client's bare `test`,
// it still reaches every non-test check `validate` reaches, and `validate`
// itself (CI's inner loop) is untouched.
const clients = ["web", "cli", "tui", "launcher"];
const clientScripts = Object.fromEntries(
clients.map((c) => [
c,
JSON.parse(
readFileSync(join(repoRoot, "clients", c, "package.json"), "utf8"),
).scripts,
]),
);
it("starts from `local:validate`, and never from `validate`", () => {
assert.match(scripts["local:gate"], /^npm run local:validate && /);
assert.doesNotMatch(scripts["local:gate"], /\brun validate(?=$|[\s&;])/);
// `local:validate` must not reach a client's `validate` either — that is
// the one door through which the bare `test` leg would come back.
const reached = reachableScripts(scripts, "local:validate");
for (const c of clients)
assert.ok(
!reached.has(`validate:${c}`),
`local:validate must not reach validate:${c}`,
);
assert.match(
scripts["local:validate"],
/\brun check(?=$|[\s&;])/,
"local:validate must run each client's `check`",
);
for (const c of clients)
assert.match(
scripts["local:validate"],
new RegExp(`(?:cd (?:\\.\\./|clients/)${c}|--prefix clients/${c})`),
`local:validate must visit clients/${c}`,
);
});
it("shares the guards and `validate:core` with `validate`", () => {
// One list of durable guards, reached by both entry points — so a guard
// added to `validate:guards` lands in CI and in the gate together.
const gate = reachableScripts(scripts, "local:validate");
const inner = reachableScripts(scripts, "validate");
for (const name of ["validate:guards", "validate:core"]) {
assert.ok(gate.has(name), `local:validate must reach ${name}`);
assert.ok(inner.has(name), `validate must reach ${name}`);
}
// Everything `validate` reaches at the root that is not a client
// delegation is reached by the gate too.
for (const name of inner)
if (
name !== "validate" &&
!/^validate:(web|cli|tui|launcher)$/.test(name)
)
assert.ok(gate.has(name), `local:validate must reach ${name}`);
});
it("each client's `validate` is `check` plus `test`, and `check` runs no tests", () => {
for (const c of clients) {
const s = clientScripts[c];
assert.equal(
s.validate,
"npm run check && npm run test",
`clients/${c} validate`,
);
const fromCheck = reachableScripts(s, "check");
for (const name of fromCheck)
assert.doesNotMatch(
name,
/^(pre|post)?test(:|$)/,
`clients/${c} check must not reach ${name}`,
);
assert.ok(
!/\bvitest\b/.test([...fromCheck].map((n) => s[n]).join(" ")),
`clients/${c} check must not invoke vitest`,
);
// And `check` is the whole of `validate` minus the test leg: what
// `validate` reaches is exactly itself, plus what `check` reaches, plus
// what `test` reaches (its `pretest` build preambles included).
assert.deepEqual(
[...reachableScripts(s, "validate")].sort(),
[
...new Set([
"validate",
...fromCheck,
...reachableScripts(s, "test"),
]),
].sort(),
`clients/${c} validate must reach exactly check + test`,
);
}
});
});
it("has no `ci` alias, not even a deprecated one", () => {
// The rename is only worth anything if the old name is GONE (#2146): an
// alias preserves exactly the association being scrubbed, and leaves