From cc417cdc7423937fd57a29c989217bb427db62c9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 10:46:41 -0700 Subject: [PATCH] Keep declaration files out of the module order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A specifier that resolves ONLY to a declaration file has no runtime module to link: Node throws ERR_MODULE_NOT_FOUND at it. Preflight already refused the import form (SC1010), but the re-export arm and both require arms took the declaration file as a module-order edge. Once a declaration file is a program module, its own imports are lowered as code — which is how a package's own `.d.mts` files emit SC2013 for every specifier inside them (the pi/typebox shape: 133 errors, all at import statements inside `node_modules/typebox/build/**/*.d.mts`). Refuse the re-export form with the same SC1010 the import form uses, and skip declaration files as edges in both require arms. `splitFiles` gains the matching invariant: a declaration file is never lowered as a runtime module even if one reaches the order by another path. Test: tests/diagnostics/declaration-only-module (SC1010 snapshot) and the recorded preflight/order baselines. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 534ec68eb13f15812376aa5126f013da693a48d6) --- .../src/frontend/lowering/lower-modules.ts | 10 ++++- packages/compiler/src/frontend/program.ts | 38 ++++++++++++++++++- .../declaration-only-module/main.ts.json | 18 +++++++++ .../declaration-only-module/main.ts | 7 ++++ .../declaration-only-module/types.d.ts | 6 +++ .../declaration-only-module/main.ts.txt | 8 ++++ 6 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json create mode 100644 tests/diagnostics/declaration-only-module/main.ts create mode 100644 tests/diagnostics/declaration-only-module/types.d.ts create mode 100644 tests/harness/__snapshots__/declaration-only-module/main.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 00299ce58..173b44db4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -328,7 +328,15 @@ function appendEntryModules( * (tests, coverage on broken files) may arrive with an empty order — fall * back to the entry alone. */ export function splitFiles(lowerer: Lowerer): FileParts[] { - const files = lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]; + // A declaration file is never a runtime module: it carries no + // implementation, so it has no %init to lower and its imports never load + // anything. Preflight keeps declarations out of the module order (a + // specifier that resolves only to declarations is refused at its own + // statement); this filter is the invariant that keeps a stray declaration + // from being lowered as code. + const files = (lowerer.moduleOrder.length > 0 ? lowerer.moduleOrder : [lowerer.entry]).filter( + (sf) => !sf.isDeclarationFile, + ); return files.map((sf) => { const fp: FileParts = { sf, fnDecls: [], classDecls: [], topStmts: [] }; const rebindable = collectRebindableFunctions(lowerer, sf); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index bcd5e44dc..1b4eb3579 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6060,6 +6060,21 @@ function preflight7(load: LoadResult): { continue; } } + // A re-export whose specifier resolves ONLY to a declaration file + // has no runtime module to link (Node throws + // ERR_MODULE_NOT_FOUND), and a declaration file is not a program + // module: an edge to it would lower the declarations' own imports + // as code. + if (reDep !== null && reDep.isDeclarationFile) { + diags.push( + unsupportedDiag( + "SC1010", + locOf7(stmt), + `the '${fromSpec}' re-export (it resolves only to type declarations — there is no runtime implementation to compile)`, + ), + ); + continue; + } if (reDep && !reDep.fileName.endsWith(".json")) { deps.push({ dep: reDep, stmt }); depPositions.get(sf)!.push({ dep: reDep, pos: ts.tokenStartOfNode(stmt) }); @@ -6250,6 +6265,22 @@ function preflight7(load: LoadResult): { const clause = stmt.importClause; const dep = isRelative ? resolveImport7(program, sf, spec) : (npmStaticDep ?? projDep); const isJson = dep !== null && dep.fileName.endsWith(".json"); + // A specifier that resolves ONLY to a declaration file has no runtime + // module to link: Node throws ERR_MODULE_NOT_FOUND at this import. + // Declarations are not program modules — taking one as a module-order + // edge would lower its imports as if it were code (SC2013 for every + // specifier inside a package's `.d.ts`). Type-only imports were + // already erased above; this is the value form. + if (dep !== null && dep.isDeclarationFile && !isJson) { + diags.push( + unsupportedDiag( + "SC1010", + locOf7(stmt), + `the '${spec}' import (it resolves only to type declarations — there is no runtime implementation to compile)`, + ), + ); + continue; + } // 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 @@ -6532,6 +6563,10 @@ function preflight7(load: LoadResult): { } } if (earlyRequireFence(req, bindingKind, loc)) continue; + // A declaration file is never a runtime module (see the import + // arm): the require throws MODULE_NOT_FOUND in Node, so it is not + // a module-order edge. + if (dep !== null && dep.isDeclarationFile) continue; if (dep) deps.push({ dep }); } } @@ -6610,7 +6645,8 @@ function preflight7(load: LoadResult): { lazy ? { dep, lazy: true, call } : { dep, call }; const projectDep = resolveImport7(program, sf, spec, "require"); if (projectDep !== null) { - if (!projectDep.fileName.endsWith(".json")) deps.push(edgeTo(projectDep)); + if (!projectDep.fileName.endsWith(".json") && !projectDep.isDeclarationFile) + deps.push(edgeTo(projectDep)); continue; } if (isRelativeSpecifier(spec) || spec.startsWith("#")) continue; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json new file mode 100644 index 000000000..44a984f57 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/declaration-only-module/main.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/diagnostics/declaration-only-module/main.ts" + ], + "diags": [ + { + "code": "SC1010", + "message": "the './types.js' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet", + "loc": { + "file": "/tests/diagnostics/declaration-only-module/main.ts", + "start": 246, + "end": 281 + }, + "milestone": "M4", + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + } + ] +} diff --git a/tests/diagnostics/declaration-only-module/main.ts b/tests/diagnostics/declaration-only-module/main.ts new file mode 100644 index 000000000..4e507818a --- /dev/null +++ b/tests/diagnostics/declaration-only-module/main.ts @@ -0,0 +1,7 @@ +// A value import whose specifier resolves ONLY to a declaration file has no +// runtime module to link — Node throws ERR_MODULE_NOT_FOUND here, and a +// declaration file is not a program module (its own imports must never be +// lowered as code). +import { value } from "./types.js"; + +console.log(value); diff --git a/tests/diagnostics/declaration-only-module/types.d.ts b/tests/diagnostics/declaration-only-module/types.d.ts new file mode 100644 index 000000000..6daffe15f --- /dev/null +++ b/tests/diagnostics/declaration-only-module/types.d.ts @@ -0,0 +1,6 @@ +// A module that exists only as declarations: it has no runtime +// implementation for either side to link. +export interface Shape { + kind: string; +} +export declare const value: number; diff --git a/tests/harness/__snapshots__/declaration-only-module/main.ts.txt b/tests/harness/__snapshots__/declaration-only-module/main.ts.txt new file mode 100644 index 000000000..40ff9d4db --- /dev/null +++ b/tests/harness/__snapshots__/declaration-only-module/main.ts.txt @@ -0,0 +1,8 @@ +declaration-only-module/main.ts:5:1 - error SC1010: the './types.js' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet + + 4 | // lowered as code). + 5 | import { value } from "./types.js"; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 6 | + + hint: relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported \ No newline at end of file