diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 812581b88..562b879a2 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -636,7 +636,13 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { // graph: resolveImport follows its node_modules link to the loaded // TypeScript entry. Its internal `.js` spellings have already been // resolved to `.ts` by that graph. - if (!isRelativeSpecifier(spec) && resolveImport(lowerer.program, fp.sf, spec) !== null) { + const projectDep = isRelativeSpecifier(spec) + ? null + : resolveImport(lowerer.program, fp.sf, spec); + if (projectDep !== null) { + // A Node island package that tsconfig `paths` maps to its installed + // directory resolves to its declarations: it loads in Node. + if (projectDep.isDeclarationFile) collectIslandImport(lowerer, fp.sf, stmt, spec); continue; } const npm = resolveNpmImport(fp.sf.fileName, spec); diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index b7886cbec..c7ec1467b 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5754,6 +5754,16 @@ function preflight7(load: LoadResult): { ); continue; } + // A re-export from a Node island package that tsconfig `paths` maps + // to its installed directory: its declarations are no program + // module (the lowering reports the island boundary). + if ( + projectReexport !== null && + projectReexport.isDeclarationFile && + !isRelativeSpecifier(fromSpec) && + isFrontendIsland(packageNameOfSpecifier(fromSpec)) + ) + continue; const reDep = projectReexport; // `export * as ns from "./m"` re-exports the module NAMESPACE // object under a name: importers' `x.ns.member` reads resolve @@ -5876,6 +5886,16 @@ function preflight7(load: LoadResult): { // 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) { + // A Node island package that tsconfig `paths` maps to its + // installed directory resolves to its declarations here: it loads + // in Node, typed by them, like an island package resolved through + // node_modules (scope-B NI-4). + if ( + projDep !== null && + projDep.isDeclarationFile && + isFrontendIsland(packageNameOfSpecifier(spec)) + ) + continue; if (projDep !== null && projDep.isDeclarationFile) { diags.push( unsupportedDiag( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6839-node-island-path-mapped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6839-node-island-path-mapped/main.ts.json new file mode 100644 index 000000000..8ae987602 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6839-node-island-path-mapped/main.ts.json @@ -0,0 +1,18 @@ +{ + "order": [ + "/tests/corpus/6839-node-island-path-mapped/main.ts" + ], + "diags": [ + { + "code": "SC1010", + "message": "the 'pathkit' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet", + "loc": { + "file": "/tests/corpus/6839-node-island-path-mapped/main.ts", + "start": 234, + "end": 294 + }, + "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/corpus/6839-node-island-path-mapped/main.ts b/tests/corpus/6839-node-island-path-mapped/main.ts new file mode 100644 index 000000000..603392d02 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/main.ts @@ -0,0 +1,21 @@ +// @requires-libnode +// tsconfig `paths` maps an island package to its installed directory (a +// monorepo pinning one copy): its import resolves to the package's +// declarations, which type the program, and the package loads in Node. +import { Paths, heavier, route, type Route } from "pathkit"; + +interface Table { + title: string; + rows: T[]; +} + +function render(table: Table): string { + return `${table.title}: ${table.rows.map((r) => r.label()).join(", ")}`; +} + +const routes: Route[] = [route("/a", 1), route("/b", 3), route("/c", 2)]; +const table: Table = { title: "routes", rows: routes }; +console.log(render(table)); +const top = heavier(routes[0]!, routes[1]!); +console.log(top.rank, top.label(), top === routes[1], routes.length); +console.log(Paths.join("api", "v1")); diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.d.mts b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.d.mts new file mode 100644 index 000000000..aa3c28f80 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.d.mts @@ -0,0 +1,10 @@ +export interface Route { + readonly path: string; + readonly weight: number; + label(): string; +} +export type Weighted = T & { readonly rank: number }; +export declare function route(path: string, weight: number): Route; +export declare function heavier(a: T, b: T): Weighted; +import * as Paths from "./paths.mjs"; +export { Paths }; diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.mjs b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.mjs new file mode 100644 index 000000000..e41172550 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.mjs @@ -0,0 +1,13 @@ +// The loader picks its part with a computed require() specifier, which +// scriptc does not compile natively: the package runs in a Node island. +import { createRequire } from "node:module"; +const load = createRequire(import.meta.url); +const { Route } = ["./route.cjs"].map((p) => load(p))[0]; +export function route(path, weight) { + return new Route(path, weight); +} +export function heavier(a, b) { + return Object.assign(b.weight > a.weight ? b : a, { rank: 1 }); +} +import * as Paths from "./paths.mjs"; +export { Paths }; diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.d.mts b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.d.mts new file mode 100644 index 000000000..9d19dad8e --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.d.mts @@ -0,0 +1 @@ +export declare function join(...parts: string[]): string; diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.mjs b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.mjs new file mode 100644 index 000000000..e343ff3d2 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.mjs @@ -0,0 +1,3 @@ +export function join(...parts) { + return "/" + parts.join("/"); +} diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/route.cjs b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/route.cjs new file mode 100644 index 000000000..0d53a5a65 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/route.cjs @@ -0,0 +1,11 @@ +"use strict"; +class Route { + constructor(path, weight) { + this.path = path; + this.weight = weight; + } + label() { + return this.path + "@" + this.weight; + } +} +exports.Route = Route; diff --git a/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/package.json b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/package.json new file mode 100644 index 000000000..f39f36eb8 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/package.json @@ -0,0 +1,7 @@ +{ + "name": "pathkit", + "version": "2.0.0", + "type": "module", + "types": "./build/index.d.mts", + "exports": { ".": { "import": "./build/index.mjs", "default": "./build/index.mjs" } } +} diff --git a/tests/corpus/6839-node-island-path-mapped/tsconfig.json b/tests/corpus/6839-node-island-path-mapped/tsconfig.json new file mode 100644 index 000000000..aa3b6fe0b --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "strict": true, + "paths": { + "pathkit": ["./node_modules/pathkit"] + } + } +} diff --git a/tests/fixtures/node-island/path-mapped-reexport.ts b/tests/fixtures/node-island/path-mapped-reexport.ts new file mode 100644 index 000000000..7bb102298 --- /dev/null +++ b/tests/fixtures/node-island/path-mapped-reexport.ts @@ -0,0 +1,7 @@ +// Re-exports from an island package that tsconfig `paths` maps to its +// installed directory: the type-only one is erased, the value one reports +// the island boundary, and the package's declarations never compile as a +// program module. The test builds it beside corpus 6839's pathkit package. +export type { Route } from "pathkit"; +export { route } from "pathkit"; +console.log("loaded"); diff --git a/tests/harness/corpus-costs/6839-node-island-path-mapped/main.ts.json b/tests/harness/corpus-costs/6839-node-island-path-mapped/main.ts.json new file mode 100644 index 000000000..f9a85c4e9 --- /dev/null +++ b/tests/harness/corpus-costs/6839-node-island-path-mapped/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 30000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index b6b54643b..971311d75 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -354,3 +354,22 @@ test("recursive JSON types cross into Node", async () => { const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); expect(ir).toContain("nodeIsland.callSync"); }, 300_000); + +// Needs no libnode pack: the build fails before linking. +test("re-exporting a path-mapped island package reports the island boundary", async () => { + const dir = join(outDir, "path-mapped-reexport"); + rmSync(dir, { recursive: true, force: true }); + const corpus = join(repoRoot, "tests/corpus/6839-node-island-path-mapped"); + cpSync(join(corpus, "node_modules"), join(dir, "node_modules"), { recursive: true }); + copyFileSync(join(corpus, "tsconfig.json"), join(dir, "tsconfig.json")); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/path-mapped-reexport.ts"), + join(dir, "main.ts"), + ); + const result = await compile(join(dir, "main.ts"), { outPath: join(dir, "main"), outDir: dir }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => `${d.code} ${d.loc.file.slice(dir.length + 1)}`)).toEqual([ + "SC8010 main.ts", + ]); +}, 300_000);