mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
The --dynamic import() subgraph rides the shared cycle admission
- Extract preflight's benign-cycle admission (the cheap per-edge rule plus the declaration-only init window analysis, with Tarjan SCC and cluster-verdict memos) into makeCycleAdmission; preflight's static walk and appendDynamicImportModules now judge back edges identically, so benign cycles behind dynamic import() compile as Node cache hits instead of blanket SC1016. - appendDynamicImportModules runs once in lowerToIr before any pass constructs: the old per-constructor run minted cycle refusals into the discovery pass, whose diagnostics are discarded by design, so inadmissible import() cycles silently compiled; refusals now land on the emit pass with the same narrowed reason (binding and line, or the offending top-level statement) the static walk mints. - SC1016 gains a registry hint naming the admitted shape and the fix; diagnostics snapshots, order-parity baselines, and the surface manifest repin with it. - Corpus 2604 (mutual class cycle across two modules), 2605 (three-module function ring with two back edges and a function-body-only value use), 2606 (import() into an admitted mutual-recursion cycle) — all byte-identical to Node through both backends; diagnostics fixture dyn-import-cycle-tdz pins the dynamic-path TDZ refusal.
This commit is contained in:
@@ -96,7 +96,18 @@ export const UNSUPPORTED: Record<string, UnsupportedEntry> = {
|
||||
hint: "export declarations directly: export function f() {}",
|
||||
},
|
||||
SC1015: { feature: "dynamic import()", milestone: "M4" },
|
||||
SC1016: { feature: "circular imports", milestone: "later" },
|
||||
// Benign cycles are ADMITTED (Node runs them as cache hits): cycles of
|
||||
// ES modules whose top levels are declaration-only and whose
|
||||
// cycle-crossing bindings are used only inside function bodies (mutual
|
||||
// recursion, cross-module class references), plus closing edges that
|
||||
// bind nothing readable. SC1016 remains for exactly the cycles where
|
||||
// Node's partial initialization is observable — the message names the
|
||||
// offending binding or top-level statement.
|
||||
SC1016: {
|
||||
feature: "circular imports",
|
||||
milestone: "later",
|
||||
hint: "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window",
|
||||
},
|
||||
// Class DECLARATIONS shipped; SC1020's remaining use is class expressions.
|
||||
SC1020: { feature: "class expressions", milestone: "later" },
|
||||
// SC1021 (arrow functions/closures) and SC1022 (nested function
|
||||
|
||||
@@ -8,7 +8,8 @@ import { dirname as dirnamePath, resolve as resolvePath } from "node:path";
|
||||
import { NpmGraphBuilder, packageNameOfPath, probeNodeImportRefusal } from "../npm.js";
|
||||
import { isNpmStaticPackage } from "../npm-static.js";
|
||||
import { isJsSourceFileName, isRelativeSpecifier } from "../shared.js";
|
||||
import { cjsExportAssignmentOf, cjsExportDiscardReason, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js";
|
||||
import { cjsExportAssignmentOf, cjsExportDiscardReason, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, makeCycleAdmission, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js";
|
||||
import type { CycleEdge } from "../program.js";
|
||||
import { invalidJsonModuleDiag, npmEmbedFailedDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js";
|
||||
import { BOOL, DYN, IrClassDef, IrExpr, IrFunction, IrGlobal, IrRecordShape, IrStmt, IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, arrayOf, canConvertToDyn, isUnitType } from "../../ir/nodes.js";
|
||||
import { ENTRY_NAME, PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, newFnCtx, uncheckedOverloadHandleCall } from "./lowerer.js";
|
||||
@@ -114,31 +115,49 @@ export interface FileParts {
|
||||
* still evaluates exactly when Node evaluates it: the import() site's
|
||||
* namespace builder calls the guarded init on the engine microtask.
|
||||
* Fixpoint: an added module's own import() sites are scanned too. Static
|
||||
* cycles inside the added subgraph get preflight's SC1016 via `onCycle`
|
||||
* (preflight only walked the entry's static graph, so it never saw
|
||||
* these). Idempotent — re-running on an already-extended order adds
|
||||
* nothing (both lowering passes share one array). */
|
||||
* cycles inside the added subgraph ride preflight's own admission engine
|
||||
* (makeCycleAdmission — preflight only walked the entry's static graph,
|
||||
* so it never judged these): benign back edges are Node cache hits the
|
||||
* run-once init guards reproduce, and inadmissible ones get preflight's
|
||||
* SC1016 with the same narrowed reason via `onCycle`. Idempotent —
|
||||
* re-running on an already-extended order adds nothing (both lowering
|
||||
* passes share one array). */
|
||||
export function appendDynamicImportModules(
|
||||
program: ts.Program,
|
||||
order: ts.SourceFile[],
|
||||
onCycle: (cycle: string) => void,
|
||||
onCycle: (cycle: string, reason: string) => void,
|
||||
): void {
|
||||
if (order.length === 0) return; // no preflight order — sites keep their fences
|
||||
const state = new Map<ts.SourceFile, "visiting" | "done">();
|
||||
for (const sf of order) state.set(sf, "done");
|
||||
const added: ts.SourceFile[] = [];
|
||||
const stack: string[] = [];
|
||||
const staticEdgesOf = (sf: ts.SourceFile): CycleEdge[] =>
|
||||
orderedImportsOf(program, sf).flatMap(({ stmt, dep }) =>
|
||||
dep !== null && dep !== sf
|
||||
? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }]
|
||||
: [],
|
||||
);
|
||||
const cycleAdmissionReason = makeCycleAdmission(program, staticEdgesOf);
|
||||
const visit = (sf: ts.SourceFile): void => {
|
||||
const s = state.get(sf);
|
||||
if (s === "done") return;
|
||||
if (s === "visiting") {
|
||||
const cycleStart = stack.indexOf(sf.fileName);
|
||||
onCycle([...stack.slice(cycleStart), sf.fileName].join(" → "));
|
||||
return;
|
||||
}
|
||||
if (state.get(sf) !== undefined) return;
|
||||
state.set(sf, "visiting");
|
||||
stack.push(sf.fileName);
|
||||
for (const { dep } of orderedImportsOf(program, sf)) if (dep && dep !== sf) visit(dep);
|
||||
for (const e of staticEdgesOf(sf)) {
|
||||
const s = state.get(e.dep);
|
||||
if (s === "done") continue;
|
||||
if (s === "visiting") {
|
||||
// A back edge: Node answers it from the cache. Same admission
|
||||
// question as preflight's static walk, same fence when refused.
|
||||
const reason = cycleAdmissionReason(sf, e);
|
||||
if (reason !== null) {
|
||||
const cycleStart = stack.indexOf(e.dep.fileName);
|
||||
onCycle([...stack.slice(cycleStart), e.dep.fileName].join(" → "), reason);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
visit(e.dep);
|
||||
}
|
||||
stack.pop();
|
||||
state.set(sf, "done");
|
||||
added.push(sf);
|
||||
|
||||
@@ -394,8 +394,25 @@ export function lowerToIr(
|
||||
const dynamic = options.dynamic ?? false;
|
||||
const targetPlatform = options.targetPlatform ?? process.platform;
|
||||
const startupCrash = options.startupCrash ?? null;
|
||||
// --dynamic: modules reachable only through dynamic import() of the
|
||||
// program's own files join the compiled graph here, ONCE, before any
|
||||
// pass constructs (nothing calls their %init at startup — the import()
|
||||
// site's namespace builder does, on the engine microtask, Node's
|
||||
// evaluation point for them). Inadmissible static cycles inside the
|
||||
// added subgraph are minted here and handed to the EMIT pass: the
|
||||
// discovery pass's diagnostics are discarded by design, and after this
|
||||
// extension of the shared array no later pass re-walks the subgraph.
|
||||
const dynamicCycleDiags: ScrDiagnostic[] = [];
|
||||
if (dynamic) {
|
||||
appendDynamicImportModules(program, moduleOrder, (cycle, reason) => {
|
||||
dynamicCycleDiags.push(
|
||||
unsupportedDiag("SC1016", { file: entry.fileName, start: 0, end: 0 }, `circular imports (${cycle}; ${reason})`),
|
||||
);
|
||||
});
|
||||
}
|
||||
const reachable = new Lowerer(program, entry, moduleOrder, dynamic, { targetPlatform }).discover(options.libRoots);
|
||||
const emit = new Lowerer(program, entry, moduleOrder, dynamic, { reachable, targetPlatform, startupCrash });
|
||||
for (const d of dynamicCycleDiags) emit.pushDiag(d);
|
||||
const result = emit.run();
|
||||
if (options.coverage !== true) return result;
|
||||
const remainder = new Lowerer(program, entry, moduleOrder, dynamic, {
|
||||
@@ -1137,18 +1154,12 @@ export class Lowerer {
|
||||
// lowering, long after the constructor completes.
|
||||
isProgramFile: (sf) => this.fileTag.has(sf),
|
||||
};
|
||||
// --dynamic: modules reachable only through dynamic import() of the
|
||||
// program's own files join the compiled graph (nothing calls their
|
||||
// %init at startup — the import() site's namespace builder does, on
|
||||
// the engine microtask, which is Node's evaluation point for them).
|
||||
// Mutates the SHARED moduleOrder array; idempotent across passes.
|
||||
if (dynamic) {
|
||||
appendDynamicImportModules(program, this.moduleOrder, (cycle) => {
|
||||
this.pushDiag(
|
||||
unsupportedDiag("SC1016", { file: entry.fileName, start: 0, end: 0 }, `circular imports (${cycle})`),
|
||||
);
|
||||
});
|
||||
}
|
||||
// --dynamic: modules reachable only through dynamic import() joined
|
||||
// moduleOrder BEFORE any pass constructed — lowerToIr runs
|
||||
// appendDynamicImportModules once on the shared array (a per-pass run
|
||||
// here minted cycle refusals into the DISCOVERY pass, whose
|
||||
// diagnostics are discarded by design, and the extended order left
|
||||
// nothing for the emit pass to re-detect).
|
||||
this.moduleOrder.forEach((sf, i) => {
|
||||
this.fileTag.set(sf, sf === entry ? "" : `%m${i}.`);
|
||||
});
|
||||
|
||||
@@ -687,9 +687,8 @@ function nsBindingUsesAreBareStatements7(
|
||||
* 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). */
|
||||
interface ModuleEdge7 {
|
||||
export interface CycleEdge {
|
||||
dep: ts.SourceFile;
|
||||
backAdmissible: () => boolean;
|
||||
stmt?: ts.ImportDeclaration | ts.ExportDeclaration;
|
||||
}
|
||||
|
||||
@@ -997,6 +996,117 @@ function backEdgeUseOffence7(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The benign-cycle admission engine over one import graph, shared by
|
||||
* preflight's static-module walk and the --dynamic subgraph walk
|
||||
* (appendDynamicImportModules). Given a BACK edge (importer → e.dep with
|
||||
* e.dep already mid-initialization in a depth-first walk), answers null
|
||||
* to ADMIT the cycle — the guarded %init calls reproduce Node's cache-hit
|
||||
* order and nothing can observe the partial initialization — or the
|
||||
* human-readable reason it keeps the SC1016 fence. Two admission chances:
|
||||
* - the CHEAP PER-EDGE rule: the closing statement binds nothing (a
|
||||
* side-effect import) or binds only namespace objects whose every use
|
||||
* is a bare expression statement — no read crosses the edge at all;
|
||||
* - the DECLARATION-ONLY INIT WINDOW rule (the admission block above):
|
||||
* every cluster member is an ES module with an inert top level, and
|
||||
* the closing edge's bindings are used only in deferred positions.
|
||||
* Tarjan SCCs (lazy, rooted at each queried importer) and cluster
|
||||
* verdicts are memoized across calls, so `edgesOf` must answer the same
|
||||
* edges for the same file every time. */
|
||||
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): the
|
||||
// reason the cluster's cycles stay fenced, or null when its every
|
||||
// member passes the inert-top-level bar.
|
||||
const sccVerdict = new Map<ts.SourceFile[], string | null>();
|
||||
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)) {
|
||||
let reason: string | null = null;
|
||||
for (const m of comp) {
|
||||
if (isCjsJsFile7(m)) {
|
||||
reason = `${m.fileName} is a CommonJS module — admission covers ES-module cycles only`;
|
||||
break;
|
||||
}
|
||||
const off = nonInertTopLevel7(program, m);
|
||||
if (off !== null) {
|
||||
reason = `top-level code at ${lineOf(off)} can run user code during the cycle's init window — only declaration-only module bodies are admitted`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
sccVerdict.set(comp, reason);
|
||||
}
|
||||
const clusterReason = sccVerdict.get(comp)!;
|
||||
if (clusterReason !== null) return clusterReason;
|
||||
const use = backEdgeUseOffence7(program, importer, e.stmt);
|
||||
if (use !== null) {
|
||||
return `the cycle-crossing binding '${use.name}' is read at ${lineOf(use.node)}, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1394,22 +1504,21 @@ function preflight7(load: LoadResult): {
|
||||
});
|
||||
};
|
||||
|
||||
// Module edges, each carrying its BACK-EDGE admissibility: an edge that
|
||||
// closes a cycle is 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). Node evaluates such cycles
|
||||
// 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. Named/default bindings and `export ... from`
|
||||
// re-exports get the SECOND admission chance when they close a cycle —
|
||||
// the declaration-only init window analysis (see the benign-cycle
|
||||
// admission block above): cycles that fail it keep the SC1016 fence,
|
||||
// its order exactly. Cycles that fail both keep the SC1016 fence,
|
||||
// because a read through them mid-cycle observes TDZ (let/const) or
|
||||
// undefined (var) in Node — runtime evaluation-order semantics the
|
||||
// static story does not model.
|
||||
const edges = new Map<ts.SourceFile, ModuleEdge7[]>();
|
||||
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
|
||||
@@ -1424,7 +1533,7 @@ function preflight7(load: LoadResult): {
|
||||
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: ModuleEdge7[] = [];
|
||||
const deps: CycleEdge[] = [];
|
||||
edges.set(sf, deps);
|
||||
refusals.set(sf, []);
|
||||
depPositions.set(sf, []);
|
||||
@@ -1504,7 +1613,7 @@ function preflight7(load: LoadResult): {
|
||||
}
|
||||
}
|
||||
if (reDep && !reDep.fileName.endsWith(".json")) {
|
||||
deps.push({ dep: reDep, backAdmissible: () => false, stmt });
|
||||
deps.push({ dep: reDep, stmt });
|
||||
depPositions.get(sf)!.push({ dep: reDep, pos: stmt.getStart(sf) });
|
||||
}
|
||||
continue;
|
||||
@@ -1733,21 +1842,7 @@ function preflight7(load: LoadResult): {
|
||||
}
|
||||
}
|
||||
if (dep && !isJson) {
|
||||
// Back-edge admissibility (see the edges declaration): no clause,
|
||||
// or namespace-only bindings whose uses are all bare statements.
|
||||
// Lazy — the file scan only runs if this edge ever closes a cycle.
|
||||
const importClause = clause;
|
||||
const importSf = sf;
|
||||
deps.push({
|
||||
dep,
|
||||
backAdmissible: () =>
|
||||
importClause === undefined ||
|
||||
(importClause.name === undefined &&
|
||||
(importClause.namedBindings === undefined ||
|
||||
(ts.isNamespaceImport(importClause.namedBindings) &&
|
||||
nsBindingUsesAreBareStatements7(program, importSf, importClause.namedBindings.name)))),
|
||||
stmt,
|
||||
});
|
||||
deps.push({ dep, stmt });
|
||||
depPositions.get(sf)!.push({ dep, pos: stmt.getStart(sf) });
|
||||
}
|
||||
}
|
||||
@@ -1832,7 +1927,7 @@ function preflight7(load: LoadResult): {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (dep) deps.push({ dep, backAdmissible: () => false });
|
||||
if (dep) deps.push({ dep });
|
||||
}
|
||||
}
|
||||
if (!ts.isExternalModule(sf)) {
|
||||
@@ -1845,7 +1940,7 @@ function preflight7(load: LoadResult): {
|
||||
const npmReq = !spec.startsWith("#") ? resolveNpmImport7(sf.fileName, spec) : null;
|
||||
if (npmReq !== null && isNpmStaticPackage(npmReq.packageName)) {
|
||||
const nDep = npmStaticProgramDep(program, npmReq.packageName, npmReq.typesFile);
|
||||
if (nDep !== null) deps.push({ dep: nDep, backAdmissible: () => false });
|
||||
if (nDep !== null) deps.push({ dep: nDep });
|
||||
continue;
|
||||
}
|
||||
if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) {
|
||||
@@ -1862,7 +1957,7 @@ function preflight7(load: LoadResult): {
|
||||
diags.push(unsupportedDiag("SC1012", loc, "require() of JSON modules"));
|
||||
continue;
|
||||
}
|
||||
if (dep) deps.push({ dep, backAdmissible: () => false });
|
||||
if (dep) deps.push({ dep });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1878,95 +1973,15 @@ function preflight7(load: LoadResult): {
|
||||
// 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 longer cycle whose closing edge is backAdmissible (see the edges
|
||||
// declaration: nothing binds, or only namespace objects bound to
|
||||
// bare-statement uses — no read can hit TDZ or a stale slot);
|
||||
// - a cycle passing the DECLARATION-ONLY INIT WINDOW analysis (the
|
||||
// benign-cycle admission block): every module of the cycle's
|
||||
// - 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 cycle cluster (SCC) memo: computed once, on the first back edge
|
||||
// the cheap per-edge rule does not admit. Tarjan over the same edges
|
||||
// the order walk uses (self-edges skipped — Node's self-reference rule
|
||||
// handles those above).
|
||||
let sccOf: Map<ts.SourceFile, ts.SourceFile[]> | null = null;
|
||||
const computeSccs = (): Map<ts.SourceFile, ts.SourceFile[]> => {
|
||||
const out = 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 edges.get(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) out.set(w, comp);
|
||||
}
|
||||
};
|
||||
for (const sf of edges.keys()) if (!index.has(sf)) strongconnect(sf);
|
||||
return out;
|
||||
};
|
||||
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): the
|
||||
// reason the cluster's cycles stay fenced, or null when its every
|
||||
// member passes the inert-top-level bar.
|
||||
const sccVerdict = new Map<ts.SourceFile[], string | null>();
|
||||
const cycleAdmissionReason = (importer: ts.SourceFile, e: ModuleEdge7): string | null => {
|
||||
if (e.stmt === undefined) return "the cycle closes through a require() edge";
|
||||
sccOf ??= computeSccs();
|
||||
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)) {
|
||||
let reason: string | null = null;
|
||||
for (const m of comp) {
|
||||
if (isCjsJsFile7(m)) {
|
||||
reason = `${m.fileName} is a CommonJS module — admission covers ES-module cycles only`;
|
||||
break;
|
||||
}
|
||||
const off = nonInertTopLevel7(program, m);
|
||||
if (off !== null) {
|
||||
reason = `top-level code at ${lineOf(off)} can run user code during the cycle's init window — only declaration-only module bodies are admitted`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
sccVerdict.set(comp, reason);
|
||||
}
|
||||
const clusterReason = sccVerdict.get(comp)!;
|
||||
if (clusterReason !== null) return clusterReason;
|
||||
const use = backEdgeUseOffence7(program, importer, e.stmt);
|
||||
if (use !== null) {
|
||||
return `the cycle-crossing binding '${use.name}' is read at ${lineOf(use.node)}, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// read can execute before every member initialized).
|
||||
const cycleAdmissionReason = makeCycleAdmission(program, (sf) => edges.get(sf) ?? []);
|
||||
const order: ts.SourceFile[] = [];
|
||||
const state = new Map<ts.SourceFile, "visiting" | "done">();
|
||||
const stack: string[] = [];
|
||||
@@ -1982,19 +1997,17 @@ function preflight7(load: LoadResult): {
|
||||
// already evaluating) — benign exactly when nothing can observe
|
||||
// the partial initialization through this edge's bindings, by the
|
||||
// cheap per-edge rule or the declaration-only init window rule.
|
||||
if (!e.backAdmissible()) {
|
||||
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})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"kind": "diagnostic-fence",
|
||||
"name": "circular imports",
|
||||
"status": "unsupported",
|
||||
"code": "SC1016"
|
||||
"code": "SC1016",
|
||||
"note": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
|
||||
},
|
||||
{
|
||||
"id": "diagnostic.sc1020",
|
||||
|
||||
@@ -5018,6 +5018,29 @@
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/2604-cycle-classes-mutual/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/2604-cycle-classes-mutual/group.ts",
|
||||
"<repo>/tests/corpus/2604-cycle-classes-mutual/item.ts",
|
||||
"<repo>/tests/corpus/2604-cycle-classes-mutual/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/2605-cycle-three-module/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/2605-cycle-three-module/c.ts",
|
||||
"<repo>/tests/corpus/2605-cycle-three-module/b.ts",
|
||||
"<repo>/tests/corpus/2605-cycle-three-module/a.ts",
|
||||
"<repo>/tests/corpus/2605-cycle-three-module/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/2606-dynamic-import-cycle/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/2606-dynamic-import-cycle/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/300-if-else.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/300-if-else.ts"
|
||||
@@ -6089,7 +6112,8 @@
|
||||
"start": 0,
|
||||
"end": 0
|
||||
},
|
||||
"milestone": "later"
|
||||
"milestone": "later",
|
||||
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6155,7 +6179,8 @@
|
||||
"start": 0,
|
||||
"end": 0
|
||||
},
|
||||
"milestone": "later"
|
||||
"milestone": "later",
|
||||
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6174,7 +6199,8 @@
|
||||
"start": 0,
|
||||
"end": 0
|
||||
},
|
||||
"milestone": "later"
|
||||
"milestone": "later",
|
||||
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6193,7 +6219,8 @@
|
||||
"start": 0,
|
||||
"end": 0
|
||||
},
|
||||
"milestone": "later"
|
||||
"milestone": "later",
|
||||
"hint": "cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -6254,6 +6281,12 @@
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/diagnostics/dyn-import-cycle-tdz/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/dyn-import-cycle-tdz/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/diagnostics/dynamic-import-static.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/dynamic-import-static.ts"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Item } from "./item.ts";
|
||||
|
||||
export class Group {
|
||||
readonly label: string;
|
||||
readonly count: number;
|
||||
constructor(label: string, count: number) {
|
||||
this.label = label;
|
||||
this.count = count;
|
||||
}
|
||||
lead(): Item {
|
||||
return new Item(this.label + "-lead");
|
||||
}
|
||||
roster(): string {
|
||||
return this.lead().describe() + " x" + String(this.count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// A CLASS cycle: Item and Group live in mutually-importing modules and
|
||||
// construct each other from methods. Both top levels are declaration-only
|
||||
// and every cycle-crossing reference sits inside a method body, so the
|
||||
// cycle is admitted — Node runs it as a cache hit and nothing observes
|
||||
// the partial initialization.
|
||||
import { Group } from "./group.ts";
|
||||
|
||||
export class Item {
|
||||
readonly name: string;
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
crew(n: number): Group {
|
||||
return new Group(this.name, n);
|
||||
}
|
||||
describe(): string {
|
||||
return "item:" + this.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Item } from "./item.ts";
|
||||
import { Group } from "./group.ts";
|
||||
|
||||
const g = new Item("alpha").crew(3);
|
||||
console.log(g.label, g.count);
|
||||
console.log(g.lead().name);
|
||||
console.log(g.roster());
|
||||
console.log(new Group("beta", 2).lead().crew(9).count);
|
||||
@@ -0,0 +1,10 @@
|
||||
// A THREE-module import ring (a → b → c → a) of mutually-recursive
|
||||
// functions. Every module's top level is declaration-only and each
|
||||
// cycle-crossing binding is only called inside a function body, so the
|
||||
// whole ring is admitted; Node evaluates c, b, a, main and the guarded
|
||||
// %init calls reproduce that order exactly.
|
||||
import { hop } from "./b.ts";
|
||||
|
||||
export function start(n: number): string {
|
||||
return n <= 0 ? "!" : "a" + hop(n - 1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { skip } from "./c.ts";
|
||||
|
||||
export function hop(n: number): string {
|
||||
return n <= 0 ? "!" : "b" + skip(n - 1);
|
||||
}
|
||||
|
||||
export const bTag: string = "B-side";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { start } from "./a.ts";
|
||||
import { bTag } from "./b.ts";
|
||||
|
||||
export function skip(n: number): string {
|
||||
return n <= 0 ? "!" : "c" + start(n - 1);
|
||||
}
|
||||
|
||||
// A value binding through the cycle, used only inside a function body:
|
||||
// by the time any caller can reach this, every ring member initialized.
|
||||
export function sideTag(): string {
|
||||
return "seen " + bTag;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { start } from "./a.ts";
|
||||
import { hop } from "./b.ts";
|
||||
import { sideTag } from "./c.ts";
|
||||
|
||||
console.log(start(7));
|
||||
console.log(hop(4));
|
||||
console.log(start(0));
|
||||
console.log(sideTag());
|
||||
@@ -0,0 +1,18 @@
|
||||
// @dynamic
|
||||
// Dynamic import() into a benign import cycle: ping.ts and pong.ts are
|
||||
// mutually-recursive modules reachable ONLY through import(), so the
|
||||
// static preflight never sees them — the --dynamic subgraph walk
|
||||
// (appendDynamicImportModules) judges the cycle with the same admission
|
||||
// engine and admits it: declaration-only top levels, cycle-crossing
|
||||
// bindings called only inside function bodies. The run-once init guards
|
||||
// reproduce Node's cache-hit evaluation of the pair on the microtask.
|
||||
async function main(): Promise<void> {
|
||||
console.log("start");
|
||||
const ns = await import("./ping.ts");
|
||||
console.log(ns.ping(5));
|
||||
console.log(ns.ping(0));
|
||||
const again = await import("./pong.ts");
|
||||
console.log(again.pong(2));
|
||||
}
|
||||
main();
|
||||
console.log("after main() call");
|
||||
@@ -0,0 +1,5 @@
|
||||
import { pong } from "./pong.ts";
|
||||
|
||||
export function ping(n: number): string {
|
||||
return n <= 0 ? "." : "i" + pong(n - 1);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ping } from "./ping.ts";
|
||||
|
||||
export function pong(n: number): string {
|
||||
return n <= 0 ? "." : "o" + ping(n - 1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { b } from "./b.ts";
|
||||
|
||||
export const a: string = "A";
|
||||
|
||||
export function useB(): string {
|
||||
return b;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { a } from "./a.ts";
|
||||
|
||||
export const b: string = a + "B";
|
||||
@@ -0,0 +1,12 @@
|
||||
// @dynamic
|
||||
// The REFUSED shape through the --dynamic subgraph: the cycle behind this
|
||||
// import() reads a cycle-crossing binding at a module's top level, which
|
||||
// in Node observes the partially-initialized exporter (TDZ
|
||||
// ReferenceError). The dynamic-subgraph walk fences it with the same
|
||||
// narrowed SC1016 preflight's static walk mints, naming the binding and
|
||||
// the offending line.
|
||||
async function main(): Promise<void> {
|
||||
const ns = await import("./a.ts");
|
||||
console.log(ns.useB());
|
||||
}
|
||||
main();
|
||||
@@ -2,4 +2,6 @@ cjs-require-cycle/a.js:1:1 - error SC1016: circular imports (cjs-require-cycle/a
|
||||
|
||||
1 | 'use strict';
|
||||
| ^
|
||||
2 |
|
||||
2 |
|
||||
|
||||
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
|
||||
@@ -2,4 +2,6 @@ cycle-tdz-read/a.ts:1:1 - error SC1016: circular imports (cycle-tdz-read/a.ts
|
||||
|
||||
1 | import { b } from "./b.ts";
|
||||
| ^
|
||||
2 | export const a: string = "A";
|
||||
2 | export const a: string = "A";
|
||||
|
||||
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
|
||||
@@ -2,4 +2,6 @@ cycle-window-call/a.ts:1:1 - error SC1016: circular imports (cycle-window-call/a
|
||||
|
||||
1 | import { bee } from "./b.ts";
|
||||
| ^
|
||||
2 | export function aye(n: number): number {
|
||||
2 | export function aye(n: number): number {
|
||||
|
||||
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
|
||||
@@ -2,4 +2,6 @@ cycle/a.ts:1:1 - error SC1016: circular imports (cycle/a.ts → cycle/b.ts → c
|
||||
|
||||
1 | import { b } from "./b.ts";
|
||||
| ^
|
||||
2 | export const a: number = b + 1;
|
||||
2 | export const a: number = b + 1;
|
||||
|
||||
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
|
||||
@@ -0,0 +1,7 @@
|
||||
dyn-import-cycle-tdz/main.ts:1:1 - error SC1016: circular imports (dyn-import-cycle-tdz/a.ts → dyn-import-cycle-tdz/b.ts → dyn-import-cycle-tdz/a.ts; the cycle-crossing binding 'a' is read at dyn-import-cycle-tdz/b.ts:3, outside any function body — a read during the init window observes the partially-initialized module (Node's TDZ ReferenceError / stale var), which is not modeled) are not supported yet
|
||||
|
||||
1 | // @dynamic
|
||||
| ^
|
||||
2 | // The REFUSED shape through the --dynamic subgraph: the cycle behind this
|
||||
|
||||
hint: cycles of ES modules with declaration-only top levels whose cycle-crossing bindings are only used inside function bodies compile as-is; move the named top-level read or call into a function body (or break the named edge) so nothing runs during the cycle's init window
|
||||
Reference in New Issue
Block a user