From c86b3a153ba38227b5ac261084bcb74df670d320 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 08:47:36 -0700 Subject: [PATCH] Keep the new lowering paths off already-typed code The conditional-spread, asserted-probe, and asserted-element-probe commits each answered a shape that previously refused, but three paths also re-lowered code that already compiled: - A checked-dynamic literal's conditional source is now lowered arm by arm only when the literal is built for a declared record target, so a literal already built as a plain dynamic object keeps its established lowering. - An asserted member probe counts arms the way the extraction threshold always has, plus getter-backed arms beside an arm with no read at all: a union whose arms all declare the member keeps the assertion's extraction. - An asserted element probe checks the stored value's checker type before speculatively lowering the receiver, so a non-union receiver leaves no synthesized helpers behind. The zero-cost gate reports no difference for corpus/7020-conditional- spread-fields.ts, corpus/6474-index-record-spread-literals.ts, corpus/6475-dynamic-spread-literals.ts, corpus/asserted-arm-probe.ts, and corpus/8014-foreign-element-callbacks.ts; the new corpus programs (8501-8504, 8510-8513, tuple-dynamic-index, record-subset-keyed-read, union-mixed-field-write) pin the behavior both ways. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/asserted-receiver.ts | 31 +++++++++---------- .../lowering/expressions/element-access.ts | 5 +++ .../lowering/expressions/object-literals.ts | 8 ++++- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts index 13ed89d25..0f4aae800 100644 --- a/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts +++ b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts @@ -120,33 +120,32 @@ export function assertedProbeAccessOf( } /** True when the union's record arms read `member` unevenly: at least one - * declares it — as a data field or as a getter, which Node's read calls — - * and at least one does not (an index-signature arm may hold it, which the - * read answers per value; an arm with only a setter of that name reads - * undefined). Those are exactly the arms assertedMemberRead answers per - * arm; anything else keeps the assertion's extraction (and with it the - * checked-narrow fence for a lying one). */ + * declares it as a data field and at least one does not (it declares a + * getter instead, or nothing), or at least one answers it through a getter + * while another arm has no read of it at all. Those are exactly the arms + * assertedMemberRead answers per arm; anything else keeps the assertion's + * extraction (and with it the checked-narrow fence for a lying one). An arm + * with only a setter of that name reads undefined (Node never invokes a + * setter on a read), so it counts as an arm with no read. */ export function partialRecordMember(lowerer: Lowerer, union: IrType, member: string): boolean { if (union.kind !== "union") return false; const arms = lowerer.unions.get(union.unionId)?.arms ?? []; let records = 0; - let declaring = 0; + let dataFields = 0; + let getters = 0; + let absent = 0; for (const arm of arms) { if (isUnitType(arm)) continue; if (arm.kind !== "record") return false; const shape = lowerer.shapes.get(arm.shapeId); if (!shape || shape.tuple) return false; records++; - // A setter-only arm is not a declaring arm (the read answers undefined, - // Node never invokes a setter on a read), but it no longer disqualifies - // the union: the per-arm answer names it. - if ( - shape.fields.some((field) => field.name === member) || - shape.fields.some((field) => field.name === `%get:${member}`) - ) - declaring++; + if (shape.fields.some((field) => field.name === member)) dataFields++; + else if (shape.fields.some((field) => field.name === `%get:${member}`)) getters++; + else absent++; } - return records > 0 && declaring > 0 && declaring < records; + if (records === 0) return false; + return (dataFields > 0 && dataFields < records) || (getters > 0 && absent > 0); } /** The asserted record type whose checked extraction is deferred for the diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 2ea1b1aad..97ac647c4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -109,8 +109,13 @@ function assertedProbeElementRead( if (assertion === null || assertedProbeAccessOf(assertion) !== expr) return null; const name = probeMemberName(expr); if (name === null) return null; + // Only a union of arms has a per-arm answer. Check the checker type first: + // speculatively lowering a receiver that is not a union would leave the + // helpers it synthesized behind even though the probe declines. + if ((lowerer.typeOf(assertion.expression).flags & ts.TypeFlags.Union) === 0) return null; const recv = tryLowerExpression(lowerer, expr.expression); if (recv === null) return null; + if (recv.type.kind !== "union") return null; return assertedMemberRead(lowerer, expr.expression, name, recv, locOf(expr)); } diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 6b0129fa5..9699a4fb9 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -234,6 +234,7 @@ export function lowerDynObjectLiteral( lowerer: Lowerer, expr: ts.ObjectLiteralExpression, boxValue?: (node: ts.Expression, value: IrExpr) => IrExpr, + conditionalArms = false, ): IrExpr { const loc = locOf(expr); let fields: { key: IrExpr; value: IrExpr }[] = []; @@ -308,6 +309,7 @@ export function lowerDynObjectLiteral( builtin === "path" || builtin === "path/posix" || builtin === "path/win32" ? pathModuleValue(lowerer, builtin === "path" ? "path/posix" : builtin, locOf(prop)) : !boxValue && + conditionalArms && (conditionalSpreadOf(prop.expression) !== null || andSpreadOf(lowerer, prop.expression) !== null) ? lowerConditionalDynSource(lowerer, prop.expression) @@ -3009,7 +3011,11 @@ function dynSpreadLiteral( ) ) return null; - return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), type); + // The target is a declared record, so a conditional source's arm has the + // record as its checker context: lower the arms as the dyn values whose + // own keys the copy reads, not against the record one arm need not + // complete. + return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr, undefined, true), type); } /** A conditional spread source in a checked-dynamic literal, as the dyn