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 f1e705aae..5d5e934fe 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -929,11 +929,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" @@ -942,9 +951,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);