Files
scriptc/packages/compiler/test/union-array-lowering.test.ts
T
Malte UblandClaude Opus 5.5 378901df95 Convert every arm of a mixed union into the array width
`["one"] | string[]` flowing into a `string[]` slot compiled to the
checked extraction against the array arm alone, so a tuple value threw at
runtime. Node accepts it: the value is an array, and nothing about a
`string[]` position distinguishes which arm produced it.

Let the union-width plan cover a mixed union that holds the destination
arm beside a convertible one, and place that plan before the
checked-extraction rule, so each arm converts on its tag (a tuple rebuilds,
an array copies). A union whose only non-unit arm is the destination
(`T[] | undefined`) still has nothing to convert and keeps its tested
extraction.

tests/corpus/8513-tuple-union-array.ts pins the binding, call-argument,
readonly, and single-tuple shapes against Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:37:11 -07:00

142 lines
4.9 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
interface IrShape {
names: string[];
callees: string[];
}
/** Lower one program to IR and collect every function name and call target. */
async function lower(
source: string,
): Promise<{ ok: true; ir: IrShape } | { ok: false; codes: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) };
const mod = deserializeModule(await readFile(outPath, "utf8"));
const callees: string[] = [];
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (value === null || typeof value !== "object") return;
const node = value as { kind?: unknown; callee?: unknown };
if (node.kind === "call" && typeof node.callee === "string") callees.push(node.callee);
for (const child of Object.values(value)) visit(child);
};
visit(mod.functions);
return { ok: true, ir: { names: mod.functions.map((f) => f.name), callees } };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const helpers = (ir: IrShape, prefix: string): string[] =>
ir.names.filter((name) => name.startsWith(prefix));
test("a union of tuples into an array slot lifts every arm on its tag", async () => {
const result = await lower(`
type Variant = ["one"] | ["two", "three"];
function pick(flag: boolean): Variant {
return flag ? ["one"] : ["two", "three"];
}
function take(xs: string[]): number {
return xs.length;
}
console.log(take(pick(true)));
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
const arrayHelpers = helpers(result.ir, "%union.arr");
expect(arrayHelpers.length).toBeGreaterThan(0);
// The conversion is a call to the arm-dispatching helper, not a narrowed
// extraction of one tuple arm.
for (const name of arrayHelpers) expect(result.ir.callees).toContain(name);
});
test("the as const key-table shape lowers through the same helper", async () => {
const result = await lower(`
const SEQUENCES = { up: ["\\x1b[A", "\\x1bOA"], f9: ["\\x1b[20~"] } as const;
type SequenceKey = keyof typeof SEQUENCES;
function matches(data: string, sequences: readonly string[]): boolean {
return sequences.includes(data);
}
console.log(matches("\\x1bOA", SEQUENCES["up" as SequenceKey]));
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0);
});
test("a single tuple keeps the existing tuple-to-array path", async () => {
const result = await lower(`
const single: ["x", "y"] = ["x", "y"];
const widened: string[] = single;
console.log(widened.join(""));
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(helpers(result.ir, "%tup.arr").length).toBeGreaterThan(0);
expect(helpers(result.ir, "%union.arr")).toEqual([]);
});
test("a mixed union that holds the array arm converts every arm", async () => {
const result = await lower(`
type T = string[] | ["a"];
function pick(flag: boolean): T {
return flag ? ["b", "c"] : ["a"];
}
const widened: string[] = pick(true);
console.log(widened.length);
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
// Node has one array either way, so the tuple arm rebuilds instead of the
// checked extraction trapping on it.
expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0);
expect(helpers(result.ir, "%union.narrow")).toEqual([]);
});
test("a union whose only non-unit arm is the slot keeps its checked extraction", async () => {
const result = await lower(`
type T = string[] | undefined;
function pick(flag: boolean): T {
return flag ? ["b", "c"] : undefined;
}
const widened: string[] = pick(true)!;
console.log(widened.length);
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
// Nothing to convert: the destination arm comes out through the same
// checked extraction this shape always took.
expect(helpers(result.ir, "%union.narrow").length).toBeGreaterThan(0);
expect(helpers(result.ir, "%union.arr")).toEqual([]);
});
test("a union holding an undefined arm still lowers through the helper", async () => {
const result = await lower(`
type Pair = [string, string] | [string];
const table: Record<string, Pair> = { one: ["a", "b"] };
const widened: string[] = table["one"]!;
console.log(widened.join(""));
export {};
`);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0);
});