From ba01821d081ce92b6e8f23d1f6d7973355166cd1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:04:36 -0500 Subject: [PATCH 1/3] Expect the word-segments pilot to segment in its Node island --- tests/harness/npm-static.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 076733739..fb469ec22 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,6 +15,7 @@ import { promisify } from "node:util"; import { beforeEach, describe, expect, test } from "vitest"; import { analyze, compile } from "@scriptc/compiler"; import { skipUnownedTest } from "./shard.js"; +import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -586,7 +587,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + // The package builds its segmenter at module load, a run-time fence that + // surely fires, so the package runs in its Node island and segments as + // Node does. + test("a word segmenter a package builds at load runs in its Node island", async () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); @@ -599,12 +603,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { outDir, outPath: join(outDir, "program"), }); + if (libnodePack() === null) { + expect(result.ok, "no libnode pack: the compile stops").toBe(false); + if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005"); + return; + } if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const nativeRes = await runBinary(result.binaryPath, []); - expect(nativeRes.stdout.toString()).toMatch( - /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, - ); + expect(nativeRes.stdout.toString()).toBe("two| |words\n"); expect(nativeRes.exitCode).toBe(0); }); From 637d78bb6757d71fc43831a76933bd16cf96c57b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 19:25:28 -0700 Subject: [PATCH 2/3] Load island packages that tsconfig paths map to their directory A monorepo can pin one copy of a package with a tsconfig `paths` entry that names its installed directory. Its import then resolves as a project import to the package's declarations, so when the package runs in a Node island, preflight refused it as types-only (SC1010) and the lowering skipped it as a project module, leaving its bindings unlowered (SC2013). Both now treat it as the island import it is: typed by its declarations, loaded in Node. Corpus 6839 imports an ESM island package through such a mapping and uses its interfaces and generic aliases from typed code. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 8 +++++++- packages/compiler/src/frontend/program.ts | 10 ++++++++++ .../6839-node-island-path-mapped/main.ts.json | 18 +++++++++++++++++ .../6839-node-island-path-mapped/main.ts | 20 +++++++++++++++++++ .../node_modules/pathkit/build/index.d.mts | 8 ++++++++ .../node_modules/pathkit/build/index.mjs | 11 ++++++++++ .../node_modules/pathkit/build/route.cjs | 11 ++++++++++ .../node_modules/pathkit/package.json | 7 +++++++ .../tsconfig.json | 8 ++++++++ .../6839-node-island-path-mapped/main.ts.json | 1 + 10 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6839-node-island-path-mapped/main.ts.json create mode 100644 tests/corpus/6839-node-island-path-mapped/main.ts create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.d.mts create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.mjs create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/route.cjs create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/package.json create mode 100644 tests/corpus/6839-node-island-path-mapped/tsconfig.json create mode 100644 tests/harness/corpus-costs/6839-node-island-path-mapped/main.ts.json 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..e77ba1e20 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5876,6 +5876,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..593858f4d --- /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": 287 + }, + "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..5c679b22c --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/main.ts @@ -0,0 +1,20 @@ +// @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 { 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); 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..fdf410b6b --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.d.mts @@ -0,0 +1,8 @@ +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; 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..c6ad6a3a1 --- /dev/null +++ b/tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/index.mjs @@ -0,0 +1,11 @@ +// 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 }); +} 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/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 } From 91b437c090f3cce273da868b7c6c76775c3e4390 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 20:45:36 -0700 Subject: [PATCH 3/3] Keep path-mapped island declarations out of the module graph A re-export from an island package that tsconfig `paths` maps to its installed directory pushed the package's declaration file into the module order, so the lowering compiled the declarations' own imports (SC2013 in the package's .d.mts). Such a re-export now adds no module edge; the lowering reports the island boundary as for any island re-export. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/program.ts | 10 ++++++++++ .../6839-node-island-path-mapped/main.ts.json | 2 +- .../6839-node-island-path-mapped/main.ts | 3 ++- .../node_modules/pathkit/build/index.d.mts | 2 ++ .../node_modules/pathkit/build/index.mjs | 2 ++ .../node_modules/pathkit/build/paths.d.mts | 1 + .../node_modules/pathkit/build/paths.mjs | 3 +++ .../node-island/path-mapped-reexport.ts | 7 +++++++ tests/harness/node-island.test.ts | 19 +++++++++++++++++++ 9 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.d.mts create mode 100644 tests/corpus/6839-node-island-path-mapped/node_modules/pathkit/build/paths.mjs create mode 100644 tests/fixtures/node-island/path-mapped-reexport.ts diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index e77ba1e20..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 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 index 593858f4d..8ae987602 100644 --- 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 @@ -9,7 +9,7 @@ "loc": { "file": "/tests/corpus/6839-node-island-path-mapped/main.ts", "start": 234, - "end": 287 + "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 index 5c679b22c..603392d02 100644 --- a/tests/corpus/6839-node-island-path-mapped/main.ts +++ b/tests/corpus/6839-node-island-path-mapped/main.ts @@ -2,7 +2,7 @@ // 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 { heavier, route, type Route } from "pathkit"; +import { Paths, heavier, route, type Route } from "pathkit"; interface Table { title: string; @@ -18,3 +18,4 @@ 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 index fdf410b6b..aa3c28f80 100644 --- 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 @@ -6,3 +6,5 @@ export interface Route { 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 index c6ad6a3a1..e41172550 100644 --- 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 @@ -9,3 +9,5 @@ export function 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/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/node-island.test.ts b/tests/harness/node-island.test.ts index 8a1388dd9..442869468 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);