Skip to content

Commit d8a0eb9

Browse files
committed
fix(scripts): judge guard adoption over a gate's import closure, not one file
checkGuardAdoption() read exactly the files named in GUARDED_GATES, so the import test, the armed test and both call bans were statements about one file's text. A gate that moved its counting into a sibling module presented a gate file with no banned shape in it and passed all four on whatever strict call it kept -- measured at 0 problems against the checker as #10599 left it. Recorded as latent on the grounds that neither gate has a helper module. Both do: scripts/eslint-stack-headroom.mjs is in both closures and has held a raw eslint.lintFiles([file]) since #10449, with checkGuardAdoption() returning []. Nothing was mis-measured by it -- the canary hands its results to collectFatalMessages() itself -- so this stays a bound rather than a live false green, but the sibling module the defect needs already exists. The population is now derived instead of listed: a gate's local import closure, walked transitively over relative specifiers, guard module excluded (its raw calls are the implementation and its own lintFilesStrict( definition would answer an armed test about a call somewhere else). The bans extend to that closure; the import and armed tests stay file-scoped on the gate, because read over the closure they are satisfied by modules that are not the gate. Where the walk stops being decidable -- a computed specifier, an unreadable import, one that leaves the repo -- it reports that rather than claiming a closure it did not walk. lintFilesUnguarded({ why }) is the lintFiles twin of #10599's declaration, so a closure module that lints raw for a real reason can say so instead of being kept in a hand-maintained exemption list. canaryParseFailures() is its first caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
1 parent 9dd192d commit d8a0eb9

3 files changed

Lines changed: 469 additions & 20 deletions

File tree

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

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
collectFatalMessages,
103103
guardAdoptionProblems,
104104
lintFilesStrict,
105+
lintFilesUnguarded,
105106
lintTextStrict,
106107
lintTextUnguarded,
107108
} from './eslint-fatal-guard.mjs';
@@ -362,6 +363,124 @@ const GUARD_ADOPTION_CASES = [
362363
[FIXTURE_IMPORT, FIXTURE_CALL_STRICT, '// was: ' + FIXTURE_COUNT], []],
363364
];
364365

366+
// ── Guard-closure fixtures (#10625) ───────────────────────────────────────
367+
//
368+
// Every case above is one file's text, which is exactly the bound this set
369+
// exists to close: a gate that moved its counting into a sibling module
370+
// presents a gate file with no banned shape in it, and passes all four tests
371+
// on whatever strict call it kept. Measured against the checker as #10599 left
372+
// it, the first case below came back with ZERO problems.
373+
//
374+
// TWO decoy disciplines apply here, and they are NOT the same rule:
375+
//
376+
// • CALL shapes still take the `+` split. The bans read `stripComments`,
377+
// which keeps string literals (#10598), so a contiguous `.lintFiles` + `(`
378+
// in this file is a decoy in the live-tree scan of this very gate.
379+
// • SPECIFIERS do not, and must not be "made consistent" with them. The
380+
// closure walk is literal-AWARE — an import spelling inside a string is
381+
// skipped, which is the only reason scripts/invoked-as.mjs (which writes
382+
// `await import(${…})` into a template) is not read as a computed import
383+
// of its own. The `a specifier inside a string is not an import` case
384+
// below is that behaviour asserted, and it needs the specifier spelled
385+
// contiguously to mean anything.
386+
const FAKE_GATE = 'scripts/__closure_fixture_gate__.mjs';
387+
const FAKE_HELPER = 'scripts/__closure_fixture_helper__.mjs';
388+
const FAKE_DEEPER = 'scripts/__closure_fixture_deeper__.mjs';
389+
const FIXTURE_IMPORT_HELPER = "import { measure } from './__closure_fixture_helper__.mjs';";
390+
const FIXTURE_IMPORT_DEEPER = "import { count } from './__closure_fixture_deeper__.mjs';";
391+
const FIXTURE_DELEGATE = 'const second = await measure(eslint, code);';
392+
const FIXTURE_IMPORT_FILES_DECLARED = "import { lintFilesUnguarded } from './eslint-fatal-guard.mjs';";
393+
const FIXTURE_CALL_FILES_DECLARED =
394+
"const [r] = await lintFilesUnguarded" + "(eslint, [TARGET], { why: 'ground truth' });";
395+
396+
/** A gate that is itself guarded, and hands a second population to a helper. */
397+
const DELEGATING_GATE = [FIXTURE_IMPORT, FIXTURE_CALL_STRICT, FIXTURE_IMPORT_HELPER, FIXTURE_DELEGATE];
398+
399+
const VIA_CLOSURE = "is in the gate's local import closure";
400+
401+
/**
402+
* The closure walk in both directions, over synthetic file TREES.
403+
*
404+
* `[name, files, expected]`, where `files` maps a repo-relative path to its
405+
* source lines and each `expected` entry is the list of needles ONE problem
406+
* must carry — the file it names and which test fired, because a case that
407+
* only counts problems cannot tell a right answer from a coincidence.
408+
*/
409+
const GUARD_CLOSURE_CASES = [
410+
// THE REPRODUCTION. Zero problems before this card.
411+
['the counted call moved one import out',
412+
{ [FAKE_GATE]: DELEGATING_GATE, [FAKE_HELPER]: [FIXTURE_CALL_RAW] },
413+
[[FAKE_HELPER, RAW_CALL, VIA_CLOSURE]]],
414+
// …and the same move with the other method, which is #10599's hole one
415+
// indirection further out.
416+
['a second population counted through lintText, one import out',
417+
{ [FAKE_GATE]: DELEGATING_GATE, [FAKE_HELPER]: [FIXTURE_COUNT] },
418+
[[FAKE_HELPER, BARE_TEXT, VIA_CLOSURE]]],
419+
// The positive control. A zero-hit result over the reject cases means
420+
// nothing without a helper that is supposed to come back clean and does.
421+
['a guarded helper is guarded',
422+
{ [FAKE_GATE]: DELEGATING_GATE, [FAKE_HELPER]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT] },
423+
[]],
424+
// The declaration, one import out: the shape scripts/eslint-stack-headroom.mjs
425+
// uses for its canary. Without this the only way to keep a legitimate raw
426+
// call green is a hand-kept exemption list, which is the thing a derived
427+
// closure was chosen to avoid.
428+
['a declared non-measurement in a helper is not a finding',
429+
{ [FAKE_GATE]: DELEGATING_GATE,
430+
[FAKE_HELPER]: [FIXTURE_IMPORT_FILES_DECLARED, FIXTURE_CALL_FILES_DECLARED] },
431+
[]],
432+
// Transitive, not one level: the walk is what makes the population derived,
433+
// and a one-level walk would just move the same bound to the second hop.
434+
['a raw call two imports out',
435+
{ [FAKE_GATE]: DELEGATING_GATE,
436+
[FAKE_HELPER]: [FIXTURE_IMPORT_DEEPER, 'export const measure = count;'],
437+
[FAKE_DEEPER]: [FIXTURE_CALL_RAW] },
438+
[[FAKE_DEEPER, RAW_CALL]]],
439+
// The guard module is not in anyone's closure. It holds the raw calls BY
440+
// DESIGN, and — the trap — its own `lintFilesStrict(` definition would
441+
// answer an armed test that is supposed to be about a call somewhere else.
442+
['the guard module itself is never scanned',
443+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT],
444+
'scripts/eslint-fatal-guard.mjs': [FIXTURE_CALL_RAW, FIXTURE_COUNT] },
445+
[]],
446+
// The mask, on the WALK rather than on the bans: a commented-out import
447+
// does not put a file in the closure, so the raw call in it is not this
448+
// gate's problem. Under-masking here fabricates a finding out of prose.
449+
['a commented-out import does not reach the helper',
450+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT, '// ' + FIXTURE_IMPORT_HELPER],
451+
[FAKE_HELPER]: [FIXTURE_CALL_RAW] },
452+
[]],
453+
// The literal direction of the same question — the scripts/invoked-as.mjs
454+
// shape, which is in BOTH gates' real closures today.
455+
['a specifier inside a string is not an import',
456+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT,
457+
'const template = ' + JSON.stringify(FIXTURE_IMPORT_HELPER) + ';'],
458+
[FAKE_HELPER]: [FIXTURE_CALL_RAW] },
459+
[]],
460+
// Where the walk STOPS being decidable, it says so rather than reporting a
461+
// closure it cannot claim to have walked. This is the one bound that the
462+
// three cards before this one each discovered the expensive way.
463+
['a computed specifier is reported, not passed over',
464+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT, 'const m = await import(specifier);'] },
465+
[[FAKE_GATE, 'cannot resolve']]],
466+
// A closure member that is not there at all: the walk's own blind spot,
467+
// reported for the same reason a missing GATE is.
468+
['an unresolvable import is reported',
469+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT, FIXTURE_IMPORT_HELPER] },
470+
[[FAKE_HELPER, 'unreadable']]],
471+
// `path.resolve` clamps at `/`, so an import walking out of the repo used to
472+
// come back as a file at the top of it. The bans cannot cover what is not in
473+
// the tree, so the honest answer is to name it.
474+
['an import that leaves the repository is reported',
475+
{ [FAKE_GATE]: [FIXTURE_IMPORT, FIXTURE_CALL_STRICT, "import { x } from '../../outside.mjs';"] },
476+
[[FAKE_GATE, 'outside the repository']]],
477+
// A cycle must terminate and must report the raw call exactly once.
478+
['a cycle in the closure terminates',
479+
{ [FAKE_GATE]: DELEGATING_GATE,
480+
[FAKE_HELPER]: [FIXTURE_CALL_RAW, "import { gate } from './__closure_fixture_gate__.mjs';"] },
481+
[[FAKE_HELPER, RAW_CALL]]],
482+
];
483+
365484
async function selfTest() {
366485
const failures = [];
367486
const assert = (cond, msg) => { if (!cond) failures.push(msg); };
@@ -623,6 +742,26 @@ async function selfTest() {
623742
threw instanceof TypeError && /requires `why`/.test(threw?.message ?? ''),
624743
`lintTextUnguarded must refuse an undeclared call (threw: ${threw?.message ?? 'nothing'})`,
625744
);
745+
746+
// The `lintFiles` twin, same bargain (#10625). It exists because the
747+
// bans now reach a gate's whole import closure, and the closure module
748+
// that lints raw for a real reason needs the same way to say so a gate
749+
// has. If it can be called without naming one, it is not a declaration.
750+
let filesThrew = null;
751+
try {
752+
await lintFilesUnguarded({ lintFiles: async () => [parses] }, [LINT_TARGET], {});
753+
} catch (err) { filesThrew = err; }
754+
assert(
755+
filesThrew instanceof TypeError && /requires `why`/.test(filesThrew?.message ?? ''),
756+
`lintFilesUnguarded must refuse an undeclared call (threw: ${filesThrew?.message ?? 'nothing'})`,
757+
);
758+
const declared = await lintFilesUnguarded({ lintFiles: async () => [broken] }, [LINT_TARGET], {
759+
why: 'self-test: proves a declared call passes the fatal STRAIGHT through',
760+
});
761+
assert(
762+
Array.isArray(declared) && declared.length === 1 && declared[0] === broken,
763+
'lintFilesUnguarded must return what ESLint returned, fatals and all — it adds no behaviour',
764+
);
626765
}
627766

628767
// A guard imported once is not a guard still called — proved in both
@@ -636,6 +775,27 @@ async function selfTest() {
636775
);
637776
}
638777

778+
// The same sentence about the FILES the tests are applied to (#10625).
779+
// Every case above is one file's text; a measurement moved into a sibling
780+
// module is invisible to all four of them, so the population is walked
781+
// rather than listed — and that walk gets asserted in both directions for
782+
// the same reason the tests themselves do.
783+
for (const [name, files, expected] of GUARD_CLOSURE_CASES) {
784+
const problems = checkGuardAdoption('/__fixture_root__', {
785+
gates: [FAKE_GATE],
786+
readFile: (file) => {
787+
const lines = files[file];
788+
if (lines === undefined) throw new Error(`ENOENT: ${file}`);
789+
return lines.join('\n');
790+
},
791+
});
792+
assert(
793+
problems.length === expected.length &&
794+
expected.every((needles) => problems.some((p) => needles.every((n) => p.includes(n)))),
795+
`guard closure, ${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(problems)}`,
796+
);
797+
}
798+
639799
// And the live tree. This is also the only wired coverage of the OTHER
640800
// gate's call site: `pnpm check:slot-lookup` has no --self-test hook, and
641801
// CI runs this one before the gate itself.
@@ -725,8 +885,9 @@ async function selfTest() {
725885
`✓ self-test: ${reports.length} reporting shape(s), ${silent.length} silent counterpart(s), ` +
726886
`grandfathering + test-glob channels proved in both directions, ${cases.length} ratchet case(s), ` +
727887
`fatal-parse guard proved both ways over real ESLint output, every counted lint call in both ` +
728-
`gates routed through it (adoption proved both ways over ${GUARD_ADOPTION_CASES.length} synthetic ` +
729-
`gate source(s), files AND text), ` +
888+
`gates AND in the closures they import routed through it (adoption proved both ways over ` +
889+
`${GUARD_ADOPTION_CASES.length} synthetic gate source(s), files AND text, and the closure walk ` +
890+
`over ${GUARD_CLOSURE_CASES.length} synthetic tree(s)), ` +
730891
`and ${HEADROOM_CANARY_FILE} parses at --stack-size=${PARSER_STACK_SIZE_KB} through this gate's own channel.`,
731892
);
732893
}

0 commit comments

Comments
 (0)