@@ -102,6 +102,8 @@ import {
102102 collectFatalMessages ,
103103 guardAdoptionProblems ,
104104 lintFilesStrict ,
105+ lintTextStrict ,
106+ lintTextUnguarded ,
105107} from './eslint-fatal-guard.mjs' ;
106108import {
107109 HEADROOM_CANARY_FILE ,
@@ -288,12 +290,16 @@ function baselineKeysAddedSinceMergeBase(baselineKeys) {
288290const FIXTURE_CALL_STRICT = 'const results = await lintFilesStrict' + '(eslint, [TARGET], { gate: G });' ;
289291const FIXTURE_CALL_RAW = 'const results = await eslint.lintFiles' + '([TARGET]);' ;
290292const FIXTURE_IMPORT = "import { lintFilesStrict } from './eslint-fatal-guard.mjs';" ;
293+ const FIXTURE_IMPORT_TEXT = "import { lintTextStrict } from './eslint-fatal-guard.mjs';" ;
291294const FIXTURE_PROSE = '// on the same input. scripts/eslint-fatal-guard.mjs carries the measurement and' ;
292- const FIXTURE_COUNT = 'const sites = (await eslint.lintText(code)).messages.filter(matches).length;' ;
295+ const FIXTURE_COUNT = 'const sites = (await eslint.lintText' + '(code)).messages.filter(matches).length;' ;
296+ const FIXTURE_CALL_TEXT_STRICT = 'const [r] = await lintTextStrict' + '(eslint, code, { gate: G });' ;
297+ const FIXTURE_CALL_TEXT_DECLARED = "const [r] = await lintTextUnguarded" + "(eslint, code, { why: 'ground truth' });" ;
293298
294299const NO_IMPORT = 'does not import scripts/eslint-fatal-guard.mjs' ;
295300const NOT_ARMED = 'Importing the guard does not arm it' ;
296301const RAW_CALL = 'directly, so a parse failure in its population' ;
302+ const BARE_TEXT = 'A counted lintText result discards a parse failure' ;
297303
298304/**
299305 * The adoption check in both directions, over sources written here.
@@ -311,10 +317,13 @@ const GUARD_ADOPTION_CASES = [
311317 // The measured reproduction: the real import line deleted, the docblock left
312318 // exactly as it was. Against the raw text this came back CLEAN and the
313319 // self-test printed "both gates still routed through it".
314- [ 'a docblock mention is not an import' , [ FIXTURE_PROSE , FIXTURE_COUNT ] , [ NO_IMPORT ] ] ,
320+ // (FIXTURE_COUNT is a bare `lintText` count, so it now trips #10599's test
321+ // too — the fixture really does carry both defects, and a case that under-
322+ // states what its own source does is a case nobody can re-derive.)
323+ [ 'a docblock mention is not an import' , [ FIXTURE_PROSE , FIXTURE_COUNT ] , [ NO_IMPORT , BARE_TEXT ] ] ,
315324 // "A guard imported once is not a guard still called" — the docblock's own
316325 // thesis, which nothing used to assert.
317- [ 'imports the guard and never calls it' , [ FIXTURE_PROSE , FIXTURE_IMPORT , FIXTURE_COUNT ] , [ NOT_ARMED ] ] ,
326+ [ 'imports the guard and never calls it' , [ FIXTURE_PROSE , FIXTURE_IMPORT , FIXTURE_COUNT ] , [ NOT_ARMED , BARE_TEXT ] ] ,
318327 // The same sentence one step further: commenting the call out leaves the
319328 // identifier in the text.
320329 [ 'a commented-out call is not a call' , [ FIXTURE_IMPORT , '// ' + FIXTURE_CALL_STRICT ] , [ NOT_ARMED ] ] ,
@@ -326,6 +335,31 @@ const GUARD_ADOPTION_CASES = [
326335 // fabricates a finding out of prose (#9367), and this check must not.
327336 [ 'a commented-out raw call is not a raw call' ,
328337 [ FIXTURE_IMPORT , FIXTURE_CALL_STRICT , '// was: ' + FIXTURE_CALL_RAW ] , [ ] ] ,
338+
339+ // ── The lintText half (#10599) ─────────────────────────────────────────
340+ //
341+ // THE REPRODUCTION. Against the checker as #10458 left it this source came
342+ // back with ZERO problems: it imports the guard, it still calls
343+ // lintFilesStrict() for the first population, and it never touches
344+ // `.lintFiles(` — so all three tests passed while the SECOND population,
345+ // counted out of `eslint.lintText()`, went entirely unguarded.
346+ [ 'a guarded call plus a second population measured through lintText' ,
347+ [ FIXTURE_IMPORT , FIXTURE_CALL_STRICT , FIXTURE_COUNT ] , [ BARE_TEXT ] ] ,
348+ // The negative control that makes the ban a rule rather than a spelling
349+ // preference: the counted call routed through the guard is CLEAN.
350+ [ 'a counted lintText routed through the guard is guarded' ,
351+ [ FIXTURE_IMPORT , FIXTURE_CALL_STRICT , FIXTURE_CALL_TEXT_STRICT ] , [ ] ] ,
352+ // And the declared non-measurement — the shape this gate's own ground-truth
353+ // fixtures use, which must not be a finding or the ban is unusable.
354+ [ 'a declared non-measurement is not a finding' ,
355+ [ FIXTURE_IMPORT , FIXTURE_CALL_STRICT , FIXTURE_CALL_TEXT_DECLARED ] , [ ] ] ,
356+ // A gate whose whole population is text is ARMED without ever calling
357+ // lintFilesStrict(). Reporting that one unguarded would be a false positive
358+ // of this card's own making.
359+ [ 'armed through lintTextStrict alone' , [ FIXTURE_IMPORT_TEXT , FIXTURE_CALL_TEXT_STRICT ] , [ ] ] ,
360+ // The mask, on the new test too: prose about a bare call is not a bare call.
361+ [ 'a commented-out lintText is not a lintText' ,
362+ [ FIXTURE_IMPORT , FIXTURE_CALL_STRICT , '// was: ' + FIXTURE_COUNT ] , [ ] ] ,
329363] ;
330364
331365async function selfTest ( ) {
@@ -344,8 +378,24 @@ async function selfTest() {
344378 baseConfig : measuringConfig ( drop ) ,
345379 allowInlineConfig : false ,
346380 } ) ;
381+ // Counted, therefore guarded (#10599). A fixture that stops parsing yields
382+ // zero messages matching the rule — which is precisely what the `silent`
383+ // cases below assert — so an unguarded count here reads a TYPO as proof the
384+ // rule is correctly quiet. Measured on this tree, the `argument 1, object
385+ // literal` report fixture with its closing paren removed:
386+ //
387+ // parses → hits()=1, fatalErrorCount=0
388+ // typo → hits()=0, fatalErrorCount=1, "Parsing error: ')' expected."
389+ //
390+ // The fatal is now a self-test FAILURE naming the fixture, not a zero.
347391 const hits = async ( code , filePath = 'packages/objectql/src/__selftest__.ts' ) => {
348- const [ result ] = await eslint . lintText ( code , { filePath, warnIgnored : false } ) ;
392+ const [ result ] = await lintTextStrict ( eslint , code , {
393+ filePath,
394+ warnIgnored : false ,
395+ gate : 'self-test fixture' ,
396+ repoRoot,
397+ onFatal : ( report ) => { failures . push ( report ) ; return [ ] ; } ,
398+ } ) ;
349399 return ( result ?. messages ?? [ ] ) . filter ( ( m ) => m . ruleId === QUERY_OPTIONS_RULE_ID ) . length ;
350400 } ;
351401
@@ -416,9 +466,13 @@ async function selfTest() {
416466 baseConfig : eslintConfig ,
417467 allowInlineConfig : false ,
418468 } ) ;
419- const [ result ] = await blocking . lintText ( code , {
469+ // Counted, and the assertion below is `=== 0` — same reason as hits().
470+ const [ result ] = await lintTextStrict ( blocking , code , {
420471 filePath : 'packages/objectql/src/__selftest__.test.ts' ,
421472 warnIgnored : false ,
473+ gate : 'self-test fixture (blocking config)' ,
474+ repoRoot,
475+ onFatal : ( report ) => { failures . push ( report ) ; return [ ] ; } ,
422476 } ) ;
423477 const blocked = ( result ?. messages ?? [ ] ) . filter ( ( m ) => m . ruleId === QUERY_OPTIONS_RULE_ID ) . length ;
424478 assert ( blocked === 0 , 'a *.test.ts path must NOT be blocked by the rule (first cut is non-test)' ) ;
@@ -460,9 +514,11 @@ async function selfTest() {
460514 // are therefore driven through real ESLint output, and the fixture is a file
461515 // that genuinely does not parse rather than a hand-built message object.
462516 {
463- const [ broken ] = await eslint . lintText ( 'export const x = (' , {
517+ const [ broken ] = await lintTextUnguarded ( eslint , 'export const x = (' , {
464518 filePath : 'packages/objectql/src/__selftest_unparseable__.ts' ,
465519 warnIgnored : false ,
520+ why : 'ground truth: what raw ESLint returns for a file that will not parse. '
521+ + 'Routing this through the guard would prove the guard with the guard.' ,
466522 } ) ;
467523 assert (
468524 ( broken ?. messages ?? [ ] ) . some ( ( m ) => m . fatal ) ,
@@ -480,9 +536,11 @@ async function selfTest() {
480536 `the collected fatal must name the file and the parse error (got ${ JSON . stringify ( fatals [ 0 ] ) } )` ,
481537 ) ;
482538
483- const [ parses ] = await eslint . lintText ( 'export const x = 1;' , {
539+ const [ parses ] = await lintTextUnguarded ( eslint , 'export const x = 1;' , {
484540 filePath : 'packages/objectql/src/__selftest_parses__.ts' ,
485541 warnIgnored : false ,
542+ why : 'ground truth, the other direction: the guard must stay silent on this one, '
543+ + 'so it has to reach collectFatalMessages() unfiltered.' ,
486544 } ) ;
487545 assert (
488546 collectFatalMessages ( [ parses ] , repoRoot ) . length === 0 ,
@@ -514,6 +572,59 @@ async function selfTest() {
514572 'lintFilesStrict must pass the results through when every file parsed' ,
515573 ) ;
516574
575+ // The lintText twin, both ways (#10599). The source ban below is only
576+ // worth having if the spelling it forces actually guards, and a
577+ // pass-through that never checked would satisfy every source test in this
578+ // file while doing nothing — the exact shape #10123 was.
579+ {
580+ let textReported = null ;
581+ let forwarded = null ;
582+ const stubBroken = { lintText : async ( _code , options ) => { forwarded = options ; return [ broken ] ; } } ;
583+ const textRefused = await lintTextStrict ( stubBroken , 'irrelevant' , {
584+ filePath : 'packages/objectql/src/__selftest_unparseable__.ts' ,
585+ warnIgnored : false ,
586+ gate : 'self-test' ,
587+ repoRoot,
588+ onFatal : ( report ) => { textReported = report ; return 'refused' ; } ,
589+ } ) ;
590+ assert ( textRefused === 'refused' , 'lintTextStrict must not return results when the text did not parse' ) ;
591+ assert (
592+ ( textReported ?? '' ) . includes ( '__selftest_unparseable__.ts' ) && / P a r s i n g e r r o r / i. test ( textReported ?? '' ) ,
593+ `lintTextStrict's failure text must name the file and the parse error (got: ${ textReported } )` ,
594+ ) ;
595+ // The guard's own options must not reach ESLint: it rejects unknown keys,
596+ // so a leak here is a crash at every call site, not a silent oddity.
597+ assert (
598+ forwarded !== null
599+ && Object . keys ( forwarded ) . sort ( ) . join ( ',' ) === 'filePath,warnIgnored' ,
600+ `lintTextStrict must forward only lintText's own options (forwarded ${ JSON . stringify ( forwarded ) } )` ,
601+ ) ;
602+
603+ let textFired = false ;
604+ const stubClean = { lintText : async ( ) => [ parses ] } ;
605+ const textPassed = await lintTextStrict ( stubClean , 'irrelevant' , {
606+ gate : 'self-test' ,
607+ repoRoot,
608+ onFatal : ( ) => { textFired = true ; } ,
609+ } ) ;
610+ assert (
611+ ! textFired && Array . isArray ( textPassed ) && textPassed . length === 1 ,
612+ 'lintTextStrict must pass the results through when the text parsed' ,
613+ ) ;
614+
615+ // And the declaration is a declaration: an undeclared escape is a throw,
616+ // not a quiet pass. An escape hatch nobody has to name is just the bare
617+ // call with extra steps.
618+ let threw = null ;
619+ try {
620+ await lintTextUnguarded ( stubClean , 'irrelevant' , { filePath : 'x.ts' } ) ;
621+ } catch ( err ) { threw = err ; }
622+ assert (
623+ threw instanceof TypeError && / r e q u i r e s ` w h y ` / . test ( threw ?. message ?? '' ) ,
624+ `lintTextUnguarded must refuse an undeclared call (threw: ${ threw ?. message ?? 'nothing' } )` ,
625+ ) ;
626+ }
627+
517628 // A guard imported once is not a guard still called — proved in both
518629 // directions over the fixtures above, because the live-tree call that
519630 // follows can only ever confirm the direction this tree is already in.
@@ -613,8 +724,9 @@ async function selfTest() {
613724 console . log (
614725 `✓ self-test: ${ reports . length } reporting shape(s), ${ silent . length } silent counterpart(s), ` +
615726 `grandfathering + test-glob channels proved in both directions, ${ cases . length } ratchet case(s), ` +
616- `fatal-parse guard proved both ways over real ESLint output, both gates still routed through it ` +
617- `(adoption proved both ways over ${ GUARD_ADOPTION_CASES . length } synthetic gate source(s)), ` +
727+ `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), ` +
618730 `and ${ HEADROOM_CANARY_FILE } parses at --stack-size=${ PARSER_STACK_SIZE_KB } through this gate's own channel.` ,
619731 ) ;
620732}
0 commit comments