@@ -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+
365484async 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 && / r e q u i r e s ` w h y ` / . 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 && / r e q u i r e s ` w h y ` / . 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