From 7687cf2d26c068f7c623e23f0af588966955f4ec Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 17:50:36 -0700 Subject: [PATCH] Type island packages from their declarations in failed builds and generics When a native build failed and the packages it compiled with runtime fences then failed as islands too, the native build's diagnostics were reported. That build types those packages from their JavaScript, where declared-only types (interfaces, `Static`-style aliases) are `any`, so typed user code reported cascades the island build does not have (a schema library's `TSchema`). The island build's errors are reported instead. In a generic function instance over island types (``), a `T` value bound to dyn now crosses into the island the way `any` does, instead of being refused as a native object. Corpus 6829 types user code with an island package's interfaces, generic aliases and a generic function; node-island.test.ts pins the reported diagnostics of a failed build. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 14 +++---- .../frontend/lowering/lower-node-island.ts | 16 ++++++-- .../main.ts.json | 6 +++ .../6829-node-island-declared-types/main.ts | 39 +++++++++++++++++++ .../node_modules/schemakit/index.d.ts | 27 +++++++++++++ .../node_modules/schemakit/index.js | 37 ++++++++++++++++++ .../node_modules/schemakit/package.json | 1 + tests/fixtures/node-island/declared-types.ts | 27 +++++++++++++ .../main.ts.json | 1 + tests/harness/node-island.test.ts | 25 ++++++++++++ 10 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json create mode 100644 tests/corpus/6829-node-island-declared-types/main.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json create mode 100644 tests/fixtures/node-island/declared-types.ts create mode 100644 tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 8a5d11caa..2abf95ffb 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -156,18 +156,20 @@ export function prepareExecutableModule( ); } // A failed program whose compiled packages need the runtime or a - // mutable object model (NI-2's report): try them as islands, and - // report this failure if that build fails too. + // mutable object model (NI-2's report): try them as islands. If that + // build fails too, its failure is the one reported. This build typed + // those packages from their JavaScript, where the types they only + // declare (interfaces, aliases such as `Static`) are `any`, so its + // diagnostics describe a program the island build does not compile: + // there the packages' declarations type the user code. const marked = retryFences ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) : null; if (marked !== null) { - const diagnostics = lowered.diagnostics; - const texts = fe.sourceTexts(); fe.dispose(); frontendDisposed(); disposed = true; - const islanded = prepareExecutableModule( + return prepareExecutableModule( entryPath, opts, ffi, @@ -177,8 +179,6 @@ export function prepareExecutableModule( marked, false, ); - if (islanded.ok) return islanded; - return { ok: false, diagnostics, sourceTexts: texts }; } return fail(lowered.diagnostics); } diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index f8ae8250a..f03f9c406 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -871,11 +871,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { // NI-5: the runtime exports dyn objects by reference and copies only // primitives, strings and (for now) arrays. return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); - case "refused": + case "refused": { + const type = lowerer.typeOf(node); + // A type parameter the instance binds to dyn (a generic function + // over island types, ``): its values are dyn + // values, and they cross the way `any` values do. + if ( + (type.flags & ts.TypeFlags.TypeParameter) !== 0 && + lowerer.typeParamResolver(type)?.kind === "dyn" + ) + return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); // NI-5: closures, promises, records and class instances cross by // reference (scr_node_island_export.c): their dyn boxes keep the // native referent, and the closure's thunk checks its arguments. - if (exportedByReference(lowerer, lowerer.typeOf(node))) { + if (exportedByReference(lowerer, type)) { const lowered = lowerer.lowerExpr(node); const value = lowerer.coerceToExpected(lowered, DYN); return lowered.type.kind === "object" @@ -884,9 +893,10 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { } return boundaryFail( lowerer, - `a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`, + `a '${lowerer.checker.typeToString(type)}' value (a native object would cross as a copy and lose its identity)`, node, ); + } } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..8a467a09c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6829-node-island-declared-types/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6829-node-island-declared-types/main.ts b/tests/corpus/6829-node-island-declared-types/main.ts new file mode 100644 index 000000000..52c2e5c40 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Typed code names an island package's declared interfaces and generic +// aliases: the package's declarations type it, while its values cross the +// boundary as handles. +import { Arr, Num, Str, check, type Static, type TArray, type TNumber, type TSchema, type TString } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const sum: Tool> = { + name: "sum", + parameters: Arr(Num()), + execute: (id, params) => `${id}: ${params.reduce((a, b) => a + b, 0)}`, +}; +const shout: Tool = { + name: "shout", + parameters: Str(), + execute: (id, params) => `${id}: ${params.toUpperCase()}`, +}; + +function run(tool: Tool, input: unknown): string { + if (!check(tool.parameters, input)) return `${tool.name}: rejected ${JSON.stringify(input)}`; + return tool.execute(tool.name, input); +} + +function describeAll(schemas: TSchema[]): string[] { + return schemas.map((s) => `${s.kind} ${s.describe()}`); +} + +console.log(run(sum, [1, 2, 3])); +console.log(run(sum, [1, "2"])); +console.log(run(shout, "quiet")); +console.log(run(shout, 7)); +console.log(describeAll([sum.parameters, shout.parameters, Arr(Arr(Str()))]).join(" | ")); +const tools: Tool[] = [sum, shout]; +console.log(tools.map((t) => t.name).join(",")); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/declared-types.ts b/tests/fixtures/node-island/declared-types.ts new file mode 100644 index 000000000..05835c751 --- /dev/null +++ b/tests/fixtures/node-island/declared-types.ts @@ -0,0 +1,27 @@ +// A failed build whose package runs in a Node island reports the island +// build's errors: there the package's declarations type the program. The +// test builds it beside corpus 6829's schemakit package. +import { Num, check, type Static, type TNumber, type TSchema } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const double: Tool = { + name: "double", + parameters: Num(), + execute: (id, params) => `${id}: ${params * 2}`, +}; +const input: unknown = 4; +if (check(double.parameters, input)) console.log(double.execute(double.name, input)); +// The build's own error: member decorators have no native lowering. +function logged(method: () => number, context: ClassMethodDecoratorContext): void {} +class Counter { + @logged + next(): number { + return 1; + } +} +console.log(new Counter().next()); diff --git a/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..c2f7906fb --- /dev/null +++ b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 62000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index cbb9daf66..85774285e 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -354,3 +354,28 @@ test("recursive JSON types cross into Node", async () => { const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); expect(ir).toContain("nodeIsland.callSync"); }, 300_000); + +// When a build fails and the packages it compiled with runtime fences then +// fail as islands too, the island build's errors are reported. That build +// types the user code from the packages' declarations; the native one +// typed it from their JavaScript, where declared-only types (`Static`) +// are `any`. Needs no libnode pack: both builds fail before linking. +test("a failed build reports the island build's errors", async () => { + const dir = join(outDir, "declared-types"); + rmSync(dir, { recursive: true, force: true }); + cpSync( + join(repoRoot, "tests/corpus/6829-node-island-declared-types/node_modules"), + join(dir, "node_modules"), + { recursive: true }, + ); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-types.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; + const messages = result.diagnostics.map((d) => `${d.code}: ${d.message}`); + expect(messages.some((m) => m.startsWith("SC1090: method decorators"))).toBe(true); + expect(messages.filter((m) => /Tool|Static|'check'/.test(m))).toEqual([]); +}, 300_000);