Skip to content

Commit 92abae3

Browse files
committed
fix(scripts): check-single-authz-resolver 的 walk 收 .mts/.cts,排除项同族扩展 (#6070)
`'x.mts'.endsWith('.ts')` is false — the character before `ts` is `m`, not `.` — so the collector walked past every `.mts`/`.cts` source under the declared scan root `packages/`. Check (1) concluded "no duplicate request-context resolver exists" from a corpus that structurally excluded 12 files. Neither existing corpus assertion can see this: the root resolves (#4930) and yields well over a thousand `.ts` files, far above the per-root floor of one (#5916). A floor answers "did this root produce anything", never "did it produce everything it declares". - walk() now filters on an extension FAMILY: SCANNED_EXT /\.[mc]?ts$/ minus EXCLUDED_EXT /\.(?:test|d)\.[mc]?ts$/, so the test/declaration exclusions widen in the same step — widening only the collector would re-plant the same bug one level down (`x.test.mts` scannable while `x.test.ts` is not). The repo has none of those four shapes today; they are excluded anyway. - Corpus 1475 -> 1487 files (+12, all `packages/spec/scripts/**`). None trips check (1)'s heuristic, so the real run stays green — what changes is that the verdict is now drawn from what the gate says it reads. - Self-test pins both directions: the corpus must GROW by exactly the collectable fixtures, AND the duplicates written in `.mts`/`.cts` must be caught and named. Either alone is satisfiable by a filter that collects nothing. Also writes the `.d.ts` fixture that assertion's label has claimed since it was written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
1 parent f6609e6 commit 92abae3

1 file changed

Lines changed: 81 additions & 9 deletions

File tree

scripts/check-single-authz-resolver.mjs

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
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
@@ -65,6 +65,28 @@
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

6991
import {
7092
mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync,
@@ -93,6 +115,19 @@ const SCAN_ROOTS = ['packages'];
93115

94116
const 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 = /\.[mc]?ts$/;
129+
const EXCLUDED_EXT = /\.(?:test|d)\.[mc]?ts$/;
130+
96131
/** A declared scan root that could not be resolved to a directory. Carries the names. */
97132
class 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
*/
153189
class 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

Comments
 (0)