diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index edf8fc573..ea36773c6 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -9512,7 +9512,7 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { * and `switch (r.kind)` work without dedicated test nodes. Anything else * on a union receiver is rejected specifically (narrow first). */ export function lowerUnionProperty(L: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - if (expr.questionDotToken) return null; + if (L.chainBlocked(expr)) return null; const receiverIr = L.mapTypeOf(L.typeOf(expr.expression)); if (receiverIr?.kind !== "union") return null; // Lower the receiver FIRST and read its actual IR union: a partially @@ -9523,8 +9523,10 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { // A checker-union receiver whose VALUE lowered to a plain RECORD (the // merged-signature fiction — `runner(cmd, args)` where runner joined // a structural runner type with spawnSync's, and the local adopted - // the record arm): read the record field directly, the dyn-receiver - // fallback's discipline. + // the record arm), or to a concrete CLASS object behind an erasing + // widening assertion (`concrete as A | B`): read the actual value's + // field directly. Assertions change the checker type, not the runtime + // representation; manufacturing a tagged union here would be wrong. // A checker-union receiver whose VALUE lowered checked-dynamic (a // never-tainted JS chain — `cmd[1].length` on `const cmd = ['pwd', // []]`, where the element read stayed a dyn node): read through the @@ -9548,6 +9550,20 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr { } return null; } + if (value.type.kind === "object") { + const fieldType = L.classes.get(value.type.className)?.fields.get(expr.name.text); + if (fieldType) { + return { + kind: "fieldGet", + obj: value, + className: value.type.className, + field: expr.name.text, + type: fieldType, + loc: locOf(expr), + }; + } + return null; + } if (value.type.kind !== "union") { throw new Error("lowerer bug: union-typed receiver lowered to a non-union"); } diff --git a/tests/harness/union-receiver.test.ts b/tests/harness/union-receiver.test.ts new file mode 100644 index 000000000..675c4556d --- /dev/null +++ b/tests/harness/union-receiver.test.ts @@ -0,0 +1,126 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +interface RunResult { + stdout: Buffer; + stderr: Buffer; + exitCode: number; +} + +async function run(cmd: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: "buffer" }); + return { stdout, stderr, exitCode: 0 }; + } catch (err) { + if ( + typeof err !== "object" || err === null || + !("code" in err) || typeof err.code !== "number" || + !("stdout" in err) || !Buffer.isBuffer(err.stdout) || + !("stderr" in err) || !Buffer.isBuffer(err.stderr) + ) { + throw err; + } + return { stdout: err.stdout, stderr: err.stderr, exitCode: err.code }; + } +} + +async function compileAndCompare( + name: string, + source: string, + backend: "c" | "llvm", + dynamic: boolean, +): Promise { + const key = createHash("sha256") + .update(source) + .update(`${backend}-${sanitize ? "san" : "plain"}`) + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `union-receiver-${key}`); + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${name}.cjs`); + writeFileSync(file, source); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize, + dynamic, + backend, + }); + if (!result.ok) { + throw new Error( + "union-receiver program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const [nodeResult, nativeResult] = await Promise.all([ + run("node", [file]), + run(result.binaryPath, []), + ]); + expect(nativeResult.stdout).toEqual(nodeResult.stdout); + expect(nativeResult.stderr).toEqual(nodeResult.stderr); + expect(nativeResult.exitCode).toBe(nodeResult.exitCode); +} + +const prelude = `// @ts-check +class A { value = "A"; } +class B { value = "B"; } +/** @typedef {A | B} Item */ +const concrete = new A(); +`; + +describe.each(["c", "llvm"] as const)( + `concrete receivers behind union assertions, %s backend${sanitize ? " (sanitized)" : ""}`, + (backend) => { + test("preserves direct and optional reads without dynamic marshalling", async () => { + await compileAndCompare( + "static-reads", + `${prelude} +console.log(/** @type {Item} */ (concrete).value); +console.log(/** @type {Item} */ (concrete)?.value); +`, + backend, + false, + ); + }); + + test("preserves a concrete class receiver in a dyn object-literal argument", async () => { + await compileAndCompare( + "dyn-object-arg", + `${prelude} +const dyn = JSON.parse('{"values":[]}'); +dyn.values.push({ value: /** @type {Item} */ (concrete).value }); +console.log(dyn.values[0].value); +`, + backend, + true, + ); + }); + + test("covers direct dyn-call arguments and optional property access", async () => { + await compileAndCompare( + "dyn-call-variants", + `${prelude} +const dyn = JSON.parse('{"values":[]}'); +dyn.values.push(/** @type {Item} */ (concrete).value); +dyn.values.push({ + direct: /** @type {Item} */ (concrete).value, + optional: /** @type {Item} */ (concrete)?.value, +}); +console.log(dyn.values[0], dyn.values[1].direct, dyn.values[1].optional); +`, + backend, + true, + ); + }); + }, +);