diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..b2e457681 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -43,6 +43,7 @@ import { symbolFieldDisplayName, } from "../symbol-fields.js"; import { planUnionRetag } from "../union-retag.js"; +import { literalValues } from "../../literal-values.js"; import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; import { builtinGlobalValue } from "./property-access.js"; import { holdsEvolvingArray } from "../lower-stmts.js"; @@ -850,6 +851,10 @@ export function lowerObjectLiteral( lowerer: Lowerer, expr: ts.ObjectLiteralExpression, expected?: IrType & { kind: "record" }, + /** Build exactly at `expected` (one arm of a distributed union spread, + * lowerDistributedUnionSpread): the checker types the literal as the + * whole union, so its contextual type cannot choose the layout. */ + exact = false, ): IrExpr { // A spread of an untyped (`any`) source into a typed slot: TypeScript // types such a literal `any`, and the copy holds whatever own properties @@ -968,7 +973,7 @@ export function lowerObjectLiteral( ) { return lowerDynObjectLiteral(lowerer, expr); } - const unionClone = lowerUnionRecordClone(lowerer, expr); + const unionClone = exact ? null : lowerUnionRecordClone(lowerer, expr); if (unionClone !== null) return unionClone; // The RUNTIME-KEYED literal (JS): a computed key that doesn't fold to a // compile-time string means the literal's shape is not a compile-time @@ -1106,7 +1111,7 @@ export function lowerObjectLiteral( ) { tsType = lowerer.checker.getAwaitedType(tsType) ?? tsType; } - let mapped = lowerer.mapTypeOf(tsType); + let mapped = exact && expected ? expected : lowerer.mapTypeOf(tsType); // A checked-dynamic source has runtime keys even when later explicit // fields make the literal's inferred type look like a fixed record. // Keep a checked-dynamic destination in that representation so copying @@ -1314,6 +1319,13 @@ export function lowerObjectLiteral( lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } if (!mapped || mapped.kind !== "record") { + // Distribute against the contextual union when there is one (its arms + // carry the discriminants), else against the literal's own union. + const ctx = lowerer.mapTypeOf(tsType); + const distributed = exact + ? null + : lowerDistributedUnionSpread(lowerer, expr, ctx?.kind === "union" ? ctx : mapped); + if (distributed) return distributed; // A spread merge over UNION-typed sources (`{ ...base, ...override }` // with `base`, `override` of arm-union types) has one field set per // combination of source arms, so its own type is a union of records. @@ -1915,7 +1927,7 @@ export function lowerObjectLiteral( ? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop)) : prop === expr.properties.nodes[0] && leadingSpreadLowered !== null ? leadingSpreadLowered - : lowerer.lowerExpr(srcNode), + : (lowerer.spreadArmOverrides.get(srcNode) ?? lowerer.lowerExpr(srcNode)), ); // Array callbacks can receive a record-or-undefined ABI even when // TypeScript describes a required record. Select the copy strategy @@ -2663,6 +2675,217 @@ function isEmptyArrayLiteral(node: ts.Node): boolean { return ts.isArrayLiteralExpression(inner) && inner.elements.nodes.length === 0; } +/** `{ ...u, k: v }` where the spread source `u` is a union of records (and + * unit arms): TypeScript types the literal as the union of one spread per + * source arm, so it is built that way. The source evaluates once into a + * hidden local; a tag dispatch builds the running arm's literal with that + * arm as the source, at the destination arm chosen for it: + * 1. the arms the discriminant's last writer selects (an explicit + * property, or the source arm when it carries the field); + * 2. among those, the source arm itself (`{ ...command, id }`); + * 3. else the one arm whose keys are exactly the keys the literal writes. + * Every key a contributor may write must be a field of the destination: + * the record path's width copy would drop the others, and Node keeps them. + * So a genuine merge (`{ ...http, ...patch }` where `patch` may carry a + * stdio-only key) stays refused. A stored source the checker narrowed + * converts through the checked re-tag first, so an excluded arm (a hidden + * absence arm included) throws the catchable TypeError. Null when the + * literal is not this shape, before anything is lowered except the + * source; the caller keeps its refusal. */ +function lowerDistributedUnionSpread( + lowerer: Lowerer, + expr: ts.ObjectLiteralExpression, + mapped: IrType | null, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + if (mapped?.kind !== "union") return null; + const target = lowerer.unions.get(mapped.unionId); + if (!target) return null; + const props = expr.properties.nodes; + // Exactly one distributed source: a plain spread whose checker type maps + // to a union of records and unit arms. Several would be a product. + let spreadIndex = -1; + let srcNode: ts.Expression | null = null; + for (let i = 0; i < props.length; i++) { + const prop = props[i]!; + if (!ts.isSpreadAssignment(prop)) continue; + if ( + conditionalSpreadOf(prop.expression) !== null || + andSpreadOf(lowerer, prop.expression) !== null || + conditionalRecordSpreadOf(lowerer, prop.expression) !== null + ) + continue; + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (lowerer.mapTypeOf(lowerer.typeOf(node))?.kind !== "union") continue; + if (srcNode !== null) return null; + spreadIndex = i; + srcNode = node; + } + if (srcNode === null) return null; + const site = srcNode; + // The source evaluates before the dispatch, so everything written before + // it must be free of effects. + for (const prop of props.slice(0, spreadIndex)) { + if (ts.isShorthandPropertyAssignment(prop)) continue; + if (ts.isPropertyAssignment(prop) && pureKeyExpr(lowerer, prop.initializer)) continue; + return null; + } + const siteType = lowerer.mapTypeOf(lowerer.typeOf(site)); + if (siteType?.kind !== "union") return null; + const siteDef = lowerer.unions.get(siteType.unionId); + if (!siteDef || !siteDef.arms.every((arm) => arm.kind === "record" || isUnitType(arm))) + return null; + const discField = target.discriminant?.field; + // The discriminant values the literal's LAST writer of that field gives + // this source arm; null when unknown. + const discriminantOf = (tag: number, arm: IrType): ReturnType => { + if (discField === undefined) return null; + for (let i = props.length - 1; i >= 0; i--) { + const prop = props[i]!; + if (i === spreadIndex) { + if (arm.kind !== "record") continue; + if (!lowerer.shapes.get(arm.shapeId)?.fields.some((f) => f.name === discField)) continue; + const own = siteDef.discriminant; + return own?.field === discField + ? (own.cases.find((c) => c.tag === tag)?.values ?? null) + : null; + } + if (ts.isSpreadAssignment(prop)) return null; + if ( + (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && + propNameText(lowerer, prop.name) === discField + ) + return ts.isPropertyAssignment(prop) + ? literalValues(lowerer.typeOf(prop.initializer)) + : literalValues(lowerer.typeOf(prop.name)); + } + return null; + }; + // Every key some contributor may write for this source arm; null when a + // contributor has no fixed key set (an index signature, a non-record). + const writtenKeys = (arm: IrType): Set | null => { + const names = new Set(); + for (let i = 0; i < props.length; i++) { + const prop = props[i]!; + if (ts.isSpreadAssignment(prop)) { + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + const t = i === spreadIndex ? arm : lowerer.mapTypeOf(lowerer.typeOf(node)); + if (t !== null && isUnitType(t)) continue; + if (t?.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + if (!shape || shape.indexValue || shape.tuple) return null; + for (const f of shape.fields) names.add(f.name); + } else if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) { + names.add(propNameText(lowerer, prop.name)); + } else { + return null; + } + } + return names; + }; + const destinationOf = (tag: number, arm: IrType): (IrType & { kind: "record" }) | null => { + const keys = writtenKeys(arm); + if (keys === null) return null; + let candidates = target.arms + .map((a, t) => ({ a, t })) + .filter((c): c is { a: IrType & { kind: "record" }; t: number } => c.a.kind === "record") + .filter((c) => { + const fields = lowerer.shapes.get(c.a.shapeId)!.fields; + return [...keys].every((k) => fields.some((f) => f.name === k)); + }); + const values = discriminantOf(tag, arm); + if (values && values.length > 0 && target.discriminant) { + const cases = target.discriminant.cases; + candidates = candidates.filter((c) => + (cases.find((k) => k.tag === c.t)?.values ?? []).some((v) => values.includes(v)), + ); + } + if (candidates.length === 1) return candidates[0]!.a; + if (arm.kind === "record") { + const same = candidates.find((c) => c.a.shapeId === arm.shapeId); + if (same) return same.a; + } + const exactKeys = candidates.filter( + (c) => lowerer.shapes.get(c.a.shapeId)!.fields.length === keys.size, + ); + return exactKeys.length === 1 ? exactKeys[0]!.a : null; + }; + // Plan every arm before lowering anything. + const plan: { tag: number; arm: IrType; destination: IrType & { kind: "record" } }[] = []; + for (let tag = 0; tag < siteDef.arms.length; tag++) { + const arm = siteDef.arms[tag]!; + const destination = destinationOf(tag, arm); + if (destination === null) return null; + plan.push({ tag, arm, destination }); + } + const loc = locOf(expr); + let source = lowerer.lowerExpr(site); + if (source.type.kind === "union" && !typeEquals(source.type, siteType)) { + const helper = lowerer.narrowedRetagHelper( + site, + source.type.unionId, + siteType.unionId, + source.loc, + ); + if (helper) + source = { kind: "call", callee: helper, args: [source], type: siteType, loc: source.loc }; + } + if (!typeEquals(source.type, siteType)) return null; + const saved = lowerer.declareHiddenLocal("%spreadArms", siteType); + const ref = varRef(saved.id, siteType, loc); + let result: IrExpr | null = null; + for (let i = plan.length - 1; i >= 0; i--) { + const { tag, arm, destination } = plan[i]!; + const armValue: IrExpr = + arm.kind === "record" + ? { kind: "unionNarrow", value: ref, unionId: siteType.unionId, tag, type: arm, loc } + : { + kind: "recordLit", + fields: [], + type: { kind: "record", shapeId: lowerer.shapes.intern([]) }, + loc, + }; + lowerer.spreadArmOverrides.set(site, armValue); + let built: IrExpr; + try { + built = lowerObjectLiteral(lowerer, expr, destination, true); + } finally { + lowerer.spreadArmOverrides.delete(site); + } + const wrapped = lowerer.coerceToExpected(built, mapped); + if (!typeEquals(wrapped.type, mapped)) return null; + result = + result === null + ? wrapped + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + value: ref, + unionId: siteType.unionId, + tag, + negated: false, + type: BOOL, + loc, + }, + then: wrapped, + else_: result, + type: mapped, + loc, + }; + } + if (result === null) return null; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: saved.id, init: source, loc }], + result, + type: mapped, + loc, + }; +} + /** A union of plain records can preserve its existing variant while * replacing shared fields. Dispatch before constructing the copy: only * the selected arm evaluates overrides, and recordClone snapshots fields diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..b4864293b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3548,6 +3548,9 @@ export class Lowerer { readonly neverValued = new Set(); readonly chainNarrowedType = new Map(); readonly chainHandled = new Set(); + /** One arm's narrowed value for a spread source while a distributed + * union spread lowers that arm (lowerDistributedUnionSpread). */ + readonly spreadArmOverrides = new Map(); /** for-of-over-matchAll bindings whose `.index` reads the companion-index * array: binding SYMBOL → the hidden number[] of match start indices plus * the hidden cursor holding THIS iteration's position (registered while @@ -4617,6 +4620,7 @@ export class Lowerer { "neverValued", "chainNarrowedType", "chainHandled", + "spreadArmOverrides", "splitIdentifierUses", "typeMemo", "fnStack", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json new file mode 100644 index 000000000..9f12d2cbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-clone-override-width.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-clone-override-width.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json new file mode 100644 index 000000000..6a601d723 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-distribution.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-spread-distribution.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json deleted file mode 100644 index 0d30ae9e6..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/union-clone-override-width.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/union-clone-override-width.ts" - ], - "diags": [] -} diff --git a/tests/diagnostics/union-clone-override-width.ts b/tests/corpus/union-clone-override-width.ts similarity index 79% rename from tests/diagnostics/union-clone-override-width.ts rename to tests/corpus/union-clone-override-width.ts index 9da9a008a..475632532 100644 --- a/tests/diagnostics/union-clone-override-width.ts +++ b/tests/corpus/union-clone-override-width.ts @@ -3,8 +3,7 @@ type WiderPayload = { tag: "number"; value: number; extra: boolean } | { tag: "t type Container = { kind: "left"; payload: Payload; left: number } | { kind: "right"; payload: Payload; right: number }; function copy(source: Container, payload: WiderPayload): boolean { - // Retagging the override into source.payload would discard extra and - // copy its record. The inferred spread result keeps the wider payload. + // An override wider than every source arm's field: the distributed spread builds each arm at the inferred result's arm, which keeps the wider payload. const result = { ...source, payload }; return result.payload.extra; } diff --git a/tests/corpus/union-spread-distribution.ts b/tests/corpus/union-spread-distribution.ts new file mode 100644 index 000000000..e1a6b54a9 --- /dev/null +++ b/tests/corpus/union-spread-distribution.ts @@ -0,0 +1,134 @@ +// Object spreads whose source is a union of records: TypeScript types +// `{ ...u, k: v }` as one spread per arm of `u`, and the literal is built +// that way. The running arm decides which destination arm holds the result: +// the discriminant's last writer, else the source arm itself, else the one +// arm with exactly the written keys. JSON.stringify pins key order too. + +// 1. One union source plus an override, inside an array callback, after a +// discriminant test narrowed the stored union. +type Text = { type: "text"; text: string }; +type Image = { type: "image"; data: string }; +type UserMsg = { role: "user"; content: string | (Text | Image)[]; ts: number }; +type AsstMsg = { role: "assistant"; content: Text[]; model: string }; +type ToolMsg = { role: "tool"; content: (Text | Image)[]; toolId: string; isError?: boolean }; +type Msg = UserMsg | AsstMsg | ToolMsg; + +function stripImages(msgs: Msg[]): Msg[] { + return msgs.map((msg) => { + if (msg.role === "user" || msg.role === "tool") { + const content = msg.content; + if (Array.isArray(content)) { + const filtered = content.map((c) => + c.type === "image" ? { type: "text" as const, text: "[image]" } : c, + ); + return { ...msg, content: filtered }; + } + } + return msg; + }); +} + +// 2. A union source plus a field the arms declare optional, cast back. +type Cmd = + | { id?: string; type: "prompt"; message: string } + | { id?: string; type: "abort" } + | { id?: string; type: "set_model"; provider: string; modelId: string }; +let next = 0; +function withId(command: Cmd): Cmd { + const id = `req_${++next}`; + return { ...command, id } as Cmd; +} + +// 3. An optional group `{} | { during }` spread into a discriminated result. +type Result = + | { ok: true; value: number; during?: { seq: number } } + | { ok: false; error: string; during?: { seq: number } }; +function finish(error: string | undefined, seq: number | undefined): Result { + const ended = seq === undefined ? {} : { during: { seq } }; + return error ? { ok: false, error, ...ended } : { ok: true, value: 1, ...ended }; +} + +// 4. A discriminant test leaves several arms; overrides set and clear keys. +type Ref = + | { status: "resolved"; name: string; level?: string; warning?: string } + | { status: "ambiguous"; names: string[]; level?: string; warning?: string } + | { status: "not-found" }; +function relevel(inner: Ref): Ref { + if (inner.status === "not-found") return inner; + return { ...inner, level: undefined, warning: "w" }; +} + +// 5. The source supplies the second half of an event whose discriminant +// value is shared by two arms; the written keys pick the arm. +type Ev = + | { type: "start" } + | { type: "retry"; source: "branch" } + | { type: "retry"; source: "compact"; reason: "manual" | "auto" } + | { type: "end"; count: number }; +const events: string[] = []; +function emit(event: Ev): void { + events.push(JSON.stringify(event)); +} +function retrying(source: { source: "branch" } | { source: "compact"; reason: "manual" | "auto" }) { + emit({ type: "retry", ...source }); +} + +// 6. A cast-built event from a union base. +type BaseA = { type: "settle"; id: string }; +type BaseB = { type: "turn"; id: string; turn: number }; +type SettleEv = BaseA & { entries: string[]; more: boolean }; +type TurnEv = BaseB & { entries: string[]; more: boolean }; +function boundary(base: BaseA | BaseB, entries: string[]): SettleEv | TurnEv { + return { ...base, entries, more: entries.length > 1 } as SettleEv | TurnEv; +} + +// 7. A message update whose override's type differs per arm. +type Part = { type: "text"; text: string }; +type UMsg = { role: "user"; content: string | Part[] }; +type AMsg = { role: "assistant"; content: Part[]; model: string }; +type Note = { role: "note"; body: string }; +type AnyMsg = UMsg | AMsg | Note; +function replace(messages: AnyMsg[], text: string): AnyMsg[] { + return messages.map((message) => { + if (message.role !== "user" && message.role !== "assistant") return message; + const content = message.role === "assistant" ? [{ type: "text" as const, text }] : text; + return { ...message, content } as AnyMsg; + }); +} + +const msgs: Msg[] = [ + { role: "user", content: [{ type: "image", data: "x" }, { type: "text", text: "hi" }], ts: 1 }, + { role: "assistant", content: [{ type: "text", text: "ok" }], model: "m" }, + { role: "tool", content: [{ type: "image", data: "y" }], toolId: "t1", isError: false }, + { role: "user", content: "plain", ts: 2 }, +]; +for (const m of stripImages(msgs)) console.log(JSON.stringify(m)); +console.log(JSON.stringify(withId({ type: "prompt", message: "go" }))); +console.log(JSON.stringify(withId({ type: "abort", id: "old" }))); +console.log(JSON.stringify(withId({ type: "set_model", provider: "p", modelId: "q" }))); +console.log(JSON.stringify(finish(undefined, undefined))); +console.log(JSON.stringify(finish("bad", 3))); +console.log(JSON.stringify(finish(undefined, 4))); +const resolved = relevel({ status: "resolved", name: "a", level: "hi" }); +console.log(JSON.stringify(resolved), "level" in resolved, "warning" in resolved); +console.log(JSON.stringify(relevel({ status: "ambiguous", names: ["a", "b"] }))); +console.log(JSON.stringify(relevel({ status: "not-found" }))); +retrying({ source: "branch" }); +retrying({ source: "compact", reason: "auto" }); +console.log(events.join(" ")); +console.log(JSON.stringify(boundary({ type: "settle", id: "s" }, ["a"]))); +console.log(JSON.stringify(boundary({ type: "turn", id: "t", turn: 3 }, ["a", "b"]))); +for (const m of replace( + [ + { role: "user", content: "old" }, + { role: "assistant", content: [], model: "m" }, + { role: "note", body: "n" }, + ], + "new", +)) + console.log(JSON.stringify(m)); +// The spread copies a snapshot: a later write to the source is not seen. +const base: Cmd = { type: "prompt", message: "before" }; +const copied = withId(base); +if (base.type === "prompt") base.message = "after"; +console.log(JSON.stringify(copied), JSON.stringify(base)); diff --git a/tests/harness/__snapshots__/union-clone-override-width.ts.txt b/tests/harness/__snapshots__/union-clone-override-width.ts.txt deleted file mode 100644 index da3819baf..000000000 --- a/tests/harness/__snapshots__/union-clone-override-width.ts.txt +++ /dev/null @@ -1,8 +0,0 @@ -union-clone-override-width.ts:8:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of '{ kind: "left"; left: number; payload: WiderPayload; } | { kind: "right"; right: number; payload: WiderPayload; }' can represent it (candidate arms: '{ kind: "left"; left: number; payload: WiderPayload; }', '{ kind: "right"; right: number; payload: WiderPayload; }') - - 7 | // copy its record. The inferred spread result keeps the wider payload. - 8 | const result = { ...source, payload }; - | ^~~~~~~~~~~~~~~~~~~~~~ - 9 | return result.payload.extra; - - hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/corpus-costs/union-clone-override-width.ts.json b/tests/harness/corpus-costs/union-clone-override-width.ts.json new file mode 100644 index 000000000..67573559b --- /dev/null +++ b/tests/harness/corpus-costs/union-clone-override-width.ts.json @@ -0,0 +1 @@ +{ "plain": 4537, "san": 6468 } diff --git a/tests/harness/corpus-costs/union-spread-distribution.ts.json b/tests/harness/corpus-costs/union-spread-distribution.ts.json new file mode 100644 index 000000000..d5d3b948a --- /dev/null +++ b/tests/harness/corpus-costs/union-spread-distribution.ts.json @@ -0,0 +1 @@ +{ "plain": 6958, "san": 12595 }