Skip to content

Commit 5612dd9

Browse files
tooling(test-source-alias): a type reference or literal text is not a clocked load (#25781)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoGqmNfHDtRjRpMLtsG8NzYZ)_ Fixes #25766 Part of #25677 (cutover prep G, the gate half of decision F; the test-file half is #25763). Clause-②: no Metadata-protocol: none ## What this changes One file, `scripts/check-test-source-alias.mjs`, the clocked-window rule only (`moduleLoadSites`). The ledger half of the gate (`extractRuntimeImports`, `KNOWN_UNALIASED_TEST_IMPORTS`, the vitest alias reading) is untouched. Before this PR the rule counted every `import('x')` the import regex could see in the comment-masked text, and its only type-position guard was `typeof` in front of the keyword. Two kinds of sites were therefore read as clocked loads although they load nothing: 1. **Type references.** `type T = import('p').T` and `const s: import('p').Schema = ...` are erased by `tsc`. The new `isImportTypeReference` decides this from what FOLLOWS the call: a type reference is always qualified (a bare `import('p')` in a type position is a compile error) and its member chain is never called. A runtime `import()` is awaited, assigned, passed, or chained into a call (`.then(...)`, a generic `.then` call with a type argument, `?.then(...)`). The head cannot decide it: `{ key: import('p') }` is an object-literal load behind the same colon an annotation uses. Type arguments after the member are balanced (braces, semicolons and function-type arrows inside them included), so the token after the closing angle bracket tells a type argument list from a generic call. Only the `import(` form is read this way; `require('p').member` is the ordinary CJS spelling of a runtime load and stays a load. Anything the reader cannot parse reads as a load (the loud direction). 2. **Text.** A dynamic form written inside a string or a template body (a scanner's own test feeding it input, a child-process script) is not code. A match now counts only when its keyword is executed code: plain code in the `codeOnly` projection, or a template interpolation interior, which `codeOnly` blanks too, read from `scanSource`'s `interpolation` array. That scan runs only when a keyword is not plain code. Both rules skip the site for the module-scope set as well, matching what the header already said for `typeof`: an erased site is neither a finding nor a compliance token. A module-top `type T = import('p').T` therefore no longer pays, on paper, for a real clocked `await import('p')` in the same file. ## Measurements **Merged layout** (verify branch `claude/project-thread-dwhq6r-25697-verify` at 9e37acd, this diff applied at the root with `git apply`, `node scripts/check-test-source-alias.mjs`): | | before | after | |---|---|---| | unique clocked findings (file:line) | 37 | 30 | | the card's 7 false positives | 7 | 0 | | new clocked findings | | 0 | | non-clocked findings (ledger, alias) | 42 | 42, byte-identical lines | The 7 that are gone are exactly the card's table: `spec-symbol-4650.test.ts` in plugin-calendar :58, plugin-form :50, plugin-grid :73, plugin-kanban :50, `dashboard-global-filters-spec-binding.test.ts:102`, `data-table.test.ts:66`, `ui/scripts/__tests__/check-spec-range-floors.test.ts:286`. The 30 left are #25763's real ones. No `ui/` file is edited and nothing is added to `KNOWN_UNALIASED_TEST_IMPORTS` or any allowlist. **objectstack main** (merge base ab510f4): - `pnpm check:test-source-alias`: self-test OK, run OK, same green line as before (76 packages scanned, 60 registered, 55 published subpaths). - Ledger recompute: `node scripts/check-test-source-alias.mjs --list` before and after are identical (60 of 76 packages, 295 package-dependency pairs). - Corpus-wide reading, with no population filter: every tracked test file's sites, before vs after. main: 48 site entries reclassified, all of them removals, 0 additions. merged: 145 removals, 0 additions. Every main removal was read by hand. They are child-process scripts in template literals (`SEED_CHILD`, `WORKER_SOURCE`, `PROBE`, `DRIVER_CJS`, an `os start` stub), RuleTester-style code strings, and erased type positions (`s is import('typescript').X` predicates, `as import('node:crypto').BinaryLike` casts, `{ knex: import('knex').Knex }` annotations). None of the files is a finding before or after, because none of these specifiers is in its package's measured set. - Gate wall time on this box: 19.5 s before, 18.2 s after. ## Self-test New battery `a type reference or literal text is not a load (#25766)`, 21 cases. The roster floor goes from 10 to 11 batteries. - End to end: fixture (29) `packages/clocked-type-reference` holds an alias and an annotation (type arguments carrying a semicolon) inside a test body, plus a dynamic import as template text, each putting its specifier in the measured set. A module-top alias of `@fx/core` sits beside a real `await import('@fx/core')`. Only the real load may be reported, and it must be reported. - Shape table read straight off `moduleLoadSites`. 8 erased rows (alias, annotation, generic annotation, qualified chain with array and indexed access, parameter annotation, string, template body, `require` in a template body) and a module-top `export type` row. 9 load rows: unawaited assignment, object-literal value, awaited, `.then(...)`, generic `.then` call, `?.then(...)`, a load inside a template interpolation, a load right after an erased alias on the same line (the #12555 lesson: erasing one site must not swallow its neighbour), and `require('p').member`. On scratch copies of the gate (repo root pinned, imports pointed at this tree; the unmutated copy shows 12 relocation-only failures, subtracted below), each mutation reds rows of the new battery: | mutation | new-battery rows red | |---|---| | drop the executed-keyword test | the 3 text rows, the end-to-end text and real-load legs | | drop the type-reference test | the 5 type rows, module-top alias, end-to-end legs, the same-line neighbour row | | executed-keyword test without the interpolation array | the interpolation load row | | type reference ignores a following call | the `.then(...)` and generic `.then` rows | | a generic argument list reads as a load | the generic annotation row, module-top generic alias, end-to-end legs | | a head-based rule (colon or equals in front means type) | the unawaited-assignment and object-literal rows | | the type rule also applied to `require` | the `require('p').member` row | Every erased row is reached by at least one mutation. The awaited and `?.then(...)` load rows are not reached by these seven; they stand guard against a wider erasure rule. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 85fe6b0 (1 path, +173/-4). Exit codes were captured before any pipe. Every derived command exits 0: `node scripts/check-ci-filter-parity.mjs`, `check-closing-keyword-parity.mjs` (+ `--self-test`), `check-comment-mask-corpus.mjs`, `check-declaration-mirrors.mjs` (+ `--self-test`), `check-position-name-fold-loaders.mjs` (+ `--self-test`), `check-scripts-symbol-anchors.mjs` (+ `--self-test`), `check-self-test-wired.mjs` (+ `--self-test`), `check-self-test-workflow-commands.mjs` (+ `--self-test`), `check-whole-set-label-write.mjs` (+ `--self-test`), `scripts/pm/bare-root-worklist.mjs --self-test`; `pnpm check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:cross-package-test-inputs`, `check:driver-memory-census`, `check:entry-guard`, `check:gitlink-declared`, `check:nul-bytes`, `check:parse-guard`, `check:pm-dispatch-gates`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`, `check:ratchet-remedy-authority`, `check:refd-timer-probe`, `check:test-source-alias`, `check:watch-hint-literal`. Reconciled at 85fe6b0 with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list`: 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. The first run of six of them exited 3 (PREREQUISITE NOT MET: `yaml`, `typescript`, `@typescript-eslint/parser` missing in a fresh worktree). After `pnpm install --frozen-lockfile` all six exit 0. NOT MEASURED locally, left to CI: the two families that take a value from the workflow (`check-shard-attestation.mjs --emit`, `check-test-completeness.mjs`) and the Test Core job steps. No changeset: the root package is private and `scripts/**` ships in no package's `files[]`. The PR carries `skip-changeset`. ## Acceptance notes 1. **Out of scope by the card, noted:** the ledger half (`extractRuntimeImports`) still reads type references and string or template text as runtime imports. That is why such specifiers keep entering a package's measured set (all 7 merged-layout sites were in it). It belongs to the vitest-alias half that the cutover adaptation commit owns. No finding is wrong because of it after this PR, since the clocked rule no longer reports those sites. 2. **Observed, not fixed (outside the card's step 1):** a STATIC import spelled as text inside a string or template at module top still counts as a module-scope compliance token. That is the fail-open direction. Measured with a scratch copy that applies the same executed-keyword test to static matches: 67 such tokens on main and 141 on the merged layout, and none of them pairs with a clocked load of the same specifier in its file. Today it hides nothing. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01Mj3sUeTnVuFsWPY37TxDmS)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0cf338 commit 5612dd9

1 file changed

Lines changed: 173 additions & 4 deletions

File tree

‎scripts/check-test-source-alias.mjs‎

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,11 @@
259259
// so flagging it would be inventing a failure. Both are accepted; a
260260
// type-only clause is accepted as NEITHER, since it never resolves.
261261
// - `typeof import('x')` is a TYPE QUERY, erased before anything loads. Never
262-
// a finding, and never a compliance token either.
262+
// a finding, and never a compliance token either. The same holds for an
263+
// `import('x').Member` TYPE REFERENCE — an alias, an annotation — told from
264+
// a runtime `import()` by its uncalled member chain (`isImportTypeReference`
265+
// says why the tail decides and the head cannot), and for a dynamic form
266+
// written as TEXT inside a string or template body (#25766).
263267
// - The population is the specifiers THIS package really resolves through
264268
// `dist/` — the same measurement `KNOWN_UNALIASED_TEST_IMPORTS` mirrors, at
265269
// specifier granularity. A dep already aliased to source still costs a
@@ -346,7 +350,7 @@
346350
// node scripts/check-test-source-alias.mjs --self-test
347351

348352
import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
349-
import { stripComments, maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs';
353+
import { stripComments, maskComments, maskCommentsAndLiterals, scanSource } from './js-comment-mask.mjs';
350354
import { join, resolve, relative, dirname } from 'node:path';
351355
import { fileURLToPath } from 'node:url';
352356
import {
@@ -850,6 +854,71 @@ const CONTROL_HEADS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with',
850854
/** `typeof import('x')` — a type query, erased before anything resolves. */
851855
const TYPE_QUERY_BEFORE = /\btypeof\s*$/;
852856

857+
/**
858+
* Is the `import('x')` call ending just before `end` a TYPE REFERENCE — an alias
859+
* (`type T = import('x').T`), an annotation (`const s: import('x').Schema = …`),
860+
* a union member, an `as`/`satisfies` target? Each is erased by `tsc` exactly as
861+
* `typeof import('x')` is, and resolves nothing (#25766).
862+
*
863+
* Read from what FOLLOWS the call, never from what precedes it. A type reference
864+
* is always QUALIFIED (a bare `import('x')` in a type position is a compile
865+
* error) and its member chain is never CALLED; a runtime `import()` is awaited,
866+
* assigned, passed, or chained into a call (`.then(…)`, `.then<T>(…)`,
867+
* `?.then(…)`). The head cannot tell them apart: `{ key: import('x') }` is an
868+
* object-literal LOAD behind the same `:` an annotation uses, and a union or a
869+
* generic argument has a head no short list names.
870+
*
871+
* `import(` only — `require('x').member` is the ordinary CJS spelling of a
872+
* runtime load. `code` is the masked projection; anything this cannot read
873+
* reads as a LOAD, the loud direction.
874+
*/
875+
function isImportTypeReference(code, end) {
876+
let i = end;
877+
const skipSpace = () => {
878+
while (i < code.length && /\s/.test(code[i])) i++;
879+
};
880+
skipSpace();
881+
if (code[i] !== '.') return false;
882+
while (code[i] === '.') {
883+
i++;
884+
skipSpace();
885+
const start = i;
886+
while (i < code.length && /[A-Za-z0-9_$]/.test(code[i])) i++;
887+
if (i === start) return false;
888+
skipSpace();
889+
}
890+
if (code[i] === '<') {
891+
// `.Box<T>` is a type argument list, `.then<T>(…)` a generic CALL: only the
892+
// token after the matching `>` separates them.
893+
const close = closingAngle(code, i);
894+
if (close < 0) return false;
895+
i = close + 1;
896+
skipSpace();
897+
}
898+
return code[i] !== '(';
899+
}
900+
901+
/**
902+
* The `>` closing the `<` at `open`, reading forward. -1 if none. Braces, parens
903+
* and brackets inside the argument list are balanced past (`Box<{ a: A; b: B }>`
904+
* carries a `;`), and the `>` of a function type's `=>` closes nothing.
905+
*/
906+
function closingAngle(code, open) {
907+
let angle = 0;
908+
let nest = 0;
909+
for (let i = open; i < code.length; i++) {
910+
const c = code[i];
911+
if (c === '(' || c === '[' || c === '{') nest++;
912+
else if (c === ')' || c === ']' || c === '}') {
913+
if (--nest < 0) return -1;
914+
} else if (c === '<') angle++;
915+
else if (c === '>' && code[i - 1] !== '=') {
916+
if (--angle === 0) return nest === 0 ? i : -1;
917+
} else if (c === ';' && nest === 0) return -1;
918+
}
919+
return -1;
920+
}
921+
853922
/**
854923
* Two same-length projections of one source, so a byte offset means the same
855924
* thing in both: `commentsOnly` keeps every string intact (the import regex has
@@ -1017,11 +1086,21 @@ function functionBodyRanges(code) {
10171086
* `moduleScope` is every specifier loaded during COLLECTION — a static import or
10181087
* a module-scope dynamic one; `clocked` is every dynamic load that sits inside a
10191088
* function body, with the line and the spelling the diagnostic quotes. Type-only
1020-
* clauses and `typeof import(…)` queries appear in neither: they never resolve.
1089+
* clauses, `typeof import(…)` queries and `import(…).Member` type references
1090+
* appear in neither: they never resolve. Nor does a dynamic form written inside
1091+
* a string or a template body — a scanner fixture's input is text (#25766).
10211092
*/
10221093
function moduleLoadSites(source, ctx = sourceScanContext(source)) {
10231094
const { commentsOnly, codeOnly, ranges } = ctx;
10241095
const inFunction = (index) => ranges.some(([start, end]) => index > start && index < end);
1096+
// The pattern reads `commentsOnly`, where literal content survives so the
1097+
// specifier can be read; a keyword the language does not execute is blank in
1098+
// `codeOnly`. So is a `${…}` interior, which IS executed — `scanSource`'s
1099+
// `interpolation` array tells the two apart, scanned only when a keyword is
1100+
// not plain code.
1101+
let interpolation = null;
1102+
const executes = (at, keyword) =>
1103+
codeOnly.startsWith(keyword, at) || (interpolation ??= scanSource(source).interpolation)[at] === 1;
10251104
const moduleScope = new Set();
10261105
const clocked = [];
10271106
IMPORT_PATTERNS.lastIndex = 0;
@@ -1039,7 +1118,9 @@ function moduleLoadSites(source, ctx = sourceScanContext(source)) {
10391118
// The pattern's leading `(?:^|[\s;{(=,])` eats one delimiter unless the match
10401119
// begins the file, so the keyword starts one character in.
10411120
const head = match.index + (/^[a-z]/.test(match[0]) ? 0 : 1);
1121+
if (!executes(head, match[4] != null ? 'require' : 'import')) continue;
10421122
if (TYPE_QUERY_BEFORE.test(codeOnly.slice(Math.max(0, head - 16), head))) continue;
1123+
if (match[3] != null && isImportTypeReference(codeOnly, match.index + match[0].length)) continue;
10431124
if (!inFunction(head)) {
10441125
moduleScope.add(spec);
10451126
continue;
@@ -2745,6 +2826,27 @@ function buildFixtureTree() {
27452826
"it('y', async () => loadThing('@fx/core'));\n",
27462827
});
27472828

2829+
// (29) A TYPE REFERENCE OR LITERAL TEXT IS NOT A LOAD (#25766). The merged
2830+
// ui/ tree's three false-positive shapes, end to end: an alias and an
2831+
// annotation (generic arguments carrying `;` included) inside a test body,
2832+
// and a dynamic form written as template TEXT, the way a scanner's own test
2833+
// feeds it input. Each puts its specifier in the measured set, so a reader
2834+
// that counts it reports it. The module-top alias of `@fx/core` is the other
2835+
// half: erased, it must not pay for the real load on line 7.
2836+
fixture(root, 'packages/clocked-type-reference', {
2837+
'package.json': ARTIFACT_MANIFEST('@fx/clocked-type-reference'),
2838+
'src/thing.test.ts':
2839+
"import { it } from 'vitest';\n" +
2840+
"type Core = import('@fx/core').Alive;\n" +
2841+
"it('y', async () => {\n" +
2842+
" type Log = import('@fx/core/logger').Log;\n" +
2843+
" const opts: import('@fx/core/logger').Options<{ a: 1; b: 2 }> = {};\n" +
2844+
" const text = `const m = await import('@fx/core/nested');`;\n" +
2845+
" const mod: Core = await import('@fx/core');\n" +
2846+
' return [opts, text, mod];\n' +
2847+
'});\n',
2848+
});
2849+
27482850
return root;
27492851
}
27502852

@@ -2777,6 +2879,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
27772879
'the cross-boundary walk (#8351)': 7,
27782880
'the latent half (#9674)': 8,
27792881
'the clocked-window rule (#10126)': 19,
2882+
'a type reference or literal text is not a load (#25766)': 21,
27802883
'the import clause is bounded to ONE statement (#12555)': 11,
27812884
'the declared population must stay READABLE by the dispatch deriver': 11,
27822885
'the declaration must still BE the workspace (#11510)': 22,
@@ -2785,7 +2888,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
27852888

27862889
// DELETING an entry silences that battery's floor exactly as effectively as
27872890
// zeroing it, so the roster's own size is pinned too.
2788-
const SELF_TEST_BATTERY_FLOOR = 10;
2891+
const SELF_TEST_BATTERY_FLOOR = 11;
27892892

27902893
// The key an assertion is filed under when no battery is open. It is not a
27912894
// declared battery, so it reds by the same set difference rather than silently
@@ -3239,6 +3342,72 @@ function selfTest() {
32393342
'a routed call in a file that already loads the specifier at module top was reported — that is the remedy, not the defect',
32403343
);
32413344

3345+
// ── a type reference or literal text is not a load (#25766) ───────────
3346+
//
3347+
// End to end first: fixture (29) puts each erased shape's specifier in the
3348+
// measured set, so only the line-7 load may be reported — and it must BE
3349+
// reported, because the module-top `type Core = import(…)` paid nothing.
3350+
battery('a type reference or literal text is not a load (#25766)');
3351+
const typeRefFile = 'packages/clocked-type-reference/src/thing.test.ts';
3352+
expect(
3353+
clockedIn(typeRefFile).every((f) => !f.includes('@fx/core/logger')),
3354+
'an `import(…).Member` alias or annotation inside a test body was reported as a clocked load — it is erased by tsc',
3355+
);
3356+
expect(
3357+
clockedIn(typeRefFile).every((f) => !f.includes('@fx/core/nested')),
3358+
'a dynamic import written as TEXT inside a template literal was reported as a clocked load',
3359+
);
3360+
expect(
3361+
clockedIn(typeRefFile).length === 1 && clockedIn(`${typeRefFile}:7`).length === 1,
3362+
'the real load beside the erased shapes was not reported exactly once — a module-top type alias silenced it, or a type position leaked in',
3363+
);
3364+
3365+
// Then the shape table, read straight off `moduleLoadSites`: no population
3366+
// filter and no per-specifier dedupe stand between a row and its verdict.
3367+
// `p` is the specifier under test; `q` marks an erased neighbour.
3368+
const inBody = (body) => `it('t', async () => {\n${body}\n});\n`;
3369+
const ERASED = [
3370+
['an alias in a test body', inBody(" type T = import('p').T;")],
3371+
['an annotation', inBody(" const s: import('p').Schema = {};")],
3372+
['an annotation whose type arguments carry `;` and `=>`', inBody(" const s: import('p').Box<{ a: A; f: (x: B) => C }> = make();")],
3373+
['a qualified chain, an array and an indexed access', inBody(" let e: import('p').ns.Entry[];\n let k: import('p').Schema['key'];")],
3374+
['a parameter annotation', inBody(" const f = (x: import('p').T) => x;")],
3375+
['a dynamic import inside a string', inBody(" const src = \"await import('p')\";")],
3376+
['a dynamic import inside a template body', inBody(" const src = `const m = await import('p');`;")],
3377+
['a require inside a template body', inBody(" const src = `const x = require('p');`;")],
3378+
];
3379+
for (const [shape, source] of ERASED) {
3380+
const sites = moduleLoadSites(source);
3381+
expect(
3382+
sites.clocked.length === 0 && !sites.moduleScope.has('p'),
3383+
`${shape} was read as a load — it resolves nothing at run time, so it is neither a finding nor a compliance token`,
3384+
);
3385+
}
3386+
// At module top an alias is not a compliance token either: an exported,
3387+
// generic alias must not satisfy the rule on the file's behalf.
3388+
expect(
3389+
!moduleLoadSites("export type T<X> = import('p').Box<X>;\n").moduleScope.has('p'),
3390+
'a module-top `export type … = import(…).Member` was read as paying the load during collection',
3391+
);
3392+
const LOADS = [
3393+
['an assigned, unawaited import', inBody(" const m = import('p');")],
3394+
['an object-literal value', inBody(" const holder = { key: import('p') };\n return holder;")],
3395+
['an awaited import', inBody(" await import('p');")],
3396+
['a `.then(…)` chain', inBody(" import('p').then((m) => m);")],
3397+
['a generic `.then<T>(…)` call', inBody(" import('p').then<unknown>((m) => m);")],
3398+
['an optional `?.then(…)` call', inBody(" import('p')?.then((m) => m);")],
3399+
['a load inside a template interpolation', inBody(' const s = `${await import(\'p\')}`;')],
3400+
['a load right after an erased alias on the same line', inBody(" type T = import('q').T; const m = await import('p');")],
3401+
['a CJS `require(…).member`', inBody(" const x = require('p').member;")],
3402+
];
3403+
for (const [shape, source] of LOADS) {
3404+
const sites = moduleLoadSites(source);
3405+
expect(
3406+
sites.clocked.length === 1 && sites.clocked[0].spec === 'p',
3407+
`${shape} inside a test body was not read as exactly one clocked load — the erasure rule swallowed a real load`,
3408+
);
3409+
}
3410+
32423411
// ── the import clause is bounded to ONE statement (#12555) ────────────
32433412
//
32443413
// All three rows of the card's table, pinned as a set comparison so the two

0 commit comments

Comments
 (0)