Keep declaration files out of the module order

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 <noreply@anthropic.com>
(cherry picked from commit 534ec68eb13f15812376aa5126f013da693a48d6)
This commit is contained in:
Malte Ubl
2026-10-29 17:57:17 -07:00
parent 794ede51db
commit cc417cdc74
6 changed files with 85 additions and 2 deletions
@@ -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);
+37 -1
View File
@@ -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;
@@ -0,0 +1,18 @@
{
"order": [
"<repo>/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": "<repo>/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"
}
]
}
@@ -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);
+6
View File
@@ -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;
@@ -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