Files
scriptc/packages/compiler/src/frontend/program.ts
T
Chris Tate d0cff5e6f3 Run the npm case fixtures natively and fix what they found
The npm case fixtures lost their only native test with the native-only
compilation change. tests/harness/npm.test.ts runs every case again with the
default static npm compile and compares it with Node. A KNOWN table records
the cases the compile refuses (with the diagnostic code), the cases whose
run reaches a deferred refusal (the code in the program's output), and the
two that still diverge, which run as expected failures. The suite joins the
case-sharded CI corpus files.

Fixes from the run:

- A package imported only by subpath (`pkg/env.js` from a package with no
  "main", "exports" or index file) was refused as having no runtime entry.
  Eligibility now resolves the imported specifier when the root has none.
- diagnostics_channel: a throwing subscriber stopped publish, and a
  throwing bindStore transform stopped runStores. Node runs the remaining
  subscribers, skips that store, and reports each error as uncaught on a
  next tick; the runtime now does the same.
- A '#' import that the package's "imports" field maps to another package
  failed at run time with Node's "not defined" error, which Node does not
  raise. It is now a compile-time SC1010 refusal.
- An uncaught runtime refusal whose code is an SC code now names the code.
- Mapping a type in the optional array-element analysis could lower an
  unsupported local class and let its PoisonError escape as a compiler
  crash; the analysis now treats that element ABI as unknown, so the
  program fails with the recorded diagnostic.
- A lexical module binding initialized from an unchecked array element
  holding a function failed validation (SC9001): its widened storage was a
  union, which a TDZ guard cannot hold. Such bindings now keep checked-value
  storage and their guard.
- A dynamically reached ArrayBuffer had no constructor property.
2026-10-15 14:15:33 -05:00

5272 lines
222 KiB
TypeScript

/* THE program lifecycle: load → preflight — tsconfig adoption, the tsc
* gate with its project-world second chance, the import/export form
* fences, CommonJS require discipline, and the module evaluation order —
* on the native TypeScript compiler (one spawned tsgo server behind
* Ts7Host, through the ts7/ adapter) with scriptc's OWN resolver
* (resolve.ts) standing in for ts.resolveModuleName /
* ts.resolveTypeReferenceDirective, which typescript@7.0.2 no longer
* ships.
*
* HISTORY. This began as a deliberately MECHANICAL PORT of the
* typescript@5.9.3 checkPreflight (the adapter's design: swap the ts
* import, keep every spelling), shipped alongside it for phases 2-3 with
* the order-parity suite holding both lanes to identical diagnostics and
* module order over the whole corpus. The phase-4 flip deleted the 5.9.3
* pipeline; its recorded verdicts live on as the canary baselines
* (test/ts7/baselines/order-parity/), and the rationale prose the old
* program.ts carried was folded in here where behavior demands it.
*
* Differences from the retired 5.9.3 lane, each deliberate and each
* pinned by the canary baselines:
* - Module resolution is resolve.ts (answer-parity proven by its suite).
* - Diagnostics carry pos/end and flat text + messageChain; toPassthrough
* renders through the adapter's flattenDiagnosticMessageText, whose
* output is identical to 5.9.3's flattener output.
* - Finding 5 (tsgo models CommonJS strictly): TS2309 joins the JS-file
* relaxation here — 5.9.3 never fires it on JS CJS files, tsgo does on
* the table-then-member pattern. The EXPORT-IDENTITY fences do not live
* in tsc codes at all: cjsExportDiscardReason (lowering) names every
* genuinely discarded export, so relaxing the checker's stricter
* modeling loses nothing.
* - isNodeEsmFile: 7's client SourceFile has no impliedNodeFormat, so this
* lane spells Node's format decision directly: fixed extensions first,
* then an explicit nearest-package `type`, then syntax detection for
* ambiguous .js/.ts files.
* - Malformed tsconfig.json: tsgo's parseConfigFile swallows JSONC syntax
* errors (recovers to empty options) where 5.9.3 reported a passthrough
* diagnostic, so this lane validates the config's JSONC syntax itself
* (jsoncSyntaxError) and fails preflight like 5.9.3 does — with
* JSON.parse's wording rather than tsc's, the one message-text delta on
* that path (no snapshot pins it). */
import { builtinModules } from "node:module";
import { dirname, isAbsolute, resolve } from "node:path";
import * as ts from "./ts7/adapter.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import {
commonJsModuleSyntaxDiag,
invalidPackageConfigDiag,
strictNullChecksFloorDiag,
tscPassthroughDiag,
unsupportedDiag,
} from "../diagnostics/diagnostic.js";
import {
isNodeModulesPath,
nearestInvalidPackageJsonPath,
nearestPackageType,
nearestPkgJsonPath,
packageImportTarget,
projectDtsRuntimeSibling,
resolveBareModule,
resolveProjectModule,
resolveTypeDirective,
setProjectPathMappings,
setProjectRealm,
} from "./resolve.js";
import { probeNodeImportRefusal, probeNodeRequireRefusal } from "./npm.js";
import {
isNpmStaticPackage,
npmStaticActive,
npmStaticFsShadow,
npmStaticPackageOfPath,
reportNpmStaticOffender,
setNpmStaticDeclarationOverloads,
setNpmStaticPackages,
} from "./npm-static.js";
import { isPrunedNpmReexport, planNpmStaticReexports } from "./npm-static-prune.js";
import { isNpmStaticSubclassArgument } from "./npm-static-subtyping.js";
import {
npmStaticDeclarationReexports,
npmStaticRuntimeClassTargets,
parseNpmStaticDeclarationOverloads,
parseNpmStaticDeclarationProperties,
} from "./npm-static-declaration-syntax.js";
import type { FrontendServices } from "./services.js";
import type {
NpmStaticDeclarationOverloads,
NpmStaticDeclarationProperties,
NpmStaticOverloadSignature,
} from "./npm-static-declaration-syntax.js";
import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js";
import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js";
import {
ambientDtsPath,
fallbackDtsPath,
isNodeTypesPath,
overridesDtsPath,
tsgoPath,
} from "./dts-paths.js";
import {
builtinDefaultImportModule,
canonicalBuiltinModule,
SUPPORTED_NODE_MODULES,
unsupportedModuleFeatureOf,
} from "./builtin-modules.js";
import {
clearWorkspacePackages,
isRelativeSpecifier,
isWorkspacePackageName,
npmPackageNameOf,
registerWorkspacePackage,
workspacePackageOfPath,
} from "./workspace-registry.js";
import {
ADOPTED_OPTIONS,
isJsSourceFileName,
JS_ANY_OPERATOR_CODES,
JS_RELAXED_TSC_CODES,
} from "./tsc-codes.js";
import { trackedFileExists, trackedReadFile, trackedRealpath } from "./input-tracker.js";
import { forkTargetPaths } from "./fork-target.js";
import { workerTargetPaths } from "./worker-target.js";
import { moduleSourceCandidates } from "./module-source-candidates.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
const BASE_OPTIONS: ts.Ts7CompilerOptions = {
strict: true,
};
const FORCED_OPTIONS: ts.Ts7CompilerOptions = {
target: ts.ScriptTarget.ESNext as number,
module: ts.ModuleKind.ESNext as number,
moduleResolution: ts.ModuleResolutionKind.Bundler,
// Every file Node executes has its own lexical module wrapper: ESM has a
// module environment and CommonJS has the per-file function wrapper. In
// particular, an ambiguous file whose ONLY ESM marker is top-level await
// must not remain a TypeScript global script (otherwise declarations in
// sibling files merge and reads cross module boundaries). Runtime format
// classification is intentionally independent below; this option is only
// the binder's scope decision.
moduleDetection: ts.ModuleDetectionKind.Force,
// Explicit resource management is an ESNext library slice in TS 7 even
// though Node 24 ships the runtime protocol. Include that focused lib
// without widening the rest of the standard-library target past ES2025.
lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts"],
types: [],
allowImportingTsExtensions: true,
allowJs: true,
checkJs: true,
resolveJsonModule: true,
noEmit: true,
};
/** JSONC syntax validation for a tsconfig text: tsgo's parseConfigFile
* RECOVERS over syntax errors without reporting them (probed: a hard-broken
* file answers empty options, no diagnostic), where 5.9.3's readConfigFile
* reported the first parse error — a preflight-visible difference (a broken
* config fails the build instead of being adopted). tsconfig's grammar is
* JSON plus comments and trailing commas, so stripping exactly those and
* handing the rest to JSON.parse decides validity without either
* TypeScript's parser. The
* MESSAGE is JSON.parse's, not 5.9.3's ("'}' expected.") — the one
* remaining wording delta on this path, unpinned by any snapshot. */
function jsoncSyntaxError(text: string): string | null {
let out = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"') {
const from = i;
i++;
while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1;
out += text.slice(from, i + 1);
continue;
}
if (ch === "/" && text[i + 1] === "/") {
while (i < text.length && text[i] !== "\n") i++;
out += "\n";
continue;
}
if (ch === "/" && text[i + 1] === "*") {
const close = text.indexOf("*/", i + 2);
i = close < 0 ? text.length : close + 1;
continue;
}
out += ch;
}
// Trailing commas: `,` followed only by whitespace before } or ].
out = out.replace(/,(\s*[}\]])/g, "$1");
if (out.trim() === "") return null; // an empty config file is legal
try {
JSON.parse(out);
return null;
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
}
/** Preserve TypeScript 5.9's computed synthetic-default permission from the
* project's own module settings. Preflight later forces ESNext/Bundler for
* scriptc's checker, so those forced options must not decide which source
* import forms were legal in the original project. */
function projectAllowsSyntheticDefaultImports(options: Record<string, unknown>): boolean {
const explicitSyntheticDefaultImports = options["allowSyntheticDefaultImports"];
if (explicitSyntheticDefaultImports !== undefined)
return explicitSyntheticDefaultImports === true;
const moduleKind = options["module"];
const explicitEsModuleInterop = options["esModuleInterop"];
const esModuleInterop =
explicitEsModuleInterop !== undefined
? explicitEsModuleInterop === true
: moduleKind === ts.ModuleKind.Node16 ||
moduleKind === ts.ModuleKind.Node18 ||
moduleKind === ts.ModuleKind.Node20 ||
moduleKind === ts.ModuleKind.NodeNext ||
moduleKind === ts.ModuleKind.Preserve;
return (
esModuleInterop ||
moduleKind === ts.ModuleKind.System ||
options["moduleResolution"] === ts.ModuleResolutionKind.Bundler
);
}
/** The project's tsconfig adoption in the 7 world: tsgo's own config parser
* (extends chains resolved server-side), the ADOPTED_OPTIONS subset taken,
* the rest forced, the strictNullChecks floor enforced. */
function adoptProjectConfig7(
host: ts.Ts7Host,
entryPath: string,
): {
configFile: string | null;
options: ts.Ts7CompilerOptions;
diags: ScrDiagnostic[];
projectLib?: readonly string[];
forcedStrictNullChecks?: boolean;
} {
const configFile = ts.findConfigFile(dirname(entryPath), ts.sys.fileExists) ?? null;
if (!configFile) {
return { configFile, options: { ...BASE_OPTIONS, ...FORCED_OPTIONS }, diags: [] };
}
const diags: ScrDiagnostic[] = [];
const syntaxError = jsoncSyntaxError(ts.sys.readFile(configFile) ?? "");
if (syntaxError !== null) {
diags.push(tscPassthroughDiag(syntaxError, { file: configFile, start: 0, end: 0 }));
return { configFile, options: { ...BASE_OPTIONS, ...FORCED_OPTIONS }, diags };
}
const parsed = host.parseConfigFile(configFile);
const adopted: Record<string, unknown> = {};
for (const key of ADOPTED_OPTIONS) {
const value = parsed.options[key];
if (value !== undefined) adopted[key] = value;
}
if (projectAllowsSyntheticDefaultImports(parsed.options)) {
adopted["allowSyntheticDefaultImports"] = true;
}
// TS 7 accepts `paths`, but no longer accepts `baseUrl` as a compiler
// option. Translate the established baseUrl+paths spelling into absolute
// targets: the synthesized config lives beside the entry rather than the
// real config, so preserving relative targets would change their meaning
// without a diagnostic. The absolute map is also the own resolver's
// configuration.
const rawPaths = parsed.options["paths"];
if (rawPaths !== undefined && typeof rawPaths === "object" && rawPaths !== null) {
const configDir = dirname(configFile);
const rawBaseUrl = parsed.options["baseUrl"];
const base =
typeof rawBaseUrl === "string"
? isAbsolute(rawBaseUrl)
? rawBaseUrl
: resolve(configDir, rawBaseUrl)
: configDir;
const paths: Record<string, string[]> = {};
for (const [key, targets] of Object.entries(rawPaths as Record<string, unknown>)) {
if (!Array.isArray(targets)) continue;
paths[key] = targets
.filter((target): target is string => typeof target === "string")
.map((target) => (isAbsolute(target) ? target : resolve(base, target)));
}
adopted["paths"] = paths;
}
const nullChecks = adopted["strictNullChecks"] ?? adopted["strict"] ?? false;
const forcedStrictNullChecks = nullChecks !== true;
if (forcedStrictNullChecks) {
diags.push(strictNullChecksFloorDiag(configFile));
adopted["strictNullChecks"] = true;
}
// The project's own library selection, kept only to explain errors: the
// checker always uses scriptc's fixed library set (FORCED_OPTIONS).
const rawLib = parsed.options["lib"];
const projectLib = Array.isArray(rawLib)
? rawLib.filter((entry): entry is string => typeof entry === "string")
: undefined;
return {
configFile,
options: { ...BASE_OPTIONS, ...adopted, ...FORCED_OPTIONS },
diags,
...(projectLib === undefined ? {} : { projectLib }),
forcedStrictNullChecks,
};
}
/** The target project's @types/node, resolved with the OWN resolver's
* type-directive lookup anchored at the ENTRY (typeRoots-free — exactly the
* secondary lookup 5.9.3 ran with typeRoots emptied). */
function resolveNodeTypes7(entryPath: string): string | null {
const file = resolveTypeDirective("node", entryPath);
return file !== null && isNodeTypesPath(file) ? file : null;
}
export interface TypeWorldDifferences {
/** No @types/node: the bundled fallback declarations type Node. */
nodeFallback: boolean;
/** The project tsconfig's "lib" entries (scriptc checks with its own). */
projectLib: readonly string[] | null;
/** The project disables strictNullChecks; scriptc forces it on. */
forcedStrictNullChecks: boolean;
}
export interface LoadResult {
program: ts.Program;
entry: ts.SourceFile;
/** User source files in Node's evaluation order: depth-first postorder
* of the import graph from the entry, each module once. The entry is
* last. Empty until checkPreflight runs (and empty when preflight fails
* structurally). */
moduleOrder: ts.SourceFile[];
/** Node's startup refusal, when the graph carries an import edge Node
* rejects before ANY module evaluates — a RESOLUTION refusal (a bare
* specifier nothing installed resolves, an exports/imports map that
* refuses the subpath or names a missing file, an invalid "#"
* specifier: fetch/resolve phase, ERR_MODULE_NOT_FOUND and friends) or
* the module-LINK SyntaxError (a named import of a CommonJS export
* Node's lexer cannot detect: instantiate phase). Either way Node
* refuses the whole program before evaluation, so the lowering compiles
* the program to exactly that startup crash (buildMain); resolution
* refusals win over the link SyntaxError because Node's fetch phase
* runs first. Null when the graph resolves and links clean. Set by
* checkPreflight. */
startupCrash?: StartupCrash | null;
configDiags: ScrDiagnostic[];
/** How scriptc's type world differs from the project's own tsc setup —
* used only to label the TypeScript errors those differences cause. */
typeWorld?: TypeWorldDifferences;
/** Coverage-only external host modules: exact bare specifier → local
* declaration file. These participate in checker resolution but never
* become runtime module edges. */
externalTypes: ReadonlyMap<string, string>;
/** The mapped declarations plus their relative declaration-file closure,
* keyed by normalized file name and attributed to every owning external
* specifier. Multiple exact module names may deliberately share one
* declaration surface. */
externalTypeSpecifiersByFile: ReadonlyMap<string, readonly string[]>;
projectWorld: () => ts.Program;
}
function declarationReexportTarget(fromFile: string, specifier: string): string | null {
const base = resolve(dirname(fromFile), specifier);
const candidates: string[] = [];
const extension = /\.(mjs|cjs|js)$/.exec(base)?.[1];
if (extension !== undefined) {
const stem = base.slice(0, -(extension.length + 1));
if (extension === "mjs") candidates.push(`${stem}.d.mts`, `${stem}.d.ts`);
else if (extension === "cjs") candidates.push(`${stem}.d.cts`, `${stem}.d.ts`);
else candidates.push(`${stem}.d.ts`, `${stem}.d.mts`, `${stem}.d.cts`);
} else {
candidates.push(base, `${base}.d.ts`, `${base}.d.mts`, `${base}.d.cts`);
}
candidates.push(
resolve(base, "index.d.ts"),
resolve(base, "index.d.mts"),
resolve(base, "index.d.cts"),
);
return candidates.find((candidate) => trackedFileExists(candidate)) ?? null;
}
function runtimeReexportTarget(fromFile: string, specifier: string): string | null {
const base = resolve(dirname(fromFile), specifier);
const candidates = [
base,
`${base}.js`,
`${base}.mjs`,
`${base}.cjs`,
resolve(base, "index.js"),
resolve(base, "index.mjs"),
resolve(base, "index.cjs"),
];
const target = candidates.find(
(candidate) => trackedFileExists(candidate) && isJsSourceFileName(candidate),
);
return target === undefined ? null : (trackedRealpath(target) ?? target);
}
/** Visits an opted package's own declaration entry and relative barrel
* closure. This is intentionally narrower than TypeScript module
* resolution: bare edges name other packages and never inherit trust. */
function visitNpmStaticDeclarationClosure(
services: FrontendServices,
entryPath: string,
onSource: (path: string, source: string) => void,
): void {
const seen = new Set<string>();
const packageJson = nearestPkgJsonPath(entryPath);
const packageRoot = (packageJson === null ? dirname(entryPath) : dirname(packageJson))
.split("\\")
.join("/");
const visit = (path: string): void => {
path = resolve(path);
const normalized = path.split("\\").join("/");
if (normalized !== packageRoot && !normalized.startsWith(packageRoot + "/")) return;
if (seen.has(path)) return;
seen.add(path);
const source = trackedReadFile(path);
if (source === null) return;
onSource(path, source);
for (const specifier of npmStaticDeclarationReexports(services.parse(path, source, "ts"))) {
const target = declarationReexportTarget(path, specifier);
if (target !== null) visit(target);
}
};
visit(entryPath);
}
function npmStaticDeclarationOverloadsOf(
services: FrontendServices,
entryPath: string,
): NpmStaticDeclarationOverloads {
const classes = new Map<string, Map<string, readonly NpmStaticOverloadSignature[]>>();
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
for (const [className, methods] of parseNpmStaticDeclarationOverloads(
services.parse(path, source, "ts"),
)) {
const target =
classes.get(className) ?? new Map<string, readonly NpmStaticOverloadSignature[]>();
classes.set(className, target);
for (const [methodName, signatures] of methods) {
if (!target.has(methodName)) target.set(methodName, signatures);
}
}
});
return classes;
}
function npmStaticDeclarationPropertiesOf(
services: FrontendServices,
entryPath: string,
): NpmStaticDeclarationProperties {
const classes = new Map<string, Map<string, string>>();
visitNpmStaticDeclarationClosure(services, entryPath, (path, source) => {
for (const [className, properties] of parseNpmStaticDeclarationProperties(
services.parse(path, source, "ts"),
)) {
const target = classes.get(className) ?? new Map<string, string>();
classes.set(className, target);
for (const [propertyName, type] of properties) {
if (!target.has(propertyName)) target.set(propertyName, type);
}
}
});
return classes;
}
/** See LoadResult.startupCrash: Node's exact error message, the IR error
* class that carries it (a RUNTIME_ERROR_CLASSES name — %Error for the
* resolver's ERR_MODULE_NOT_FOUND family, %TypeError for invalid-specifier
* refusals, %SyntaxError for either named-import link check), and the source
* position of the refused edge. */
export interface StartupCrash {
message: string;
className: "%Error" | "%TypeError" | "%SyntaxError";
loc: { file: string; start: number; end: number };
}
/** True when a coverage mapping names one exact bare host module. TypeScript
* gives `paths` keys containing `*` pattern semantics, so accepting one here
* would broaden the mapping beyond the specifier the caller named. */
export function isExactExternalTypeSpecifier(specifier: string): boolean {
return (
specifier !== "" &&
!specifier.startsWith(".") &&
!specifier.startsWith("/") &&
!specifier.startsWith("#") &&
!specifier.startsWith("node:") &&
!specifier.includes("*")
);
}
function externalHostModuleDiag7(specifier: string, node: ts.Node): ScrDiagnostic {
return unsupportedDiag(
"SC1010",
locOf7(node),
`the '${specifier}' external host module (types supplied by --external-types, but no runtime implementation or scriptc lowering was provided)`,
"the declaration mapping is analysis-only: coverage continues through project code, while executing this module requires an embedder integration with explicit runtime semantics",
);
}
/** Expand each mapped declaration entry through RELATIVE declaration
* dependencies. Declaration packages commonly expose a barrel index.d.ts;
* the structural types declared in its sibling files are part of the same
* checker-only host surface. Bare imports deliberately do not inherit this
* trust: they name a separate package/surface and need their own mapping. */
function externalTypeFileClosure7(
program: ts.Program,
externalTypes: ReadonlyMap<string, string>,
): ReadonlyMap<string, readonly string[]> {
const ownersByFile = new Map<string, Set<string>>();
const addOwner = (file: string, specifier: string): boolean => {
let owners = ownersByFile.get(file);
if (owners === undefined) {
owners = new Set();
ownersByFile.set(file, owners);
}
if (owners.has(specifier)) return false;
owners.add(specifier);
return true;
};
for (const [specifier, file] of externalTypes) {
addOwner(tsgoPath(resolve(file)), specifier);
}
const queue: { sf: ts.SourceFile; owner: string }[] = [];
for (const [owner, entry] of externalTypes) {
const file = tsgoPath(resolve(entry));
const sf = program.getSourceFile(file);
if (sf?.isDeclarationFile) queue.push({ sf, owner });
}
const visited = new Set<string>();
while (queue.length > 0) {
const { sf, owner } = queue.shift()!;
const file = tsgoPath(resolve(sf.fileName));
const visitKey = `${file}\0${owner}`;
if (visited.has(visitKey)) continue;
visited.add(visitKey);
const admit = (dep: ts.SourceFile | null): void => {
if (dep === null || !dep.isDeclarationFile) return;
const depFile = tsgoPath(resolve(dep.fileName));
if (!addOwner(depFile, owner)) return;
queue.push({ sf: dep, owner });
};
// SourceFile.imports includes import/export declarations, import =
// require(), and import("…") type nodes. Only relative edges inherit
// the mapped entry's ownership.
for (const node of sf.imports) {
if (ts.isStringLiteralLike(node) && isRelativeSpecifier(node.text)) {
admit(resolveImport7(program, sf, node.text));
}
}
// Triple-slash path references are the other declaration-local edge.
for (const ref of sf.referencedFiles) {
const depPath = tsgoPath(resolve(dirname(sf.fileName), ref.fileName));
admit(program.getSourceFile(depPath) ?? null);
}
}
return new Map([...ownersByFile].map(([file, owners]) => [file, [...owners]] as const));
}
/** Program roots hidden behind canonical createRequire bindings. tsgo
* discovers ordinary import/require edges itself, but a source binding
* returned by createRequire is intentionally just a function to the type
* checker. Rebuild the native program with each literal project target as
* an explicit root, to a fixpoint, so the same TS 7 AST/checker world owns
* their exports and transitive graph. */
function createRequireProgramRoots7(program: ts.Program): string[] {
const known = new Set(program.getSourceFileNames().map((name) => tsgoPath(resolve(name))));
const roots = new Set<string>();
for (const sf of program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile || sf.fileName.endsWith(".json")) continue;
const calls: { callee: ts.Identifier; spec: string }[] = [];
for (const node of moduleSourceCandidates(program, sf).calls) {
if (
node.questionDotToken !== undefined ||
node.arguments.length !== 1 ||
!ts.isStringLiteralLike(node.arguments[0]!) ||
!ts.isIdentifier(node.expression)
) {
continue;
}
calls.push({ callee: node.expression, spec: node.arguments[0]!.text });
}
// Root discovery only needs the callee's binding. A first-miss lookup
// otherwise fetches symbols and types for every identifier in the file,
// including all unreachable bodies in large published packages.
program.getTypeChecker().prefetchSymbolNodesExact(calls.map((call) => call.callee));
for (const { callee, spec } of calls) {
if (!isCreateRequireBinding7(program, callee)) continue;
if (canonicalBuiltinModule(spec) !== null) continue;
let target = resolveProjectModule(sf.fileName, spec);
if (target === null && !spec.startsWith("#")) {
const npm = resolveNpmImport7(sf.fileName, spec, "require");
if (
npm !== null &&
isNpmStaticPackage(npm.packageName) &&
isJsSourceFileName(npm.typesFile)
) {
target = npm.typesFile;
}
}
// A directory's package.json may point at a native addon. Keep it
// out of TypeScript's source roots so the require call can report
// the native-addon boundary instead of an unsupported-file error.
if (target === null || target.endsWith(".json") || target.endsWith(".node")) continue;
const normalized = tsgoPath(resolve(target));
if (!known.has(normalized)) roots.add(normalized);
}
}
return [...roots].sort();
}
/** Classify the explicitly selected installed package's own sources. */
export function entryPackageFilePredicate(entryPath: string): (file: string) => boolean {
const entryFile = tsgoPath(entryPath);
const installedAt = entryFile.lastIndexOf("/node_modules/");
const prefix =
installedAt < 0
? null
: entryFile.slice(0, installedAt) + "/node_modules/" + npmPackageNameOf(entryFile) + "/";
return (file) => {
const normalized = tsgoPath(file);
return (
normalized === entryFile ||
(prefix !== null &&
normalized.startsWith(prefix) &&
!normalized.slice(prefix.length).split("/").includes("node_modules"))
);
};
}
/** maxNodeModuleJsDepth can omit relative imports within an admitted
* package. Discover its runtime sources to a fixpoint so the checker and
* module resolver see the same graph regardless of import traversal order. */
function entryPackageProgramRoots7(program: ts.Program, entryPath: string): string[] {
const belongsToEntry = entryPackageFilePredicate(entryPath);
const roots = new Set<string>();
for (const sf of program.getImplementationSourceFiles()) {
if (
sf.isDeclarationFile ||
(!belongsToEntry(sf.fileName) && npmStaticPackageOfPath(sf.fileName) === null)
)
continue;
for (const specifier of sf.imports) {
if (!ts.isStringLiteralLike(specifier)) continue;
const target = resolveProjectModule(sf.fileName, specifier.text);
if (
target !== null &&
(belongsToEntry(target) || npmStaticPackageOfPath(target) !== null) &&
isJsSourceFileName(target) &&
program.getSourceFile(target) === undefined
)
roots.add(target);
}
}
return [...roots].sort();
}
function loadProgram7(
host: ts.Ts7Host,
entryPath: string,
externalTypes: ReadonlyMap<string, string> = new Map(),
): LoadResult & { disposeAll: () => void } {
const config = adoptProjectConfig7(host, entryPath);
const configuredPaths = config.options["paths"];
setProjectPathMappings(
configuredPaths !== undefined && typeof configuredPaths === "object" && configuredPaths !== null
? (configuredPaths as Record<string, string[]>)
: null,
);
const nodeTypes = config.configFile ? resolveNodeTypes7(entryPath) : null;
// skipLibCheck is FORCED with @types/node in the program: checking a
// third-party lib's internals against OUR lib choice (es2025, no dyn) is
// not scriptc's fence and drowns real diagnostics in hundreds of
// .d.ts-internal errors. Fence discipline never depended on it: the
// lowerer checks provenance and forms at every use site.
let options: ts.Ts7CompilerOptions = nodeTypes
? { ...config.options, skipLibCheck: true }
: { ...config.options };
// --npm-static: opted-in packages' shipped JS must be TYPE-INCLUDED (not
// just resolved) — without maxNodeModuleJsDepth, node_modules JS types as
// an implicit-any module (TS7016) and nothing infers. Only flagged
// compiles pay this; flagless builds keep the exact historical options.
// Eight levels retain package-internal re-export chains such as Effect's;
// the former four-level cap left valid bindings outside the checker graph.
if (npmStaticActive()) options.maxNodeModuleJsDepth = 8;
// --provenance-sources: the registered entries become tsconfig "paths"
// so tsgo's OWN resolution of the bare specifiers lands on the same
// source files the preflight resolver answers — the checker types the
// driver against the package's real TypeScript, not its shipped .d.ts.
const provenance = provenancePaths();
if (provenance !== null || externalTypes.size > 0) {
const paths: Record<string, string[]> = {
...(configuredPaths as Record<string, string[]> | undefined),
// Keep an index-signature source for native lowering when provenance is absent.
// oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
...(provenance ?? {}),
};
for (const [specifier, declarationPath] of externalTypes) {
paths[specifier] = [declarationPath];
}
options = { ...options, paths };
}
const coreRoots = [entryPath, ambientDtsPath(), nodeTypes ?? fallbackDtsPath()];
const programRoots = [...coreRoots];
let program = host.createProgram([...programRoots, overridesDtsPath()], options);
try {
for (let pass = 0; pass < 32; pass++) {
const candidates = [
...entryPackageProgramRoots7(program, entryPath),
...createRequireProgramRoots7(program),
...forkTargetPaths(program, program.getImplementationSourceFiles()),
...workerTargetPaths(program, program.getImplementationSourceFiles()),
];
const extraRoots = candidates.filter(
(root, index) => !programRoots.includes(root) && candidates.indexOf(root) === index,
);
if (extraRoots.length === 0) break;
program.dispose();
programRoots.push(...extraRoots);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
if (pass === 31) throw new Error("static program-root discovery did not converge");
}
const entry = program.getSourceFile(entryPath);
if (!entry) throw new Error(`could not load ${entryPath}`);
const externalTypeSpecifiersByFile = externalTypeFileClosure7(program, externalTypes);
let projectWorld: ts.Program | null = null;
return {
program,
entry,
moduleOrder: [],
configDiags: config.diags,
typeWorld: {
nodeFallback: nodeTypes === null,
projectLib: config.projectLib ?? null,
forcedStrictNullChecks: config.forcedStrictNullChecks ?? false,
},
externalTypes,
externalTypeSpecifiersByFile,
projectWorld: () => (projectWorld ??= host.createProgram(programRoots, options)),
disposeAll: () => {
try {
projectWorld?.dispose();
} finally {
program.dispose();
}
},
};
} catch (error) {
program.dispose();
throw error;
}
}
/** The pipeline surface: ONE tsgo program serves preflight AND the
* lowering. The caller runs checkPreflight, hands program/entry/
* moduleOrder straight to lowerToIr, and MUST dispose() when done — the
* spawned tsgo server must not outlive the compile. */
export interface ProgramLoadOptions {
/** Package names whose shipped JavaScript is compiled as program code. */
npmStatic?: readonly string[];
/** Exact host module specifiers paired with their declaration paths. */
externalTypes?: readonly (readonly [string, string])[];
}
export function loadProgram(
entryPath: string,
services: FrontendServices,
opts?: ProgramLoadOptions,
): LoadResult & { services: FrontendServices; dispose: () => void } {
entryPath = resolve(entryPath);
const externalTypeEntries = opts?.externalTypes ?? [];
const externalTypes = new Map<string, string>();
for (const [specifier, file] of externalTypeEntries) {
if (!isExactExternalTypeSpecifier(specifier)) {
throw new TypeError(
`invalid external type specifier ${JSON.stringify(specifier)} (expected an exact bare package specifier)`,
);
}
if (!/\.d\.(?:ts|mts|cts)$/.test(file)) {
throw new TypeError(
`invalid external type declaration ${JSON.stringify(file)} for ${JSON.stringify(specifier)} (expected a .d.ts, .d.mts, or .d.cts file)`,
);
}
const declarationPath = resolve(file);
if (!trackedFileExists(declarationPath)) {
throw new TypeError(
`external type declaration for ${JSON.stringify(specifier)} does not name a readable file: ${declarationPath}`,
);
}
externalTypes.set(specifier, declarationPath);
}
const npmStaticPackages = [...new Set(opts?.npmStatic ?? [])];
// Resolve authored declaration entries before enabling the JS-only
// package resolver. Safe overload metadata binds through the runtime
// entry to one exact implementation file; it never contributes values
// or executable module edges.
const declarationOverloads = new Map<string, NpmStaticDeclarationOverloads>();
const declarationProperties = new Map<string, NpmStaticDeclarationProperties>();
const projectionOwners = new Map<string, string | null>();
for (const pkg of npmStaticPackages) {
const resolved = resolveBareModule(entryPath, pkg, "types-only");
if (
resolved === null ||
resolved.packageName !== pkg ||
!/\.d\.(?:ts|mts|cts)$/.test(resolved.typesFile)
) {
continue;
}
const overloads = npmStaticDeclarationOverloadsOf(services, resolved.typesFile);
const properties = npmStaticDeclarationPropertiesOf(services, resolved.typesFile);
const classNames = new Set([...overloads.keys(), ...properties.keys()]);
if (classNames.size === 0) continue;
const runtime = resolveBareModule(entryPath, pkg, "js-only");
if (runtime === null || !isJsSourceFileName(runtime.typesFile)) continue;
const runtimeSource = trackedReadFile(runtime.typesFile);
if (runtimeSource === null) continue;
const targets = npmStaticRuntimeClassTargets(
services.parse(runtime.typesFile, runtimeSource, "js"),
runtimeSource,
classNames,
);
for (const [className, { specifier, localName }] of targets) {
const methods = overloads.get(className);
const fields = properties.get(className);
if (methods === undefined && fields === undefined) continue;
const target =
specifier === null
? runtime.typesFile
: runtimeReexportTarget(runtime.typesFile, specifier);
if (target === null) continue;
const targetPackage = npmPackageNameOf(target);
const targetNorm = target.split("\\").join("/");
const insideWorkspace =
runtime.workspaceDir !== undefined &&
targetNorm.startsWith(runtime.workspaceDir.split("\\").join("/") + "/");
if (targetPackage !== pkg && !insideWorkspace) continue;
const projectionKey = JSON.stringify([targetNorm, localName]);
const previousOwner = projectionOwners.get(projectionKey);
if (previousOwner !== undefined && previousOwner !== className) {
// Two public class declarations naming one runtime class are
// ambiguous. Keep inference instead of choosing one alias's ABI.
projectionOwners.set(projectionKey, null);
const priorMethods = new Map(declarationOverloads.get(targetNorm) ?? []);
priorMethods.delete(localName);
declarationOverloads.set(targetNorm, priorMethods);
const priorFields = new Map(declarationProperties.get(targetNorm) ?? []);
priorFields.delete(localName);
declarationProperties.set(targetNorm, priorFields);
continue;
}
projectionOwners.set(projectionKey, className);
// The safe declaration grammar can name only its own class. Bind
// that type to the actual runtime class after a named ESM alias.
const renameSelf = (type: string): string =>
type
.split(/([\s[\]<>()|,]+)/)
.map((name) => (name === className ? localName : name))
.join("");
if (methods !== undefined) {
const byClass = new Map(declarationOverloads.get(targetNorm) ?? []);
byClass.set(
localName,
new Map(
[...methods].map(([name, signatures]) => [
name,
signatures.map((signature) => ({
parameters: signature.parameters.map((parameter) => ({
name: parameter.name,
type: renameSelf(parameter.type),
optional: parameter.optional,
})),
returnType: renameSelf(signature.returnType),
})),
]),
),
);
declarationOverloads.set(targetNorm, byClass);
}
if (fields !== undefined) {
const byClass = new Map(declarationProperties.get(targetNorm) ?? []);
byClass.set(
localName,
new Map([...fields].map(([name, type]) => [name, renameSelf(type)])),
);
declarationProperties.set(targetNorm, byClass);
}
}
}
setNpmStaticPackages(npmStaticPackages);
setNpmStaticDeclarationOverloads(declarationOverloads, declarationProperties);
// Workspace-package registrations reset per load (same discipline as the
// npm-static set), then the opted-in names are probed UP FRONT: a
// workspace-linked opt-in resolves to files whose realpaths carry no
// node_modules segment, and the tsgo host's fs shadow must already
// recognize those directories while the PROGRAM is created (hiding the
// package's declaration twins along its internal relative edges).
// Non-opted workspace packages register lazily as preflight's own
// resolver discovers their import edges.
clearWorkspacePackages();
for (const pkg of opts?.npmStatic ?? []) {
const probe = resolveBareModule(entryPath, pkg);
if (probe?.workspaceDir !== undefined)
registerWorkspacePackage(probe.packageName, probe.workspaceDir);
}
// Declaration-twin hiding is scoped to the entry's own package realm
// (see projectDtsRuntimeSibling) — set before the host exists so the
// program never sees an out-of-realm twin hidden.
setProjectRealm(entryPath);
// Two fs shadows compose: --npm-static's per-package hiding, and the
// always-on project declaration-TWIN hiding (a .d.ts beside runtime JS
// outside node_modules — the classic typed-JS-library entry — must not exist for
// the checker, so its resolution lands on the JS Node actually loads;
// resolve.ts answers the same sibling for scriptc's own edges).
const npmShadow = npmStaticFsShadow(services);
const fsShadow = {
readFile: (path: string) => npmShadow?.readFile(path),
hideFile: (path: string) =>
(npmShadow?.hideFile(path) ?? false) || projectDtsRuntimeSibling(path) !== null,
};
const host = services.createProgramHost({ cwd: dirname(entryPath), fsShadow });
try {
const load = loadProgram7(host, entryPath, externalTypes);
let authoredHost: ts.Ts7Host | null = null;
let authoredLoad: ReturnType<typeof loadProgram7> | null = null;
return {
...load,
services,
projectWorld:
npmStaticPackages.length === 0
? load.projectWorld
: () => {
// Check authored code against the package's published declarations.
// Runtime bodies still lower from JavaScript in the original world;
// inference there must not manufacture errors in valid user code.
if (!authoredLoad) {
authoredHost = services.createProgramHost({ cwd: dirname(entryPath) });
authoredLoad = loadProgram7(authoredHost, entryPath, externalTypes);
}
return authoredLoad.projectWorld();
},
dispose: () => {
try {
authoredLoad?.disposeAll();
} finally {
authoredHost?.close();
try {
load.disposeAll();
} finally {
host.close();
}
}
},
};
} catch (error) {
host.close();
throw error;
}
}
/** tsc diagnostics (syntax + types), the supported-import fence, and the
* module evaluation order: fills load.moduleOrder (the SourceFiles
* themselves — the lowering consumes them directly) and returns the
* preflight diagnostics. Preserve the caller's complete load shape when
* mutating it, including its owned services and disposal callback. */
export function checkPreflight<T extends LoadResult>(load: T): ScrDiagnostic[] {
const { diags, moduleOrder, startupCrash } = preflight7(load);
load.moduleOrder = moduleOrder;
load.startupCrash = startupCrash;
return diags;
}
/* Finding 5: tsgo's strict CommonJS modeling fires checker codes on JS
* shapes 5.9.3 accepted, and the real hazards those shapes carry are fenced
* by scriptc's OWN analysis, never by these codes — so for JS FILES ONLY
* they join the relaxation (TS sources keep the full gate; the order-parity
* harness holds both lanes to identical verdicts, so a case where 5.9.3
* itself raises one of these on a JS file would fail there, not slip by):
* - TS2309 ("export assignment with other exported elements") on the
* table-then-member pattern — cjsExportDiscardReason names every export
* Node actually discards.
* - TS2323 ("cannot redeclare exported variable") on symbol-keyed expando
* assignments in CJS classes (`this[kSym] = v` under `module.exports =
* Class`) — 5.9.3 synthesized expando symbols, tsgo trips its
* redeclaration check instead (the corpus's countdown fixture). */
// Ordinary JavaScript functions are constructable even when the checker
// retains only their call signature after a factory return. Heritage
// lowering separately verifies the actual constructor implementation.
const TS7_JS_RELAXED_EXTRA: ReadonlySet<number> = new Set([2309, 2323, 2507]);
function suppressedJsStrictness7(d: ts.Diagnostic): boolean {
const file = d.fileName;
if (!file || !isJsSourceFileName(file)) return false;
if (JS_RELAXED_TSC_CODES.has(d.code) || TS7_JS_RELAXED_EXTRA.has(d.code)) return true;
// The 8xxx range is TypeScript's JSDoc-specific diagnostic family
// (malformed tags, signature/tag mismatches): comment semantics with no
// runtime meaning in a JS file.
if (d.code >= 8000 && d.code <= 8999) return true;
if (JS_ANY_OPERATOR_CODES.has(d.code)) {
return ts.flattenDiagnosticMessageText(d, "\n").includes("'any'");
}
return false;
}
/** The specifier of a `require("...")` call: the callee is the bare
* identifier `require` and the single argument a string literal. The one
* recognizer preflight edges, global collection, and statement lowering
* share — anything require-shaped it does NOT match (computed specifiers,
* extra arguments) is not a module edge and fences at its use site. */
function requireSpecOf7(node: ts.Node): string | null {
let value = node;
while (
ts.isParenthesizedExpression(value) ||
ts.isAsExpression(value) ||
ts.isTypeAssertion(value) ||
ts.isNonNullExpression(value)
) {
value = value.expression;
}
if (!ts.isCallExpression(value)) return null;
if (!ts.isIdentifier(value.expression) || value.expression.text !== "require") return null;
if (value.arguments.length !== 1) return null;
const arg = value.arguments[0]!;
return ts.isStringLiteral(arg) ? arg.text : null;
}
/** True for statements that are CommonJS require IMPORTS at a module's top
* level: `const x = require("s")` / `const { a, b } = require("s")`
* (every declarator a require), and the bare side-effect `require("s");`.
* These lower to NOTHING — the bindings are alias plumbing (tsc models
* them as import aliases) and the module edge lives in the order walk. */
function isRequireStatement7(stmt: ts.Statement): boolean {
if (ts.isExpressionStatement(stmt)) return requireSpecOf7(stmt.expression) !== null;
if (!ts.isVariableStatement(stmt)) return false;
const decls = stmt.declarationList.declarations;
return (
decls.length > 0 &&
decls.every((d) => d.initializer !== undefined && requireSpecOf7(d.initializer) !== null)
);
}
/** The require() occurrences of one top-level statement: the declaration
* forms and the bare side-effect call. Empty for everything else. */
function requiresOf7(
stmt: ts.Statement,
): { spec: string; node: ts.Node; decl: ts.VariableDeclaration | null }[] {
if (ts.isExpressionStatement(stmt)) {
const spec = requireSpecOf7(stmt.expression);
return spec !== null ? [{ spec, node: stmt.expression, decl: null }] : [];
}
if (!ts.isVariableStatement(stmt)) return [];
const out: { spec: string; node: ts.Node; decl: ts.VariableDeclaration | null }[] = [];
for (const decl of stmt.declarationList.declarations) {
const spec = decl.initializer !== undefined ? requireSpecOf7(decl.initializer) : null;
if (spec !== null) out.push({ spec, node: decl, decl });
}
return out;
}
/** The exact `Object.defineProperty(exports|module.exports, "__esModule",
* { value: true })` interop stamp (shared by preflight and the no-op lowering). */
export function isEsModuleStamp(expr: ts.Expression): boolean {
if (!ts.isCallExpression(expr) || expr.questionDotToken !== undefined) return false;
const callee = expr.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== "Object" ||
!ts.isIdentifier(callee.name) ||
callee.name.text !== "defineProperty"
) {
return false;
}
if (expr.arguments.length !== 3) return false;
const [recv, nameArg, desc] = expr.arguments as unknown as [
ts.Expression,
ts.Expression,
ts.Expression,
];
const isExports =
(ts.isIdentifier(recv) && recv.text === "exports") ||
(ts.isPropertyAccessExpression(recv) &&
ts.isIdentifier(recv.expression) &&
recv.expression.text === "module" &&
ts.isIdentifier(recv.name) &&
recv.name.text === "exports");
if (!isExports) return false;
if (!ts.isStringLiteral(nameArg) || nameArg.text !== "__esModule") return false;
if (!ts.isObjectLiteralExpression(desc) || desc.properties.length !== 1) return false;
const p = desc.properties[0]!;
return (
ts.isPropertyAssignment(p) &&
ts.isIdentifier(p.name) &&
p.name.text === "value" &&
p.initializer.kind === ts.SyntaxKind.TrueKeyword
);
}
/** Plain CommonJS prologue writes only publish values; in particular,
* publishing a hoisted function does not invoke its body. Later runnable
* statements may call the export or mutate its receiver, and still need
* the conservative TDZ scan over the whole prefix. */
function pureCjsExport7(program: ts.Program, stmt: ts.Statement): boolean {
const cjs = cjsExportAssignmentOf(stmt);
if (cjs === null) return false;
const sf = stmt.getSourceFile();
const plainMember = (left: ts.Expression): boolean => {
if (!ts.isPropertyAccessExpression(left) || left.questionDotToken) return false;
// A replacement export can have setters or non-writable function
// properties. Only the wrapper's original object gets this exception.
if (sf.statements.some((s) => cjsExportAssignmentOf(s)?.kind === "table")) return false;
if (left.name.text === "__proto__" || left.name.text === "__esModule") return false;
let receiver = left.expression;
if (isModuleExportsAccess(receiver)) {
receiver = receiver.expression;
return ts.isIdentifier(receiver) && !sourceBinding7(program, receiver);
}
return (
ts.isIdentifier(receiver) && receiver.text === "exports" && !sourceBinding7(program, receiver)
);
};
if (cjs.kind === "member") {
if (!plainMember(cjs.expr.left)) return false;
} else {
const receiver = (cjs.expr.left as ts.PropertyAccessExpression).expression as ts.Identifier;
if (sourceBinding7(program, receiver)) return false;
}
let value = cjs.expr.right;
// tsc emits chains such as exports.A = exports.B = void 0.
while (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
if (!plainMember(value.left)) return false;
value = value.right;
}
if (ts.isVoidExpression(value) && ts.isNumericLiteral(value.expression)) return true;
if (
ts.isStringLiteralLike(value) ||
ts.isNumericLiteral(value) ||
value.kind === ts.SyntaxKind.TrueKeyword ||
value.kind === ts.SyntaxKind.FalseKeyword ||
value.kind === ts.SyntaxKind.NullKeyword
)
return true;
if (!ts.isIdentifier(value)) return false;
if (value.text === "undefined" && !sourceBinding7(program, value)) return true;
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(value);
return (
symbol !== undefined &&
checker
.declarationsOf(symbol)
.some((decl) => ts.isFunctionDeclaration(decl) && decl.parent === stmt.parent)
);
}
function sourceBinding7(program: ts.Program, ident: ts.Identifier): boolean {
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(ident);
return (
symbol !== undefined &&
checker
.declarationsOf(symbol)
.some((decl) => !decl.getSourceFile().isDeclarationFile && !ts.isSourceFile(decl))
);
}
function purePrefixDecl7(decl: ts.VariableDeclaration): boolean {
const init = decl.initializer;
return (
init === undefined ||
requireSpecOf7(init) !== null ||
ts.isStringLiteralLike(init) ||
ts.isNumericLiteral(init) ||
init.kind === ts.SyntaxKind.TrueKeyword ||
init.kind === ts.SyntaxKind.FalseKeyword ||
init.kind === ts.SyntaxKind.NullKeyword
);
}
/** True for top-level statements that cannot run user code: directives,
* empty statements, hoisted declarations, require statements themselves,
* literal-initialized variables, and CommonJS function-export prologues.
* A require preceded ONLY by these cannot observe its own bindings early;
* require cycles are checked separately by the module graph fences. */
function purePrefixStmt7(program: ts.Program, s: ts.Statement): boolean {
if (ts.isEmptyStatement(s) || ts.isFunctionDeclaration(s)) return true;
if (ts.isExpressionStatement(s) && ts.isStringLiteral(s.expression)) return true; // directive
if (isRequireStatement7(s)) return true;
if (isCjsJsFile7(s.getSourceFile(), program)) {
if (ts.isExpressionStatement(s) && isEsModuleStamp(s.expression)) {
const call = s.expression as ts.CallExpression;
const object = (call.expression as ts.PropertyAccessExpression).expression as ts.Identifier;
let receiver = call.arguments[0]!;
if (ts.isPropertyAccessExpression(receiver)) receiver = receiver.expression;
if (
!sourceBinding7(program, object) &&
ts.isIdentifier(receiver) &&
!sourceBinding7(program, receiver)
)
return true;
}
if (pureCjsExport7(program, s)) return true;
}
if (ts.isVariableStatement(s)) {
return s.declarationList.declarations.every(purePrefixDecl7);
}
return false;
}
/** Can code that runs BEFORE the declaration at index `k` reach one of
* its bindings? Static require aliases and symbol field keys bypass
* storage reads, so an early use could erase a TDZ error or a var's
* initial undefined value. Conservative reachability: earlier
* statements' whole subtrees (arrows and function expressions included)
* read the binding directly, or reference a hoisted function/class whose
* body (transitively through other hoisted declarations) reads it — a
* referenced function value may be invoked immediately by whatever takes
* it. Asynchronously-scheduled callbacks run after the whole module body,
* hence after the require: never an early read. Returns the first
* reachable binding's name, or null when position is provably free.
* (The CheckerFacade memoizes symbol queries, and 7's client dedupes
* symbol identity by server handle, so the Map-keyed-by-Symbol discipline
* carries over from the 5.9.3 original unchanged.) */
export function bindingEarlyUse7(
program: ts.Program,
sf: ts.SourceFile,
k: number,
decl: ts.VariableDeclaration,
precedingDecls: readonly ts.VariableDeclaration[],
): string | null {
const checker = program.getTypeChecker();
const bound: ts.Identifier[] = [];
if (ts.isIdentifier(decl.name)) bound.push(decl.name);
else if (ts.isObjectBindingPattern(decl.name)) {
for (const el of decl.name.elements)
if (el.name !== undefined && ts.isIdentifier(el.name)) bound.push(el.name);
}
const bindings = new Map<ts.Symbol, string>();
for (const id of bound) {
const sym = checker.getSymbolAtLocation(id);
if (sym) bindings.set(sym, id.text);
}
if (bindings.size === 0) return null;
const stmts = sf.statements;
const hoisted = new Map<ts.Symbol, ts.Statement>();
for (const s of stmts) {
if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && s.name) {
const sym = checker.getSymbolAtLocation(s.name);
if (sym) hoisted.set(sym, s);
}
}
let hit: string | null = null;
const scanned = new Set<ts.Statement>();
const work: ts.Statement[] = [];
const scan = (root: ts.Node): void => {
// Iterative walk (walkPreorder): a statement's subtree can be
// pathologically deep (the binderBinaryExpressionStress chains), and
// this scan must survive it so the nesting fence can answer later.
ts.walkPreorder(root, (n) => {
if (ts.isIdentifier(n)) {
const sym = ts.isShorthandPropertyAssignment(n.parent)
? checker.getShorthandAssignmentValueSymbol(n.parent)
: checker.getSymbolAtLocation(n);
if (sym) {
const name = bindings.get(sym);
if (name !== undefined) {
hit = name;
return "stop";
}
const target = hoisted.get(sym);
if (target && !scanned.has(target)) {
scanned.add(target);
work.push(target);
}
}
}
return undefined;
});
};
// These are the exact roots the TDZ analysis scans eagerly. Their files
// are phase-managed already, so warm their deferred identifiers as one
// symbol-only batch instead of paying one IPC query per occurrence.
const roots = [
...stmts.slice(0, k).filter((stmt) => !ts.isFunctionDeclaration(stmt)),
...precedingDecls,
];
checker.prefetchSymbolRoots(roots);
for (const root of roots) {
if (hit !== null) break;
scan(root);
}
while (hit === null && work.length > 0) {
// A scanned reference can make a hoisted declaration's body reachable
// to this analysis. Batch every currently discovered root before
// retaining the historical LIFO traversal order.
checker.prefetchSymbolRoots(work);
scan(work.pop()!);
}
return hit;
}
/** Bare `require("...")` EXPRESSION STATEMENTS anywhere below the top
* level of a file — inside top-level ifs/blocks/loops and inside function
* bodies (the lazy-require idiom). No bindings, pure side-effect loads. */
function nestedBareRequiresOf7(sf: ts.SourceFile): ts.CallExpression[] {
const out: ts.CallExpression[] = [];
// Iterative walk (walkPreorder): this sweep sees every JS file whole,
// including pathologically deep expression chains that must live to be
// fenced by name, not crash a recursive visit.
ts.walkPreorder(sf, (n) => {
if (
ts.isExpressionStatement(n) &&
!ts.isSourceFile(n.parent) &&
ts.isCallExpression(n.expression) &&
requireSpecOf7(n.expression) !== null
) {
out.push(n.expression);
}
});
return out;
}
type RequireCallBindingKind7 = "ambient" | "createRequire" | "source";
function stripRequireCasts7(expr: ts.Expression): ts.Expression {
let cur = expr;
while (
ts.isParenthesizedExpression(cur) ||
ts.isAsExpression(cur) ||
ts.isTypeAssertion(cur) ||
ts.isNonNullExpression(cur)
) {
cur = cur.expression;
}
return cur;
}
/** The supported-builtin provenance of an imported identifier, narrowed to
* the one member this preflight distinction needs. Mirrors the lowering's
* builtinImportOf alias handling so a re-export facade remains supported. */
function isCreateRequireImport7(program: ts.Program, ident: ts.Identifier): boolean {
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(ident);
const decl = symbol ? checker.declarationsOf(symbol)[0] : undefined;
if (decl === undefined || !ts.isImportSpecifier(decl)) return false;
const importDecl = decl.parent?.parent?.parent;
if (ts.isImportDeclaration(importDecl) && ts.isStringLiteral(importDecl.moduleSpecifier)) {
const module = canonicalBuiltinModule(importDecl.moduleSpecifier.text);
const member = decl.propertyName?.text ?? decl.name.text;
if (module === "module" && member === "createRequire") return true;
}
if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) {
const target = checker.getAliasedSymbol(symbol);
const targetDecl = checker.declarationsOf(target)[0];
if (targetDecl !== undefined && targetDecl.getSourceFile().isDeclarationFile) {
for (
let p: ts.Node | undefined = targetDecl.parent;
p !== undefined && !ts.isSourceFile(p);
p = p.parent
) {
if (ts.isModuleDeclaration(p) && ts.isStringLiteral(p.name)) {
return (
canonicalBuiltinModule(p.name.text) === "module" && target.name === "createRequire"
);
}
}
}
}
return false;
}
/** True only for createRequire bindings the lowering can erase. Mutable
* declaration syntax needs a whole-file proof: no writes or escapes, and
* no code before initialization can reach the binding. */
export function isCreateRequireBinding7(program: ts.Program, callee: ts.Identifier): boolean {
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(callee);
const decl = symbol
? checker
.declarationsOf(symbol)
.find((d): d is ts.VariableDeclaration => ts.isVariableDeclaration(d))
: undefined;
if (
decl === undefined ||
decl.initializer === undefined ||
!ts.isVariableDeclarationList(decl.parent)
) {
return false;
}
return (
isCreateRequireBaseCall7(program, decl.initializer) &&
((decl.parent.flags & ts.NodeFlags.Const) !== 0 ||
stableCreateRequireBindingReason7(program, decl) === null)
);
}
/** Only literal calls and literal resolution queries can erase the loader
* value. In particular, assignments, exported aliases, shorthand properties,
* optional calls, and passing the loader to user code cannot pass this test. */
function staticCreateRequireUse7(expr: ts.Expression): boolean {
let use: ts.Node = expr;
while (ts.isParenthesizedExpression(use.parent)) use = use.parent;
if (
ts.isPropertyAccessExpression(use.parent) &&
use.parent.expression === use &&
use.parent.name.text === "resolve" &&
!use.parent.questionDotToken
) {
use = use.parent;
if (
ts.isPropertyAccessExpression(use.parent) &&
use.parent.expression === use &&
use.parent.name.text === "paths" &&
!use.parent.questionDotToken
)
use = use.parent;
}
const call = use.parent;
return (
ts.isCallExpression(call) &&
call.expression === use &&
!call.questionDotToken &&
call.arguments.length === 1 &&
ts.isStringLiteralLike(call.arguments[0]!)
);
}
function stableCreateRequireBindingReason7(
program: ts.Program,
decl: ts.VariableDeclaration,
): string | null {
const cache = program.analysis.createRequireReasons;
if (cache.has(decl)) return cache.get(decl)!;
const check = (): string | null => {
const stmt = decl.parent?.parent;
if (
!ts.isIdentifier(decl.name) ||
!ts.isVariableStatement(stmt) ||
!ts.isSourceFile(stmt.parent)
) {
return "its mutable createRequire binding is outside the module's top level";
}
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(decl.name);
if (!symbol || checker.declarationsOf(symbol).length !== 1)
return "its createRequire binding is redeclared";
if ((ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) !== 0)
return "its createRequire result escapes through an exported binding";
const sf = decl.getSourceFile();
const refs = identifierOccurrences7(sf, decl.name.text);
checker.prefetchSymbolNodesExact(refs);
for (const ref of refs) {
if (ref === decl.name || inTypePosition7(ref)) continue;
let target = ts.isShorthandPropertyAssignment(ref.parent)
? checker.getShorthandAssignmentValueSymbol(ref.parent)
: checker.getSymbolAtLocation(ref);
if (target && (target.flags & ts.SymbolFlags.Alias) !== 0)
target = checker.getAliasedSymbol(target);
if (target === symbol && !staticCreateRequireUse7(ref)) {
return "its createRequire binding is reassigned, escapes, or uses a computed specifier";
}
}
const preceding = stmt.declarationList.declarations.slice(
0,
stmt.declarationList.declarations.indexOf(decl),
);
if (bindingEarlyUse7(program, sf, sf.statements.indexOf(stmt), decl, preceding) !== null) {
return "its createRequire binding can be used before initialization";
}
return null;
};
const reason = check();
cache.set(decl, reason);
return reason;
}
function isCreateRequireBaseCall7(program: ts.Program, expr: ts.Expression): boolean {
const init = stripRequireCasts7(expr);
if (
!ts.isCallExpression(init) ||
init.questionDotToken !== undefined ||
!ts.isIdentifier(init.expression) ||
!isCreateRequireImport7(program, init.expression) ||
init.arguments.length !== 1
) {
return false;
}
const base = stripRequireCasts7(init.arguments[0]!);
if (ts.isIdentifier(base) && base.text === "__filename") return !sourceBinding7(program, base);
return (
ts.isPropertyAccessExpression(base) &&
base.questionDotToken === undefined &&
ts.isMetaProperty(base.expression) &&
base.expression.keywordToken === ts.SyntaxKind.ImportKeyword &&
(base.name.text === "url" || base.name.text === "filename")
);
}
/** Refuse require indirection the lowering cannot erase, while
* admitting literal calls through the same stable/inline shapes as program
* code. Inspect symbols, not names: nested shadows are unrelated bindings. */
function npmStaticModuleImportReason7(
program: ts.Program,
stmt: ts.ImportDeclaration,
): string | null {
const clause = stmt.importClause;
if (!clause) return null;
const bindings = clause.namedBindings;
if (clause.name || (bindings && !ts.isNamedImports(bindings))) {
return "its node:module namespace or default import has no static createRequire usage proof";
}
if (!bindings || !ts.isNamedImports(bindings)) return null;
const checker = program.getTypeChecker();
const sf = stmt.getSourceFile();
for (const specifier of bindings.elements) {
if ((specifier.propertyName?.text ?? specifier.name.text) !== "createRequire") continue;
const imported = checker.getSymbolAtLocation(specifier.name);
if (!imported) return "its createRequire import could not be resolved";
checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, specifier.name.text));
let reason: string | null = null;
const checkUse = (expr: ts.Expression): void => {
if (!staticCreateRequireUse7(expr)) {
reason =
"its createRequire result escapes or is used with a computed specifier (static requires need literal specifiers)";
}
};
ts.walkPreorder(sf, (node) => {
if (
reason ||
!ts.isIdentifier(node) ||
node.text !== specifier.name.text ||
node === specifier.name ||
checker.getSymbolAtLocation(node) !== imported
)
return;
const call = node.parent;
if (
!ts.isCallExpression(call) ||
call.expression !== node ||
!isCreateRequireBaseCall7(program, call)
) {
reason =
"its createRequire import is used outside a supported call based on the current file";
return;
}
const decl = call.parent;
if (
ts.isVariableDeclaration(decl) &&
decl.initializer === call &&
ts.isIdentifier(decl.name)
) {
if ((ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) !== 0) {
reason = "its createRequire result escapes through an exported binding";
return;
}
if (!isCreateRequireBinding7(program, decl.name)) {
reason = stableCreateRequireBindingReason7(program, decl);
return;
}
const sym = checker.getSymbolAtLocation(decl.name);
const name = decl.name.text;
checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, name));
ts.walkPreorder(sf, (ref) => {
if (
ts.isIdentifier(ref) &&
ref.text === name &&
ref !== decl.name &&
checker.getSymbolAtLocation(ref) === sym
)
checkUse(ref);
});
} else {
checkUse(call);
}
});
if (reason) return reason;
}
return null;
}
/** Classifies a require-shaped call as Node's ambient CommonJS global, the
* one supported createRequire source binding, or another source binding.
* The scanners still collect createRequire calls because they carry static
* module edges; other source bindings get a pointed fence before lowering
* can mistake them for module syntax. */
function requireCallBindingKind7(program: ts.Program, node: ts.Node): RequireCallBindingKind7 {
const raw = ts.isVariableDeclaration(node) ? node.initializer : node;
const call = raw && ts.isExpression(raw) ? stripRequireCasts7(raw) : raw;
if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) return "ambient";
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(call.expression);
if (symbol === undefined) return "ambient";
const decls = checker.declarationsOf(symbol);
if (decls.every((d) => d.getSourceFile().isDeclarationFile)) return "ambient";
return isCreateRequireBinding7(program, call.expression) ? "createRequire" : "source";
}
function esmRequireFeature7(kind: Exclude<RequireCallBindingKind7, "createRequire">): string {
return kind === "ambient"
? "require() in an ES module (Node throws ReferenceError — use import)"
: "calls through a source binding named 'require' (this spelling is reserved for CommonJS/createRequire module edges — rename the binding)";
}
const CJS_WRAPPER_NAMES = new Set(["require", "module", "exports", "__dirname", "__filename"]);
function bindingDeclaresCjsWrapper7(name: ts.BindingName): ts.Identifier | null {
if (ts.isIdentifier(name)) return CJS_WRAPPER_NAMES.has(name.text) ? name : null;
for (const el of name.elements) {
if (el.name === undefined) continue;
const hit = bindingDeclaresCjsWrapper7(el.name);
if (hit !== null) return hit;
}
return null;
}
function insideFunctionLike7(node: ts.Node): boolean {
for (let p = node.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (ts.isFunctionLike(p)) return true;
}
return false;
}
/** The first syntax marker Node's ambiguous-file detector recognizes. This
* cannot use ts.isExternalModule: moduleDetection=force deliberately gives
* EVERY executable file module-local checker scope, including CommonJS.
* Runtime classification remains Node's own narrower question: value-level
* import/export forms, import.meta, top-level await/for-await, or a top-level
* lexical declaration that would redeclare a CommonJS wrapper binding. */
function nodeEsmSyntaxMarker7(sf: ts.SourceFile): ts.Node | null {
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt) && !erasedTypeOnlyImport(stmt)) return stmt;
if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly && !erasedTypeOnlyReexport(stmt)) {
return stmt;
}
if (ts.isExportAssignment(stmt)) return stmt;
if (
ts.canHaveModifiers(stmt) &&
ts.getModifiers(stmt)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true
) {
return stmt;
}
if (
ts.isVariableStatement(stmt) &&
(stmt.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0
) {
for (const decl of stmt.declarationList.declarations) {
const hit = bindingDeclaresCjsWrapper7(decl.name);
if (hit !== null) return hit;
}
}
if (
ts.isClassDeclaration(stmt) &&
stmt.name !== undefined &&
CJS_WRAPPER_NAMES.has(stmt.name.text)
) {
return stmt.name;
}
}
let found: ts.Node | null = null;
ts.walkPreorder(sf, (node) => {
if (ts.isMetaProperty(node) && node.keywordToken === ts.SyntaxKind.ImportKeyword) {
found = node;
return "stop";
}
// Only await syntax needs an ancestor walk. Doing this for every node
// makes a deeply nested expression take quadratic time to classify.
if (
(ts.isAwaitExpression(node) ||
(ts.isForOfStatement(node) && node.awaitModifier !== undefined)) &&
!insideFunctionLike7(node)
) {
found = node;
return "stop";
}
return undefined;
});
return found;
}
/** True when Node would treat this file as an ES MODULE (never defining
* require/__dirname there): fixed extensions first, then an explicit
* nearest-package type, then Node's syntax markers for ambiguous .js/.ts
* files. TypeScript's external-module bit is forced for checker scoping and
* therefore intentionally plays no part in this runtime decision. */
function isNodeEsmFile7(sf: ts.SourceFile, program?: ts.Program): boolean {
const cached = program?.analysis.nodeEsmFiles.get(sf);
if (cached !== undefined) return cached;
let result: boolean;
if (sf.fileName.endsWith(".cjs") || sf.fileName.endsWith(".cts")) {
result = false;
} else if (sf.fileName.endsWith(".mjs") || sf.fileName.endsWith(".mts")) {
result = true;
} else {
const packageType = nearestPackageType(sf.fileName);
result =
packageType === "module"
? true
: packageType === "commonjs"
? false
: nodeEsmSyntaxMarker7(sf) !== null;
}
program?.analysis.nodeEsmFiles.set(sf, result);
return result;
}
/** A JavaScript source file Node treats as CommonJS (not an ES module):
* ESM namespace/default machinery must not claim it — its export surface
* is module.exports, read through the CJS interop paths and their own
* fences. */
function isCjsJsFile7(sf: ts.SourceFile, program?: ts.Program): boolean {
return isJsSourceFileName(sf.fileName) && !isNodeEsmFile7(sf, program);
}
/** The exported spelling of isCjsJsFile7 (the lowering's gate for
* module.exports surfaces — `ts.isExternalModule` is NOT the CJS test:
* tsgo marks CJS files with export assignments as external modules). */
export function isCjsJsFile(sf: ts.SourceFile, program?: ts.Program): boolean {
return isCjsJsFile7(sf, program);
}
/** The self-import TDZ fences: for `import { x } from "<self>"` (default
* and namespace forms included), any TOP-LEVEL reference lexically above
* the aliased declaration observes Node's temporal dead zone — the direct
* spelling of the same read is tsc's own TS2448, which the import
* indirection dodges, so preflight restores the bar with SC1030. Function
* aliases hoist (initialized at link — readable anywhere); references
* inside function-like bodies follow the direct spelling's existing
* policy (tsc accepts those too) and are not fenced here. Namespace
* bindings fence per MEMBER access (`self.x` above x's declaration);
* the bare namespace binding itself is never TDZ. */
function selfImportTdzFences7(
program: ts.Program,
sf: ts.SourceFile,
clause: ts.ImportClause,
diags: ScrDiagnostic[],
): void {
const checker = program.getTypeChecker();
const insideFunction = (node: ts.Node): boolean => {
for (let p = node.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (ts.isFunctionLike(p)) return true;
}
return false;
};
// The aliased declaration's TDZ horizon: the position after which reads
// are initialized. Hoisted functions have none.
const tdzEndOf = (aliased: ts.Symbol): number | null => {
const d = checker.valueDeclarationOf(aliased) ?? checker.declarationsOf(aliased)[0];
if (d === undefined || d.getSourceFile() !== sf) return null;
if (ts.isFunctionDeclaration(d)) return null;
return d.getEnd();
};
const fence = (ref: ts.Identifier, name: string): void => {
diags.push(
unsupportedDiag(
"SC1030",
{ file: sf.fileName, start: ref.getStart(sf), end: ref.getEnd() },
`the self-import binding '${name}' read above its declaration (the import aliases this module's OWN binding — Node initializes it when the declaration executes, so this read throws ReferenceError; move the read below the declaration)`,
),
);
};
const checkNamed = (bindingName: ts.Identifier): void => {
const sym = checker.getSymbolAtLocation(bindingName);
if (sym === undefined || !(sym.flags & ts.SymbolFlags.Alias)) return;
checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, bindingName.text));
const end = tdzEndOf(checker.getAliasedSymbol(sym));
if (end === null) return;
const visit = (node: ts.Node): void => {
if (
ts.isIdentifier(node) &&
node !== bindingName &&
node.text === bindingName.text &&
node.getStart(sf) < end &&
!insideFunction(node) &&
checker.getSymbolAtLocation(node) === sym
) {
fence(node, bindingName.text);
}
ts.forEachChild(node, visit);
};
visit(sf);
};
if (clause.name !== undefined) checkNamed(clause.name);
if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
for (const el of clause.namedBindings.elements) {
if (!el.isTypeOnly) checkNamed(el.name);
}
}
if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) {
const nsName = clause.namedBindings.name;
const nsSym = checker.getSymbolAtLocation(nsName);
if (nsSym === undefined) return;
checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, nsName.text));
const visit = (node: ts.Node): void => {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === nsName.text &&
ts.isIdentifier(node.name) &&
!insideFunction(node) &&
checker.getSymbolAtLocation(node.expression) === nsSym
) {
const memberSym = checker.getSymbolAtLocation(node.name);
if (memberSym !== undefined) {
const end = tdzEndOf(memberSym);
if (end !== null && node.getStart(sf) < end)
fence(node.expression, `${nsName.text}.${node.name.text}`);
}
}
ts.forEachChild(node, visit);
};
visit(sf);
}
}
/** True when every reference to the namespace-import binding `nsName`
* (outside its own declaration) is exactly a bare expression statement's
* expression — the shape whose lowering is Node's no-op. A self-import's
* references all live in the importing file, so one file-local scan
* answers completely. */
function nsBindingUsesAreBareStatements7(
program: ts.Program,
sf: ts.SourceFile,
nsName: ts.Identifier,
): boolean {
const checker = program.getTypeChecker();
const bindingSym = checker.getSymbolAtLocation(nsName);
if (bindingSym === undefined) return false;
checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, nsName.text));
let bareOnly = true;
const visit = (node: ts.Node): void => {
if (!bareOnly) return;
if (
ts.isIdentifier(node) &&
node !== nsName &&
node.text === nsName.text &&
checker.getSymbolAtLocation(node) === bindingSym
) {
if (
!(
node.parent !== undefined &&
ts.isExpressionStatement(node.parent) &&
node.parent.expression === node
)
) {
bareOnly = false;
return;
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return bareOnly;
}
/* ── benign-cycle admission ──────────────────────────────────────────────
* Node runs legal ESM cycles all over the ecosystem: the revisited module
* is a cache hit, and the only hazard is a READ of a binding before its
* declaration executed (TDZ ReferenceError for let/const/class, a stale
* `undefined` for var). Cycles where such a read is REACHABLE are admitted
* by the exact initialization rule (exactCycleRefusal7 below), which
* models the TDZ with initialization checks. A cycle needs no checks when
* partial initialization is provably unobservable — the DECLARATION-ONLY
* INIT WINDOW rule:
*
* 1. Every module of the cycle cluster (the strongly-connected component
* — any member may be mid-initialization while another's top level
* runs, and every module that can even NAME a cluster binding during
* the window is itself in the cluster) is an ES module whose top
* level is INERT: declarations and expressions that cannot call into
* user code (nonInertTopLevel7).
* 2. Every use of each binding the cycle-closing statement imports sits
* in a DEFERRED position — inside a function body, a parameter
* default, or an instance-field initializer (inDeferredPosition7).
*
* Together: cluster bindings are read only from function bodies, and no
* top level in the window can call a function, so the first possible read
* happens after every member initialized — the guarded %init calls then
* reproduce Node's cache-hit order exactly, and Node's TDZ/stale-slot
* outcomes are unreachable. */
/** A module edge, carrying what cycle admission needs when it turns out
* to close one. `stmt` is the importing/re-exporting statement; absent
* for require() edges, which are never admitted (their CJS home fails the
* cluster check anyway). */
export interface CycleEdge {
dep: ts.SourceFile;
stmt?: ts.ImportDeclaration | ts.ExportDeclaration;
}
/** True when the reference at `node` can only evaluate AFTER the module
* graph finished initializing: inside a function-like BODY, inside a
* parameter (default initializers evaluate at call time), or inside an
* INSTANCE field initializer (runs at construction, and no admitted top
* level constructs). Static field initializers, static blocks, heritage
* clauses, computed names, and decorators all run at the class statement
* itself — not deferred. */
function inDeferredPosition7(node: ts.Node): boolean {
let child: ts.Node = node;
for (
let p: ts.Node | undefined = node.parent;
p !== undefined && !ts.isSourceFile(p);
p = p.parent
) {
if (p.kind === ts.SyntaxKind.Decorator) return false;
if (ts.isClassStaticBlockDeclaration(p)) return false;
if (ts.isFunctionLike(p)) {
if ((p as { body?: ts.Node }).body === child) return true;
if (ts.isParameter(child)) return true;
}
if (
ts.isPropertyDeclaration(p) &&
p.initializer === child &&
(ts.getCombinedModifierFlags(p) & ts.ModifierFlags.Static) === 0
) {
return true;
}
child = p;
}
return false;
}
/** True for identifiers in pure TYPE positions (annotations, `typeof`
* queries, interface/alias bodies) — erased at runtime, never a read. A
* CLASS `extends` heritage expression is the one TypeNode-shaped position
* that IS a runtime read. */
function inTypePosition7(node: ts.Node): boolean {
for (
let p: ts.Node | undefined = node.parent;
p !== undefined && !ts.isSourceFile(p);
p = p.parent
) {
if (ts.isTypeNode(p)) {
const heritage = p.parent;
const isClassExtends =
p.kind === ts.SyntaxKind.ExpressionWithTypeArguments &&
heritage !== undefined &&
ts.isHeritageClause(heritage) &&
heritage.token === ts.SyntaxKind.ExtendsKeyword &&
(ts.isClassDeclaration(heritage.parent) || ts.isClassExpression(heritage.parent));
if (!isClassExtends) return true;
}
if (ts.isTypeAliasDeclaration(p) || ts.isInterfaceDeclaration(p)) return true;
}
return false;
}
/** The first top-level construct of `sf` that could execute USER code
* during a cycle's init window, or null when the module's top level is
* declaration-only ("inert"). The expression whitelist is deliberately
* conservative — anything that could CALL user code (directly, through a
* callback-invoking builtin, a getter, an iterator, or an implicit
* coercion of a user object) disqualifies the module's cycles. */
function nonInertTopLevel7(program: ts.Program, sf: ts.SourceFile): ts.Node | null {
const nonInert = topLevelInertness7(program);
for (const stmt of sf.statements) {
const offence = nonInert(stmt);
if (offence !== null) return offence;
}
return null;
}
/** The per-statement form of nonInertTopLevel7: answers, for one
* top-level statement, the first construct that could execute user code
* while the statement runs, or null when the statement is inert. */
function topLevelInertness7(program: ts.Program): (stmt: ts.Statement) => ts.Node | null {
const checker = program.getTypeChecker();
const PRIM =
ts.TypeFlags.StringLike |
ts.TypeFlags.NumberLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.ESSymbolLike |
ts.TypeFlags.EnumLike |
ts.TypeFlags.Undefined |
ts.TypeFlags.Null |
ts.TypeFlags.Void |
ts.TypeFlags.Never;
/** Coercion-safe: the expression's type is primitive in every arm, so
* ToString/ToNumber/ToPrimitive can never reach a user valueOf. */
const primitiveTyped = (e: ts.Expression): boolean => {
const t = checker.getTypeAtLocation(e);
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
return parts.every((p) => (p.flags & PRIM) !== 0);
};
/** The identifier's symbol lives entirely in declaration files — a
* global builtin (process, WeakSet, Object, …) whose property reads and
* calls are runtime-implemented, never user code. */
const dtsRooted = (e: ts.Expression): boolean => {
let root: ts.Expression = e;
while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root))
root = root.expression;
if (ts.isMetaProperty(root)) return true; // import.meta
if (!ts.isIdentifier(root)) return false;
let sym = checker.getSymbolAtLocation(root);
if (sym === undefined) return false;
if (sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym);
const decls = checker.declarationsOf(sym);
return decls.length > 0 && decls.every((d) => d.getSourceFile().isDeclarationFile);
};
const hasDecorator = (n: ts.Node): boolean =>
((n as { modifiers?: readonly ts.Node[] }).modifiers ?? []).some(
(m) => m.kind === ts.SyntaxKind.Decorator,
);
/** No function-like node anywhere in the subtree — arguments to builtin
* calls must not smuggle a callback the builtin could invoke. */
const containsFunctionLike = (n: ts.Node): boolean => {
let found = false;
ts.walkPreorder(n, (c) => {
if (ts.isFunctionLike(c) || ts.isClassExpression(c) || ts.isClassDeclaration(c)) {
found = true;
return "stop";
}
return undefined;
});
return found;
};
const inertClass = (c: ts.ClassLikeDeclaration): ts.Node | null => {
if (hasDecorator(c)) return c;
for (const h of c.heritageClauses ?? []) {
if (h.token !== ts.SyntaxKind.ExtendsKeyword) continue;
for (const t of h.types) if (!inert(t.expression)) return t.expression;
}
for (const m of c.members) {
if (ts.isClassStaticBlockDeclaration(m)) return m;
if (hasDecorator(m)) return m;
const memberName = (m as { name?: ts.PropertyName }).name;
if (memberName !== undefined && ts.isComputedPropertyName(memberName)) {
if (!inert(memberName.expression) || !primitiveTyped(memberName.expression))
return memberName;
}
if (
ts.isPropertyDeclaration(m) &&
(ts.getCombinedModifierFlags(m) & ts.ModifierFlags.Static) !== 0 &&
m.initializer !== undefined &&
!inert(m.initializer)
) {
return m.initializer;
}
}
return null;
};
const COERCING_FREE = new Set<number>([
ts.SyntaxKind.EqualsEqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken,
ts.SyntaxKind.CommaToken,
ts.SyntaxKind.InstanceOfKeyword,
]);
const inert = (e: ts.Expression): boolean => {
if (
ts.isStringLiteral(e) ||
ts.isNumericLiteral(e) ||
ts.isBigIntLiteral(e) ||
ts.isRegularExpressionLiteral(e) ||
ts.isNoSubstitutionTemplateLiteral(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword ||
ts.isIdentifier(e) ||
ts.isMetaProperty(e) ||
ts.isArrowFunction(e) ||
ts.isFunctionExpression(e)
) {
return true;
}
if (ts.isClassExpression(e)) return inertClass(e) === null;
if (
ts.isParenthesizedExpression(e) ||
ts.isAsExpression(e) ||
ts.isSatisfiesExpression(e) ||
ts.isNonNullExpression(e)
) {
return inert(e.expression);
}
if (ts.isPropertyAccessExpression(e)) return dtsRooted(e);
if (ts.isElementAccessExpression(e)) {
return dtsRooted(e) && inert(e.argumentExpression) && primitiveTyped(e.argumentExpression);
}
if (ts.isTemplateExpression(e)) {
return e.templateSpans.every((s) => inert(s.expression) && primitiveTyped(s.expression));
}
if (ts.isVoidExpression(e) || ts.isTypeOfExpression(e)) return inert(e.expression);
if (ts.isPrefixUnaryExpression(e)) {
if (e.operator === ts.SyntaxKind.ExclamationToken) return inert(e.operand);
if (
e.operator === ts.SyntaxKind.PlusToken ||
e.operator === ts.SyntaxKind.MinusToken ||
e.operator === ts.SyntaxKind.TildeToken
) {
return inert(e.operand) && primitiveTyped(e.operand);
}
return false;
}
if (ts.isBinaryExpression(e)) {
if (!inert(e.left) || !inert(e.right)) return false;
if (COERCING_FREE.has(e.operatorToken.kind)) return true;
return primitiveTyped(e.left) && primitiveTyped(e.right);
}
if (ts.isConditionalExpression(e))
return inert(e.condition) && inert(e.whenTrue) && inert(e.whenFalse);
if (ts.isArrayLiteralExpression(e))
return e.elements.every((el) => !ts.isSpreadElement(el) && inert(el));
if (ts.isObjectLiteralExpression(e)) {
return e.properties.every((p) => {
if (ts.isShorthandPropertyAssignment(p)) return true;
if (!ts.isPropertyAssignment(p) || ts.isComputedPropertyName(p.name)) return false;
return inert(p.initializer);
});
}
if (ts.isCallExpression(e) || ts.isNewExpression(e)) {
if (!dtsRooted(e.expression) || !ts.isIdentifier(chainRoot7(e.expression))) return false;
const args = e.arguments ?? [];
// A CALLABLE argument is admissible when it is itself dts-rooted:
// a builtin-owned function value (the `promisify(fs.readFile)`
// at every cycle member's top level) is runtime-implemented — even
// if the builtin callee invokes it, no user code runs and no
// cluster binding is observable. Function literals and user
// callables keep the refusal.
return args.every(
(a) =>
inert(a) &&
!containsFunctionLike(a) &&
(checker.getCallSignatures(checker.getTypeAtLocation(a)).length === 0 || dtsRooted(a)),
);
}
return false;
};
return (stmt: ts.Statement): ts.Node | null => {
if (
ts.isImportDeclaration(stmt) ||
ts.isExportDeclaration(stmt) ||
ts.isImportEqualsDeclaration(stmt) ||
ts.isFunctionDeclaration(stmt) ||
ts.isInterfaceDeclaration(stmt) ||
ts.isTypeAliasDeclaration(stmt) ||
ts.isEmptyStatement(stmt) ||
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) &
ts.ModifierFlags.Ambient) !==
0
) {
return null;
}
if (ts.isEnumDeclaration(stmt)) {
return stmt.members.find((m) => m.initializer !== undefined && !inert(m.initializer)) ?? null;
}
if (ts.isClassDeclaration(stmt)) return inertClass(stmt);
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (!ts.isIdentifier(d.name)) return d.name; // patterns can invoke getters/iterators
if (d.initializer !== undefined && !inert(d.initializer)) return d.initializer;
}
return null;
}
if (ts.isExpressionStatement(stmt)) return inert(stmt.expression) ? null : stmt;
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
return inert(stmt.expression) ? null : stmt;
}
return stmt;
};
}
/** The head of a property/element-access chain. */
function chainRoot7(e: ts.Expression): ts.Expression {
let root: ts.Expression = e;
while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root))
root = root.expression;
return root;
}
/** Identifier occurrences a binding-identity preflight scan will query.
* Gathered without checker traffic so managed deferred bodies can be
* warmed in one exact symbol batch before the semantic walk. */
function identifierOccurrences7(root: ts.Node, text: string): ts.Identifier[] {
const out: ts.Identifier[] = [];
ts.walkPreorder(root, (node) => {
if (ts.isIdentifier(node) && node.text === text) out.push(node);
return undefined;
});
return out;
}
/** The first use of a binding this cycle-closing import introduces that
* sits OUTSIDE a deferred position (a read there can observe the
* partially-initialized exporter), or null when every use defers.
* Re-export statements bind nothing locally — always null: the alias is
* link-time plumbing, never an evaluation-time read. Export specifiers
* of an imported binding are the same alias story, not reads. */
function backEdgeUseOffence7(
program: ts.Program,
stmt: ts.ImportDeclaration | ts.ExportDeclaration,
occurrences: ReadonlyMap<string, readonly ts.Identifier[]>,
): { name: string; node: ts.Node } | null {
if (!ts.isImportDeclaration(stmt) || stmt.importClause === undefined) return null;
const checker = program.getTypeChecker();
const clause = stmt.importClause;
const bindingNames: ts.Identifier[] = [];
if (clause.name !== undefined) bindingNames.push(clause.name);
if (clause.namedBindings !== undefined) {
if (ts.isNamespaceImport(clause.namedBindings)) bindingNames.push(clause.namedBindings.name);
else
for (const el of clause.namedBindings.elements)
if (!el.isTypeOnly) bindingNames.push(el.name);
}
for (const bindingName of bindingNames) {
const sym = checker.getSymbolAtLocation(bindingName);
if (sym === undefined) continue;
const references = occurrences.get(bindingName.text) ?? [];
checker.prefetchSymbolNodesExact(references);
for (const node of references) {
if (
!(node.parent !== undefined && ts.isExportSpecifier(node.parent)) &&
checker.getSymbolAtLocation(node) === sym &&
!inTypePosition7(node) &&
!inDeferredPosition7(node)
) {
return { name: bindingName.text, node };
}
}
}
return null;
}
export function makeCycleAdmission(
program: ts.Program,
edgesOf: (sf: ts.SourceFile) => readonly CycleEdge[],
): (importer: ts.SourceFile, e: CycleEdge) => string | null {
// Tarjan over the same edges the order walk uses (self-edges skipped —
// Node's self-reference rule makes those benign before admission is
// ever asked). State persists across lazily-added roots: a later
// strongconnect over an unvisited root composes with earlier runs
// exactly like the classic all-roots loop.
const sccOf = new Map<ts.SourceFile, ts.SourceFile[]>();
const index = new Map<ts.SourceFile, number>();
const low = new Map<ts.SourceFile, number>();
const onStack = new Set<ts.SourceFile>();
const tstack: ts.SourceFile[] = [];
let next = 0;
const strongconnect = (v: ts.SourceFile): void => {
index.set(v, next);
low.set(v, next);
next++;
tstack.push(v);
onStack.add(v);
for (const e of edgesOf(v)) {
const w = e.dep;
if (w === v) continue;
if (!index.has(w)) {
strongconnect(w);
low.set(v, Math.min(low.get(v)!, low.get(w)!));
} else if (onStack.has(w)) {
low.set(v, Math.min(low.get(v)!, index.get(w)!));
}
}
if (low.get(v) === index.get(v)) {
const comp: ts.SourceFile[] = [];
for (;;) {
const w = tstack.pop()!;
onStack.delete(w);
comp.push(w);
if (w === v) break;
}
for (const w of comp) sccOf.set(w, comp);
}
};
const lineOf = (node: ts.Node): string => {
const nsf = node.getSourceFile();
return `${nsf.fileName}:${ts.getLineAndCharacterOfPosition(nsf, node.getStart(nsf)).line + 1}`;
};
// Cluster verdict memo (keyed by the component array identity): true
// when every member is an ES module passing the inert-top-level bar.
const sccVerdict = new Map<ts.SourceFile[], boolean>();
// The exact-initialization verdict, same keying (exactCycleRefusal7).
const exactVerdict = new Map<ts.SourceFile[], string | null>();
// A module can close many cycle edges, each importing many bindings.
// Index its identifiers once for this admission pass. ASTs are immutable;
// the pass owns the index so it cannot retain a disposed program.
const bindingUses = new Map<ts.SourceFile, Map<string, ts.Identifier[]>>();
const usesOf = (sf: ts.SourceFile): Map<string, ts.Identifier[]> => {
const cached = bindingUses.get(sf);
if (cached !== undefined) return cached;
const uses = new Map<string, ts.Identifier[]>();
ts.walkPreorder(sf, (node) => {
if (ts.isImportDeclaration(node)) return "skip";
if (ts.isIdentifier(node)) {
const references = uses.get(node.text);
if (references !== undefined) references.push(node);
else uses.set(node.text, [node]);
}
return undefined;
});
bindingUses.set(sf, uses);
return uses;
};
return (importer: ts.SourceFile, e: CycleEdge): string | null => {
if (e.stmt === undefined) return "the cycle closes through a require() edge";
// Cheap per-edge admission: nothing readable binds through the edge.
if (ts.isImportDeclaration(e.stmt)) {
const clause = e.stmt.importClause;
if (
clause === undefined ||
(clause.name === undefined &&
(clause.namedBindings === undefined ||
(ts.isNamespaceImport(clause.namedBindings) &&
nsBindingUsesAreBareStatements7(program, importer, clause.namedBindings.name))))
) {
return null;
}
}
if (!index.has(importer)) strongconnect(importer);
const comp = sccOf.get(e.dep);
if (comp === undefined || comp.length < 2 || !comp.includes(importer)) {
return "the cycle's module cluster could not be analyzed";
}
if (!sccVerdict.has(comp)) {
sccVerdict.set(
comp,
comp.every((m) => !isCjsJsFile7(m, program) && nonInertTopLevel7(program, m) === null),
);
}
if (
sccVerdict.get(comp) === true &&
backEdgeUseOffence7(program, e.stmt, usesOf(importer)) === null
) {
return null;
}
// The EXACT INITIALIZATION rule: the cycle runs in Node's evaluation
// order and every binding that can be observed before its declaration
// runs carries an initialization check (cycleEarlyBindings).
if (!exactVerdict.has(comp)) {
const reason = exactCycleRefusal7(program, comp, lineOf);
exactVerdict.set(comp, reason);
if (reason === null) {
const members = program.analysis.exactCycleMembers;
for (const m of comp) members.add(m);
}
}
return exactVerdict.get(comp) ?? null;
};
}
/* ── exact cyclic initialization ─────────────────────────────────────────
* Cycles that fail the declaration-only rule still evaluate exactly like
* Node when their shape is representable: the guarded %init calls already
* run every module body in ES evaluation order (depth-first postorder,
* a module mid-evaluation answered from the cache), function declarations
* are callable from the start, and live bindings are shared globals. What
* remains is the temporal dead zone: a let/const binding read before its
* declaration ran throws ReferenceError. cycleEarlyBindings finds every
* such binding whose read CAN run early; the lowering gives each one an
* initialization flag and checks it at every read or write that can run
* before the declaration. The shapes below have no such representation
* and keep the SC1016 fence. */
/** The modules of `program` whose import cycles were admitted by the
* exact initialization rule (not the declaration-only rule). */
export function exactCycleMembers(program: ts.Program): ReadonlySet<ts.SourceFile> {
return program.analysis.exactCycleMembers;
}
/** True when the module's top level (outside every function-like body)
* contains `await` or `for await`. */
function hasTopLevelAwait7(sf: ts.SourceFile): boolean {
let found = false;
ts.walkPreorder(sf, (node) => {
if (found) return "stop";
if (node !== sf && ts.isFunctionLike(node)) return "skip";
if (
ts.isAwaitExpression(node) ||
(ts.isForOfStatement(node) && node.awaitModifier !== undefined)
) {
found = true;
return "stop";
}
return undefined;
});
return found;
}
/** The reason a strongly-connected module cluster cannot be initialized
* exactly, or null when it can. */
function exactCycleRefusal7(
program: ts.Program,
comp: readonly ts.SourceFile[],
lineOf: (node: ts.Node) => string,
): string | null {
const members = new Set(comp);
const targetOf = (stmt: ts.ImportDeclaration | ts.ExportDeclaration): ts.SourceFile | null => {
const spec = stmt.moduleSpecifier;
if (spec === undefined || !ts.isStringLiteral(spec)) return null;
const sf = stmt.getSourceFile();
return resolveImport7(program, sf, spec.text) ?? npmStaticDepSf7(program, sf, spec.text);
};
for (const m of comp) {
if (isCjsJsFile7(m, program) || /\.c[jt]s$/.test(m.fileName)) {
return `${m.fileName} is a CommonJS module — cyclic initialization covers ES modules only`;
}
if (hasTopLevelAwait7(m)) {
return `${m.fileName} uses top-level await inside the cycle`;
}
for (const stmt of m.statements) {
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
return `the default export expression at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
if (
ts.isExportDeclaration(stmt) &&
!stmt.isTypeOnly &&
(stmt.exportClause === undefined || ts.isNamespaceExport(stmt.exportClause))
) {
const dep = targetOf(stmt);
if (dep !== null && members.has(dep)) {
return `the namespace re-export at ${lineOf(stmt)} names a module of the cycle`;
}
}
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause !== undefined &&
!stmt.importClause.isTypeOnly &&
stmt.importClause.namedBindings !== undefined &&
ts.isNamespaceImport(stmt.importClause.namedBindings)
) {
const dep = targetOf(stmt);
const nsName = stmt.importClause.namedBindings.name;
if (
dep !== null &&
members.has(dep) &&
!nsBindingUsesAreBareStatements7(program, m, nsName)
) {
return `the namespace import '${nsName.text}' at ${lineOf(stmt)} names a module of the cycle`;
}
}
if (ts.isClassDeclaration(stmt) && stmt.name === undefined) {
return `the anonymous default class at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
const ambient =
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) &
ts.ModifierFlags.Ambient) !==
0;
if (ambient) continue;
if (
ts.isEnumDeclaration(stmt) &&
(ts.getCombinedModifierFlags(stmt) & ts.ModifierFlags.Const) === 0
) {
return `the enum '${stmt.name.text}' at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
if (ts.isModuleDeclaration(stmt) && !typeOnlyNamespace7(stmt)) {
return `the namespace at ${lineOf(stmt)} has no initialization state inside the cycle`;
}
}
}
return null;
}
/** A module-scope binding of an exactly-initialized cycle that some read
* or write can reach before its declaration ran. */
export interface CycleEarlyBinding {
symbol: ts.Symbol;
/** The declaration's name. */
name: ts.Identifier;
kind: "lexical" | "var" | "class";
module: ts.SourceFile;
/** One reference that can run before the declaration. */
reference: ts.Identifier;
/** A reference in a position the lowering may resolve at compile time
* from the binding's type (a property key, a `typeof` operand). */
folded?: ts.Identifier;
/** Modules whose TOP-LEVEL code runs only after the declaration ran:
* modules outside the cycle and cycle members evaluated later. */
settled: ReadonlySet<ts.SourceFile>;
}
/** Every binding of the program's exactly-initialized cycles that can be
* observed before its declaration executes. `order` is the compiled module
* order; `singleRoot` is false when more than one module graph root runs
* (fork or worker entries), so a cycle can be entered at different
* members. With one root and a purely ES-module graph the evaluation order
* is static and the analysis is precise up to "user code may run": a
* binding needs its check when its declaration completes after some
* top-level code that can call user functions and a deferred position
* (function body, parameter default, instance field) references it, or
* when the top level of a cycle member evaluated before it reads it.
* Otherwise every deferred reference and every top-level reference from
* another member counts. */
export function cycleEarlyBindings(
program: ts.Program,
entry: ts.SourceFile,
order: readonly ts.SourceFile[],
singleRoot: boolean,
): CycleEarlyBinding[] {
const exact = program.analysis.exactCycleMembers;
if (exact.size === 0) return [];
// Every lowering pass of one compile asks with the same inputs.
const key = `${entry.fileName}\0${order.map((sf) => sf.fileName).join("\0")}\0${singleRoot}`;
const memo = program.analysis.cycleEarlyBindings;
if (memo !== null && memo.key === key) return memo.bindings;
const bindings = computeCycleEarlyBindings7(program, entry, order, singleRoot, exact);
program.analysis.cycleEarlyBindings = { key, bindings };
return bindings;
}
function computeCycleEarlyBindings7(
program: ts.Program,
entry: ts.SourceFile,
order: readonly ts.SourceFile[],
singleRoot: boolean,
exact: ReadonlySet<ts.SourceFile>,
): CycleEarlyBinding[] {
const inOrder = new Set(order);
const members = [...exact].filter((m) => inOrder.has(m));
if (members.length === 0) return [];
const memberSet = new Set(members);
const depsOf = (sf: ts.SourceFile): ts.SourceFile[] =>
orderedImportsOf(program, sf).flatMap(({ dep }) =>
dep !== null && dep !== sf && inOrder.has(dep) ? [dep] : [],
);
// Strongly-connected components among the exactly-initialized members.
const comps: ts.SourceFile[][] = [];
{
const index = new Map<ts.SourceFile, number>();
const low = new Map<ts.SourceFile, number>();
const onStack = new Set<ts.SourceFile>();
const stack: ts.SourceFile[] = [];
let next = 0;
const connect = (v: ts.SourceFile): void => {
index.set(v, next);
low.set(v, next);
next++;
stack.push(v);
onStack.add(v);
for (const w of depsOf(v)) {
if (!memberSet.has(w)) continue;
if (!index.has(w)) {
connect(w);
low.set(v, Math.min(low.get(v)!, low.get(w)!));
} else if (onStack.has(w)) {
low.set(v, Math.min(low.get(v)!, index.get(w)!));
}
}
if (low.get(v) === index.get(v)) {
const comp: ts.SourceFile[] = [];
for (;;) {
const w = stack.pop()!;
onStack.delete(w);
comp.push(w);
if (w === v) break;
}
if (comp.length > 1) comps.push(comp);
}
};
for (const m of members) if (!index.has(m)) connect(m);
}
if (comps.length === 0) return [];
// The static evaluation order: Node's depth-first postorder from the
// entry over ES import edges. Known only for one root and a graph with
// no CommonJS module (a require() evaluates its target mid-body).
const position = new Map<ts.SourceFile, number>();
if (
singleRoot &&
!order.some((sf) => isCjsJsFile7(sf, program) || /\.c[jt]s$/.test(sf.fileName))
) {
const seen = new Set<ts.SourceFile>();
const visit = (sf: ts.SourceFile): void => {
seen.add(sf);
for (const dep of depsOf(sf)) if (!seen.has(dep)) visit(dep);
position.set(sf, position.size);
};
visit(entry);
}
const checker = program.getTypeChecker();
const nonInert = topLevelInertness7(program);
const out: CycleEarlyBinding[] = [];
for (const comp of comps) {
const known = comp.every((m) => position.has(m));
const evaluation = known
? comp.slice().sort((a, b) => position.get(a)! - position.get(b)!)
: comp.slice();
interface Candidate {
symbol: ts.Symbol;
name: ts.Identifier;
kind: CycleEarlyBinding["kind"];
module: ts.SourceFile;
/** User code can run between the cycle's start and the end of the
* declaration (always true when the order is unknown). */
exposed: boolean;
rank: number;
start: number;
end: number;
reference: ts.Identifier | null;
folded: ts.Identifier | null;
}
const candidates = new Map<ts.Symbol, Candidate>();
const names = new Set<string>();
let dirty = !known;
evaluation.forEach((m, rank) => {
for (const stmt of m.statements) {
const offence = nonInert(stmt);
const exposed = dirty || offence !== null;
const declared: { name: ts.Identifier; kind: CycleEarlyBinding["kind"] }[] = [];
if (
ts.isVariableStatement(stmt) &&
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) &
ts.ModifierFlags.Ambient) ===
0
) {
const lexical =
(stmt.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0;
for (const d of stmt.declarationList.declarations) {
for (const name of boundNames7(d.name)) {
declared.push({ name, kind: lexical ? "lexical" : "var" });
}
}
} else if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) {
declared.push({ name: stmt.name, kind: "class" });
}
for (const { name, kind } of declared) {
const symbol = checker.getSymbolAtLocation(name);
if (symbol === undefined) continue;
candidates.set(symbol, {
symbol,
name,
kind,
module: m,
exposed,
rank,
start: stmt.getStart(m),
end: stmt.getEnd(),
reference: null,
folded: null,
});
names.add(name.text);
}
if (offence !== null) dirty = true;
}
});
if (candidates.size === 0) continue;
const rankOf = new Map(evaluation.map((m, i) => [m, i] as const));
// References: every identifier spelled like a candidate, resolved
// through import aliases to the declaration's symbol.
for (const sf of order) {
const refs: ts.Identifier[] = [];
ts.walkPreorder(sf, (node) => {
if (ts.isImportDeclaration(node)) return "skip";
if (ts.isIdentifier(node) && names.has(node.text)) refs.push(node);
return undefined;
});
if (refs.length === 0) continue;
checker.prefetchSymbolNodesExact(refs);
for (const ref of refs) {
const parent = ref.parent;
if (parent !== undefined && ts.isExportSpecifier(parent)) continue;
if (inTypePosition7(ref)) continue;
let symbol = checker.getSymbolAtLocation(ref);
if (
parent !== undefined &&
ts.isShorthandPropertyAssignment(parent) &&
parent.name === ref
) {
symbol = checker.getShorthandAssignmentValueSymbol(parent) ?? symbol;
}
if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
const c = symbol === undefined ? undefined : candidates.get(symbol);
if (c === undefined || ref === c.name) continue;
if (c.folded === null && foldablePosition7(ref)) c.folded = ref;
if (c.reference !== null) continue;
// A class body names its own inner binding, initialized throughout.
if (
c.kind === "class" &&
sf === c.module &&
ref.getStart(sf) >= c.start &&
ref.getEnd() <= c.end
)
continue;
let early: boolean;
if (inDeferredPosition7(ref)) early = c.exposed;
else if (sf === c.module) early = ref.getStart(sf) < c.start;
else if (!memberSet.has(sf) || !rankOf.has(sf)) early = false;
else early = !known || rankOf.get(sf)! < c.rank;
if (early) c.reference = ref;
}
}
for (const c of candidates.values()) {
if (c.reference === null) continue;
const settled = new Set<ts.SourceFile>(order.filter((sf) => !rankOf.has(sf)));
if (known) for (const m of evaluation.slice(c.rank + 1)) settled.add(m);
out.push({
symbol: c.symbol,
name: c.name,
kind: c.kind,
module: c.module,
reference: c.reference,
...(c.folded !== null ? { folded: c.folded } : {}),
settled,
});
}
}
return out;
}
/** Top-level let/const and class bindings, in modules outside the
* exactly-initialized cycles, that code running before their declaration
* completes can read.
* Function declarations are callable from the module's start, so a call
* made by earlier top-level code (or by the declaration's own initializer)
* can observe the temporal dead zone. Reachability is syntactic: a stored
* callable (a function or class declaration, or a function, class or
* object-literal method stored by a variable initializer) becomes reachable
* once reachable code names its binding, and every other function-like in
* reachable code counts as reachable because it may be handed to a callee.
* A binding is reported only when one of its references in a deferred
* position is reachable before the declaration ran, so modules that call
* nothing early report nothing. Bindings of exactly-initialized cycles are
* covered by cycleEarlyBindings. */
export function moduleEarlyBindings(
program: ts.Program,
order: readonly ts.SourceFile[],
): CycleEarlyBinding[] {
const key = order.map((sf) => sf.fileName).join("\0");
const memo = program.analysis.moduleEarlyBindings;
if (memo !== null && memo.key === key) return memo.bindings;
const exact = program.analysis.exactCycleMembers;
const out: CycleEarlyBinding[] = [];
for (const sf of order) {
if (sf.isDeclarationFile || exact.has(sf)) continue;
out.push(...moduleEarlyBindingsOf7(program, sf, order));
}
program.analysis.moduleEarlyBindings = { key, bindings: out };
return out;
}
function moduleEarlyBindingsOf7(
program: ts.Program,
sf: ts.SourceFile,
order: readonly ts.SourceFile[],
): CycleEarlyBinding[] {
const ambient = (stmt: ts.Statement): boolean =>
(ts.getCombinedModifierFlags(stmt as unknown as ts.Declaration) & ts.ModifierFlags.Ambient) !==
0;
// Candidate bindings, by declaring statement.
interface Candidate {
name: ts.Identifier;
index: number;
start: number;
/** A class declaration: its own body sees the inner binding. */
cls?: ts.ClassDeclaration;
}
const candidates: Candidate[] = [];
const candidateNames = new Set<string>();
// Stored callables, by the name of the binding that stores them.
const stored = new Set<ts.Node>();
const storedBy = new Map<string, { name: ts.Identifier; units: ts.Node[] }[]>();
// Parts of a stored node that run where it is evaluated: a class's
// heritage, computed keys, decorators and static initializers, and a
// stored method's computed key.
const immediate = new Map<ts.Node, ts.Node[]>();
const store = (name: ts.Identifier, units: ts.Node[]): void => {
if (units.length === 0) return;
for (const u of units) stored.add(u);
const list = storedBy.get(name.text) ?? [];
list.push({ name, units });
storedBy.set(name.text, list);
};
const checker = program.getTypeChecker();
const storedIn = (e: ts.Expression, units: ts.Node[]): void => {
while (
ts.isParenthesizedExpression(e) ||
ts.isAsExpression(e) ||
ts.isSatisfiesExpression(e) ||
ts.isNonNullExpression(e)
)
e = e.expression;
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) {
units.push(e);
} else if (ts.isClassExpression(e)) {
units.push(e);
immediate.set(e, classSyncParts7(e));
} else if (ts.isObjectLiteralExpression(e)) {
for (const p of e.properties) {
if (
ts.isMethodDeclaration(p) ||
ts.isGetAccessorDeclaration(p) ||
ts.isSetAccessorDeclaration(p)
) {
// A computed key evaluates where the literal does; a plain
// member-chain key (`[Equal.symbol]`) runs no user code.
if (!ts.isComputedPropertyName(p.name)) units.push(p);
else if (memberChainKey7(p.name.expression)) {
units.push(p);
immediate.set(p, [p.name]);
}
} else if (ts.isPropertyAssignment(p)) storedIn(p.initializer, units);
}
} else if (ts.isArrayLiteralExpression(e)) {
for (const el of e.elements) if (!ts.isSpreadElement(el)) storedIn(el, units);
} else if (ts.isCallExpression(e)) {
// `const f = wrap(callback)` where wrap only returns closures over
// the callback: it runs when the stored result is called.
e.arguments.forEach((arg, index) => {
let fn: ts.Expression = arg;
while (ts.isParenthesizedExpression(fn)) fn = fn.expression;
if (
(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
returnsOnlyClosuresOver7(checker, e.expression, index)
)
units.push(fn);
});
}
};
sf.statements.forEach((stmt, index) => {
if (ambient(stmt)) return;
if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined && stmt.body !== undefined) {
store(stmt.name, [stmt]);
} else if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) {
store(stmt.name, [stmt]);
candidates.push({ name: stmt.name, index, start: stmt.getStart(sf), cls: stmt });
candidateNames.add(stmt.name.text);
} else if (ts.isVariableStatement(stmt)) {
const lexical = (stmt.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0;
for (const d of stmt.declarationList.declarations) {
if (ts.isIdentifier(d.name) && d.initializer !== undefined) {
const units: ts.Node[] = [];
storedIn(d.initializer, units);
store(d.name, units);
}
if (!lexical) continue;
for (const name of boundNames7(d.name)) {
candidates.push({ name, index, start: stmt.getStart(sf) });
candidateNames.add(name.text);
}
}
}
});
if (candidates.length === 0) return [];
// Resolve every identifier that could name a candidate or a stored
// callable in one batch.
const idents: ts.Identifier[] = [];
ts.walkPreorder(sf, (node) => {
if (ts.isImportDeclaration(node)) return "skip";
if (ts.isIdentifier(node) && (candidateNames.has(node.text) || storedBy.has(node.text)))
idents.push(node);
return undefined;
});
checker.prefetchSymbolNodesExact(idents);
const symbolOf = (id: ts.Identifier): ts.Symbol | undefined => {
const parent = id.parent;
let symbol = checker.getSymbolAtLocation(id);
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === id) {
symbol = checker.getShorthandAssignmentValueSymbol(parent) ?? symbol;
}
if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
return symbol;
};
// A callback handed to a timer, a microtask queue, a promise reaction or
// a host (Node) API runs as a later job or event. Without top-level await
// the module's body completes before any job runs, so such a callback is
// never early. JavaScript builtins (array callbacks, the promise
// executor, replacers) and the host APIs that call back synchronously
// keep the conservative rule.
if (!hasTopLevelAwait7(sf)) {
const callbacks = (e: ts.Expression): void => {
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) stored.add(e);
else if (ts.isObjectLiteralExpression(e)) {
for (const p of e.properties) {
if (ts.isMethodDeclaration(p)) stored.add(p);
else if (ts.isPropertyAssignment(p)) callbacks(p.initializer);
}
}
};
ts.walkPreorder(sf, (node) => {
if (
(ts.isCallExpression(node) || ts.isNewExpression(node)) &&
laterJobCallee7(checker, node.expression)
) {
for (const arg of node.arguments ?? []) callbacks(arg);
}
return undefined;
});
}
const unitsBySymbol = new Map<ts.Symbol, ts.Node[]>();
for (const list of storedBy.values()) {
for (const { name, units } of list) {
const symbol = checker.getSymbolAtLocation(name);
if (symbol === undefined) continue;
unitsBySymbol.set(symbol, [...(unitsBySymbol.get(symbol) ?? []), ...units]);
}
}
const candidateSymbols = new Map<ts.Symbol, Candidate>();
for (const c of candidates) {
const symbol = checker.getSymbolAtLocation(c.name);
if (symbol !== undefined) candidateSymbols.set(symbol, c);
}
// Walk reachable code, collecting the candidate references it holds.
const reachedUnits = new Set<ts.Node>();
const reachedRefs = new Map<ts.Symbol, ts.Identifier>();
const work: ts.Node[] = [];
const visit = (root: ts.Node): void => {
ts.walkPreorder(root, (node) => {
if (node !== root && stored.has(node)) {
for (const part of immediate.get(node) ?? []) visit(part);
return "skip";
}
if (ts.isImportDeclaration(node)) return "skip";
if (!ts.isIdentifier(node)) return undefined;
if (!candidateNames.has(node.text) && !storedBy.has(node.text)) return undefined;
if (inTypePosition7(node)) return undefined;
const parent = node.parent;
if (parent !== undefined && ts.isExportSpecifier(parent)) return undefined;
if (parent !== undefined && declaredName7(parent) === node) return undefined;
const symbol = symbolOf(node);
if (symbol === undefined) return undefined;
const c = candidateSymbols.get(symbol);
if (
c !== undefined &&
node !== c.name &&
!reachedRefs.has(symbol) &&
!(c.cls !== undefined && node.getStart(sf) >= c.cls.pos && node.end <= c.cls.end)
) {
if (inDeferredPosition7(node) || node.getStart(sf) < c.start) reachedRefs.set(symbol, node);
}
for (const unit of unitsBySymbol.get(symbol) ?? []) {
if (reachedUnits.has(unit)) continue;
reachedUnits.add(unit);
work.push(unit);
}
return undefined;
});
};
const drain = (): void => {
while (work.length > 0) visit(work.pop()!);
};
const out: CycleEarlyBinding[] = [];
const settled = new Set(order.filter((m) => m !== sf));
let next = 0;
const report = (declaration: ts.Node): void => {
while (
next < candidates.length &&
candidates[next]!.name.getStart(sf) >= declaration.getStart(sf) &&
candidates[next]!.name.getEnd() <= declaration.getEnd()
) {
const c = candidates[next++]!;
const symbol = checker.getSymbolAtLocation(c.name);
const reference = symbol === undefined ? undefined : reachedRefs.get(symbol);
if (symbol === undefined || reference === undefined) continue;
out.push({
symbol,
name: c.name,
kind: c.cls !== undefined ? "class" : "lexical",
module: sf,
reference,
settled,
});
}
};
for (const stmt of sf.statements) {
if (ts.isClassDeclaration(stmt)) {
// The binding initializes once the class definition, static
// initializers included, has run.
for (const part of classSyncParts7(stmt)) visit(part);
drain();
report(stmt);
} else if (ts.isVariableStatement(stmt)) {
// Declarators run in order: a later declarator's initializer cannot
// observe an earlier binding before it was initialized.
for (const d of stmt.declarationList.declarations) {
visit(d);
drain();
report(d);
}
} else if (!ts.isFunctionDeclaration(stmt)) visit(stmt);
drain();
}
return out;
}
/** True when the program function `callee` names reads its parameter
* `index` only inside function expressions it returns directly, and never
* through `arguments` in its own body: calling it cannot run that argument
* before the returned closure is called. */
function returnsOnlyClosuresOver7(
checker: ts.TypeChecker,
callee: ts.Expression,
index: number,
): boolean {
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
const name = ts.isIdentifier(callee)
? callee
: ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)
? callee.name
: undefined;
if (name === undefined) return false;
let symbol = checker.getSymbolAtLocation(name);
if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
const decls = symbol === undefined ? [] : checker.declarationsOf(symbol);
if (decls.length !== 1) return false;
const decl = decls[0]!;
if (decl.getSourceFile().isDeclarationFile) return false;
let fn: ts.Node | undefined;
if (ts.isFunctionDeclaration(decl)) fn = decl;
else if (ts.isVariableDeclaration(decl) && decl.initializer !== undefined) {
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (ts.isFunctionExpression(init) || ts.isArrowFunction(init)) fn = init;
}
if (fn === undefined) return false;
const like = fn as ts.FunctionLikeDeclaration;
const body = like.body;
const param = like.parameters[index];
if (body === undefined || !ts.isBlock(body) || param === undefined) return false;
if (!ts.isIdentifier(param.name) || param.dotDotDotToken !== undefined) return false;
if (like.parameters.slice(0, index).some((p) => p.dotDotDotToken !== undefined)) return false;
const paramName = param.name.text;
let deferred = true;
ts.walkPreorder(body, (node) => {
if (!deferred) return "stop";
if (ts.isFunctionLike(node) || ts.isClassExpression(node) || ts.isClassDeclaration(node)) {
let child: ts.Node = node;
while (ts.isParenthesizedExpression(child.parent)) child = child.parent;
const returned = ts.isReturnStatement(child.parent) && child.parent.expression === child;
const uses = (part: ts.Node): boolean => {
let found = false;
ts.walkPreorder(part, (inner) => {
if (ts.isIdentifier(inner) && inner.text === paramName) found = true;
return found ? "stop" : undefined;
});
return found;
};
// A returned closure may use the parameter; any other nested
// function could escape and run before the result is called. A
// returned class still evaluates its heritage, computed keys,
// decorators and static initializers when the callee runs.
if (returned) {
if (
(ts.isClassExpression(node) || ts.isClassDeclaration(node)) &&
classSyncParts7(node).some(uses)
)
deferred = false;
return "skip";
}
if (uses(node)) deferred = false;
return "skip";
}
if (ts.isIdentifier(node) && (node.text === paramName || node.text === "arguments")) {
if (node !== param.name) deferred = false;
}
return undefined;
});
return deferred;
}
/** The parts of a class that run where the class is evaluated: heritage
* expressions, decorators, computed member keys, static field
* initializers and static blocks. Methods and instance fields wait for a
* call or a construction. */
function classSyncParts7(c: ts.ClassLikeDeclaration): ts.Node[] {
const decorators = (n: ts.Node): ts.Node[] =>
((n as { modifiers?: readonly ts.Node[] }).modifiers ?? []).filter(
(m) => m.kind === ts.SyntaxKind.Decorator,
);
const parts: ts.Node[] = [...decorators(c), ...(c.heritageClauses ?? [])];
for (const m of c.members) {
parts.push(...decorators(m));
const memberName = (m as { name?: ts.PropertyName }).name;
if (memberName !== undefined && ts.isComputedPropertyName(memberName)) parts.push(memberName);
if (ts.isClassStaticBlockDeclaration(m)) parts.push(m);
else if (
ts.isPropertyDeclaration(m) &&
m.initializer !== undefined &&
(ts.getCombinedModifierFlags(m) & ts.ModifierFlags.Static) !== 0
)
parts.push(m.initializer);
}
return parts;
}
/** An identifier or a chain of plain property reads. */
function memberChainKey7(e: ts.Expression): boolean {
while (ts.isPropertyAccessExpression(e)) e = e.expression;
return ts.isIdentifier(e);
}
/** The name a declaration node binds, if any. */
function declaredName7(node: ts.Node): ts.Node | undefined {
if (
ts.isVariableDeclaration(node) ||
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isClassDeclaration(node) ||
ts.isClassExpression(node) ||
ts.isBindingElement(node) ||
ts.isParameter(node)
)
return node.name;
return undefined;
}
/** A declared builtin whose callback arguments run only as later jobs or
* events: the timers, queueMicrotask, promise reactions, and Node APIs
* other than the ones that call back before returning. */
function laterJobCallee7(checker: ts.TypeChecker, callee: ts.Expression): boolean {
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
let name: ts.Identifier;
if (ts.isIdentifier(callee)) name = callee;
else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
name = callee.name;
} else return false;
if (SYNC_CALLBACK_HOST_APIS7.has(name.text)) return false;
let root: ts.Expression = callee;
while (ts.isPropertyAccessExpression(root)) root = root.expression;
if (ts.isIdentifier(root) && root.text === "assert") return false;
let symbol = checker.getSymbolAtLocation(name);
if (symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol);
}
if (symbol === undefined) return false;
const decls = checker.declarationsOf(symbol);
if (decls.length === 0 || !decls.every((d) => d.getSourceFile().isDeclarationFile)) return false;
if (LATER_JOB_BUILTINS7.has(name.text)) return true;
// The JavaScript standard library's callbacks mostly run synchronously;
// package declarations describe user code.
return decls.every((d) => {
const file = d.getSourceFile().fileName;
return isNodeTypesPath(file) || file === fallbackDtsPath();
});
}
/** Standard-library functions whose callbacks run as later jobs. */
const LATER_JOB_BUILTINS7 = new Set([
"setTimeout",
"setInterval",
"setImmediate",
"queueMicrotask",
"then",
"catch",
"finally",
]);
/** Host APIs that can invoke a callback argument before returning. */
const SYNC_CALLBACK_HOST_APIS7 = new Set([
"apply",
"bind",
"call",
"doesNotReject",
"doesNotThrow",
"emit",
"enterWith",
"exit",
"rejects",
"run",
"runInAsyncScope",
"throws",
]);
/** True for a reference whose value the lowering can take from the
* binding's checker type instead of its storage: a property key (element
* access argument, computed name, `in` operand, through parentheses,
* casts and template spans) or a `typeof` operand. */
function foldablePosition7(ref: ts.Identifier): boolean {
let child: ts.Node = ref;
for (let p: ts.Node | undefined = ref.parent; p !== undefined; p = p.parent) {
if (
ts.isParenthesizedExpression(p) ||
ts.isAsExpression(p) ||
ts.isSatisfiesExpression(p) ||
ts.isNonNullExpression(p) ||
ts.isTemplateSpan(p) ||
ts.isTemplateExpression(p)
) {
child = p;
continue;
}
if (ts.isTypeOfExpression(p)) return true;
if (ts.isElementAccessExpression(p)) return p.argumentExpression === child;
if (ts.isComputedPropertyName(p)) return true;
if (ts.isBinaryExpression(p))
return p.operatorToken.kind === ts.SyntaxKind.InKeyword && p.left === child;
return false;
}
return false;
}
/** The identifiers a binding name (identifier or destructuring pattern)
* declares. */
function boundNames7(name: ts.BindingName): ts.Identifier[] {
if (ts.isIdentifier(name)) return [name];
const out: ts.Identifier[] = [];
for (const el of name.elements) {
if (ts.isOmittedExpression(el) || el.name === undefined) continue;
out.push(...boundNames7(el.name));
}
return out;
}
/** A namespace declaration whose body holds only types (interfaces, type
* aliases, nested type-only namespaces) — no runtime object exists. */
function typeOnlyNamespace7(decl: ts.ModuleDeclaration): boolean {
let body = decl.body;
while (body !== undefined && ts.isModuleDeclaration(body)) body = body.body;
if (body === undefined || !ts.isModuleBlock(body)) return true;
return body.statements.every(
(s) =>
ts.isInterfaceDeclaration(s) ||
ts.isTypeAliasDeclaration(s) ||
(ts.isModuleDeclaration(s) && typeOnlyNamespace7(s)),
);
}
/** Resolves an import specifier from `from` to a source file of the
* program, or null (unresolvable / outside the program). Exported (as
* resolveImport) for the lowering: CommonJS require statements lower to
* guarded %init calls of exactly the module preflight resolved here. */
function resolveImport7(
program: ts.Program,
from: ts.SourceFile,
specifier: string,
resolutionKind: "import" | "require" = "import",
): ts.SourceFile | null {
const resolved = resolveProjectModule(from.fileName, specifier);
if (resolved !== null) return program.getSourceFile(resolved) ?? null;
const npm = resolveNpmImport7(from.fileName, specifier, resolutionKind);
return npm && isNpmStaticPackage(npm.packageName)
? npmStaticProgramDep(program, npm.packageName, npm.typesFile)
: null;
}
function resolveNpmImport7(
fromFileName: string,
specifier: string,
resolutionKind: "import" | "require" = "import",
): { packageName: string; version?: string; typesFile: string } | null {
if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) {
return null;
}
// --provenance-sources: a registered specifier is NOT an npm import —
// its attested source compiles as program modules (resolveProjectModule
if (provenanceEntryFor(specifier) !== null) return null;
const resolved = resolveBareModule(fromFileName, specifier, undefined, resolutionKind);
if (!resolved) return null;
if (!isNodeModulesPath(resolved.typesFile)) {
// A workspace-linked package (the node_modules entry is a symlink into
// the project — every monorepo tool's internal-package install): Node
// resolves and runs it exactly like any installed package, so it IS an
// npm import; the realpath'd answer escaping node_modules must not
// refuse the edge. Registered so path-keyed package attribution
// (workspace-registry.ts) recognizes the package's real files. Anything else
// whose answer left node_modules stays refused.
if (resolved.workspaceDir === undefined) return null;
registerWorkspacePackage(resolved.packageName, resolved.workspaceDir);
}
return {
packageName: resolved.packageName,
...(resolved.version !== undefined ? { version: resolved.version } : {}),
typesFile: resolved.typesFile,
};
}
function locOf7(node: ts.Node): { file: string; start: number; end: number } {
const sf = node.getSourceFile();
return { file: sf.fileName, start: node.getStart(sf), end: node.getEnd() };
}
/** The PROGRAM source file an opted-in --npm-static package's resolved
* entry maps to, or null with the package marked an offender (resolution
* still answering declarations, or the file missing from the type-checked
* program — a path-identity mismatch between the two resolvers). */
function npmStaticProgramDep(
program: ts.Program,
packageName: string,
resolvedFile: string,
): ts.SourceFile | null {
if (!isJsSourceFileName(resolvedFile)) {
reportNpmStaticOffender(
packageName,
`its import resolves to '${resolvedFile}', not to runtime JavaScript`,
);
return null;
}
let dep = program.getSourceFile(resolvedFile) ?? null;
if (dep === null) {
// A workspace-linked package shipping its .ts SOURCE beside the
// compiled .js: the checker's resolution preferred the source (it
// outranks the sibling .js under bundler resolution), so the program
// holds the .ts twin at the same stem — the same module, in its
// richer spelling.
for (const ext of [".ts", ".tsx", ".mts", ".cts"]) {
dep = program.getSourceFile(resolvedFile.replace(/\.(js|mjs|cjs)$/, ext)) ?? null;
if (dep !== null) break;
}
}
if (dep === null) {
reportNpmStaticOffender(
packageName,
`its entry '${resolvedFile}' did not join the type-checked program`,
);
return null;
}
return dep;
}
/** `const process = require('node:process')` (identifier binding) and the
* bare side-effect `require('node:process')`: Node's process module IS
* the global process object, so the binding is a stdlib-global alias
* (surfaces.ts registers it; reads lower through the process surface) and
* the bare load is a no-op. These forms are exempt from the SC1010
* builtin fence; every other shape (destructuring, subpath) keeps it. */
function processModuleAliasRequire7(spec: string, decl: ts.VariableDeclaration | null): boolean {
if (spec !== "process" && spec !== "node:process") return false;
return decl === null || ts.isIdentifier(decl.name);
}
/** `import process from 'node:process'`: the ESM spelling of the same
* global-object alias as processModuleAliasRequire7. Keep this exemption
* deliberately narrow: named and namespace imports need a represented
* module namespace, while the default binding is exactly globalThis.process. */
function processModuleAliasImport7(spec: string, stmt: ts.ImportDeclaration): boolean {
if (spec !== "process" && spec !== "node:process") return false;
const clause = stmt.importClause;
return clause !== undefined && clause.name !== undefined && clause.namedBindings === undefined;
}
/** The whole TS7-lane lifecycle for one entry: spawn (or share) a tsgo
* host, build the lowering-world program, run preflight, and release its
* snapshot. The caller owns the supplied host and can reuse or close it. */
export function checkPreflightTs7(
entryPath: string,
host: ts.Ts7Host,
): { diags: ScrDiagnostic[]; moduleOrder: string[] } {
const load = loadProgram7(host, resolve(entryPath));
try {
const { diags, moduleOrder } = preflight7(load);
return { diags, moduleOrder: moduleOrder.map((sf) => sf.fileName) };
} finally {
load.disposeAll();
}
}
function preflight7(load: LoadResult): {
diags: ScrDiagnostic[];
moduleOrder: ts.SourceFile[];
startupCrash: StartupCrash | null;
} {
const { program, entry } = load;
const diags: ScrDiagnostic[] = [...load.configDiags];
// An explicit entry can itself live in an installed package. Its own
// source files are program code, including relative barrels and sibling
// modules; their location must not erase the module graph.
// Match the full installed directory, not just the package name, and
// exclude nested installations so dependencies keep their npm policy.
const entryPackageFile = entryPackageFilePredicate(entry.fileName);
// Workspace-linked packages register BEFORE the tsc gate. Their files
// live at realpaths OUTSIDE node_modules (the monorepo-tool symlink
// shape), so nothing path-shaped marks them as npm surface — yet their
// shipped JS follows the same foreign-tsconfig rules as node_modules JS.
// The lazy
// registration inside the import-fence walk below runs AFTER errorsOf,
// too late for the gate, so one pass over the program's import edges
// registers them up front: every bare specifier any program file loads —
// import/export declarations, dynamic import("literal") (tsgo chases
// those into the program too; a CLI reaches its workspace sibling exactly
// that way), require("literal") — resolved with the own resolver,
// workspace answers recorded.
{
const supportedBuiltins = new Set<string>(SUPPORTED_NODE_MODULES);
const probed = new Set<string>();
const probe = (fromFile: string, spec: string): void => {
if (
isRelativeSpecifier(spec) ||
spec.startsWith("#") ||
spec.startsWith("node:") ||
supportedBuiltins.has(spec)
) {
return;
}
const key = `${dirname(fromFile)} ${spec}`;
if (probed.has(key)) return;
probed.add(key);
const r = resolveBareModule(fromFile, spec);
if (r !== null && r.workspaceDir !== undefined)
registerWorkspacePackage(r.packageName, r.workspaceDir);
};
for (const sf of program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile || sf.fileName.endsWith(".json")) continue;
for (const specifier of moduleSourceCandidates(program, sf).specifiers)
probe(sf.fileName, specifier);
}
}
const toPassthrough = (d: ts.Diagnostic): ScrDiagnostic => {
const message = ts.flattenDiagnosticMessageText(d, "\n");
// File-less (global/options) diagnostics anchor at the entry with a
// zero span, exactly like the 5.9.3 lane's `d.start ?? 0` fallback.
const file = d.fileName ?? entry.fileName;
const start = d.fileName !== undefined ? d.pos : 0;
const end = d.fileName !== undefined ? d.end : 0;
const diag = tscPassthroughDiag(message, { file, start, end });
const hint = typeWorldHint(d, message, load);
if (hint !== null) {
diag.typeWorld = true;
diag.hint = hint;
}
return diag;
};
/* TS7 checker change (not finding 5, same discipline): tsgo types a
* NAMESPACE import as the spec's non-callable module namespace object
* even when the module is declared with the callable `export =` shape —
* 5.9.3 bound the callable value directly, so `import * as a from
* "node:assert"; a(x)` typechecked there and draws TS2349 here. Those
* callable module objects are scriptc's OWN declared surface, and calls
* through the namespace binding are fenced per site by the lowering (the
* assert-fences fixture pins the wording), so tsgo's extra TS2349 on
* exactly those callees — an identifier that is a namespace-import
* binding of a supported builtin module — is suppressed to the 5.9.3
* verdict. Nothing else rides this: any other non-callable call keeps
* tsc's voice in both lanes. */
const builtinNamespaceNames = new Map<string, Set<string>>();
const namespaceCalleeSuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
if (d.code !== 2349 || d.fileName === undefined) return false;
let names = builtinNamespaceNames.get(d.fileName);
if (!names) {
names = new Set();
builtinNamespaceNames.set(d.fileName, names);
const sf = p.getSourceFile(d.fileName);
for (const stmt of sf?.statements ?? []) {
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (canonicalBuiltinModule(stmt.moduleSpecifier.text) === null) continue;
const bindings = stmt.importClause?.namedBindings;
if (bindings && ts.isNamespaceImport(bindings)) names.add(bindings.name.text);
}
}
if (names.size === 0) return false;
const sf = p.getSourceFile(d.fileName);
return sf !== undefined && names.has(sf.text.slice(d.pos, d.end));
};
const npmStaticFileSuppressed = (d: ts.Diagnostic): boolean =>
d.fileName !== undefined && npmStaticPackageOfPath(d.fileName) !== null;
const unselectedPackageJsFile = (file: string): boolean =>
isJsSourceFileName(file) &&
!entryPackageFile(file) &&
npmStaticPackageOfPath(file) === null &&
(isNodeModulesPath(file) || workspacePackageOfPath(file) !== null);
const nodeModulesJsSuppressed = (d: ts.Diagnostic): boolean =>
d.fileName !== undefined && unselectedPackageJsFile(d.fileName);
/* JSDoc TYPE positions in JS files are documentation Node never reads:
* a name-resolution failure THERE (2304/2552 — the pattern's utilities.js
* spells a mapped type over a @template name it never declared) types
* as the error-any and the per-site fences apply, same stance as the
* strictness families above. A 2300 duplicate-identifier PAIR formed by
* a @typedef and a same-named class/function is the same story — the
* typedef half sits inside the comment, and the code half is suppressed
* exactly when its partner (same file, same name) was comment-side, so
* a REAL duplicate declaration (both halves in code — Node's own
* SyntaxError) keeps tsc's voice. */
const insideBlockComment = (text: string, pos: number): boolean => {
const open = text.lastIndexOf("/*", pos);
if (open < 0) return false;
const close = text.indexOf("*/", open + 2);
return close === -1 || close >= pos;
};
const jsdocTypeSuppressed = (
p: ts.Program,
d: ts.Diagnostic,
commentDup: Set<string>,
): boolean => {
if (d.fileName === undefined || !isJsSourceFileName(d.fileName)) return false;
if (d.code !== 2304 && d.code !== 2552 && d.code !== 2300 && d.code !== 1003) return false;
if (d.pos === undefined) return false;
const sf = p.getSourceFile(d.fileName);
if (!sf) return false;
if (insideBlockComment(sf.text, d.pos)) return true;
return d.code === 2300 && commentDup.has(`${d.fileName}:${sf.text.slice(d.pos, d.end)}`);
};
const workspaceImplicitAnySuppressed = (p: ts.Program, d: ts.Diagnostic): boolean => {
if (d.code !== 7016 || d.fileName === undefined || d.pos === undefined || d.end === undefined)
return false;
const sf = p.getSourceFile(d.fileName);
if (!sf) return false;
const spec = sf.text.slice(d.pos, d.end).replace(/^['"]|['"]$/g, "");
const prefix = spec.startsWith("@")
? spec.split("/").slice(0, 2).join("/")
: spec.split("/")[0]!;
return isWorkspacePackageName(prefix);
};
const errorsOf = (p: ts.Program): ts.Diagnostic[] => {
const all = ts.getPreEmitDiagnostics(p);
// First pass: every comment-side 2300's (file, name) — the partners
// the second pass forgives.
const commentDup = new Set<string>();
for (const d of all) {
if (d.code !== 2300 || d.fileName === undefined || d.pos === undefined || d.end === undefined)
continue;
if (!isJsSourceFileName(d.fileName)) continue;
const sf = p.getSourceFile(d.fileName);
if (sf && insideBlockComment(sf.text, d.pos))
commentDup.add(`${d.fileName}:${sf.text.slice(d.pos, d.end)}`);
}
return all.filter(
(d) =>
d.category === ts.DiagnosticCategory.Error &&
!suppressedJsStrictness7(d) &&
!npmStaticFileSuppressed(d) &&
!nodeModulesJsSuppressed(d) &&
!namespaceCalleeSuppressed(p, d) &&
!inferredJsDiagnosticSuppressed(p, d) &&
!isNpmStaticSubclassArgument(p, d) &&
!workspaceImplicitAnySuppressed(p, d) &&
!jsdocTypeSuppressed(p, d, commentDup),
);
};
// The tsc gate, with a second chance: the LOWERING world's checker
// includes the divergence overrides (JSON.parse(): unknown, the Promise
// executor shape, ...), which are TIGHTER than the standard lib — a
// project that typechecks clean under its own tsc can still error here
// (any-typed JSON.parse results, most commonly). Those errors are OURS,
// not the project's, so they must not fail preflight: when the lowering
// world has errors, the PROJECT world (same program without the override
// declarations) is built and consulted. Clean there → preflight passes
// and the override-affected sites meet the lowerer's honest fences
// instead (the SC1100 checked-cast family). Dirty there → the project-
// world errors are the ones reported: they are reproducible with the
// project's own tsc, which is what "fix type errors first" asks for.
const tscErrors = errorsOf(program);
if (tscErrors.length > 0) {
for (const d of errorsOf(load.projectWorld())) diags.push(toPassthrough(d));
}
// Preflight and lowering share this CheckerFacade. Claim executable
// source files after workspace discovery and the tsc gate but before
// structural preflight checker queries, so a miss cannot trigger the old
// whole-file sweep (and eagerly fetch every dead body's answers). The
// structure wave includes declaration headers and top-level code;
// reachable bodies are added by lowering's worklists.
const ambient = ambientDtsPath();
const programFiles = program
.getImplementationSourceFiles()
.filter(
(sf) =>
sf.fileName !== ambient &&
!sf.isDeclarationFile &&
!sf.fileName.endsWith(".json") &&
(!isNodeModulesPath(sf.fileName) ||
entryPackageFile(sf.fileName) ||
npmStaticPackageOfPath(sf.fileName) !== null) &&
!unselectedPackageJsFile(sf.fileName),
);
const userFiles = npmStaticActive()
? planNpmStaticReexports(
program,
entry,
programFiles,
[
...createRequireProgramRoots7(program),
...forkTargetPaths(program, program.getImplementationSourceFiles()),
...workerTargetPaths(program, program.getImplementationSourceFiles()),
],
(sf, spec, resolutionKind) =>
resolveImport7(program, sf, spec, resolutionKind) ??
npmStaticDepSf7(program, sf, spec, resolutionKind),
)
: programFiles;
program.getTypeChecker().prefetchSourceFileStructures(userFiles);
// Unselected node_modules JS is not native program source even when
// maxNodeModuleJsDepth pulled it into the checker (nodeModulesJsSuppressed).
// Node stops at the nearest package.json even when it is malformed, and
// an explicit CommonJS scope (or .cjs/.cts extension) disables ambiguous-
// file syntax detection entirely. TypeScript's bundler checker models
// neither loader rule, so restore them before any module-edge analysis:
// one malformed-scope diagnostic per package file, and one pointed syntax
// diagnostic per CommonJS source file.
const invalidPackageConfigs = new Set<string>();
for (const sf of userFiles) {
const invalidConfig = nearestInvalidPackageJsonPath(sf.fileName);
if (invalidConfig !== null) {
if (!invalidPackageConfigs.has(invalidConfig)) {
invalidPackageConfigs.add(invalidConfig);
diags.push(
invalidPackageConfigDiag(invalidConfig, { file: sf.fileName, start: 0, end: 0 }),
);
}
continue;
}
if (!isNodeEsmFile7(sf, program)) {
const marker = nodeEsmSyntaxMarker7(sf);
if (marker !== null) diags.push(commonJsModuleSyntaxDiag(locOf7(marker)));
}
}
const ambientModules = new Set<string>(SUPPORTED_NODE_MODULES);
// Ambient `declare module "name"` declarations anywhere in the program —
// exact names and `*` patterns — so the import fence can say "type
// surface only, no runtime module" instead of the generic package story.
// Computed lazily: the scan only runs when a bare-import fence is about
// to fire.
let ambientModuleDecls: { names: Set<string>; patterns: string[] } | null = null;
const ambientDeclared = (spec: string): boolean => {
if (ambientModuleDecls === null) {
ambientModuleDecls = { names: new Set(), patterns: [] };
for (const f of program.getSourceFiles()) {
for (const s of f.statements) {
if (!ts.isModuleDeclaration(s) || !ts.isStringLiteral(s.name)) continue;
const name = s.name.text;
if (name.includes("*")) ambientModuleDecls.patterns.push(name);
else ambientModuleDecls.names.add(name);
}
}
}
if (ambientModuleDecls.names.has(spec)) return true;
return ambientModuleDecls.patterns.some((p) => {
const star = p.indexOf("*");
return (
spec.length >= p.length - 1 &&
spec.startsWith(p.slice(0, star)) &&
spec.endsWith(p.slice(star + 1))
);
});
};
// Module edges. An edge that closes a cycle is judged by the shared
// admission engine (makeCycleAdmission): admissible when nothing can
// OBSERVE the partially-initialized module through it — the importing
// statement binds nothing (side-effect import) or binds only NAMESPACE
// objects (initialized at link, never TDZ) whose every use is a bare
// expression statement (the no-op lowering; no member is ever read),
// or the cycle passes the declaration-only init window analysis (the
// benign-cycle admission block above). Node evaluates such cycles
// benignly (the revisited module is a cache hit, and no binding read
// can hit TDZ or a stale slot), so the guarded %init calls reproduce
// its order exactly. Cycles that fail both are judged by the exact
// initialization rule: admitted when every early read is modeled by an
// initialization check, fenced with SC1016 otherwise.
const edges = new Map<ts.SourceFile, CycleEdge[]>();
// Import edges Node's RESOLUTION refuses before any module evaluates —
// recorded in source order per file (interleaved with the resolved deps
// via `pos`) so the Node-order walk below can answer which refusal Node
// reports first. Each carries a fallback fence: a refusal in a module
// the entry never reaches contributes no startup crash (Node never
// resolves it), so it keeps today's compile-time diagnostic instead.
interface RefusalCandidate {
crash: StartupCrash;
fallback: ScrDiagnostic;
pos: number;
}
const refusals = new Map<ts.SourceFile, RefusalCandidate[]>();
const depPositions = new Map<ts.SourceFile, { dep: ts.SourceFile; pos: number }[]>();
for (const sf of userFiles) {
const deps: CycleEdge[] = [];
edges.set(sf, deps);
refusals.set(sf, []);
depPositions.set(sf, []);
for (const stmt of sf.statements) {
if (ts.isImportEqualsDeclaration(stmt)) {
// The ENTITY form (`import a = A`, `import x = N.y`) is namespace
// alias plumbing — the lowerer resolves references through it and
// fences what it cannot honor (lower-namespaces.ts). Only the
// require form is a module edge, and its RUNTIME form stays fenced
// (under the forced ESNext module world tsc itself rejects it, so
// only nested/namespace-internal sites can reach the lowering's
// fence); `import type x = require(...)` is pure type surface and
// lowers to nothing.
if (ts.isExternalModuleReference(stmt.moduleReference) && !stmt.isTypeOnly) {
const spec = stmt.moduleReference.expression;
if (
spec !== undefined &&
ts.isStringLiteralLike(spec) &&
load.externalTypes.has(spec.text)
) {
diags.push(externalHostModuleDiag7(spec.text, stmt));
} else {
diags.push(
unsupportedDiag("SC1013", locOf7(stmt), "import = require(...) assignments"),
);
}
}
continue;
}
if (ts.isExportAssignment(stmt)) {
if (stmt.isExportEquals) {
diags.push(unsupportedDiag("SC1012", locOf7(stmt), "export = assignments"));
}
continue;
}
if (ts.isExportDeclaration(stmt)) {
if (stmt.isTypeOnly || erasedTypeOnlyReexport(stmt) || isPrunedNpmReexport(program, stmt))
continue;
if (!stmt.moduleSpecifier) continue;
const fromSpec = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : "";
if (load.externalTypes.has(fromSpec)) {
diags.push(externalHostModuleDiag7(fromSpec, stmt));
continue;
}
const projectReexport = resolveImport7(program, sf, fromSpec);
if (!isRelativeSpecifier(fromSpec) && projectReexport === null) {
// NAMED re-exports from a SUPPORTED builtin pass (`export { ok }
// from "node:assert"` — a universal re-export facade facade): the
// statement binds nothing locally and evaluates nothing (builtins
// have no %init), and importers' references chase the alias to
// the builtin's own declarations exactly as if they imported the
// builtin directly — per-member support stays the lowering's
// per-site business. Star and namespace re-exports and package
// specifiers keep the fence.
if (
canonicalBuiltinModule(fromSpec) !== null &&
stmt.exportClause !== undefined &&
ts.isNamedExports(stmt.exportClause)
) {
continue;
}
// NAMED re-exports from an INSTALLED npm package (`export
// { isUrl } from "url-or-path"` — the pattern's universal facade,
// `export { visitorKeys as default } from "@glimmer/syntax"`):
// import-plus-export plumbing. The lowering owns the package
// value diagnostic when no compiled source can supply the binding.
if (
stmt.exportClause !== undefined &&
ts.isNamedExports(stmt.exportClause) &&
!fromSpec.startsWith("#") &&
resolveNpmImport7(sf.fileName, fromSpec) !== null
) {
continue;
}
diags.push(
unsupportedDiag("SC1014", locOf7(stmt), "re-exports from packages or builtin modules"),
);
continue;
}
const reDep = projectReexport;
// `export * as ns from "./m"` re-exports the module NAMESPACE
// object under a name: importers' `x.ns.member` reads resolve
// statically through the same alias machinery as `import * as ns`
// (moduleNsSymbolOf follows the chain to the module symbol), so a
// RESOLVED user module lowers; everything else keeps the fence —
// there is no namespace object to materialize.
if (stmt.exportClause && ts.isNamespaceExport(stmt.exportClause)) {
if (reDep === null || reDep.fileName.endsWith(".json") || isCjsJsFile7(reDep, program)) {
diags.push(
unsupportedDiag(
"SC1013",
locOf7(stmt),
"namespace re-exports (export * as ns) of this module form",
),
);
continue;
}
}
if (reDep && !reDep.fileName.endsWith(".json")) {
deps.push({ dep: reDep, stmt });
depPositions.get(sf)!.push({ dep: reDep, pos: stmt.getStart(sf) });
}
continue;
}
if (!ts.isImportDeclaration(stmt)) continue;
if (erasedTypeOnlyImport(stmt)) continue;
// Import-like nodes whose specifier the parse could not shape into a
// string literal — the jsdoc `/** @import x = require(...) */`
// materialization and `import defer type * as ns` both arrive here
// with no specifier text. Neither is a runtime module edge (both are
// type-only forms), and where the form is malformed TS the SC0001
// passthrough above already names it — never a crash here.
const specNode: ts.Expression | undefined = stmt.moduleSpecifier;
if (specNode === undefined || !ts.isStringLiteral(specNode)) continue;
const spec = specNode.text;
if (load.externalTypes.has(spec)) {
diags.push(externalHostModuleDiag7(spec, stmt));
continue;
}
const isRelative = isRelativeSpecifier(spec);
const isBare = !isRelative && !ambientModules.has(spec);
// Npm packages can use the same static createRequire forms as program
// code. Preserve fallback for bundler banners that pass the require
// value around or feed it computed specifiers during initialization.
if (canonicalBuiltinModule(spec) === "module") {
const pkg = npmStaticPackageOfPath(sf.fileName);
if (pkg !== null) {
const reason = npmStaticModuleImportReason7(program, stmt);
if (reason !== null) reportNpmStaticOffender(pkg, reason);
}
}
// An import edge Node's own resolution refuses BEFORE any module
// evaluates: recorded as a startup-crash candidate (the Node-order
// walk below picks the first one Node would report; the program
// compiles to exactly that crash — buildMain) with a compile-fence
// fallback for candidates the entry never reaches. The bindings of a
// refused edge never link, so every clause fence below is skipped —
// their uses meet the lowering's own fences if the code demands
// values from them.
const refuse = (
message: string,
className: StartupCrash["className"],
fallbackFeature: string,
): void => {
refusals.get(sf)!.push({
crash: { message, className, loc: locOf7(stmt) },
fallback: unsupportedDiag("SC1010", locOf7(stmt), fallbackFeature),
pos: stmt.getStart(sf),
});
};
// Project modules win before npm fallback: this is the source graph
// tsgo type-checked for tsconfig paths, package imports/self-references,
// and provenance entries. Ordinary packages still answer null here.
const projDep = isBare ? resolveImport7(program, sf, spec) : null;
// "#" specifiers can never name an npm package — they are the
// imports-field family, resolved by the project arm above.
const npm =
isBare && projDep === null && !spec.startsWith("#")
? resolveNpmImport7(sf.fileName, spec)
: null;
if (npm && isNodeTypesPath(npm.typesFile)) {
diags.push(unsupportedDiag("SC1010", locOf7(stmt), unsupportedModuleFeatureOf(spec)));
continue;
}
// --npm-static: an opted-in package's import is a PROGRAM-MODULE
// edge — its resolution (types stripped) answered the shipped JS,
// which allowJs + maxNodeModuleJsDepth pulled into the program, so
// the entry joins the module order like any user module and the
// clause fences below apply to it as to program JS. A miss (the
// resolution still answers declarations, or the file never joined
// the program) marks the package an OFFENDER: the frontend's
// selection loop rebuilds without it and reports the package refusal.
let npmStaticDep: ts.SourceFile | null = null;
if (npm !== null) {
if (isNpmStaticPackage(npm.packageName)) {
npmStaticDep = npmStaticProgramDep(program, npm.packageName, npm.typesFile);
if (npmStaticDep === null) continue;
} else {
// Types resolved, but Node's RUNTIME resolution can still refuse
// the edge (a types-only package whose exports target ships no JS,
// most commonly): that program is Node's startup crash, not an
const refusal = probeNodeImportRefusal(sf.fileName, spec);
if (refusal !== null) {
refuse(
refusal.message,
"%Error",
`the '${spec}' package (its runtime resolution fails: ${refusal.message})`,
);
}
continue;
}
}
// PROJECT imports: tsconfig paths and package.json-mediated specifiers
// that resolve to the program's own sources. A source answer is an
// ordinary user-module edge, exactly like a relative import (the
// checker resolved the bindings the same way); a refused resolution is
// Node's startup crash with Node's exact message; what keeps a compile
// fence is unsupported builtin or types-only surface.
if (isBare && npmStaticDep === null) {
if (projDep !== null && projDep.isDeclarationFile) {
diags.push(
unsupportedDiag(
"SC1010",
locOf7(stmt),
`the '${spec}' import (it resolves only to type declarations — there is no runtime implementation to compile)`,
),
);
continue;
}
if (projDep === null) {
// node:process's default export is the global process object. It
// contributes no module-order edge; the imported binding lowers
// through stdlibGlobalNameOf exactly like the bare global.
if (processModuleAliasImport7(spec, stmt)) continue;
const nodeBuiltin = spec.startsWith("node:") || nodeBuiltinNames.has(spec);
if (nodeBuiltin) {
diags.push(unsupportedDiag("SC1010", locOf7(stmt), unsupportedModuleFeatureOf(spec)));
continue;
}
if (spec === "#") {
refuse(
`Invalid module "#" is not a valid internal imports specifier name imported from ${sf.fileName}`,
"%TypeError",
`the '#' import (Node rejects the bare '#' specifier as invalid)`,
);
continue;
}
if (spec.startsWith("#")) {
const target = packageImportTarget(sf.fileName, spec);
if (target !== null && !target.startsWith("./") && !target.startsWith("../")) {
diags.push(
unsupportedDiag(
"SC1010",
locOf7(stmt),
`package.json imports entries that map to other packages (the '${spec}' import maps to '${target}')`,
),
);
continue;
}
const pkgJson = nearestPkgJsonPath(sf.fileName);
refuse(
`Package import specifier "${spec}" is not defined${pkgJson !== null ? ` in package ${pkgJson}` : ""} imported from ${sf.fileName}`,
"%TypeError",
`the '${spec}' import (no package.json imports-field entry maps it)`,
);
continue;
}
const refusal = probeNodeImportRefusal(sf.fileName, spec);
if (refusal !== null) {
const ambientNote = ambientDeclared(spec)
? `the '${spec}' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import)`
: `the '${spec}' package (nothing installed resolves it)`;
refuse(refusal.message, "%Error", ambientNote);
continue;
}
// Runtime-resolvable (or a shape the probe stays conservative
// about) with no compilable types answer: scriptc's fence.
diags.push(
unsupportedDiag(
"SC1010",
locOf7(stmt),
ambientDeclared(spec)
? `the '${spec}' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import)`
: `the '${spec}' package (nothing installed resolves it)`,
),
);
continue;
}
}
const clause = stmt.importClause;
const dep = isRelative ? resolveImport7(program, sf, spec) : (npmStaticDep ?? projDep);
const isJson = dep !== null && dep.fileName.endsWith(".json");
// SELF-imports: the bindings alias this module's OWN declarations,
// so a TOP-LEVEL read lexically above the aliased declaration is
// Node's TDZ ReferenceError at runtime (exactly what tsc's TS2448
// refuses for the DIRECT spelling — imports are exempt from that
// check, so the fence restores the same bar). Hoisted function
// aliases initialize at link and read fine anywhere; reads inside
// function bodies keep the direct spelling's existing policy.
if (dep === sf && clause !== undefined) {
selfImportTdzFences7(program, sf, clause, diags);
}
if (clause) {
// Default imports of SUPPORTED builtins pass where the spelling is
// legal: Node's default export of a CJS builtin IS the module
// object, so the binding exposes exactly the namespace-import
// surface and the lowering keys the same tables
// (builtinNamespaceModuleOf's default-import twin). JS sources
// always (Node never asks for interop flags); TS sources when the
// project's explicit or implied synthetic-default permission accepts
// the spelling. A TS project without that permission keeps the
// SC1012 fence. The callable module objects (assert, events, test)
// stay allowed everywhere. The process default is the global process
// alias, including when combined with named builtin imports.
const syntheticDefaultsOn =
(program.getCompilerOptions() as { allowSyntheticDefaultImports?: boolean })
.allowSyntheticDefaultImports === true;
const defaultOk =
spec === "process" ||
spec === "node:process" ||
builtinDefaultImportModule(spec) !== null ||
((isJsSourceFileName(sf.fileName) || syntheticDefaultsOn) &&
canonicalBuiltinModule(spec) !== null);
if (clause.name && !isJson && dep === null && !defaultOk) {
diags.push(unsupportedDiag("SC1012", locOf7(clause.name)));
}
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings) && isJson) {
diags.push(
unsupportedDiag(
"SC1012",
locOf7(clause.namedBindings),
"named imports from JSON modules",
),
);
}
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
// `import * as ns from "./m"` of a RESOLVED user module lowers:
// ns.member accesses resolve statically through the checker to
// the exporter's own registrations (lower-namespaces.ts's
// module-namespace paths); the ns OBJECT as a first-class value
// fences per site. What keeps a fence here: JSON modules (their
// namespace object has no static story — default imports bake
// the data), CommonJS JS modules (Node builds their namespace
// from its lexer's static analysis, a different surface),
// and unresolved specifiers. Supported builtins pass through as
// before (their namespace members are the declared surface).
if (isJson) {
diags.push(
unsupportedDiag(
"SC1013",
locOf7(clause.namedBindings),
"namespace imports of JSON modules",
),
);
} else if (dep !== null && isCjsJsFile7(dep, program)) {
// A CJS namespace binding whose every use is a bare expression
// statement (`cjs;` — the corpus's "the import linked"
// assertion) carries no surface question: the statement lowers
// to Node's no-op, the module still evaluates through its
// ordinary init edge, and no member is ever read through the
// lexer facade (self-imports of a CJS module included — the
// partially-built exports are never observed). Any OTHER use
// keeps the fence.
if (!nsBindingUsesAreBareStatements7(program, sf, clause.namedBindings.name)) {
diags.push(
unsupportedDiag(
"SC1013",
locOf7(clause.namedBindings),
"namespace imports of CommonJS modules",
),
);
}
} else if (dep === null && !(!isRelative && ambientModules.has(spec))) {
diags.push(unsupportedDiag("SC1013", locOf7(clause.namedBindings)));
}
}
}
if (dep && !isJson) {
deps.push({ dep, stmt });
depPositions.get(sf)!.push({ dep, pos: stmt.getStart(sf) });
}
}
{
const jsRequireFile = isJsSourceFileName(sf.fileName);
const stmts = sf.statements;
let firstRunnable = -1;
stmts.forEach((s, i) => {
if (firstRunnable < 0 && !purePrefixStmt7(program, s)) firstRunnable = i;
});
for (let k = 0; k < stmts.length; k++) {
const stmt = stmts[k]!;
for (const req of requiresOf7(stmt)) {
const loc = { file: sf.fileName, start: req.node.getStart(sf), end: req.node.getEnd() };
const bindingKind = requireCallBindingKind7(program, req.node);
// TypeScript source files join this scan only through the
// canonical createRequire bridge. Their ordinary require-shaped
// calls retain the existing checker/lowering diagnostics; JS
// files keep the full CommonJS scan.
if (!jsRequireFile && bindingKind !== "createRequire") continue;
if (isNodeEsmFile7(sf, program)) {
if (bindingKind !== "createRequire") {
diags.push(unsupportedDiag("SC1013", loc, esmRequireFeature7(bindingKind)));
continue;
}
}
if (load.externalTypes.has(req.spec)) {
continue;
}
if (req.decl && ts.isArrayBindingPattern(req.decl.name)) {
diags.push(unsupportedDiag("SC1012", loc, "array-destructuring require() bindings"));
continue;
}
if (req.decl && ts.isObjectBindingPattern(req.decl.name)) {
const bad = req.decl.name.elements.find(
(e) =>
e.name === undefined ||
!ts.isIdentifier(e.name) ||
e.initializer !== undefined ||
e.dotDotDotToken !== undefined,
);
if (bad) {
diags.push(
unsupportedDiag(
"SC1012",
loc,
"require() destructuring with defaults, rest, or nested patterns",
),
);
continue;
}
}
const isRelative = isRelativeSpecifier(req.spec);
let dep = resolveImport7(program, sf, req.spec, "require");
if (dep === null && !isRelative) {
// --npm-static: a require() of an OPTED-IN package is a
// program-module edge exactly like the import-declaration
// form above (bundle dists require their workspace siblings —
// the same resolution, the same offender discipline on a
// miss).
const npmReq = !req.spec.startsWith("#")
? resolveNpmImport7(sf.fileName, req.spec, "require")
: null;
if (npmReq !== null && isNpmStaticPackage(npmReq.packageName)) {
dep = npmStaticProgramDep(program, npmReq.packageName, npmReq.typesFile);
if (dep === null) continue; // offender recorded — the fallback loop reloads
} else {
// createRequire's non-program targets belong to its own
// lowering, where missing packages become catchable
// MODULE_NOT_FOUND throws. Only resolved program targets are
// module-order edges in this preflight walk.
if (bindingKind === "createRequire") continue;
if (
canonicalBuiltinModule(req.spec) === null &&
!processModuleAliasRequire7(req.spec, req.decl)
) {
// A binding-less require of a package NOTHING resolves:
// Node throws MODULE_NOT_FOUND at the require site — the
// lowering compiles exactly that catchable throw (the
// optional-dependency try/require pattern), so no fence.
// Binding forms keep the fence: their downstream reads
// would need the module that never loads.
if (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) {
diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(req.spec)));
}
}
continue;
}
}
if (dep && dep.fileName.endsWith(".json")) {
if (bindingKind === "createRequire") continue;
diags.push(unsupportedDiag("SC1012", loc, "require() of JSON modules"));
continue;
}
// Earlier declarators run too: `const x = read(), dep =
// require('./dep')` must not lose the guard merely because both
// initializers share one VariableStatement.
const precedingDecls =
req.decl && ts.isVariableStatement(stmt)
? stmt.declarationList.declarations.slice(
0,
stmt.declarationList.declarations.indexOf(req.decl),
)
: [];
const prefixCanRun =
(firstRunnable >= 0 && firstRunnable < k) ||
precedingDecls.some((decl) => !purePrefixDecl7(decl));
const tdzName =
prefixCanRun && req.decl
? bindingEarlyUse7(program, sf, k, req.decl, precedingDecls)
: null;
if (tdzName !== null) {
diags.push(
unsupportedDiag(
"SC1013",
loc,
`require() below code that can reach its binding '${tdzName}' (Node initializes the binding AT the require and throws ReferenceError on any earlier access, which is not modeled — move the require above the code that can run first)`,
),
);
continue;
}
if (dep) deps.push({ dep });
}
}
const nestedBareRequires = nestedBareRequiresOf7(sf);
program
.getTypeChecker()
.prefetchSymbolNodesExact(
nestedBareRequires.flatMap((call) =>
ts.isIdentifier(call.expression) ? [call.expression] : [],
),
);
for (const call of nestedBareRequires) {
const spec = requireSpecOf7(call)!;
const loc = { file: sf.fileName, start: call.getStart(sf), end: call.getEnd() };
const bindingKind = requireCallBindingKind7(program, call);
if (!jsRequireFile && bindingKind !== "createRequire") continue;
if (isNodeEsmFile7(sf, program)) {
if (bindingKind !== "createRequire") {
diags.push(unsupportedDiag("SC1013", loc, esmRequireFeature7(bindingKind)));
continue;
}
}
if (load.externalTypes.has(spec)) {
continue;
}
const projectDep = resolveImport7(program, sf, spec, "require");
if (projectDep !== null) {
if (projectDep.fileName.endsWith(".json")) {
if (bindingKind === "createRequire") continue;
diags.push(unsupportedDiag("SC1012", loc, "require() of JSON modules"));
continue;
}
deps.push({ dep: projectDep });
continue;
}
if (!isRelativeSpecifier(spec)) {
// --npm-static: opted-in packages ride the program-module edge
// (the statement-level require branch above).
const npmReq = !spec.startsWith("#")
? resolveNpmImport7(sf.fileName, spec, "require")
: null;
if (npmReq !== null && isNpmStaticPackage(npmReq.packageName)) {
const nDep = npmStaticProgramDep(program, npmReq.packageName, npmReq.typesFile);
if (nDep !== null) deps.push({ dep: nDep });
continue;
}
if (bindingKind === "createRequire") continue;
if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) {
// Binding-less by construction — same require-site throw
// channel as the statement-level form above.
if (probeNodeRequireRefusal(sf.fileName, spec) === null) {
diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec)));
}
}
continue;
}
}
}
}
// Node's evaluation order: depth-first postorder from the entry, each
// module once, back-edges answered from the cache (a module already
// evaluating is skipped) — which the guarded %init calls reproduce
// exactly. Cycles are admitted where Node's partially-initialized
// semantics are UNOBSERVABLE or exactly modeled (JS's partial
// initialization otherwise misbehaves without a diagnostic, so SC1016
// stays for the rest):
// - a DIRECT SELF-IMPORT (the package self-name reference resolving to
// the importing module itself — Node's self-reference rule): the
// imported bindings alias the module's OWN top-level bindings with
// identical timing, evaluation happens once — always benign;
// - a back edge the shared admission engine (makeCycleAdmission)
// clears: the cheap per-edge rule (nothing binds, or only namespace
// objects bound to bare-statement uses — no read can hit TDZ or a
// stale slot) or the DECLARATION-ONLY INIT WINDOW analysis (the
// benign-cycle admission block: every module of the cycle's
// strongly-connected component has an inert top level and the
// closing edge's bindings are used only in deferred positions, so no
// read can execute before every member initialized);
// - the EXACT INITIALIZATION rule: an ES-module cluster whose early
// reads are all let/const bindings with module storage — the
// lowering checks each one (cycleEarlyBindings) and throws Node's
// ReferenceError on a read before the declaration ran.
const cycleAdmissionReason = makeCycleAdmission(program, (sf) => edges.get(sf) ?? []);
const order: ts.SourceFile[] = [];
const state = new Map<ts.SourceFile, "visiting" | "done">();
const stack: string[] = [];
const visit = (sf: ts.SourceFile): void => {
state.set(sf, "visiting");
stack.push(sf.fileName);
for (const e of edges.get(sf) ?? []) {
if (e.dep === sf) continue; // self-import: a Node cache hit, not a cycle
const s = state.get(e.dep);
if (s === "done") continue;
if (s === "visiting") {
// A back-edge: Node answers it from the cache (the module is
// already evaluating) — admitted by the cheap per-edge rule, the
// declaration-only init window rule, or the exact initialization
// rule.
const reason = cycleAdmissionReason(sf, e);
if (reason !== null) {
const cycleStart = stack.indexOf(e.dep.fileName);
const cycle = [...stack.slice(cycleStart), e.dep.fileName].join(" → ");
diags.push(
unsupportedDiag(
"SC1016",
{ file: e.dep.fileName, start: 0, end: 0 },
`circular imports (${cycle}; ${reason})`,
),
);
}
continue;
}
visit(e.dep);
}
stack.pop();
state.set(sf, "done");
order.push(sf);
};
visit(entry);
// The startup crash Node's RESOLUTION phase reports: modules resolve
// their request specifiers in source order, children linking depth-first
// as each resolves (ModuleJob.syncLink) — a preorder walk over the
// resolved edges, statement-interleaved, so the first refusal found here
// is the one Node reports. Refusal candidates in modules the entry never
// reaches keep their compile-fence fallback instead (Node never resolves
// them). A found resolution crash wins over the CJS link check below:
// Node's fetch/resolve phase runs before instantiate.
let resolveCrash: StartupCrash | null = null;
{
const seen = new Set<ts.SourceFile>();
const walk = (sf: ts.SourceFile): StartupCrash | null => {
if (seen.has(sf)) return null;
seen.add(sf);
const steps: { pos: number; dep?: ts.SourceFile; crash?: StartupCrash }[] = [
...(depPositions.get(sf) ?? []),
...(refusals.get(sf) ?? []).map((r) => ({ pos: r.pos, crash: r.crash })),
].sort((a, b) => a.pos - b.pos);
for (const step of steps) {
if (step.crash !== undefined) return step.crash;
if (step.dep !== undefined && step.dep !== sf) {
const hit = walk(step.dep);
if (hit !== null) return hit;
}
}
return null;
};
resolveCrash = walk(entry);
// The crash channel engages only for programs that OTHERWISE pass
// preflight: when any other diagnostic already fails the build, every
// refusal keeps its compile-fence fallback instead — the report names
// each import's story, so edges whose crash will never compile still
// get a diagnostic. (No reachable refusal → same: fallbacks only.)
if (resolveCrash === null || diags.length > 0) {
resolveCrash = null;
for (const cands of refusals.values()) {
for (const c of cands) diags.push(c.fallback);
}
}
}
const linkCrash =
resolveCrash !== null
? null
: earlierLinkCrash(
order,
cjsNamedImportLinkCheck(program, entry, order, diags),
esmNamedImportLinkCheck(program, entry, order, diags),
);
return { diags, moduleOrder: order, startupCrash: resolveCrash ?? linkCrash };
}
/* ── the CJS named-import link check ─────────────────────────────────────
* Node builds a CommonJS module's ESM facade from its LEXER (cjs-lexer.ts
* mirrors it), never from execution — so a named import the checker binds
* happily can still fail Node's instantiate phase with a SyntaxError,
* thrown before ANY module in the graph evaluates. The checker's word is
* NOT Node's word exactly here, and shipping the checker's answer would
* diverge from Node without a diagnostic (`module.exports = { a: 7 }` served `a` where
* Node refuses the program). This walk asks Node's question at compile
* time, in Node's exact ORDER, and answers with Node's exact message. */
interface BadCjsImport {
/** The export name requested from the CJS module. */
exportName: string;
/** The import specifier as written. */
spec: string;
/** The failing name token (`a` in `import { a }` / `export { a } from`,
* the PROPERTY name in the aliased forms). */
nameNode: ts.Node;
sf: ts.SourceFile;
}
/** Walks the entry's ESM instantiate graph exactly as V8 does — children
* in request order, each module's own imports checked AFTER its children
* (postorder), regular imports sorted by LOCAL name (code units), then
* `export ... from` re-exports in source order — and answers the first
* named import of a lexer-INVISIBLE CommonJS export as Node's link-time
* SyntaxError (the caller compiles the program to that startup crash).
* ESM modules OUTSIDE the instantiate graph (reached only through
* require() of an ES module from CommonJS) instantiate mid-evaluation in
* Node, where the SyntaxError interleaves with output already produced —
* not modeled: those get a pointed fence instead. */
function cjsNamedImportLinkCheck(
program: ts.Program,
entry: ts.SourceFile,
moduleOrder: ts.SourceFile[],
diags: ScrDiagnostic[],
): StartupCrash | null {
const lexMemo = new Map<ts.SourceFile, Set<string>>();
// Project specifiers resolve as ever; aliases and package.json-mediated
// edges (#alias/self-name) join relative imports so
// a named import THROUGH one of a CommonJS module keeps Node's lexer
// check. Builtin/npm targets contribute nothing here — except opted-in
// --npm-static packages, whose CJS entries face Node's lexer exactly
// like program CJS files (their JS IS the program now).
const resolveEdge = (from: ts.SourceFile, spec: string): ts.SourceFile | null => {
const project = resolveImport7(program, from, spec);
if (project !== null) return project;
const npmStatic = npmStaticDepSf7(program, from, spec);
return npmStatic;
};
// Reexport targets union in only when they resolve to CommonJS program
// files (Node's cjsPreparseModuleExports rule).
const resolveCjsDep = (from: ts.SourceFile, spec: string): ts.SourceFile | null => {
const dep =
resolveImport7(program, from, spec, "require") ??
npmStaticDepSf7(program, from, spec, "require");
return dep !== null && isCjsJsFile7(dep, program) ? dep : null;
};
const visible = (dep: ts.SourceFile, name: string): boolean =>
// `default` is the module.exports binding itself — always provided.
// The program already owns native source ASTs; reuse them without
// starting another syntax session or reparsing their source text.
name === "default" ||
cjsVisibleNames(dep, cjsLexedExportsOfFile, resolveCjsDep, lexMemo).has(name);
/** The statement's resolved LOCAL CommonJS dependency, when it is an
* import/re-export from one. */
const cjsDepOf = (sf: ts.SourceFile, spec: string): ts.SourceFile | null => {
const dep = resolveEdge(sf, spec);
return dep !== null && isCjsJsFile7(dep, program) ? dep : null;
};
/** The first lexer-invisible CJS name request of ONE module, in V8's
* check order: all regular named imports sorted by local binding name,
* then `export { x } from` elements in source order. */
const firstInvisibleOf = (sf: ts.SourceFile): BadCjsImport | null => {
const entries: {
local: string;
exportName: string;
nameNode: ts.Node;
spec: string;
dep: ts.SourceFile;
}[] = [];
for (const stmt of sf.statements) {
if (
!ts.isImportDeclaration(stmt) ||
stmt.moduleSpecifier === undefined ||
!ts.isStringLiteral(stmt.moduleSpecifier)
)
continue;
const clause = stmt.importClause;
if (!clause || clause.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
if (!clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) continue;
const spec = stmt.moduleSpecifier.text;
const dep = cjsDepOf(sf, spec);
if (dep === null) continue;
for (const e of clause.namedBindings.elements) {
if (e.isTypeOnly) continue;
const nameNode = e.propertyName ?? e.name;
entries.push({ local: e.name.text, exportName: nameNode.text, nameNode, spec, dep });
}
}
entries.sort((a, b) => (a.local < b.local ? -1 : a.local > b.local ? 1 : 0));
for (const e of entries) {
if (!visible(e.dep, e.exportName))
return { exportName: e.exportName, spec: e.spec, nameNode: e.nameNode, sf };
}
for (const stmt of sf.statements) {
if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly) continue;
if (stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (!stmt.exportClause || !ts.isNamedExports(stmt.exportClause)) continue;
const spec = stmt.moduleSpecifier.text;
const dep = cjsDepOf(sf, spec);
if (dep === null) continue;
for (const e of stmt.exportClause.elements) {
if (e.isTypeOnly) continue;
const nameNode = e.propertyName ?? e.name;
if (!visible(dep, nameNode.text)) return { exportName: nameNode.text, spec, nameNode, sf };
}
}
return null;
};
// The instantiate graph: from an ESM entry, every import/re-export
// request in source order, recursing into ESM dependencies only (CJS
// and JSON modules are leaves — their own requires never join V8's
// linking). A CommonJS ENTRY has no instantiate phase at all.
const visited = new Set<ts.SourceFile>();
const dfs = (sf: ts.SourceFile): BadCjsImport | null => {
if (visited.has(sf)) return null;
visited.add(sf);
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) && !(ts.isExportDeclaration(stmt) && !stmt.isTypeOnly))
continue;
if (ts.isExportDeclaration(stmt) && isPrunedNpmReexport(program, stmt)) continue;
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword
)
continue;
const specNode = stmt.moduleSpecifier;
if (specNode === undefined || !ts.isStringLiteral(specNode)) continue;
const spec = specNode.text;
const dep = resolveEdge(sf, spec);
if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep, program)) continue;
const bad = dfs(dep);
if (bad !== null) return bad;
}
return firstInvisibleOf(sf);
};
const bad = isNodeEsmFile7(entry, program) ? dfs(entry) : null;
// ESM modules only require() reaches: same invisibility, but Node's
// SyntaxError fires mid-evaluation at the require — a pointed fence
// instead of a mismodeled crash position.
for (const sf of moduleOrder) {
if (!isNodeEsmFile7(sf, program) || visited.has(sf)) continue;
const b = firstInvisibleOf(sf);
if (b !== null) {
diags.push(
unsupportedDiag(
"SC1013",
locOf7(b.nameNode),
`a named import of a CommonJS export Node's lexer cannot detect, reached through require() of an ES module (Node throws its SyntaxError mid-evaluation at that require; import the CommonJS module's default and read members off it instead)`,
),
);
}
}
if (bad === null) return null;
// Node's message: V8's generic missing-export SyntaxError, rewritten to
// the CommonJS hint form only when the failing SPECIFIER STRING is also
// one of the ENTRY module's own requests and the entry's resolution of
// it is CommonJS (module_job.js consults the ROOT job's moduleRequests/
// commonJsDeps — a deep module's failure keeps the generic wording).
let flavored = false;
for (const stmt of entry.statements) {
if (!ts.isImportDeclaration(stmt) && !(ts.isExportDeclaration(stmt) && !stmt.isTypeOnly))
continue;
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword
)
continue;
const specNode = stmt.moduleSpecifier;
if (specNode === undefined || !ts.isStringLiteral(specNode) || specNode.text !== bad.spec)
continue;
if (cjsDepOf(entry, bad.spec) !== null) {
flavored = true;
break;
}
}
const loc = locOf7(bad.nameNode);
if (!flavored) {
return {
message: `The requested module '${bad.spec}' does not provide an export named '${bad.exportName}'`,
className: "%SyntaxError",
loc,
};
}
// The destructuring hint: Node regexes the failing name's SOURCE LINE
// for its brace group (greedy, first `{` to last `}` on the line) and
// swaps ` as ` for `: ` — reproduced exactly, quirks included
// (multi-line import statements simply lose the hint).
const text = bad.sf.text;
const pos = bad.nameNode.getStart(bad.sf);
const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
const lineEnd = text.indexOf("\n", pos);
let line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd);
if (line.endsWith("\r")) line = line.slice(0, -1);
const braces = /{.*}/.exec(line);
const destructuring = braces === null ? null : braces[0].replace(/\s+as\s+/g, ": ");
return {
message:
`Named export '${bad.exportName}' not found. The requested module '${bad.spec}' is a CommonJS module, which may not support all module.exports as named exports.\n` +
"CommonJS modules can always be imported via the default export, for example using:\n\n" +
`import pkg from '${bad.spec}';\n` +
(destructuring !== null ? `const ${destructuring} = pkg;\n` : ""),
className: "%SyntaxError",
loc,
};
}
/* ── the native ESM named-import link check ─────────────────────────────
* TypeScript accepts `import { Shape } from "./types.ts"` when Shape is an
* interface or type alias: the checker is answering a TYPE question. Node's
* ESM linker asks a VALUE question instead. Because Node 24's strip-only
* execution leaves that import request in the module graph, the request
* fails before any module evaluates unless the source uses `import type`.
*
* Keep this separate from cjsNamedImportLinkCheck. CommonJS exports are
* determined by Node's source lexer and have their own interop message and
* ordering rules; native ESM exports come from the resolved TypeScript
* module symbol and use Node's generic missing-export SyntaxError. */
interface BadEsmImport {
exportName: string;
spec: string;
nameNode: ts.Node;
sf: ts.SourceFile;
}
interface EsmNamedImportLinkAnalysis {
crash: StartupCrash | null;
visited: Set<ts.SourceFile>;
firstMissingOf: (sf: ts.SourceFile) => BadEsmImport | null;
}
/** Node's strip-only loader exposes an empty `default` export for a default
* interface only when the `.ts` file is in an ambiguous (typeless) package
* scope. Explicitly ESM files (`.mts` and `.mjs`) and `.ts` files in a
* `"type": "module"` package do not get that placeholder. */
function hasNodeTsDefaultInterfacePlaceholder7(sf: ts.SourceFile): boolean {
return sf.fileName.endsWith(".ts") && nearestPackageType(sf.fileName) === null;
}
/** Finds the first native-ESM link failure in the static module graph rooted
* at `entry`. This is used both for startup linking of the entry graph and
* for dynamic imports: a dynamically loaded graph rejects its import promise
* at this same link point instead of running any module body. */
function analyzeEsmNamedImportLinks(
program: ts.Program,
entry: ts.SourceFile,
): EsmNamedImportLinkAnalysis {
const checker = program.getTypeChecker();
const resolveEdge = (from: ts.SourceFile, spec: string): ts.SourceFile | null => {
const project = resolveImport7(program, from, spec);
if (project !== null) return project;
return npmStaticDepSf7(program, from, spec);
};
const runtimeExport = (
dep: ts.SourceFile,
name: string,
seenModules: ReadonlySet<ts.SourceFile> = new Set(),
): ts.Symbol | undefined => {
if (seenModules.has(dep)) return undefined;
const nextSeen = new Set(seenModules);
nextSeen.add(dep);
const module = checker.getSymbolAtLocation(dep);
const exported = module?.getExports().get(name as ts.__String);
if (exported !== undefined) {
let resolved = exported;
const seenSymbols = new Set<ts.Symbol>();
while ((resolved.flags & ts.SymbolFlags.Alias) !== 0 && !seenSymbols.has(resolved)) {
seenSymbols.add(resolved);
resolved = checker.getAliasedSymbol(resolved);
}
if (resolved.flags & ts.SymbolFlags.Value) return resolved;
// Node's strip-only TypeScript loader materializes a direct `export
// default interface X {}` as an empty default export in an ambiguous
// `.ts` file. The checker correctly classifies the declaration as
// type-only, but the native ESM linker sees the runtime placeholder.
if (
name === "default" &&
hasNodeTsDefaultInterfacePlaceholder7(dep) &&
checker
.declarationsOf(resolved)
.some(
(declaration) =>
ts.isInterfaceDeclaration(declaration) &&
ts
.getModifiers(declaration)
?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) === true &&
ts
.getModifiers(declaration)
?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true,
)
) {
return resolved;
}
return undefined;
}
// TypeScript 7's client-side module symbol exposes the module's own
// export table but not names contributed by `export *`. Follow those
// source edges explicitly, applying the ESM ambiguity rule: the same
// resolved symbol may arrive through multiple stars, while distinct
// symbols make the name unavailable. `default` never star-reexports.
if (name === "default") return undefined;
let starExport: ts.Symbol | undefined;
for (const stmt of dep.statements) {
if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly || stmt.exportClause !== undefined)
continue;
if (stmt.moduleSpecifier === undefined || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const target = resolveEdge(dep, stmt.moduleSpecifier.text);
if (target === null || target.fileName.endsWith(".json") || !isNodeEsmFile7(target, program))
continue;
const candidate = runtimeExport(target, name, nextSeen);
if (candidate === undefined) continue;
if (starExport !== undefined && starExport !== candidate) return undefined;
starExport = candidate;
}
return starExport;
};
const firstMissingOf = (sf: ts.SourceFile): BadEsmImport | null => {
const imports: {
local: string;
exportName: string;
spec: string;
nameNode: ts.Node;
dep: ts.SourceFile;
}[] = [];
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const clause = stmt.importClause;
if (clause === undefined || clause.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
const spec = stmt.moduleSpecifier.text;
const dep = resolveEdge(sf, spec);
if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep, program)) continue;
if (clause.name !== undefined) {
imports.push({
local: clause.name.text,
exportName: "default",
spec,
nameNode: clause.name,
dep,
});
}
if (clause.namedBindings === undefined || !ts.isNamedImports(clause.namedBindings)) continue;
for (const element of clause.namedBindings.elements) {
if (element.isTypeOnly) continue;
const nameNode = element.propertyName ?? element.name;
imports.push({
local: element.name.text,
exportName: nameNode.text,
spec,
nameNode,
dep,
});
}
}
// Node checks regular named requests in local-binding order. Keeping the
// same order as the CommonJS linker matters when one statement asks for
// more than one erased TypeScript export.
imports.sort((a, b) => (a.local < b.local ? -1 : a.local > b.local ? 1 : 0));
for (const request of imports) {
if (runtimeExport(request.dep, request.exportName) === undefined) {
return {
exportName: request.exportName,
spec: request.spec,
nameNode: request.nameNode,
sf,
};
}
}
for (const stmt of sf.statements) {
if (!ts.isExportDeclaration(stmt) || stmt.isTypeOnly) continue;
const moduleSpecifier = stmt.moduleSpecifier;
const exportClause = stmt.exportClause;
if (moduleSpecifier === undefined || exportClause === undefined) continue;
if (!ts.isStringLiteral(moduleSpecifier) || !ts.isNamedExports(exportClause)) continue;
const spec = moduleSpecifier.text;
const dep = resolveEdge(sf, spec);
if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep, program)) continue;
for (const element of exportClause.elements) {
if (element.isTypeOnly) continue;
const nameNode = element.propertyName ?? element.name;
if (runtimeExport(dep, nameNode.text) === undefined) {
return { exportName: nameNode.text, spec, nameNode, sf };
}
}
}
return null;
};
// The ESM instantiate graph is the same depth-first graph used by the CJS
// linker: dependencies are linked before the importing module, and a
// CommonJS/JSON target is a leaf for this native-export check.
const visited = new Set<ts.SourceFile>();
const dfs = (sf: ts.SourceFile): BadEsmImport | null => {
if (visited.has(sf)) return null;
visited.add(sf);
for (const stmt of sf.statements) {
if (!ts.isImportDeclaration(stmt) && !(ts.isExportDeclaration(stmt) && !stmt.isTypeOnly))
continue;
if (ts.isExportDeclaration(stmt) && isPrunedNpmReexport(program, stmt)) continue;
if (
ts.isImportDeclaration(stmt) &&
stmt.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword
)
continue;
const moduleSpecifier = stmt.moduleSpecifier;
if (moduleSpecifier === undefined || !ts.isStringLiteral(moduleSpecifier)) continue;
const dep = resolveEdge(sf, moduleSpecifier.text);
if (dep === null || dep.fileName.endsWith(".json") || !isNodeEsmFile7(dep, program)) continue;
const bad = dfs(dep);
if (bad !== null) return bad;
}
return firstMissingOf(sf);
};
// Native ESM linking itself starts only from an ESM entry. The caller
// supplies dynamic-only roots here too; each such root gets its own
// promise rejection rather than a startup crash.
const bad = isNodeEsmFile7(entry, program) ? dfs(entry) : null;
if (bad !== null) {
return {
crash: {
message: `The requested module '${bad.spec}' does not provide an export named '${bad.exportName}'`,
className: "%SyntaxError",
loc: locOf7(bad.nameNode),
},
visited,
firstMissingOf,
};
}
return { crash: null, visited, firstMissingOf };
}
export function esmNamedImportLinkCrash(
program: ts.Program,
entry: ts.SourceFile,
): StartupCrash | null {
return analyzeEsmNamedImportLinks(program, entry).crash;
}
function esmNamedImportLinkCheck(
program: ts.Program,
entry: ts.SourceFile,
moduleOrder: ts.SourceFile[],
diags: ScrDiagnostic[],
): StartupCrash | null {
const analysis = analyzeEsmNamedImportLinks(program, entry);
if (analysis.crash !== null) return analysis.crash;
// A CommonJS entry can synchronously require an ESM graph. Node links that
// graph at the require site, after the CommonJS module has already begun
// evaluating, so this cannot use startupCrash (which would move the error
// before earlier output). Keep the compile-time fence used by the CJS link
// checker for the analogous mid-evaluation failure instead.
for (const sf of moduleOrder) {
if (!isNodeEsmFile7(sf, program) || analysis.visited.has(sf)) continue;
const childFailure = analysis.firstMissingOf(sf);
if (childFailure !== null) {
diags.push(
unsupportedDiag(
"SC1013",
locOf7(childFailure.nameNode),
"a named import of an unavailable export in an ES module reached through require() (Node throws its SyntaxError mid-evaluation at that require; make the import match the module's runtime exports)",
),
);
}
}
return null;
}
/** Both named-export link checks run independently because their export
* questions are different (Node's CJS lexer versus TypeScript's value
* symbols). Their first failures still share one Node instantiate order: the
* module postorder established by preflight, then regular named imports by
* local binding name, then source-order re-exports. Pick the earlier result
* so a native ESM failure in a child cannot be hidden by a CJS failure in a
* later parent. */
function earlierLinkCrash(
moduleOrder: ts.SourceFile[],
first: StartupCrash | null,
second: StartupCrash | null,
): StartupCrash | null {
if (first === null) return second;
if (second === null) return first;
const moduleIndex = new Map(moduleOrder.map((sf, index) => [sf.fileName, index]));
const firstIndex = moduleIndex.get(first.loc.file) ?? Number.MAX_SAFE_INTEGER;
const secondIndex = moduleIndex.get(second.loc.file) ?? Number.MAX_SAFE_INTEGER;
if (firstIndex !== secondIndex) return firstIndex < secondIndex ? first : second;
if (first.loc.start !== second.loc.start) {
const sf = moduleOrder[firstIndex];
if (sf === undefined) return first.loc.start < second.loc.start ? first : second;
const rank = (crash: StartupCrash): [number, number] => {
const entries: { local: string; start: number; kind: 0 | 1 }[] = [];
for (const stmt of sf.statements) {
if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
const clause = stmt.importClause;
if (clause?.phaseModifier === ts.SyntaxKind.TypeKeyword) continue;
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements) {
if (!element.isTypeOnly) {
entries.push({
local: element.name.text,
start: (element.propertyName ?? element.name).getStart(sf),
kind: 0,
});
}
}
}
}
}
entries.sort((a, b) => (a.local < b.local ? -1 : a.local > b.local ? 1 : 0));
const direct = entries.findIndex((entry) => entry.start === crash.loc.start);
if (direct >= 0) return [0, direct];
let reexport = 0;
for (const stmt of sf.statements) {
const exportClause = ts.isExportDeclaration(stmt) ? stmt.exportClause : undefined;
if (
!ts.isExportDeclaration(stmt) ||
stmt.isTypeOnly ||
exportClause === undefined ||
!ts.isNamedExports(exportClause)
)
continue;
for (const element of exportClause.elements) {
if (
!element.isTypeOnly &&
(element.propertyName ?? element.name).getStart(sf) === crash.loc.start
) {
return [1, reexport];
}
if (!element.isTypeOnly) reexport++;
}
}
return [2, crash.loc.start];
};
const firstRank = rank(first);
const secondRank = rank(second);
if (firstRank[0] !== secondRank[0]) return firstRank[0] < secondRank[0] ? first : second;
return firstRank[1] <= secondRank[1] ? first : second;
}
return first;
}
/* node's builtin-module name list, for the SC1010 wording decision ("the
* 'fs' module" vs the generic package message) — shared verbatim with the
* retired 5.9.3 lane's builtinModules use. */
const nodeBuiltinNames: ReadonlySet<string> = new Set(builtinModules);
/* ── the lowering-facing surface ─────────────────────────────────────────
* A family of world-typed helpers the LOWERING imports (require
* recognizers, the CJS export-identity analysis, ordered imports,
* resolution, locations), exported under the names the 5.9.3 program.ts
* established (the phase-2 port kept every spelling); the world-neutral
* helpers re-export from their focused modules. */
export { ambientDtsPath, fallbackDtsPath, isNodeTypesPath, overridesDtsPath } from "./dts-paths.js";
export {
builtinDefaultImportModule,
canonicalBuiltinModule,
SUPPORTED_BUILTIN_MODULES,
} from "./builtin-modules.js";
export { npmPackageNameOf, workspacePackageOfPath } from "./workspace-registry.js";
export {
requireSpecOf7 as requireSpecOf,
isRequireStatement7 as isRequireStatement,
requiresOf7 as requiresOf,
isNodeEsmFile7 as isNodeEsmFile,
resolveImport7 as resolveImport,
resolveNpmImport7 as resolveNpmImport,
locOf7 as locOf,
};
/** True for JavaScript source files (.js/.mjs/.cjs) — the files whose
* types come entirely from inference (checkJs + JSDoc) and whose classes
* declare fields by constructor assignment. */
export function isJsSourceFile(sf: ts.SourceFile): boolean {
return isJsSourceFileName(sf.fileName);
}
/** `import type …` declarations and named clauses whose every element is
* `type`-qualified are ERASED by tsc at emit — Node's module graph has no
* edge there, so module order, cycle detection, init calls, and the
* startup-refusal probe must not count them either (zod v4's core modules
* are "cyclic" only through `import type * as ns` namespaces — 27 phantom
* SC1016 sites against 1 real value-level cycle; and a type-only import
* of a module Node cannot resolve is NOT a startup crash — Node never
* resolves it). Originated as the provenance-sources pilot's flag-gated
* prototype (SCRIPTC_TYPE_ONLY_EDGES), promoted to always-on with the
* full-suite differential green. Side-effect imports (no clause) stay
* real edges. */
function erasedTypeOnlyImport(stmt: ts.ImportDeclaration): boolean {
const clause = stmt.importClause;
if (clause === undefined) return false; // side-effect import: a real edge
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return true;
return (
clause.name === undefined &&
clause.namedBindings !== undefined &&
ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.length > 0 &&
clause.namedBindings.elements.every((e) => e.isTypeOnly)
);
}
/** The re-export twin: `export { type X } from "./m"` with every element
* `type`-qualified erases at emit exactly like `export type { X } from`
* (which stmt.isTypeOnly already covers) — no module edge. */
function erasedTypeOnlyReexport(stmt: ts.ExportDeclaration): boolean {
return (
stmt.exportClause !== undefined &&
ts.isNamedExports(stmt.exportClause) &&
stmt.exportClause.elements.length > 0 &&
stmt.exportClause.elements.every((e) => e.isTypeOnly)
);
}
export function orderedImportsOf(
program: ts.Program,
sf: ts.SourceFile,
): { stmt: ts.Statement; dep: ts.SourceFile | null }[] {
const out: { stmt: ts.Statement; dep: ts.SourceFile | null }[] = [];
for (const stmt of sf.statements) {
if (ts.isExportDeclaration(stmt) && isPrunedNpmReexport(program, stmt)) continue;
if (
ts.isExportDeclaration(stmt) &&
(stmt.isTypeOnly || erasedTypeOnlyReexport(stmt) || !stmt.moduleSpecifier)
)
continue;
if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt)) continue;
if (ts.isImportDeclaration(stmt) && erasedTypeOnlyImport(stmt)) continue;
if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
const spec = stmt.moduleSpecifier.text;
// Every project edge (relative, tsconfig alias, #alias, or self-name)
// resolves to the same dep so the importer's %init header calls theirs.
// Builtin and npm bare specifiers stay null — EXCEPT opted-in --npm-static
// packages, whose entries are program modules the header must init.
const dep = resolveImport7(program, sf, spec) ?? npmStaticDepSf7(program, sf, spec);
const isJson = dep !== null && dep.fileName.endsWith(".json");
out.push({ stmt, dep: isJson ? null : dep });
}
return out;
}
/** The program source file a bare specifier reaches when it names an
* opted-in --npm-static package (the shipped-JS entry preflight admitted
* as a module edge), else null. No offender reporting here — preflight
* already classified the import; this is the lookup the module-order and
* lowering paths share. */
export function npmStaticDepSf7(
program: ts.Program,
sf: ts.SourceFile,
spec: string,
resolutionKind: "import" | "require" = "import",
): ts.SourceFile | null {
if (!npmStaticActive() || isRelativeSpecifier(spec)) return null;
if (spec.startsWith("node:") || spec.startsWith("#")) return null;
const npm = resolveNpmImport7(sf.fileName, spec, resolutionKind);
if (npm === null || !isNpmStaticPackage(npm.packageName)) return null;
if (!isJsSourceFileName(npm.typesFile)) return null;
return program.getSourceFile(npm.typesFile) ?? null;
}
/** True when `expr` is the `module.exports` property access. */
export function isModuleExportsAccess(expr: ts.Expression): expr is ts.PropertyAccessExpression {
return (
ts.isPropertyAccessExpression(expr) &&
!expr.questionDotToken &&
ts.isIdentifier(expr.expression) &&
expr.expression.text === "module" &&
expr.name.text === "exports"
);
}
/** Classifies a statement as a CommonJS EXPORT assignment:
* - table: `module.exports = { ... }` — the whole export table at once
* (obj is null when the RHS is not an object literal — the
* recognized-but-unsupported form, fenced by the caller);
* - member: `exports.f = <expr>` / `module.exports.f = <expr>`.
* Null for everything else. Callers gate on top-level position in a JS
* module file — these forms anywhere else keep their generic fences. */
export function cjsExportAssignmentOf(
stmt: ts.Statement,
):
| { kind: "table"; obj: ts.ObjectLiteralExpression | null; expr: ts.BinaryExpression }
| { kind: "member"; name: ts.MemberName; value: ts.Expression; expr: ts.BinaryExpression }
| null {
if (!ts.isExpressionStatement(stmt)) return null;
const e = stmt.expression;
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null;
if (isModuleExportsAccess(e.left)) {
return { kind: "table", obj: ts.isObjectLiteralExpression(e.right) ? e.right : null, expr: e };
}
if (ts.isPropertyAccessExpression(e.left) && !e.left.questionDotToken) {
const recv = e.left.expression;
const isExports = ts.isIdentifier(recv) && recv.text === "exports";
if (isExports || isModuleExportsAccess(recv)) {
return { kind: "member", name: e.left.name, value: e.right, expr: e };
}
}
return null;
}
/** Why Node would DISCARD this CommonJS export statement, or null when it
* survives (Node's export-object identity rules — checker-free, pure
* AST): `module.exports = {...}` REPLACES the export object, so members
* attached before it (either spelling), any earlier table, and
* `exports.f =` after it (exports still references the replaced object)
* are never seen by importers. Lowering fences these instead of shipping
* the checker's everything-is-live answer — the checker/runtime split
* would otherwise diverge from Node without a diagnostic. */
export function cjsExportDiscardReason(stmt: ts.Statement): string | null {
const sf = stmt.parent;
if (!ts.isSourceFile(sf)) return null;
const mine = cjsExportAssignmentOf(stmt);
if (!mine) return null;
const stmts = sf.statements;
let lastTable = -1;
for (let i = 0; i < stmts.length; i++) {
if (cjsExportAssignmentOf(stmts[i]!)?.kind === "table") lastTable = i;
}
if (lastTable < 0) return null;
const myIdx = stmts.indexOf(stmt as never);
if (mine.kind === "table") {
return myIdx < lastTable
? "multiple 'module.exports =' assignments (Node keeps only the last one — this table is discarded)"
: null;
}
if (myIdx < lastTable) {
return "export members attached before a 'module.exports =' assignment (Node replaces the export object — importers never see this member; attach it to module.exports AFTER the table)";
}
const left = mine.expr.left as ts.PropertyAccessExpression;
return ts.isIdentifier(left.expression) && left.expression.text === "exports"
? "'exports.f =' after a 'module.exports =' assignment ('exports' still references the replaced object — Node discards this member; write module.exports.f instead)"
: null;
}
/** The object literal a `module.exports = <expr>` statement's RHS
* RESOLVES to (syntactically): the literal itself; `new Proxy(target,
* handler)` — Node programs export identity-forwarding proxies over a
* table, and the TARGET is the table (traps cannot run in a compiled
* program: every member access is statically resolved against the
* target's own type); or an identifier naming a top-level const whose
* initializer is the literal. Null for everything else. */
export function cjsExportTargetLiteral(
e: ts.Expression,
sf: ts.SourceFile,
): ts.ObjectLiteralExpression | null {
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isObjectLiteralExpression(e)) return e;
if (
ts.isNewExpression(e) &&
ts.isIdentifier(e.expression) &&
e.expression.text === "Proxy" &&
(e.arguments?.length ?? 0) >= 1
) {
return cjsExportTargetLiteral(e.arguments![0]!, sf);
}
if (ts.isIdentifier(e)) {
for (const stmt of sf.statements) {
if (!ts.isVariableStatement(stmt)) continue;
if ((stmt.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
for (const d of stmt.declarationList.declarations) {
if (
d.name !== undefined &&
ts.isIdentifier(d.name) &&
d.name.text === e.text &&
d.initializer
) {
let init: ts.Expression = d.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
return ts.isObjectLiteralExpression(init) ? init : null;
}
}
}
}
return null;
}
/** The class expression a JS module's WHOLE export is (`module.exports =
* class …{}`): the kept (last) top-level `module.exports =` statement's
* paren-unwrapped RHS when it is a class expression, together with the
* statement it sits in (in-file `module.exports` reads gate on source
* order against it). Null when the kept export is anything else. */
export function cjsClassExprWholeExportOf(
sf: ts.SourceFile,
): { classExpr: ts.ClassExpression; stmt: ts.Statement } | null {
let last: { classExpr: ts.ClassExpression; stmt: ts.Statement } | null = null;
for (const stmt of sf.statements) {
const cjs = cjsExportAssignmentOf(stmt);
if (!cjs || cjs.kind !== "table") continue;
let rhs: ts.Expression = cjs.expr.right;
while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression;
// Any LATER table replaces the export object — a non-class one takes
// the class answer back off the table.
last = ts.isClassExpression(rhs) ? { classExpr: rhs, stmt } : null;
}
return last;
}
/** True when `obj` is the effective export-table literal of a JS module:
* the object literal of a top-level `module.exports = { ... }` statement
* directly, OR the RESOLVED TARGET of the module's kept `module.exports =`
* statement (the identifier/Proxy forms — see cjsExportTargetLiteral).
* The gate for the CJS export-property alias re-resolution
* (resolveValueSymbol) and the accessor-read lift. */
export function isCjsExportTableLiteral(obj: ts.ObjectLiteralExpression): boolean {
const assign = obj.parent;
if (
ts.isBinaryExpression(assign) &&
assign.right === obj &&
isModuleExportsAccess(assign.left) &&
ts.isExpressionStatement(assign.parent) &&
ts.isSourceFile(assign.parent.parent)
) {
return isJsSourceFile(assign.parent.parent);
}
const decl = obj.parent;
if (!ts.isVariableDeclaration(decl) || decl.initializer !== obj) return false;
const sf = obj.getSourceFile();
if (!isJsSourceFile(sf)) return false;
for (const stmt of sf.statements) {
const cjs = cjsExportAssignmentOf(stmt);
if (cjs?.kind === "table" && cjsExportDiscardReason(stmt) === null) {
return cjsExportTargetLiteral(cjs.expr.right, sf) === obj;
}
}
return false;
}
/** Names other runtimes and the DOM declare that scriptc's type world
* (the ES2025 library plus Node types) does not. */
const FOREIGN_RUNTIME_GLOBALS = new Set(["Deno", "Bun"]);
const DOM_GLOBALS = new Set([
"window",
"document",
"navigator",
"location",
"localStorage",
"sessionStorage",
"HTMLElement",
"Element",
"Document",
"Window",
"BodyInit",
"HeadersInit",
"RequestInfo",
"XMLHttpRequest",
"requestAnimationFrame",
]);
/** Root identifiers whose members the bundled Node fallback declares. */
const NODE_GLOBAL_ROOTS = new Set(["process", "Buffer", "console", "globalThis"]);
/** A hint when a TypeScript error comes from scriptc's own type world rather
* than from the project: a library member newer than ES2025, a global of
* another runtime or of the DOM, a Node member the bundled fallback types
* leave out, or the strictNullChecks scriptc enforces. Null otherwise. */
function typeWorldHint(d: ts.Diagnostic, message: string, load: LoadResult): string | null {
const world = load.typeWorld;
if (world === undefined) return null;
const quoted = /'([^']+)'/.exec(message)?.[1];
// TS2304/TS2552: Cannot find name; TS2580/TS2591/TS2592: missing type
// packages; TS2583: needs a newer library.
if (d.code === 2304 || d.code === 2552 || d.code === 2583) {
if (quoted !== undefined && FOREIGN_RUNTIME_GLOBALS.has(quoted)) {
return `'${quoted}' is another runtime's global; scriptc's type world declares the ES2025 library and Node only — feature-test it through globalThis instead, e.g. '(globalThis as { ${quoted}?: unknown }).${quoted}' (the native program runs as Node would, where it is undefined)`;
}
if (quoted !== undefined && DOM_GLOBALS.has(quoted)) {
return `'${quoted}' comes from the DOM library, which scriptc's type world does not include (it checks with the ES2025 library and Node types regardless of the project's "lib") — declare the shape you need locally, or move browser-only code out of the program`;
}
if (d.code === 2583) {
return `scriptc checks with the ES2025 library regardless of the project's "lib" setting, and '${quoted ?? "this name"}' comes from a newer library that does not compile yet`;
}
}
// TS2339/TS2550/TS2551: property does not exist (2550: on this lib).
if (d.code === 2339 || d.code === 2550 || d.code === 2551) {
const typeText = /on type '([^']+)'/.exec(message)?.[1] ?? "";
const newerLib =
world.projectLib !== null &&
world.projectLib.some((lib) => /esnext|es202[6-9]|es20[3-9]\d/i.test(lib));
if (d.code === 2550 || (newerLib && /^[A-Z][A-Za-z0-9]*(?:Constructor)?$/.test(typeText))) {
return `scriptc checks with the ES2025 library regardless of the project's "lib" setting, and '${quoted ?? "this member"}' comes from a newer library that does not compile yet — use an ES2025 equivalent here`;
}
if (world.nodeFallback && d.fileName !== undefined) {
const root = receiverRoot(load.program.getSourceFile(d.fileName)?.text ?? "", d.pos);
if (
root !== null &&
(NODE_GLOBAL_ROOTS.has(root) || importsNodeBuiltin(load, d.fileName, root))
) {
return `the project has no @types/node, so scriptc types Node with its bundled declarations, which list only the members that compile natively — '${quoted ?? "this member"}' does not compile natively yet (https://scriptc.dev/limitations); installing @types/node turns this into a located refusal with a hint`;
}
}
}
if (world.forcedStrictNullChecks && /\b(?:null|undefined)\b/.test(message)) {
return `the project disables strictNullChecks, which scriptc always enables — this error appears only under strict null checks; narrow the value (or enable "strictNullChecks" in the project to see the same errors from its own tsc)`;
}
return null;
}
/** The root identifier of the property access whose member name starts at
* `pos` (`process.version` → `process`), or null. */
function receiverRoot(text: string, pos: number): string | null {
let i = pos - 1;
while (i >= 0 && /\s/.test(text[i]!)) i--;
if (text[i] !== ".") return null;
i--;
if (text[i] === "?") i--;
let root: string | null = null;
for (;;) {
while (i >= 0 && /\s/.test(text[i]!)) i--;
let j = i;
while (j >= 0 && /[\w$]/.test(text[j]!)) j--;
if (j === i) return root;
root = text.slice(j + 1, i + 1);
i = j;
while (i >= 0 && /\s/.test(text[i]!)) i--;
if (text[i] !== ".") return root;
i--;
if (text[i] === "?") i--;
}
}
/** Whether `name` is bound in `fileName` by an import of a Node builtin. */
function importsNodeBuiltin(load: LoadResult, fileName: string, name: string): boolean {
const sf = load.program.getSourceFile(fileName);
for (const stmt of sf?.statements ?? []) {
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
if (canonicalBuiltinModule(stmt.moduleSpecifier.text) === null) continue;
const clause = stmt.importClause;
if (clause?.name?.text === name) return true;
const bindings = clause?.namedBindings;
if (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === name) return true;
if (
bindings &&
ts.isNamedImports(bindings) &&
bindings.elements.some((e) => e.name.text === name)
)
return true;
}
return false;
}