Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions packages/compiler/src/frontend/lowering/lower-exprs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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");
}
Expand Down
126 changes: 126 additions & 0 deletions tests/harness/union-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -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<RunResult> {
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<void> {
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,
);
});
},
);