Skip to content

Commit 39d6a18

Browse files
claude[bot]claude
andauthored
fix(gates): give the in-process ESLint ratchets the parser stack headroom pnpm lint already has (#10464)
* fix(gates): give the in-process ESLint ratchets the parser stack headroom `pnpm lint` already has `check:slot-lookup` and `check:query-options-erasure` drive ESLint through its Node API, so neither inherits the `--stack-size=4000` the root `lint` script puts on ESLint's CLI entry. Measured on this tree: the gates' own ESLint channel fails to parse `packages/spec/src/migrations/registry.ts` 10/10 runs at the default V8 stack and 0/10 with the flag; the gates' whole-population runs failed 2 in 14 at the default stack, which is why every red read as a flake. Both gates now re-exec themselves once with the flag, following the re-exec precedent in `scripts/pm/check-governed-merges.mjs`. The headroom lives in the gate rather than in a script line so it cannot be dropped by invoking the gate a different way. A self-test asserts the current `registry.ts` still parses through the gate's own channel -- a parse, never a pinned depth or a pinned stack size, because that file gains a step per breaking protocol major and any number would expire at the next one. Part of #10449 * fix(gates): make the headroom adoption check immune to a commented-out call Found by ablating the check itself: commenting out `ensureStackHeadroom(...)` left the identifier in the source, so the regex still matched and the self-test reported the gate as armed at exactly the moment it stopped being armed. Strip whole-line comments before testing. * refactor(gates): use the repo's shared js-comment-mask instead of a private strip scripts/js-comment-mask.mjs is 'the ONE answer' to comment-or-code and exists precisely to retire per-gate copies of this logic (#9367). stripComments rather than maskComments: this check reports gate names, never offsets. Ablation re-run against it -- a commented-out ensureStackHeadroom() call is still caught. * style(gates): arm the headroom before the constant block, not inside it Pure reordering -- the re-exec already ran before any linting; this just stops it splitting the two gates' constant blocks in half. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f05b7d commit 39d6a18

3 files changed

Lines changed: 406 additions & 1 deletion

File tree

scripts/check-query-options-erasure-ratchet.mjs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ import eslintConfig, {
9898
QUERY_OPTIONS_ANY_MESSAGE,
9999
} from '../eslint.config.mjs';
100100
import { checkGuardAdoption, collectFatalMessages, lintFilesStrict } from './eslint-fatal-guard.mjs';
101+
import {
102+
HEADROOM_CANARY_FILE,
103+
PARSER_STACK_SIZE_KB,
104+
STACK_FLAG,
105+
STACK_REARM_GUARD,
106+
canaryParseFailures,
107+
checkHeadroomAdoption,
108+
ensureStackHeadroom,
109+
formatCanaryFailure,
110+
osThreadStackKb,
111+
stackRearmPlan,
112+
} from './eslint-stack-headroom.mjs';
113+
114+
// This gate lints IN-PROCESS, so it does not inherit the `--stack-size` the
115+
// root `lint` script puts on ESLint's CLI entry, and this repo's deepest file
116+
// does not parse without it (#10449). Re-exec once, before any linting --
117+
// including before `--self-test`, whose headroom assertion below is only a fact
118+
// about the gate if the self-test runs on the same stack the gate does.
119+
ensureStackHeadroom(fileURLToPath(import.meta.url));
101120

102121
const __dirname = dirname(fileURLToPath(import.meta.url));
103122
const repoRoot = resolve(__dirname, '..');
@@ -443,6 +462,77 @@ async function selfTest() {
443462
for (const problem of checkGuardAdoption(repoRoot)) assert(false, problem);
444463
}
445464

465+
// -- 5. Parser headroom: the population's deepest file must actually PARSE. --
466+
//
467+
// The fatal guard above makes an unparseable file LOUD. This makes it not
468+
// happen. Both gates lint in-process, so neither inherits the `--stack-size`
469+
// the root `lint` script puts on ESLint's CLI entry, and #10449 is what that
470+
// gap costs: `packages/spec/src/migrations/registry.ts` stopped parsing on
471+
// the default V8 stack, so both gates started failing on a file the author
472+
// never touched -- INTERMITTENTLY, because the verdict depends on the other
473+
// files in the same run, which is what let every red be re-run away as a
474+
// flake for a day.
475+
//
476+
// ⭐ Why this asserts a PARSE and not a number. `registry.ts` is an ADR-0087
477+
// D3 forever artifact that gains a step per breaking protocol major, so its
478+
// AST depth only ever rises. A pinned depth or a pinned minimum stack would
479+
// be true today and quietly wrong at the next major -- the same defect
480+
// arriving a fourth time. Asking "does the file the gate must read still read
481+
// at the headroom the gate actually has" re-measures the real question every
482+
// run and cannot expire.
483+
//
484+
// And it is an EARLY warning, not a restatement of the gate: measured on the
485+
// tree that filed #10449, this single-file parse failed 10/10 runs at the
486+
// default stack while the gates' own whole-population runs failed 2/14. The
487+
// narrow scope is the worst case, so this trips a full margin before the
488+
// population run starts reddening other people's PRs.
489+
{
490+
assert(
491+
stackRearmPlan({ execArgv: [], env: {}, flagSupported: true }).rearm === true,
492+
'a plain run must re-exec itself with parser headroom',
493+
);
494+
assert(
495+
stackRearmPlan({ execArgv: [STACK_FLAG], env: {}, flagSupported: true }).rearm === false,
496+
'a run that already carries --stack-size must not re-exec again',
497+
);
498+
assert(
499+
stackRearmPlan({ execArgv: [], env: { [STACK_REARM_GUARD]: '1' }, flagSupported: true }).rearm === false,
500+
'the guard variable must stop a second re-exec -- otherwise a rearm is a spawn loop',
501+
);
502+
assert(
503+
stackRearmPlan({ execArgv: [], env: {}, flagSupported: false }).rearm === false,
504+
'a node that rejects the flag must degrade, not spawn a child that cannot start',
505+
);
506+
507+
// The flag is on THIS process, so what follows is measured with the
508+
// headroom rather than merely alongside a flag on some command line.
509+
assert(
510+
process.execArgv.some((a) => a.startsWith('--stack-size')),
511+
`this self-test is running without --stack-size (execArgv: ${JSON.stringify(process.execArgv)}). ` +
512+
'ensureStackHeadroom() did not re-exec, so the parse proved below is not the one the gate gets.',
513+
);
514+
515+
// Above the OS thread stack V8 runs off the real stack and SIGSEGVs instead
516+
// of throwing a clean RangeError, so headroom that crosses it converts a
517+
// loud gate into a crash. Read the real limit rather than pinning one.
518+
const osStackKb = osThreadStackKb();
519+
if (osStackKb !== null) {
520+
assert(
521+
PARSER_STACK_SIZE_KB < osStackKb,
522+
`PARSER_STACK_SIZE_KB (${PARSER_STACK_SIZE_KB}) is not below this machine's ` +
523+
`thread stack (ulimit -s = ${osStackKb} KB). At or above it V8 SIGSEGVs instead of ` +
524+
'throwing, which turns every parse failure into an unexplained crash.',
525+
);
526+
}
527+
528+
const canaryFatals = await canaryParseFailures(eslint, { repoRoot });
529+
assert(canaryFatals.length === 0, formatCanaryFailure(canaryFatals));
530+
531+
// Importing the headroom module is not arming it, and a gate that quietly
532+
// stopped arming it looks exactly like one that never lost the flag.
533+
for (const problem of checkHeadroomAdoption(repoRoot)) assert(false, problem);
534+
}
535+
446536
// A missing config block must ABORT, never report clean.
447537
assert(eslintConfig.some(carriesRule), 'the config must carry the query-options rule');
448538

@@ -454,7 +544,8 @@ async function selfTest() {
454544
console.log(
455545
`✓ self-test: ${reports.length} reporting shape(s), ${silent.length} silent counterpart(s), ` +
456546
`grandfathering + test-glob channels proved in both directions, ${cases.length} ratchet case(s), ` +
457-
`fatal-parse guard proved both ways over real ESLint output, both gates still routed through it.`,
547+
`fatal-parse guard proved both ways over real ESLint output, both gates still routed through it, ` +
548+
`and ${HEADROOM_CANARY_FILE} parses at --stack-size=${PARSER_STACK_SIZE_KB} through this gate's own channel.`,
458549
);
459550
}
460551

scripts/check-slot-lookup-ratchet.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ import { ESLint } from 'eslint';
4646

4747
import eslintConfig, { SLOT_LOOKUP_ANY_MESSAGE } from '../eslint.config.mjs';
4848
import { lintFilesStrict } from './eslint-fatal-guard.mjs';
49+
import { ensureStackHeadroom } from './eslint-stack-headroom.mjs';
50+
51+
// This gate lints IN-PROCESS, so it does not inherit the `--stack-size` the
52+
// root `lint` script puts on ESLint's CLI entry, and this repo's deepest file
53+
// does not parse without it (#10449). Re-exec once, before any linting, so the
54+
// gate carries its own headroom whatever spelling invoked it.
55+
ensureStackHeadroom(fileURLToPath(import.meta.url));
4956

5057
const __dirname = dirname(fileURLToPath(import.meta.url));
5158
const repoRoot = resolve(__dirname, '..');

0 commit comments

Comments
 (0)