From 3b8b328b2d89a6015bae382bf46f1606f91614c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:50:46 +0000 Subject: [PATCH 1/2] fix(devx): seven gates that import `typescript` refuse an uninstalled tree with a named prerequisite, not a raw stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:system-context-census` and six sibling sites still carried the bare top-level `import ts from 'typescript'` (and, at one site, a bare dynamic `import('@typescript-eslint/parser')`) that PR #11824 converted everywhere else. node resolves those before any module body runs, so the gate cannot preflight its own missing dependency: on a fresh worktree — the checkout shape CLAUDE.md mandates — it died with a node-internals `ERR_MODULE_NOT_FOUND` stack and exit **1**, the same code a real finding uses. Each site now loads the dependency through `scripts/import-prerequisite.mjs`, in the shape the 27 already-converted sites use, so the answer is `PREREQUISITE NOT MET`, exit 3, and an explicit "nothing was measured". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- ...744-before-update-per-row-value-census.mjs | 3 ++- scripts/check-comment-mask-corpus.mjs | 21 +++++++++++++-- scripts/check-exported-any-returns.mts | 26 ++++++++++++------- scripts/check-sdui-lockstep.mjs | 3 ++- scripts/isystem-census.mjs | 3 ++- scripts/measure-durability-swallow-family.mjs | 3 ++- scripts/tenant-audit-census.mjs | 3 ++- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/scripts/audits/14744-before-update-per-row-value-census.mjs b/scripts/audits/14744-before-update-per-row-value-census.mjs index 893ca45067..f45fa49a38 100644 --- a/scripts/audits/14744-before-update-per-row-value-census.mjs +++ b/scripts/audits/14744-before-update-per-row-value-census.mjs @@ -68,7 +68,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import { requireDefaultExport } from '../import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); // ⛔ Never `ts.createSourceFile` directly. It does not throw on a source it // cannot read — the errors are parked on `parseDiagnostics` and the recovered // tree walks like any other, so a file this census could not parse would be diff --git a/scripts/check-comment-mask-corpus.mjs b/scripts/check-comment-mask-corpus.mjs index a2e698b94c..dd50cb13ae 100644 --- a/scripts/check-comment-mask-corpus.mjs +++ b/scripts/check-comment-mask-corpus.mjs @@ -130,6 +130,7 @@ import { tmpdir } from 'node:os'; import { dirname, extname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { requireDependency } from './import-prerequisite.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, '..'); @@ -301,9 +302,25 @@ export async function loadMasker(maskerPath) { return module.scanSource; } -/** The parser is loaded lazily so importing this module stays cheap. */ +/** + * The parser is loaded lazily so importing this module stays cheap — and through + * the prerequisite thunk, so an uninstalled tree gets a NAMED prerequisite and + * exit 3 instead of a raw `ERR_MODULE_NOT_FOUND` stack and exit 1. A dynamic + * import defers the resolution failure past linking, but it does not change what + * the failure LOOKS like: the rejection reaches the top level unhandled and node + * prints the same node-internals stack with the same exit 1 a finding uses. + * + * ⛔ `requireDependency`, not `requireDefaultExport`: this module wants the + * NAMESPACE (`parser.parse`). `@typescript-eslint/parser` has no default export + * worth reading, and the default-export helper reads `.default` strictly. + */ async function loadParser() { - const parser = await import('@typescript-eslint/parser'); + const parser = await requireDependency( + '@typescript-eslint/parser', + () => import('@typescript-eslint/parser'), + import.meta.url, + { measures: "`js-comment-mask.mjs` and an independent parser agree on every comment range in the tree" }, + ); return (source, options) => parser.parse(source, options); } diff --git a/scripts/check-exported-any-returns.mts b/scripts/check-exported-any-returns.mts index 92082495ab..d337c74ee9 100644 --- a/scripts/check-exported-any-returns.mts +++ b/scripts/check-exported-any-returns.mts @@ -118,7 +118,10 @@ // them to the rest of the program — which is why landing this file lowered the // ledger entry by 54 errors it did not author. See the PR body. -import ts from 'typescript'; +// `ts` is the RUNTIME namespace, loaded through the prerequisite thunk below; `TS` +// is the same namespace as TYPES ONLY. `import type` is erased before the module +// graph is linked, so it cannot bring back the `ERR_MODULE_NOT_FOUND` this closes. +import type TS from 'typescript'; import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; @@ -127,6 +130,11 @@ import os from 'node:os'; import { distIsStale } from './check-regen-pending.mjs'; import { isEntrypoint } from './invoked-as.mjs'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url, { + measures: 'any exported callable of an SDK package resolves to `any`', +}); + const HERE = path.dirname(url.fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, '..'); const SELF_TEST = process.argv.includes('--self-test'); @@ -200,7 +208,7 @@ export type ScanResult = { anyReturns: Map; }; -function makeProgram(files: string[], extra: ts.CompilerOptions = {}): ts.Program { +function makeProgram(files: string[], extra: TS.CompilerOptions = {}): TS.Program { return ts.createProgram(files, { module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, @@ -224,7 +232,7 @@ function makeProgram(files: string[], extra: ts.CompilerOptions = {}): ts.Progra * refuses. A named class that IS part of the surface (`RealtimeAPI`, * `QueryBuilder`) is reached anyway, as its own module export. */ -export function scan(program: ts.Program, entryFile: string): ScanResult { +export function scan(program: TS.Program, entryFile: string): ScanResult { const checker = program.getTypeChecker(); const result: ScanResult = { callables: 0, generics: 0, anyReturns: new Map() }; @@ -236,19 +244,19 @@ export function scan(program: ts.Program, entryFile: string): ScanResult { ); } - const isAny = (t: ts.Type | undefined): boolean => Boolean(t && t.flags & ts.TypeFlags.Any); - const unalias = (s: ts.Symbol): ts.Symbol => + const isAny = (t: TS.Type | undefined): boolean => Boolean(t && t.flags & ts.TypeFlags.Any); + const unalias = (s: TS.Symbol): TS.Symbol => s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; /** A `{ … }` type literal — the namespacing shape, not a named data type. */ - const isAnonymousObject = (t: ts.Type): boolean => { + const isAnonymousObject = (t: TS.Type): boolean => { if (!(t.flags & ts.TypeFlags.Object)) return false; - if (!((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous)) return false; + if (!((t as TS.ObjectType).objectFlags & ts.ObjectFlags.Anonymous)) return false; const decl = t.getSymbol()?.declarations?.[0]; return Boolean(decl && ts.isTypeLiteralNode(decl)); }; - const record = (key: string, sig: ts.Signature): void => { + const record = (key: string, sig: TS.Signature): void => { result.callables++; if ((sig.getTypeParameters() ?? []).length > 0) result.generics++; const ret = checker.getReturnTypeOfSignature(sig); @@ -272,7 +280,7 @@ export function scan(program: ts.Program, entryFile: string): ScanResult { // walk happened to reach first, so the second path's sites are invisible to the // ratchet and the ledger keys silently depend on walk order. A per-branch set // still terminates (a cycle must revisit an ancestor) and reports every path. - const walk = (type: ts.Type, path: string, depth: number, ancestors: ReadonlySet): void => { + const walk = (type: TS.Type, path: string, depth: number, ancestors: ReadonlySet): void => { if (depth > 8 || ancestors.has(type)) return; const branch = new Set(ancestors).add(type); for (const prop of checker.getPropertiesOfType(type)) { diff --git a/scripts/check-sdui-lockstep.mjs b/scripts/check-sdui-lockstep.mjs index 1b47e3f994..e91688aa0a 100644 --- a/scripts/check-sdui-lockstep.mjs +++ b/scripts/check-sdui-lockstep.mjs @@ -109,7 +109,8 @@ import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; import { parseSourceFile } from './ts-parse.mjs'; -import ts from 'typescript'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); diff --git a/scripts/isystem-census.mjs b/scripts/isystem-census.mjs index 0691d535f6..69cb3d34c2 100644 --- a/scripts/isystem-census.mjs +++ b/scripts/isystem-census.mjs @@ -75,7 +75,8 @@ import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); import { isEntrypoint } from './invoked-as.mjs'; import { parseSourceFile } from './ts-parse.mjs'; diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 5ee4a2833c..b6c8913bf4 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -256,7 +256,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); import { parseSourceFile } from './ts-parse.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 806eb8c5ae..df7c26cb1b 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -144,7 +144,8 @@ import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import { requireDefaultExport } from './import-prerequisite.mjs'; +const ts = await requireDefaultExport('typescript', () => import('typescript'), import.meta.url); import { isEntrypoint } from './invoked-as.mjs'; import { parseSourceFile } from './ts-parse.mjs'; From 023a8d147f3c0cb76dfe4752b9b246a065e6b9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:56:06 +0000 Subject: [PATCH 2/2] fix(devx): declare `import-prerequisite`'s loaders so a `.mts` gate can import them without TS7016 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-exported-any-returns.mts` is inside the ROOT tsc program, where an untyped `.mjs` import is TS7016 — measured, not assumed: converting it added exactly one error (169 vs the 168 the same tree reports with the unconverted file), and `@objectstack/spec-monorepo` is a shrink-only ratchet. The sidecar is the shape three root scripts already use for the same reason, and `check:declaration-mirrors` covers it by discovery. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- scripts/import-prerequisite.d.mts | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 scripts/import-prerequisite.d.mts diff --git a/scripts/import-prerequisite.d.mts b/scripts/import-prerequisite.d.mts new file mode 100644 index 0000000000..2dad55fc9f --- /dev/null +++ b/scripts/import-prerequisite.d.mts @@ -0,0 +1,62 @@ +// Types for the two dependency loaders `import-prerequisite.mjs` publishes to +// the gates that import it. +// +// The module itself stays `.mjs` for the reason its three sibling mirrors +// state: `pre-commit` and the gates invoke these scripts with bare `node`, and +// every root script here is authored that way. What needs the declaration is +// the other direction -- a TypeScript-authored gate (`.mts`) importing a loader +// from inside the ROOT tsc program, where an untyped `.mjs` import is TS7016. +// `check-exported-any-returns.mts` is the first such consumer; without this +// file its conversion would have added one error to the `@objectstack/spec- +// monorepo` entry of `check:type-check-debt`, a shrink-only ratchet. +// +// PARTIAL on purpose, the `check-regen-pending.d.mts` shape: the module also +// exports the exit-code constants, the classifier and its self-test, and +// omitting them cannot fail green -- a consumer importing an undeclared name +// gets TS2305, which is loud and immediate. Keep this file in step with the +// module by hand; `check:declaration-mirrors` checks name, kind and required +// arity, never types. +// +// ⛔ The return type is `any` BY MEASUREMENT, not by omission: what these +// return is whatever the loaded package exports, and the 27 `.mjs` call sites +// already bind it untyped. A consumer that wants the real namespace types +// declares them itself -- `check-exported-any-returns.mts` keeps an erased +// `import type TS from 'typescript'` beside the runtime binding for exactly +// that, and a `typescript`-shaped return here would be a lie at every other +// call site (`yaml`, `semver`, `eslint`, `github-slugger`). + +/** + * Load a dependency, or print a named `PREREQUISITE NOT MET` diagnosis and exit + * `EXIT_PREREQUISITE_NOT_MET` (3). Resolves to the module NAMESPACE. + * + * @param specifier The bare specifier, e.g. `'@typescript-eslint/parser'`. + * @param load `() => import('@typescript-eslint/parser')`, written in the CALLER + * so the failing resolution is the caller's own. + * @param importerUrl The caller's `import.meta.url`. + * @param options `measures` is what the gate would have judged, in the gate's + * own words -- the one half of the refusal text that is never shared. + */ +export function requireDependency( + specifier: string, + load: () => Promise, + importerUrl: string, + options?: { measures?: string }, +): Promise; + +/** + * `requireDependency` for the DEFAULT export -- the shape `import ts from + * 'typescript'` had. Reads `.default` strictly rather than falling back to the + * namespace. + * + * @param specifier The bare specifier, e.g. `'typescript'`. + * @param load `() => import('typescript')`, written in the CALLER. + * @param importerUrl The caller's `import.meta.url`. + * @param options `measures` is what the gate would have judged, in the gate's + * own words. + */ +export function requireDefaultExport( + specifier: string, + load: () => Promise, + importerUrl: string, + options?: { measures?: string }, +): Promise;