diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index a54681b73..053e5039f 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -739,7 +739,7 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr }; return lowerer.coerceToExpected(copy, mapped); } - if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType)) + if (!copiedType(lowerer, mapped) && copiedRecordType(lowerer, tsType)) return lowerer.coerceToExpected(raw, mapped); if (!copiedType(lowerer, mapped)) boundaryFail( @@ -1245,6 +1245,25 @@ function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean { return true; } +/** A record type an island object crosses OUT into as a deep checked + * copy: the object types exportedByReference names, and intersections of + * them — a mapped pair (`{[K in Fg]: V} & {[K in Bg]?: V}`) or a merged + * generic record (`Omit & Extras`) is one record, so the + * island's object converts into the native one the same way a plain + * record's does. An intersection with a primitive arm (`string & {brand}`) + * is a primitive value, which copies as its arm. */ +function copiedRecordType(lowerer: Lowerer, type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Intersection) !== 0) + return ts + .constituentTypes(type) + .every( + (t) => + (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 || + copiedRecordType(lowerer, t), + ); + return exportedByReference(lowerer, type); +} + /** A fresh object or array literal crosses as a copy (a plain dyn object * or array the runtime copies into V8): nothing native can alias it. Its * members cross by the same rules (toIsland). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json new file mode 100644 index 000000000..f1fc0a68d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6899-node-island-unsafe-record/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6899-node-island-unsafe-record/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6899-node-island-unsafe-record/main.ts b/tests/corpus/6899-node-island-unsafe-record/main.ts new file mode 100644 index 000000000..8eb8d894e --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/main.ts @@ -0,0 +1,21 @@ +// @requires-libnode +// A record the island hands back (`unsafe` declares the caller's own +// type, like typebox's `Type.Unsafe`) crosses out as a deep checked +// copy: the merged type is a required half and an optional half, and the +// native value carries every field the declaration promises. +import { unsafe } from "recordkit"; + +/** A mapped pair of halves — one record, spelled as an intersection. */ +type ColorValues = { [K in "fg" | "bg"]: Value } & { [K in "accent"]?: Value }; + +const colors = unsafe>({ fg: "red", bg: "blue" }); +console.log(colors.fg, colors.bg, colors.accent === undefined, Object.keys(colors).join(",")); + +const levels = unsafe>({ fg: 1, bg: 2, accent: 3 }); +console.log(levels.fg + levels.bg, levels.accent, Object.keys(levels).join(",")); + +/** Native code reads the copy's fields by their declared types. */ +function describe(values: ColorValues): string { + return `${values.fg}/${values.bg}/${values.accent ?? "-"}`; +} +console.log(describe(colors), describe({ fg: "green", bg: "black" })); diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts new file mode 100644 index 000000000..ed760c9d1 --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + readonly kind: string; + describe(): string; +} +/** Declares the type of the value it is given, like typebox's + * `Type.Unsafe`: the island hands back the caller's own type, so the + * caller's declaration must describe what the island really returns. */ +export declare function unsafe(value: T): T; +/** A schema over the properties record it is given, like `Type.Object`. */ +export declare function object(properties: T): Schema & { readonly properties: T }; +/** The sorted own keys of a value, joined. */ +export declare function keys(value: unknown): string; diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js new file mode 100644 index 000000000..4dec16d1f --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/index.js @@ -0,0 +1,9 @@ +"use strict"; + +exports.unsafe = (value) => value; +exports.object = (properties) => ({ + kind: "object", + describe: () => "object " + Object.keys(properties).sort().join(","), + properties, +}); +exports.keys = (value) => Object.keys(value).sort().join(","); diff --git a/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json new file mode 100644 index 000000000..ef697c47c --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/node_modules/recordkit/package.json @@ -0,0 +1 @@ +{ "name": "recordkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6899-node-island-unsafe-record/package.json b/tests/corpus/6899-node-island-unsafe-record/package.json new file mode 100644 index 000000000..0b88adb53 --- /dev/null +++ b/tests/corpus/6899-node-island-unsafe-record/package.json @@ -0,0 +1 @@ +{ "name": "node-island-unsafe-record", "private": true, "type": "module", "scriptc": { "packages": { "recordkit": "node" } } } diff --git a/tests/fixtures/node-island/unsafe-record-lie.ts b/tests/fixtures/node-island/unsafe-record-lie.ts new file mode 100644 index 000000000..057724d71 --- /dev/null +++ b/tests/fixtures/node-island/unsafe-record-lie.ts @@ -0,0 +1,12 @@ +// `unsafe` declares the caller's own type and hands back the value it +// was given: the record crossing out is a deep CHECKED copy, so a field +// that contradicts the declaration throws a TypeError instead of entering a +// native record (a corpus program cannot pin this: Node prints the field). +import { unsafe } from "recordkit"; + +type ColorValues = { [K in "fg" | "bg"]: string } & { [K in "accent"]?: string }; + +const raw: unknown = JSON.parse('{"fg":1,"bg":"blue"}'); +console.log("before"); +const colors = unsafe(raw as ColorValues); +console.log("after", colors.fg); diff --git a/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json new file mode 100644 index 000000000..f350af81d --- /dev/null +++ b/tests/harness/corpus-costs/6899-node-island-unsafe-record/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 33000 } diff --git a/tests/harness/node-island-handles.test.ts b/tests/harness/node-island-handles.test.ts index 650036bf4..5632a18ef 100644 --- a/tests/harness/node-island-handles.test.ts +++ b/tests/harness/node-island-handles.test.ts @@ -210,6 +210,32 @@ describe.skipIf(skip)("Node island handles", () => { ); }, 300_000); + test("an island record that contradicts its declaration throws at the crossing", async () => { + // A record the island hands back crosses as a deep checked copy: + // `unsafe` declares the caller's own record type and hands back a + // JSON object whose `fg` is a number, so the copy's conversion throws + // before a number reaches a native string field. + const root = join(outDir, "unsafe-record-lie"); + rmSync(root, { recursive: true, force: true }); + cpSync(join(repoRoot, "tests/corpus/6899-node-island-unsafe-record"), root, { + recursive: true, + }); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/unsafe-record-lie.ts"), + join(root, "main.ts"), + ); + const result = await compile(join(root, "main.ts"), { + outPath: join(root, "main"), + outDir: root, + sanitize, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(await refusal(result.binaryPath)).toContain( + "TypeError: expected string at $.fg, got number", + ); + }, 300_000); + test("an island package ships with the program", async () => { const root = join(outDir, "ships"); const src = join(root, "src");