mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-26 06:34:20 +08:00
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.
5272 lines
222 KiB
TypeScript
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;
|
|
}
|