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:
Chris Tate
2026-07-24 09:32:53 -05:00
parent 88ff8eaa60
commit f2a629ae06
24 changed files with 402 additions and 169 deletions
@@ -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}.`);
});
+146 -133
View File
@@ -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;
}
+2 -1
View File
@@ -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);
+10
View File
@@ -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";
+12
View File
@@ -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