Skip to content

Commit c2c21f3

Browse files
test(spec): the gate-spawning beforeAll in check-liveness.test.ts runs under the case budget (#21421) (#21433)
Fixes #21421 Clause-②: no The one hook in `packages/spec/scripts/liveness/check-liveness.test.ts` that spawns the whole liveness gate (the `beforeAll` of the tombstone describe, #19062) now runs under the budget its sibling cases already run the same gate under, instead of vitest's 10 s default `hookTimeout`. Test-only: no runtime, spec, config or published file changes. ## Changes One file, +105 / -1 (1673 to 1777 lines). - `const GATE_BUDGET_MS = 60_000;` beside `runGate`, with the reason it is a literal and not an import: `vitest.config.ts` exports no constant and cannot be imported from a test (its top level runs the filter preflights against `process.argv`). - The hook passes it: the closing `});` becomes `}, GATE_BUDGET_MS);`. - A new block at the end of the file reads the file's own source with the TypeScript parser and holds three facts: 1. the scan can see `runGate` as reaching `spawnSync` and finds lifecycle hooks (it cannot pass by scanning nothing); 2. every `beforeAll` / `beforeEach` / `afterAll` / `afterEach` whose callback reaches `spawnSync`, directly or through another function in the file, passes `GATE_BUDGET_MS` as its timeout argument; 3. `GATE_BUDGET_MS` is not below any `testTimeout` that `vitest.config.ts` declares (the three declarations all read 60000 today). Not done, per the ruling: no skip, retry or quarantine, no change to vitest's `hookTimeout`, no edit to `vitest.config.ts`. ## Hook census (A1) 22 lifecycle hooks in the file: 11 `beforeAll` and 11 `afterAll`, no `beforeEach` / `afterEach`. Exactly one reaches the gate, the `beforeAll` of the #19062 describe (`runGate(undefined, ['--json'])`). The other ten `beforeAll`s only `mkdtempSync` or `cpSync` the ledgers; every `afterAll` is `rmSync`. The dispatch listed ten `beforeAll`s; the file has eleven (the one in the #21127 block, which only creates a temp directory, was not in the list). It does not change the answer. ## Evidence - **Red before the fix.** With the pin in place and the hook still unfixed: `Tests 1 failed | 2 passed | 68 skipped (71)`, the failing case being the hook one, with `beforeAll at line 1412: timeout argument absent (vitest hookTimeout default, 10 s)`. Line 1412 is the old line 1401 plus the 11 lines added above it. - **Green after.** The whole file at `1ea92df0fb`: `Test Files 1 passed (1)`, `Tests 71 passed (71)`, run with `vitest run --project local` under the shared verify lock (322.8 s on a shared box). - **The pin bites in both of its directions** (one-off ablations, mutation proven on disk by `scripts/ablation-replace.mjs`, restored with blob equal to HEAD and `git diff HEAD` empty): - `GATE_BUDGET_MS` lowered to 9_999: the config case turns red (`expected 9999 to be greater than or equal to 60000`), the other two stay green. - the hook given an explicit but wrong `10_000`: the hook case turns red (`beforeAll at line 1414: timeout argument 10_000`), the other two stay green. - **Typecheck.** `pnpm --filter @objectstack/spec typecheck` exit 0 (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck` OK with its debt ledger unchanged). `tsc --noEmit -p tsconfig.scripts.json --listFiles` lists the test file once, so the scripts program does compile it. ## Gates `dispatch-gates --commands` derived 59 commands for this diff. Reconciled with `--ran`: 59 accounted for, 55 run (exit 0), 4 NOT MEASURED, 0 unrun. The four are `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:sourcemap-no-sources-content`: each exited 3 (prerequisite not met, no built `dist`) and measured nothing. They read the published build, which this diff cannot reach: `npm pack --dry-run` on `packages/spec` lists 322 files and none under `scripts/` (positive control: 83 files under `liveness/`). `check:query-options-erasure` and `check:type-check-debt` hit my 250 s cap on the first pass and were re-run with a larger cap: both exit 0. `check:nul-bytes` exit 0; control-character scan of the file clean. ## Changeset Not published: `scripts/` is outside the package's `files[]` (pack listing above). `skip-changeset` is applied. ## Acceptance notes - The third pin case (config drift) goes one step beyond the ruling's pin, to keep the one literal from drifting from `vitest.config.ts`. It is self-contained; deleting it leaves the other two intact. - This file runs in the `local` vitest project (`vitest.repo-tests.json` does not list it), not `repo`; both projects declare the same `testTimeout`. - Base is `39a912ea73`, not merged forward. The commits that reached `main` since do not touch this file, `vitest.config.ts` or the spec `package.json`. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d728b8 commit c2c21f3

1 file changed

Lines changed: 105 additions & 1 deletion

File tree

‎packages/spec/scripts/liveness/check-liveness.test.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, sta
2525
import { tmpdir } from 'node:os';
2626
import path from 'node:path';
2727
import { fileURLToPath } from 'node:url';
28+
// The parser the hook-budget block at the bottom of this file reads THIS file with.
29+
import ts from 'typescript';
2830
// The registry itself, so the denominator block at the bottom of this file can
2931
// hold the gate's output answerable to it rather than to a copied list (#18133).
3032
import {
@@ -44,6 +46,15 @@ const SPEC = path.resolve(HERE, '../..');
4446
const GATE = path.join(HERE, 'check-liveness.mts');
4547
const LEDGERS = path.join(SPEC, 'liveness');
4648

49+
// The budget the gate-spawning CASES run under: `testTimeout` in vitest.config.ts
50+
// (60_000 in both projects). A HOOK runs under `hookTimeout` instead, vitest's
51+
// 10_000 ms default, which that config does not set — so a hook that spawns the
52+
// whole gate names the case budget itself (#21421). A literal, not an import: the
53+
// config cannot be imported from a test (its top level runs the filter preflights
54+
// against process.argv) and exports no constant. The last block of this file holds
55+
// this value at or above the config's, so the two cannot drift apart unseen.
56+
const GATE_BUDGET_MS = 60_000;
57+
4758
// A repo-rooted path shaped exactly like a real pointer (so `evidence.mts`
4859
// extracts it) that this repo has never contained.
4960
const ROTTED = 'packages/plugins/driver-sql/src/sql-driver.ts';
@@ -1398,6 +1409,8 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
13981409
let tmp: string;
13991410
let carrier: Carrier;
14001411

1412+
// The one hook in this file that spawns the gate: it runs under the case budget
1413+
// (GATE_BUDGET_MS, last argument), not vitest's 10 s `hookTimeout` default (#21421).
14011414
beforeAll(() => {
14021415
tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-tombstone-'));
14031416

@@ -1428,7 +1441,7 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
14281441
enumerated: enumerated.length,
14291442
eligible: eligible.length,
14301443
};
1431-
});
1444+
}, GATE_BUDGET_MS);
14321445
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
14331446

14341447
/**
@@ -1671,3 +1684,94 @@ describe('check:liveness — a `live` row may not opt into `authorWarn` (#21127)
16711684
expect(output.split('\n').filter((l) => l.startsWith('✗')), output).toHaveLength(1);
16721685
});
16731686
});
1687+
1688+
// ---------------------------------------------------------------------------
1689+
// A hook that spawns the gate runs under the case budget (#21421)
1690+
//
1691+
// The cases above spawn the whole gate under `testTimeout`; a hook spawning the
1692+
// same gate defaults to `hookTimeout` (10 s), so a 5 to 9 s run had 1 to 5 s of
1693+
// margin and a loaded CI shard spent it — the `beforeAll` of the #19062 block
1694+
// timed out and its cases were skipped behind it. This reads THIS file's own
1695+
// source for every lifecycle hook whose callback reaches `spawnSync`, directly
1696+
// or through another function here, and holds each to `GATE_BUDGET_MS` as its
1697+
// timeout argument. Parsed rather than matched as text, so a hook is judged by
1698+
// its call, never by what a comment or this block's own prose happens to spell.
1699+
// ---------------------------------------------------------------------------
1700+
describe('check:liveness — a hook that spawns the gate carries the case budget (#21421)', () => {
1701+
const HOOKS = new Set(['beforeAll', 'beforeEach', 'afterAll', 'afterEach']);
1702+
const self = fileURLToPath(import.meta.url);
1703+
const sf = ts.createSourceFile(self, readFileSync(self, 'utf8'), ts.ScriptTarget.Latest, true);
1704+
1705+
const calleeOf = (n: ts.Node): string => (ts.isCallExpression(n) && ts.isIdentifier(n.expression) ? n.expression.text : '');
1706+
const callsAny = (root: ts.Node, names: ReadonlySet<string>): boolean => {
1707+
let hit = false;
1708+
const walk = (n: ts.Node): void => {
1709+
if (hit) return;
1710+
if (names.has(calleeOf(n))) hit = true;
1711+
else ts.forEachChild(n, walk);
1712+
};
1713+
walk(root);
1714+
return hit;
1715+
};
1716+
1717+
// Every function in this file that reaches `spawnSync`, to a fixpoint.
1718+
const bodies = new Map<string, ts.Node>();
1719+
const collect = (n: ts.Node): void => {
1720+
if (ts.isFunctionDeclaration(n) && n.name && n.body) bodies.set(n.name.text, n.body);
1721+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer
1722+
&& (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))) {
1723+
bodies.set(n.name.text, n.initializer.body);
1724+
}
1725+
ts.forEachChild(n, collect);
1726+
};
1727+
collect(sf);
1728+
const spawners = new Set(['spawnSync']);
1729+
for (let grew = true; grew;) {
1730+
grew = false;
1731+
for (const [name, body] of bodies) {
1732+
if (!spawners.has(name) && callsAny(body, spawners)) {
1733+
spawners.add(name);
1734+
grew = true;
1735+
}
1736+
}
1737+
}
1738+
1739+
const hooks: { hook: string; line: number; spawns: boolean; timeout: string | null }[] = [];
1740+
const visit = (n: ts.Node): void => {
1741+
if (HOOKS.has(calleeOf(n))) {
1742+
const [fn, timeout] = (n as ts.CallExpression).arguments;
1743+
hooks.push({
1744+
hook: calleeOf(n),
1745+
line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
1746+
spawns: fn !== undefined && callsAny(fn, spawners),
1747+
timeout: timeout === undefined ? null : timeout.getText(sf),
1748+
});
1749+
}
1750+
ts.forEachChild(n, visit);
1751+
};
1752+
visit(sf);
1753+
1754+
// The scan must be capable of finding something: a refactor that renames
1755+
// `runGate` or the hooks would otherwise leave the next case green over nothing.
1756+
it('sees the gate runner and this file\'s hooks', () => {
1757+
expect(spawners.has('runGate'), 'the scan no longer recognises runGate as reaching spawnSync').toBe(true);
1758+
expect(hooks.length, 'the scan found no lifecycle hook in this file').toBeGreaterThan(0);
1759+
});
1760+
1761+
it('every hook whose callback reaches the gate passes GATE_BUDGET_MS as its timeout', () => {
1762+
const unbudgeted = hooks
1763+
.filter((h) => h.spawns && h.timeout !== 'GATE_BUDGET_MS')
1764+
.map((h) => `${h.hook} at line ${h.line}: timeout argument ${h.timeout ?? 'absent (vitest hookTimeout default, 10 s)'}`);
1765+
expect(
1766+
unbudgeted,
1767+
'a hook that spawns the gate must run under the case budget — pass GATE_BUDGET_MS as its second argument',
1768+
).toEqual([]);
1769+
});
1770+
1771+
it('GATE_BUDGET_MS is not below the testTimeout vitest.config.ts gives the cases', () => {
1772+
const config = readFileSync(path.join(SPEC, 'vitest.config.ts'), 'utf8');
1773+
const budgets = [...config.matchAll(/\btestTimeout:\s*([\d_]+)/g)].map((m) => Number((m[1] ?? '').replaceAll('_', '')));
1774+
expect(budgets.length, 'no testTimeout found in vitest.config.ts').toBeGreaterThan(0);
1775+
expect(GATE_BUDGET_MS, `vitest.config.ts testTimeout values: ${budgets.join(', ')}`).toBeGreaterThanOrEqual(Math.max(...budgets));
1776+
});
1777+
});

0 commit comments

Comments
 (0)