@@ -412,16 +412,17 @@ const SELF_TEST_BATTERIES = Object.freeze({
412412 'the SPLIT test:repo task (#16466)' : 16 ,
413413 'the node_modules REACH rule (#16555)' : 18 ,
414414 'the TREE-SCOPED WALK (#15565)' : 24 ,
415+ 'the DOTTED NAMESPACE callee (#18643)' : 15 ,
415416 'the WORKING-TREE blind spot (#18348)' : 10 ,
416417 "the BARE SPECIFIER's PREMISE (#18236)" : 20 ,
417418} ) ;
418419
419420// DELETING an entry silences that battery's floor exactly as effectively as
420- // zeroing it, so the roster's own size is pinned too. Raised 10 -> 11 with the
421- // #18236 battery, keeping the same one-row slack the roster has always carried:
421+ // zeroing it, so the roster's own size is pinned too. Raised 11 -> 12 with the
422+ // #18643 battery, keeping the same one-row slack the roster has always carried:
422423// a floor left behind while the roster grows stops pinning the newest battery,
423424// which is the one nothing else has learned to expect yet.
424- const SELF_TEST_BATTERY_FLOOR = 11 ;
425+ const SELF_TEST_BATTERY_FLOOR = 12 ;
425426
426427// The key an assertion is filed under when no battery is open. It is not a
427428// declared battery, so it reds by the same set difference rather than silently
@@ -2100,6 +2101,10 @@ function expandLiteralSets(expr, sets) {
21002101 * ⚠️ An unlisted callee is TREATED AS A DESCENT, which is the safe direction
21012102 * here for the same reason the flat literal collector takes quoted paths without
21022103 * parsing: it can only force a declaration nobody needed, never withdraw one.
2104+ *
2105+ * ⚠️ This roster is matched against the callee's LAST dotted segment, so it holds
2106+ * METHOD names only. An OBJECT name needs the receiver roster below it — see
2107+ * `NON_DESCENT_NAMESPACES` for why the two questions are both asked.
21032108 */
21042109const NON_DESCENT_CALLEES = new Set ( [
21052110 ...PATH_ARG_READS ,
@@ -2112,6 +2117,41 @@ const NON_DESCENT_CALLEES = new Set([
21122117 'String' , 'Number' , 'Boolean' , 'Set' , 'Map' , 'Array' , 'Object' , 'JSON' , 'require' , 'import' ,
21132118] ) ;
21142119
2120+ /**
2121+ * Receivers whose MEMBERS cannot be a descent entry either — the other half of
2122+ * the roster above, asked of the callee's FIRST dotted segment.
2123+ *
2124+ * One roster matched one way was blind in a shape it looked like it covered. The
2125+ * match takes the callee's LAST dotted segment, which is the right question for a
2126+ * method name — `path.join(...)` pops to `join`, `fs.readdirSync(...)` to
2127+ * `readdirSync` — and the wrong one for an object name: `JSON.stringify(<path>)`
2128+ * pops to `stringify`, so the `'JSON'` row could only ever match a bare `JSON(...)`
2129+ * call. Measured over the 3733 test files this gate scans (this repo at
2130+ * 8904880601): `JSON` was reached 8 times, every one of them the word inside a
2131+ * comment or a message, while 3220 `JSON.<member>(` call sites passed the guard and
2132+ * entered as descents; `Object` 4 (all prose) against 4451, `vi` 0 against 10835.
2133+ * Those rows were not narrow, they were DEAD, and the first argument of every
2134+ * missed call became a candidate walk root — which is how a `JSON.stringify(...)`
2135+ * of a path inside a generated shell script came to propose `packages/spec/dist`
2136+ * as a tree this gate walks.
2137+ *
2138+ * So a dotted callee is asked both questions: its last segment against
2139+ * NON_DESCENT_CALLEES, its receiver against this set. Every name here is already a
2140+ * row above, and the receiver leg is asked of DOTTED callees only, so a bare call
2141+ * keeps exactly the verdict it had.
2142+ *
2143+ * ⚠️ The safe direction is unchanged — an unlisted name is still TREATED AS A
2144+ * DESCENT — so a row here has to earn it: no member of these namespaces can hand a
2145+ * directory to a walker. The built-in globals' statics are data operations, and
2146+ * the vitest namespaces' members take mock factories, matchers and case tables.
2147+ * ⛔ A namespace any member of which reads the filesystem does not belong here;
2148+ * `fs` and `path` are deliberately absent, and their methods are rows above.
2149+ */
2150+ const NON_DESCENT_NAMESPACES = new Set ( [
2151+ 'String' , 'Number' , 'Boolean' , 'Set' , 'Map' , 'Array' , 'Object' , 'JSON' , 'URL' , 'require' , 'import' ,
2152+ 'describe' , 'it' , 'test' , 'expect' , 'vi' ,
2153+ ] ) ;
2154+
21152155/**
21162156 * The roots a directory descent in `src` is started from, as repo-relative
21172157 * names (`''` is the repo root).
@@ -2145,7 +2185,14 @@ export function descentRoots(src, hereDepth, fileSegs, known) {
21452185 } ;
21462186 const sets = literalStringSets ( src ) ;
21472187 for ( const m of src . matchAll ( / \b ( [ A - Z a - z _ $ ] [ \w $ . ] * ) \s * \( / g) ) {
2148- if ( NON_DESCENT_CALLEES . has ( m [ 1 ] . split ( '.' ) . pop ( ) ) ) continue ;
2188+ const segs = m [ 1 ] . split ( '.' ) ;
2189+ // Two questions, one per kind of name the roster holds: a METHOD is
2190+ // recognised wherever it sits, a NAMESPACE only in the one position it can
2191+ // occupy. The receiver leg reads `segs[0]` and not any middle segment, so the
2192+ // roster's reach is exactly one receiver deep and `x.JSON.stringify(dir)`
2193+ // stays a descent.
2194+ if ( NON_DESCENT_CALLEES . has ( segs [ segs . length - 1 ] ) ) continue ;
2195+ if ( segs . length > 1 && NON_DESCENT_NAMESPACES . has ( segs [ 0 ] ) ) continue ;
21492196 const args = balancedArgs ( src , m . index + m [ 0 ] . length ) ;
21502197 if ( args === null ) continue ;
21512198 // The entry argument, by position: a walker takes the directory first.
@@ -3736,6 +3783,61 @@ function selfTest() {
37363783 ) ;
37373784 }
37383785
3786+ // ── the DOTTED NAMESPACE callee (#18643) ─────────────────────────────────
3787+ //
3788+ // The roster is matched against the callee's LAST dotted segment, so an
3789+ // OBJECT-named row only ever matched a BARE call: `JSON.stringify(<path>)`
3790+ // popped to `stringify`, entered as a descent, and its first argument became a
3791+ // candidate walk root -- which is how a path inside a generated shell script
3792+ // came to name `packages/spec/dist` as a tree this gate walks.
3793+ //
3794+ // Both halves are pinned, because either alone is satisfiable by a scan that
3795+ // simply stopped answering: the LIT legs assert a namespace member is no longer
3796+ // a descent, and the two controls above them assert that an unrostered callee
3797+ // handed the SAME argument in the SAME position still is. The DARK legs hold
3798+ // the rows that were live -- a bare `String(...)` or `require(...)` -- to the
3799+ // verdict they already had.
3800+ battery ( 'the DOTTED NAMESPACE callee (#18643)' ) ;
3801+ {
3802+ const SEGS = [ 'packages' , 'pkg' , 'src' , 'x.test.ts' ] ;
3803+ const SEED =
3804+ 'const HERE = dirname(fileURLToPath(import.meta.url));\n' + "const REPO = resolve(HERE, '../../..');\n" ;
3805+ // The anonymous descent the walk limb needs before it engages at all: without
3806+ // it every sample below yields nothing and every leg is green for the wrong
3807+ // reason. The positive controls are what prove it is engaged.
3808+ const WALKER = 'const walk = (dir) => { for (const e of readdirSync(dir)) walk(join(dir, e)); };\n' ;
3809+ const ARG = "join(REPO, 'content')" ;
3810+ const isRoot = ( src ) => walkRootsOf ( SEED + WALKER + src , 1 , SEGS ) . includes ( 'content' ) ;
3811+
3812+ ok (
3813+ 'POSITIVE CONTROL: an unrostered BARE callee handed the argument is a descent' ,
3814+ isRoot ( `const s = stringify(${ ARG } );` ) ,
3815+ ) ;
3816+ ok (
3817+ 'POSITIVE CONTROL: an unrostered DOTTED callee is one too -- the receiver leg suppresses by ROSTER, not by the dot' ,
3818+ isRoot ( `helper.crawl(${ ARG } );` ) ,
3819+ ) ;
3820+ ok ( 'LIT: `JSON.stringify(<path>)` is not a descent -- the reported shape' , ! isRoot ( `const s = JSON.stringify(${ ARG } );` ) ) ;
3821+ ok ( 'LIT: `Object.keys(<path>)` is not a descent' , ! isRoot ( `const k = Object.keys(${ ARG } );` ) ) ;
3822+ ok ( 'LIT: `Array.from(<path>)` is not a descent' , ! isRoot ( `const a = Array.from(${ ARG } );` ) ) ;
3823+ ok ( 'LIT: `vi.mock(<path>)` is not a descent -- the `vi` row reached nothing at all before' , ! isRoot ( `vi.mock(${ ARG } );` ) ) ;
3824+ ok ( 'LIT: `expect.any(<path>)` is not a descent' , ! isRoot ( `expect.any(${ ARG } );` ) ) ;
3825+ ok ( 'DARK: bare `String(<path>)` keeps its verdict' , ! isRoot ( `const s = String(${ ARG } );` ) ) ;
3826+ ok ( 'DARK: bare `require(<path>)` keeps its verdict' , ! isRoot ( `const m = require(${ ARG } );` ) ) ;
3827+ ok ( 'DARK: bare `Number(<path>)` keeps its verdict' , ! isRoot ( `const n = Number(${ ARG } );` ) ) ;
3828+ ok ( 'DARK: `new Set(<path>)` keeps its verdict' , ! isRoot ( `const s = new Set(${ ARG } );` ) ) ;
3829+ ok ( 'a rostered METHOD still suppresses when dotted: `fs.readdirSync(<path>)`' , ! isRoot ( `fs.readdirSync(${ ARG } );` ) ) ;
3830+ ok ( 'and when bare: `readdirSync(<path>)` -- what the last-segment match buys' , ! isRoot ( `readdirSync(${ ARG } );` ) ) ;
3831+ ok (
3832+ 'REACH: the receiver leg reads the FIRST segment only, so `x.JSON.stringify(<path>)` is still a descent' ,
3833+ isRoot ( `x.JSON.stringify(${ ARG } );` ) ,
3834+ ) ;
3835+ ok (
3836+ 'every namespace row is also a callee row, so no BARE call changed hands with this limb' ,
3837+ [ ...NON_DESCENT_NAMESPACES ] . every ( ( n ) => NON_DESCENT_CALLEES . has ( n ) ) ,
3838+ ) ;
3839+ }
3840+
37393841 // ── the WORKING-TREE blind spot (#18348) ──────────────────────────
37403842 //
37413843 // The three legs the card demands, driven on a REAL git tree rather than
0 commit comments