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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 09:37:12 -07:00
co-authored by Claude Opus 5.5
parent 378901df95
commit c86b3a153b
3 changed files with 27 additions and 17 deletions
@@ -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
@@ -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));
}
@@ -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