From 3328ed2aa7ff8038d71ce0dd88525ed84023f159 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:03:59 -0700 Subject: [PATCH] Lower union member reads the checker narrowed away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a member off a union-typed value is what most pi refusals at `SC1090` came down to: `credential.type` on `ApiKeyCredential | OAuthCredential`, `session.role`, `entry.timestamp`, `tool.operations`, `arms.length`, `renderer.hasOverlayEntries`. Three read shapes reach the same dispatch and now answer: - A checker-NARROWED receiver keeps the wider union it stores, because lowering erases flow narrowing (`stored?.type` after the earlier arm returned, `(entry as { role: string }).role`). The dispatcher now reads the member off whichever arm the value holds — an arm that does not declare it answers undefined, exactly Node's get — and narrows the answer to the checker's type, which traps on an arm the narrowing ruled out (types do not lie). This fallback sits after every existing lowering, so only refusals gain one. - A member the arms answer through an ACCESSOR (a getter inherited from a shared base class, `renderer.hasOverlayEntries`) has no field slot `unionDisc` could read, so the read dispatches on the tag and calls each arm's own getter. Data-field unions keep their `unionDisc` lowering. - `.length` on a union of arrays and strings (`Model[] | ModelRef[]`, `string[] | Buffer[]`) dispatches on the tag and answers each arm's own length. The member read moves out of `lowerUnionProperty` into `lowerUnionValueRead`, which both entry points share, and `missingFieldAsUndefined` threads the narrowed-receiver get semantics through the keyed read. Corpus 8520/8521/8522 pin the three shapes differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 278 +++++++++++++++++- ...8520-union-narrowed-receiver-reads.ts.json | 6 + .../8521-union-accessor-member-read.ts.json | 6 + .../corpus/8522-union-array-length.ts.json | 6 + .../8520-union-narrowed-receiver-reads.ts | 84 ++++++ .../corpus/8521-union-accessor-member-read.ts | 58 ++++ tests/corpus/8522-union-array-length.ts | 37 +++ ...8520-union-narrowed-receiver-reads.ts.json | 1 + .../8521-union-accessor-member-read.ts.json | 1 + .../8522-union-array-length.ts.json | 1 + 10 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json create mode 100644 tests/corpus/8520-union-narrowed-receiver-reads.ts create mode 100644 tests/corpus/8521-union-accessor-member-read.ts create mode 100644 tests/corpus/8522-union-array-length.ts create mode 100644 tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json create mode 100644 tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json create mode 100644 tests/harness/corpus-costs/8522-union-array-length.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf1013..e2595f465 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1617,6 +1617,19 @@ export function lowerPropertyAccessExpr( } const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc); if (asserted) return asserted; + // Lowering erases flow narrowing, so a checker-NARROWED receiver keeps + // the wider union it stores (`stored?.type` after the earlier arm + // returned, `(entry as { role: string }).role`): the checker's type + // names one record while the value still carries its siblings. Read the + // member off whichever arm the value holds — an arm that does not + // declare it answers undefined, exactly Node's get — and narrow the + // answer to the checker's type, which traps on an arm the checker's + // narrowing ruled out (types do not lie). Reaching this point means no + // lowering above claimed the read, so only refusals gain a lowering. + if (recvLowered.type.kind === "union") { + const read = lowerUnionValueRead(lowerer, expr, recvLowered, true); + if (read) return lowerer.maybeNarrow(read, expr); + } // A checker-`any` receiver whose value is a FRESH typed result of an // earlier step in the chain (`out.split(" ").length`, where split // extracted the string and answered a native string[]): the member read @@ -3642,8 +3655,33 @@ export function lowerUnionProperty( "shared property access without a represented union receiver", ); } - const def = lowerer.unions.get(value.type.unionId); - if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`); + const read = lowerUnionValueRead(lowerer, expr, value); + if (read) return read; + lowerer.unsupported( + "SC1090", + expr, + `reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` + + `with a same-typed field '${expr.name.text}'; ` + + `${NARROW_FIRST})`, + ); +} + +/** The member read itself on a lowered UNION value: `lowerUnionProperty` + * after it has produced the value (retagged to the checker's union when + * the checker removed arms) and the dispatcher's narrowed-receiver + * fallback, which reaches the same read with the value a checker-narrowed + * receiver still stores. Returns null when no arm can answer — the caller + * owns the diagnostic. */ +export function lowerUnionValueRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + value: IrExpr, + missingFieldAsUndefined = false, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const unionId = value.type.unionId; + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); const field = expr.name.text; if (field === "length") { const presentArms = def.arms.filter((arm) => !isUnitType(arm)); @@ -3675,6 +3713,42 @@ export function lowerUnionProperty( }; } } + // A union of arrays and strings (`Model[] | ModelRef[]`, + // `string[] | Buffer[]`): every arm answers its own length, so the + // read dispatches on the tag. Node's `.length` is a number for both, + // and no arm can be absent here (a unit arm would have to throw on + // this read, which the checker's narrowing already excludes). + if ( + def.arms.length > 1 && + def.arms.every((arm) => arm.kind === "array" || arm.kind === "string") + ) { + return perArmUnionRead( + lowerer, + expr, + unionId, + value, + F64, + "%unionLength", + (arm, _tag, receiver) => + arm.kind === "string" + ? { + kind: "strIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + } + : { + kind: "arrIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + }, + ); + } } const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm)); if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") { @@ -3688,11 +3762,7 @@ export function lowerUnionProperty( field, ); if (receiver === null) { - const helper = lowerer.narrowedArmHelper( - value.type.unionId, - objectType, - locOf(expr.expression), - ); + const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression)); if (helper !== null) receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression)); } @@ -3708,6 +3778,14 @@ export function lowerUnionProperty( } } } + // A member the arms answer through an ACCESSOR (a getter inherited + // from a shared base class — `renderer.hasOverlayEntries` on + // `FullscreenRenderer | RegularRenderer`): there is no field slot + // `unionDisc` could read, so dispatch on the tag and call each arm's + // own getter. Fires only when some arm's answer is an accessor, so + // data-field unions keep their `unionDisc` lowering. + const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value); + if (accessorRead) return accessorRead; let common: IrType | null = null; for (const arm of def.arms) { let ft: IrType | undefined; @@ -3736,7 +3814,15 @@ export function lowerUnionProperty( // arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv | // Record`, the tail read of `loaded?.config.script`. const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) }; - const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field); + const keyed = lowerUnionKeyedRead( + lowerer, + expr, + unionId, + value, + key, + field, + missingFieldAsUndefined, + ); if (keyed) return keyed; // Class arms declaring the field with compatible but different types // (`T | undefined` beside `T`): per-arm slot reads widened to one type. @@ -3763,12 +3849,178 @@ export function lowerUnionProperty( const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr)); if (member) return member; } - lowerer.unsupported( - "SC1090", + return null; +} + +/** Evaluate `value` once into a hidden local and answer the read by + * dispatching on the union's tag: arm `tag` (narrowed from the local) + * answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer + * is the fallback branch, which a representable union never reaches. */ +function perArmUnionRead( + lowerer: Lowerer, + expr: ts.Node, + unionId: string, + value: IrExpr, + type: IrType, + name: string, + answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr, +): IrExpr { + const loc = locOf(expr); + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); + const stable = lowerer.declareHiddenLocal(name, value.type); + const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc }); + let result: IrExpr | null = null; + def.arms.forEach((arm, tag) => { + if (isUnitType(arm)) return; + const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc }; + const read = answer(arm, tag, receiver); + result = + result === null + ? read + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: ref(), + negated: false, + type: BOOL, + loc, + }, + then: read, + else_: result, + type, + loc, + }; + }); + if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }], + result, + type, + loc, + }; +} + +/** A member read the arms answer through an ACCESSOR — an inherited + * getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record + * and class FIELD slots, so a getter arm needs the tag dispatch: each + * arm calls its own getter (once per read, as Node's read does). Data + * arms in the same union read their slot. Every arm must answer one + * type; a unit arm or an arm without the member declines (the caller's + * keyed read and fences own those). Null when no arm answers through an + * accessor — plain field unions keep `unionDisc`. */ +function lowerUnionAccessorRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + unionId: string, + value: IrExpr, +): IrExpr | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + const field = expr.name.text; + const getterName = `get:${field}`; + let type: IrType | null = null; + let anyAccessor = false; + const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (isUnitType(arm)) return null; + let answer: IrType; + let read: (receiver: IrExpr) => IrExpr; + if (arm.kind === "record") { + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple) return null; + const data = shape.fields.find((f) => f.name === field); + if (data) { + answer = data.type; + read = (receiver) => ({ + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field, + type: data.type, + loc: locOf(expr), + }); + } else { + const getter = shape.fields.find((f) => f.name === getterName); + if (!getter || getter.type.kind !== "func") return null; + anyAccessor = true; + answer = getter.type.ret; + read = (receiver) => + lowerer.coerceToExpected( + { + kind: "callValue", + callee: { + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field: getterName, + type: getter.type, + loc: locOf(expr), + }, + args: [], + type: getter.type.kind === "func" ? getter.type.ret : DYN, + loc: locOf(expr), + }, + answer, + ); + } + } else if (arm.kind === "object") { + const info = lowerer.classes.get(arm.className) ?? null; + const slot = info?.fields.get(field); + if (slot !== undefined) { + answer = slot; + read = (receiver) => ({ + kind: "fieldGet", + obj: receiver, + className: arm.className, + field, + type: slot, + loc: locOf(expr), + }); + } else { + const getter = lowerer.findMethodOn(info, getterName); + if (!getter || getter.sig.ret.kind === "void") return null; + anyAccessor = true; + answer = getter.sig.ret; + read = (receiver) => + lowerer.fieldGetExpr( + { + container: "accessor", + obj: receiver, + className: arm.className, + field, + fieldType: getter.sig.ret, + }, + locOf(expr), + expr, + ); + } + } else { + return null; + } + if (answer.kind === "void" || isUnitType(answer)) return null; + if (type !== null && !typeEquals(type, answer)) return null; + type = answer; + arms.push({ tag, read }); + } + if (!anyAccessor || type === null || arms.length < 2) return null; + return perArmUnionRead( + lowerer, expr, - `reading '${field}' on a union-typed value (every arm must be an object/record ` + - `with a same-typed field '${field}'; ` + - `${NARROW_FIRST})`, + unionId, + value, + type, + "%unionMember", + (_arm, tag, receiver) => { + const entry = arms.find((a) => a.tag === tag); + if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing"); + return entry.read(receiver); + }, ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..dbb935b8b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8520-union-narrowed-receiver-reads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..c5f221cdb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8521-union-accessor-member-read.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json new file mode 100644 index 000000000..bc5e6827e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8522-union-array-length.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8520-union-narrowed-receiver-reads.ts b/tests/corpus/8520-union-narrowed-receiver-reads.ts new file mode 100644 index 000000000..05abc2584 --- /dev/null +++ b/tests/corpus/8520-union-narrowed-receiver-reads.ts @@ -0,0 +1,84 @@ +// Lowering erases flow narrowing, so a checker-NARROWED receiver still +// stores its wider union. A member read dispatches on the arm the value +// actually holds: an arm that declares the member answers its slot, an arm +// that does not answers undefined (Node's get), and an arm the narrowing +// ruled out traps in the checked conversion. +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: Record; +} +interface OAuthCredential { + type: "oauth"; + access: string; + refresh: string; + expires: number; +} +type Credential = ApiKeyCredential | OAuthCredential; + +function pick(stored: Credential | undefined): string | undefined { + if (stored?.type === "oauth") return stored.access; + const credential = stored?.type === "api_key" ? stored : undefined; + return credential?.key; +} +console.log("key", pick({ type: "api_key", key: "k1" })); +console.log("key absent", pick({ type: "api_key" })); +console.log("oauth", pick({ type: "oauth", access: "a1", refresh: "r1", expires: 1 })); +console.log("none", pick(undefined)); + +// An assertion narrows the checker's view of arms that carry different +// payloads; every arm declares the asserted member here. +interface Message { + role: string; + content: string; +} +interface ToolResult { + role: string; + output: string[]; +} +type AgentMessage = Message | ToolResult; +function roleOf(message: AgentMessage): string { + return (message as { role: string }).role; +} +console.log("role", roleOf({ role: "user", content: "hi" })); +console.log("role", roleOf({ role: "tool", output: ["x"] })); + +// One arm lacks the member the assertion names: Node's read answers +// undefined for that arm. +interface Stamped { + timestamp?: number; + text: string; +} +interface Unstamped { + text: string; +} +function stamp(entry: Stamped | Unstamped): number | undefined { + return (entry as { timestamp?: number }).timestamp; +} +console.log("stamp", stamp({ timestamp: 5, text: "a" })); +console.log("stamp", stamp({ text: "b" })); + +// A discriminant read re-narrows the union it came from. +interface BashOperations { + exec(command: string): string; +} +interface BashResult { + exitCode: number; +} +type BashEventResult = + | { operations: BashOperations; result?: undefined } + | { operations?: undefined; result: BashResult }; +function describe(result: BashEventResult | undefined): string { + if (result?.result) return `exit ${result.result.exitCode}`; + return `operations ${typeof result?.operations}`; +} +console.log("event", describe({ result: { exitCode: 3 } })); +console.log("event", describe({ operations: { exec: (command) => command } })); +console.log("event", describe(undefined)); + +// A narrowed array whose stored union still owns an absent arm. +function firstLength(models: string[] | undefined): number { + if (models) return models.length; + return -1; +} +console.log("first", firstLength(["a", "b"]), firstLength(undefined)); diff --git a/tests/corpus/8521-union-accessor-member-read.ts b/tests/corpus/8521-union-accessor-member-read.ts new file mode 100644 index 000000000..c6168c078 --- /dev/null +++ b/tests/corpus/8521-union-accessor-member-read.ts @@ -0,0 +1,58 @@ +// A member the arms answer through an ACCESSOR — an inherited getter, or a +// getter a subclass overrides — has no field slot to read, so the read +// dispatches on the union's tag and calls the arm's own getter, once per +// read, as Node does. A data-field arm beside a getter arm reads its slot. +const logs: string[] = []; +class Panel { + items: string[]; + constructor(items: string[]) { + this.items = items; + } + get hasOverlayEntries(): boolean { + logs.push("has"); + return this.items.length > 0; + } + get count(): number { + logs.push("count"); + return this.items.length; + } +} +class MainPanel extends Panel { + mode = "main"; +} +class AltPanel extends Panel { + mode = "alt"; + get count(): number { + logs.push("alt count"); + return this.items.length * 10; + } +} +class FixedPanel { + mode = "fixed"; + items: string[]; + hasOverlayEntries = true; + constructor(items: string[]) { + this.items = items; + } + get count(): number { + logs.push("fixed count"); + return this.items.length + 100; + } +} + +function pickPanel(which: "m" | "a", items: string[]): MainPanel | AltPanel { + return which === "m" ? new MainPanel(items) : new AltPanel(items); +} +for (const which of ["m", "a"] as const) { + const panel = pickPanel(which, which === "m" ? ["x"] : []); + console.log("panel", which, panel.mode, panel.hasOverlayEntries, panel.count); +} + +function pickMixed(which: "a" | "f", items: string[]): AltPanel | FixedPanel { + return which === "a" ? new AltPanel(items) : new FixedPanel(items); +} +for (const which of ["a", "f"] as const) { + const panel = pickMixed(which, ["y", "z"]); + console.log("mixed", which, panel.mode, panel.hasOverlayEntries, panel.count); +} +console.log("logs", logs.join(",")); diff --git a/tests/corpus/8522-union-array-length.ts b/tests/corpus/8522-union-array-length.ts new file mode 100644 index 000000000..5fa467488 --- /dev/null +++ b/tests/corpus/8522-union-array-length.ts @@ -0,0 +1,37 @@ +// `.length` on a union of arrays (and strings): every arm answers its own +// length, so the read dispatches on the tag instead of demanding one +// record arm that carries a `length` field. +interface Model { + id: string; +} +interface ModelRef { + ref: string; +} +function ids(models: Model[] | ModelRef[]): number { + return models.length; +} +console.log("models", ids([{ id: "a" }, { id: "b" }])); +console.log("refs", ids([{ ref: "r" }, { ref: "s" }, { ref: "t" }])); + +function scalars(values: string[] | number[]): number { + return values.length; +} +console.log("strings", scalars(["a", "b", "c"])); +console.log("numbers", scalars([1, 2])); + +function textual(part: string | string[]): number { + return part.length; +} +console.log("text", textual("hello")); +console.log("parts", textual(["a", "b"])); + +// The dispatch reads the value ONCE, even when the receiver is a call. +let calls = 0; +function make(fill: string): string[] { + calls++; + return [fill, fill]; +} +function measured(part: string | string[]): number { + return part.length; +} +console.log("call", measured(make("q")), "calls", calls); diff --git a/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..c10c572b5 --- /dev/null +++ b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 2900 } diff --git a/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..26a06ea33 --- /dev/null +++ b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2500 } diff --git a/tests/harness/corpus-costs/8522-union-array-length.ts.json b/tests/harness/corpus-costs/8522-union-array-length.ts.json new file mode 100644 index 000000000..bef270fe0 --- /dev/null +++ b/tests/harness/corpus-costs/8522-union-array-length.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 2400 }