2020// ## Dead scan roots are a hard error (#4930)
2121//
2222// Check (1) is a *scan*: it concludes "no duplicate resolver exists" from having
23- // read every `.ts` under SCAN_ROOTS. `walk()` used to open with
23+ // read every TypeScript source under SCAN_ROOTS. `walk()` used to open with
2424// `try { entries = readdirSync(dir); } catch { return out; }`, so a root that was
2525// renamed, moved or made unreadable produced zero files — and zero files produce
2626// zero errors, which is character-for-character the same verdict as a clean
6565// The floor is per-root and never a total: with more than one root, a single
6666// populated one would otherwise cover for every evaporated sibling — which is the
6767// silent narrowing this assertion exists to stop.
68+ //
69+ // ## The extension filter is a family, not a suffix string (#6070)
70+ //
71+ // The failure above names its own successor — "sources renamed to an extension `walk`
72+ // does not collect" — and that case was already live on the day #5916 landed. The
73+ // collector tested `e.endsWith('.ts')`, and `'x.mts'.endsWith('.ts')` is **false** (the
74+ // character before `ts` is `m`, not `.`), so every `.mts` / `.cts` source under
75+ // `packages/` — twelve of them, all build/liveness scripts under `packages/spec/scripts/` —
76+ // stayed out of the corpus, and check (1)'s "no duplicate resolver exists" was concluded
77+ // from a set that structurally excluded them.
78+ //
79+ // Neither corpus assertion above can see this, by construction. `packages/` resolves,
80+ // and it yields well over a thousand `.ts` files — far above a per-root floor of one —
81+ // while every `.mts` under it is invisible. A floor answers "did this root produce
82+ // anything"; it can never answer "did it produce everything the root declares".
83+ //
84+ // So the filter is an extension FAMILY (`SCANNED_EXT` / `EXCLUDED_EXT`), not a suffix
85+ // string, and the exclusions move with it in the same step — widening only the collector
86+ // would re-plant the same bug one level down, with `x.test.mts` scannable while
87+ // `x.test.ts` is not. None of the twelve files trips check (1)'s heuristic, so the wider
88+ // corpus changes no verdict today; what changes is that the verdict is now drawn from
89+ // what the gate says it reads.
6890
6991import {
7092 mkdirSync , mkdtempSync , readFileSync , readdirSync , renameSync , rmSync , statSync , symlinkSync , writeFileSync ,
@@ -93,6 +115,19 @@ const SCAN_ROOTS = ['packages'];
93115
94116const SKIP_DIRS = new Set ( [ 'node_modules' , 'dist' , '__tests__' ] ) ;
95117
118+ /**
119+ * The extension family check (1) reads, and the two shapes excluded from it.
120+ *
121+ * Two regexes, deliberately paired and deliberately parallel. `SCANNED_EXT` replaces an
122+ * `e.endsWith('.ts')` that silently skipped `.mts` / `.cts` (see the header, #6070);
123+ * `EXCLUDED_EXT` carries the SAME family through the test/declaration exclusions, so the
124+ * widening cannot leave them one extension behind. The repo has no `.test.mts` or `.d.cts`
125+ * today — the shapes are excluded anyway, because the exclusion states what a test or
126+ * declaration file IS, not an inventory of the ones that happen to exist.
127+ */
128+ const SCANNED_EXT = / \. [ m c ] ? t s $ / ;
129+ const EXCLUDED_EXT = / \. (?: t e s t | d ) \. [ m c ] ? t s $ / ;
130+
96131/** A declared scan root that could not be resolved to a directory. Carries the names. */
97132class DeadRootError extends Error {
98133 constructor ( dead ) {
@@ -129,7 +164,8 @@ function assertRootsResolvable(root = ROOT, roots = SCAN_ROOTS) {
129164}
130165
131166/**
132- * Every non-test `.ts` file under `dir`, recursively.
167+ * Every non-test TypeScript source under `dir`, recursively — `.ts`, `.mts` and `.cts`
168+ * alike (`SCANNED_EXT`), minus the test and declaration shapes of each (`EXCLUDED_EXT`).
133169 *
134170 * Nothing here is wrapped in a catch: an unresolvable root fails loudly above, and
135171 * an error *inside* the walk (a vanished file, a permission fault) means the corpus
@@ -141,7 +177,7 @@ function walk(dir, out = []) {
141177 const p = join ( dir , e ) ;
142178 const st = statSync ( p ) ;
143179 if ( st . isDirectory ( ) ) walk ( p , out ) ;
144- else if ( e . endsWith ( '.ts' ) && ! e . endsWith ( '.test.ts' ) && ! e . endsWith ( '.d.ts' ) ) out . push ( p ) ;
180+ else if ( SCANNED_EXT . test ( e ) && ! EXCLUDED_EXT . test ( e ) ) out . push ( p ) ;
145181 }
146182 return out ;
147183}
@@ -152,7 +188,7 @@ function walk(dir, out = []) {
152188 */
153189class EmptyRootError extends Error {
154190 constructor ( empty , total ) {
155- super ( `scan root(s) contributed no scannable .ts file: ${ empty . join ( ', ' ) } (total scanned: ${ total } )` ) ;
191+ super ( `scan root(s) contributed no scannable TypeScript file: ${ empty . join ( ', ' ) } (total scanned: ${ total } )` ) ;
156192 this . name = 'EmptyRootError' ;
157193 /** @type {string[] } the roots that yielded nothing. */
158194 this . roots = empty ;
@@ -245,7 +281,7 @@ function reportEmptyRoots(err) {
245281 console . error (
246282 `\n${ err . total } file(s) were found in total across all of SCAN_ROOTS.` +
247283 `\n\nEvery entry in SCAN_ROOTS (scripts/check-single-authz-resolver.mjs) must yield at least one` +
248- `\nscannable .ts file. The root still being a directory is not enough — that is all #4930's` +
284+ `\nscannable .ts/.mts/.cts file. The root still being a directory is not enough — that is all #4930's` +
249285 `\ncheck can see. If the sources moved to a new directory, point SCAN_ROOTS at it; if the walk` +
250286 `\nfilter no longer matches them (a new extension, a widened SKIP_DIRS), fix the filter. Do NOT` +
251287 `\nlower this to a total count: one populated root would then cover for every evaporated one,` +
@@ -293,9 +329,44 @@ function selfTest() {
293329 // (1) — the walker must not report test/type files or skipped directories.
294330 write ( 'packages/rest/src/__tests__/fake.ts' , "sys_user_role sys_user_permission_set\n" ) ;
295331 write ( 'packages/rest/src/x.test.ts' , "sys_user_role sys_user_permission_set\n" ) ;
332+ // `.d.ts` was named in this assertion's label from the start but never written, so
333+ // the exclusion it claims to cover was never exercised. Added with the .mts/.cts
334+ // pass below, which extends that same exclusion to the rest of the family (#6070).
335+ write ( 'packages/rest/src/x.d.ts' , "sys_user_role sys_user_permission_set\n" ) ;
296336 write ( 'packages/rest/dist/x.ts' , "sys_user_role sys_user_permission_set\n" ) ;
297337 expect ( 'tests, .d.ts and dist/ are out of scope' , audit ( dir ) . length , 0 ) ;
298338
339+ // (1) — `.mts` / `.cts` are the same corpus (#6070). `'x.mts'.endsWith('.ts')` is
340+ // false, so the suffix-string collector walked past every module-extension source
341+ // under a root it claims to read in full. Pinned in BOTH directions, because either
342+ // one alone is satisfiable by a filter that collects nothing:
343+ // * the corpus must GROW by exactly the collectable fixtures — a count a
344+ // regressed filter cannot reach;
345+ // * and the duplicates written in them must be CAUGHT AND NAMED — a verdict an
346+ // empty corpus produces zero of, which is what made the original miss silent.
347+ const beforeExt = collectScanFiles ( dir ) . length ;
348+ write ( 'packages/rest/src/esm-resolver.mts' , "sys_user_role sys_user_permission_set\n" ) ;
349+ write ( 'packages/rest/src/cjs-resolver.cts' , "sys_user_role sys_user_permission_set\n" ) ;
350+ // The exclusions carry the same family: changing a test's or a declaration's
351+ // extension must not make it scannable. The repo has none of these four shapes
352+ // today — that is exactly why they are asserted here rather than trusted.
353+ for ( const excluded of [ 'x.test.mts' , 'x.test.cts' , 'x.d.mts' , 'x.d.cts' ] ) {
354+ write ( `packages/rest/src/${ excluded } ` , "sys_user_role sys_user_permission_set\n" ) ;
355+ }
356+ expect ( '.mts/.cts join the corpus and their test/declaration shapes stay out' ,
357+ collectScanFiles ( dir ) . length , beforeExt + 2 ) ;
358+ const extErrors = audit ( dir ) ;
359+ expect ( 'a duplicate resolver in .mts and one in .cts are both flagged' , extErrors . length , 2 ) ;
360+ expect ( 'the .mts duplicate is named' , extErrors . some ( ( e ) =>
361+ e . startsWith ( 'Possible duplicate authorization resolver: packages/rest/src/esm-resolver.mts' ) ) , true ) ;
362+ expect ( 'the .cts duplicate is named' , extErrors . some ( ( e ) =>
363+ e . startsWith ( 'Possible duplicate authorization resolver: packages/rest/src/cjs-resolver.cts' ) ) , true ) ;
364+ for ( const f of [ 'esm-resolver.mts' , 'cjs-resolver.cts' , 'x.test.mts' , 'x.test.cts' , 'x.d.mts' , 'x.d.cts' ] ) {
365+ rmSync ( join ( dir , 'packages/rest/src' , f ) ) ;
366+ }
367+ expect ( 'removing the module-extension fixtures restores the green' , audit ( dir ) . length , 0 ) ;
368+ expect ( '...and restores the corpus to its previous size' , collectScanFiles ( dir ) . length , beforeExt ) ;
369+
299370 // (2) — an entry point that stops delegating.
300371 write ( DELEGATORS [ 0 ] , '// re-inlined the session/role reads here\n' ) ;
301372 const delErrors = audit ( dir ) ;
@@ -406,10 +477,11 @@ function selfTest() {
406477 process . exit ( 1 ) ;
407478 }
408479 console . log (
409- '✓ check-single-authz-resolver self-test: duplicate detection, delegation, the dead-root ' +
410- 'hard error (red when the scan root is renamed, green when restored) and the empty-scan ' +
411- 'hard error (red when one declared root yields nothing and when the whole scan does, green ' +
412- 'when restored) all hold.' ,
480+ '✓ check-single-authz-resolver self-test: duplicate detection, delegation, the extension ' +
481+ 'family (.mts/.cts enter the corpus and their duplicates are named; .test./.d. shapes of ' +
482+ 'every extension stay out), the dead-root hard error (red when the scan root is renamed, ' +
483+ 'green when restored) and the empty-scan hard error (red when one declared root yields ' +
484+ 'nothing and when the whole scan does, green when restored) all hold.' ,
413485 ) ;
414486}
415487
0 commit comments