mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Filter a union of arrays arm by arm
`.filter` on `A[] | B[]` refused with SC2020: `lowerArrayUnionHofCall` admitted only `map` and `forEach`. Under TS 5.2's callable-union rule the predicate runs over `A | B`, and the result element is the one the predicate names — `(b): b is TextContent` over a union of block arrays answers `TextContent[]` — which no single arm's element need equal. `filter` joins the same arm dispatch: each arm filters its own concrete array through the existing per-arm helper, and its survivors then pass through the checked conversion to the checker's result element, so a predicate that kept an element it does not describe throws instead of reinterpreting a value. An arm with no such conversion (a boolean predicate that names no narrower element) leaves the SC2020 fence in place. Typed programs are untouched: the gate only widens, and `map`/`forEach` keep their exact helper key, so the helpers a program already emitted stay byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b3d69baae8
commit
c294d129d0
@@ -632,7 +632,14 @@ function arrayHofHelper(
|
||||
* original concrete array arm — never a copied `(T | U)[]` — and an
|
||||
* arm-specific function adapter wraps the element and receiver arguments
|
||||
* into the union-wide callback ABI. That preserves sparse-array reads,
|
||||
* callback mutation, and the callback's third-argument identity. */
|
||||
* callback mutation, and the callback's third-argument identity.
|
||||
*
|
||||
* `filter` joins them: the kept elements are the checker's result element
|
||||
* (`(b): b is TextContent` over `(TextContent | ImageContent)[] |
|
||||
* (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm
|
||||
* filters its own array and converts its survivors through the checked
|
||||
* narrowing — a predicate that kept an element it does not describe throws
|
||||
* the TypeError. An arm with no such conversion leaves the fence in place. */
|
||||
export function lowerArrayUnionHofCall(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
@@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall(
|
||||
receiverT: IrType & { kind: "union" },
|
||||
): IrExpr | null {
|
||||
const method = access.name.text;
|
||||
if (method !== "map" && method !== "forEach") return null;
|
||||
if (method !== "map" && method !== "forEach" && method !== "filter") return null;
|
||||
if (!lowerer.isStdlibMember(access)) return null;
|
||||
const def = lowerer.unions.get(receiverT.unionId);
|
||||
if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null;
|
||||
@@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall(
|
||||
lowerer.badType(call, lowerer.typeOf(call));
|
||||
}
|
||||
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
|
||||
const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID;
|
||||
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
|
||||
if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) {
|
||||
if (fnRet.kind === "void") {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
call,
|
||||
"'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)",
|
||||
);
|
||||
}
|
||||
lowerer.badType(call, lowerer.typeOf(call));
|
||||
}
|
||||
// `map` stores the callback's answer; `filter` keeps the elements its
|
||||
// predicate named. The kept element is the CHECKER's result element, which
|
||||
// for a written type predicate is narrower than every arm's own:
|
||||
// `(b): b is TextContent` over `(TextContent | ImageContent)[] |
|
||||
// (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm
|
||||
// filters its own concrete array and converts its survivors to that
|
||||
// element through the checked narrowing — a predicate that kept an element
|
||||
// it does not describe throws instead of misreading it. No conversion the
|
||||
// arms cannot make honestly means the fence stays.
|
||||
let outElem: IrType = VOID;
|
||||
const narrowing: (string | null)[] = [];
|
||||
if (method === "map") {
|
||||
outElem = callbackArrayElement(lowerer, call, fnRet);
|
||||
} else if (method === "filter") {
|
||||
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
|
||||
if (!result || result.kind !== "array") return null;
|
||||
outElem = result.elem;
|
||||
const typeText = filterTypeText(lowerer, call);
|
||||
for (const array of arrays) {
|
||||
if (typeEquals(array.elem, outElem)) {
|
||||
narrowing.push(null);
|
||||
continue;
|
||||
}
|
||||
const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call));
|
||||
if (helper === null) return null;
|
||||
narrowing.push(helper);
|
||||
}
|
||||
}
|
||||
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
|
||||
for (const array of arrays) {
|
||||
const armFnT = funcOf(
|
||||
[arrayValueType(lowerer, array.elem), F64, array].slice(0, arity),
|
||||
@@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall(
|
||||
arity,
|
||||
fnRet,
|
||||
outElem,
|
||||
narrowing,
|
||||
locOf(call),
|
||||
);
|
||||
return {
|
||||
@@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall(
|
||||
|
||||
function arrayUnionHofHelper(
|
||||
lowerer: Lowerer,
|
||||
method: "map" | "forEach",
|
||||
method: "map" | "forEach" | "filter",
|
||||
receiverT: IrType & { kind: "union" },
|
||||
arrays: (IrType & { kind: "array" })[],
|
||||
callbackT: IrType & { kind: "func" },
|
||||
arity: number,
|
||||
fnRet: IrType,
|
||||
outElem: IrType,
|
||||
narrowing: (string | null)[],
|
||||
loc: SrcLoc,
|
||||
): string {
|
||||
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
|
||||
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`;
|
||||
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
|
||||
// A filter's per-arm conversions are part of what the helper does, so two
|
||||
// sites that share receiver, callback and result but convert differently
|
||||
// keep their own helper. `map`/`forEach` never narrow, and leaving their
|
||||
// key untouched keeps the helpers a program already emitted byte-identical.
|
||||
const narrowingKey =
|
||||
method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : "";
|
||||
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`;
|
||||
const existing = lowerer.arrHofHelpers.get(key);
|
||||
if (existing) return existing;
|
||||
const name = keyedHelperName(lowerer, "%arr.union", key);
|
||||
lowerer.arrHofHelpers.set(key, name);
|
||||
const receiver = varRef("a.0", receiverT, loc);
|
||||
const callback = varRef("f.0", callbackT, loc);
|
||||
const branch = (array: IrType & { kind: "array" }): IrStmt[] => {
|
||||
const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => {
|
||||
const tag = lowerer.armTag(receiverT.unionId, array);
|
||||
const concrete: IrExpr = {
|
||||
kind: "unionNarrow",
|
||||
@@ -751,16 +803,38 @@ function arrayUnionHofHelper(
|
||||
if (!typeEquals(adapted.type, armFnT)) {
|
||||
throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting");
|
||||
}
|
||||
const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem);
|
||||
const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc };
|
||||
const callee = arrayHofHelper(
|
||||
lowerer,
|
||||
method,
|
||||
array.elem,
|
||||
fnRet,
|
||||
arity,
|
||||
loc,
|
||||
method === "filter" ? array.elem : outElem,
|
||||
);
|
||||
const invoke: IrExpr = {
|
||||
kind: "call",
|
||||
callee,
|
||||
args: [concrete, adapted],
|
||||
type: method === "filter" ? arrayOf(array.elem) : resultT,
|
||||
loc,
|
||||
};
|
||||
// Survivors of this arm's own array, converted to the checker's result
|
||||
// element. The helper throws for a kept element the predicate does not
|
||||
// describe, so a lying predicate never reinterprets a value.
|
||||
const armNarrow = narrowing[index] ?? null;
|
||||
const answer: IrExpr =
|
||||
armNarrow === null
|
||||
? invoke
|
||||
: { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc };
|
||||
return resultT.kind === "void"
|
||||
? [
|
||||
{ kind: "exprStmt", expr: invoke, loc },
|
||||
{ kind: "return", value: null, loc },
|
||||
]
|
||||
: [{ kind: "return", value: invoke, loc }];
|
||||
: [{ kind: "return", value: answer, loc }];
|
||||
};
|
||||
let body = branch(arrays[arrays.length - 1]!);
|
||||
let body = branch(arrays[arrays.length - 1]!, arrays.length - 1);
|
||||
for (let i = arrays.length - 2; i >= 0; i--) {
|
||||
const array = arrays[i]!;
|
||||
body = [
|
||||
@@ -775,7 +849,7 @@ function arrayUnionHofHelper(
|
||||
type: BOOL,
|
||||
loc,
|
||||
},
|
||||
then: branch(array),
|
||||
then: branch(array, i),
|
||||
else_: body,
|
||||
loc,
|
||||
},
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8530-union-array-filter.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over
|
||||
// `A | B`, and the checker's result element is the arm the predicate names.
|
||||
// The runtime value is one concrete arm's array, so the lowering must
|
||||
// dispatch on that arm (unionIsTag), filter the arm's own array, and convert
|
||||
// the survivors to the named element through the checked narrowing — never
|
||||
// hand a union receiver to a static array intrinsic.
|
||||
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, validateModule } from "../src/index.js";
|
||||
|
||||
const PRELUDE = [
|
||||
"interface TextContent { type: 'text'; text: string }",
|
||||
"interface ImageContent { type: 'image'; data: string }",
|
||||
"interface ThinkingContent { type: 'thinking'; thinking: string }",
|
||||
"type MessageContent =",
|
||||
" | TextContent[]",
|
||||
" | (ImageContent | TextContent)[]",
|
||||
" | (ThinkingContent | TextContent)[];",
|
||||
];
|
||||
|
||||
type IrModule = {
|
||||
entry: string;
|
||||
functions: { name: string; body: unknown }[];
|
||||
};
|
||||
|
||||
async function compileToIr(
|
||||
body: string[],
|
||||
): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-filter-"));
|
||||
try {
|
||||
const entry = join(dir, "main.ts");
|
||||
const outPath = join(dir, "main.ir.json");
|
||||
await writeFile(entry, [...PRELUDE, ...body].join("\n"));
|
||||
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
|
||||
if (!result.ok)
|
||||
return {
|
||||
ok: false,
|
||||
module: null,
|
||||
diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
||||
};
|
||||
const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule;
|
||||
expect(validateModule(module as never)).toEqual([]);
|
||||
return { ok: true, module, diagnostics: "" };
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function named(module: IrModule, part: string): string {
|
||||
return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part)));
|
||||
}
|
||||
|
||||
test("filter on a union of arrays dispatches on the arm", async () => {
|
||||
const compiled = await compileToIr([
|
||||
"function texts(content: MessageContent): string {",
|
||||
" return content",
|
||||
" .filter((block): block is TextContent => block.type === 'text')",
|
||||
" .map((block) => block.text)",
|
||||
" .join(' ');",
|
||||
"}",
|
||||
"const blocks: MessageContent = [{ type: 'text', text: 'a' }];",
|
||||
"console.log(texts(blocks));",
|
||||
]);
|
||||
expect(compiled.diagnostics).toBe("");
|
||||
expect(compiled.ok).toBe(true);
|
||||
const module = compiled.module!;
|
||||
// The tag dispatch that reads the runtime arm…
|
||||
const union = named(module, "%arr.union");
|
||||
expect(union).toContain("unionIsTag");
|
||||
// …and the checked narrowing each arm's survivors pass through, so a
|
||||
// predicate that kept an element it does not describe throws.
|
||||
expect(named(module, "%arr.narrow")).toContain("%narrow.checked");
|
||||
});
|
||||
|
||||
test("filter on a union of arrays keeps the fence without a narrowing", async () => {
|
||||
// A predicate that names no narrower element answers the union-wide
|
||||
// element; converting an arm's array to that element is a widening this
|
||||
// lowering does not build, so the call stays a refusal instead of
|
||||
// compiling to a different array layout.
|
||||
const compiled = await compileToIr([
|
||||
"function counts(content: MessageContent): number {",
|
||||
" return content.filter((block): boolean => block.type !== 'image').length;",
|
||||
"}",
|
||||
"console.log(counts([]));",
|
||||
]);
|
||||
expect(compiled.ok).toBe(false);
|
||||
expect(compiled.diagnostics).toContain("SC2020");
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union
|
||||
// rule): the predicate runs over `A | B`, and the checker's result element
|
||||
// is the one the predicate names — not any arm's own element. The runtime
|
||||
// value is one concrete arm's array, so the filter dispatches on that arm,
|
||||
// keeps the arm's survivors in order, and converts each survivor to the
|
||||
// named element.
|
||||
interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
interface ImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
}
|
||||
interface ThinkingContent {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
interface ToolCall {
|
||||
type: "tool";
|
||||
name: string;
|
||||
}
|
||||
type MessageContent =
|
||||
| TextContent[]
|
||||
| (ImageContent | TextContent)[]
|
||||
| (ThinkingContent | ToolCall | TextContent)[];
|
||||
|
||||
function textOf(content: MessageContent): string {
|
||||
return content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const contents: MessageContent[] = [
|
||||
[{ type: "text", text: "a" }, { type: "text", text: "b" }],
|
||||
[
|
||||
{ type: "image", data: "AA" },
|
||||
{ type: "text", text: "c" },
|
||||
],
|
||||
[
|
||||
{ type: "thinking", thinking: "t" },
|
||||
{ type: "tool", name: "n" },
|
||||
{ type: "text", text: "d" },
|
||||
],
|
||||
];
|
||||
for (const content of contents) console.log("text", textOf(content));
|
||||
|
||||
// A predicate written as a function EXPRESSION rather than an arrow: the
|
||||
// same receiver, the same per-arm dispatch.
|
||||
function expressions(content: MessageContent): string {
|
||||
return content
|
||||
.filter(function (block): block is TextContent {
|
||||
return block.type === "text";
|
||||
})
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
}
|
||||
console.log("expr", expressions(contents[1]!), expressions(contents[2]!));
|
||||
|
||||
// The predicate sees the union-wide element, so an arm-specific test is
|
||||
// legal; the survivors still convert per arm.
|
||||
function longTexts(content: MessageContent): string[] {
|
||||
return content
|
||||
.filter((block): block is TextContent => block.type === "text" && block.text !== "c")
|
||||
.map((block) => block.text);
|
||||
}
|
||||
console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+"));
|
||||
|
||||
// Node's `filter` reads the length once and visits elements in order; the
|
||||
// arm dispatch must not change either, and the result is a fresh array.
|
||||
let seen = 0;
|
||||
function counted(content: MessageContent): number {
|
||||
const kept = content.filter((block): block is TextContent => {
|
||||
seen++;
|
||||
return block.type === "text";
|
||||
});
|
||||
return kept.length;
|
||||
}
|
||||
console.log("counted", counted(contents[2]!), "seen", seen);
|
||||
// The empty array is spelled with its arm's type: a bare `[]` literal
|
||||
// assigned to this three-arm array union is a separate, unrelated gap (the
|
||||
// literal's `number[]` element has no arm conversion), so it would obscure
|
||||
// what this program pins.
|
||||
const empty: MessageContent = [] as TextContent[];
|
||||
console.log("empty", textOf(empty), counted(empty), "seen", seen);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1900, "san": 2200 }
|
||||
Reference in New Issue
Block a user