Keep object and array literals that hold an evolving empty array on that array

An object or array literal that held an evolving `const xs = []` between
writes of different element types (`const box = { items: xs }` or
`const rows = [xs]` after `xs.push(1)`, before `xs.push("two")`) took the
element type the checker gives the array at that point, so it held a copy.
`box.items.length` and `box.items === xs` printed `1 false`, where Node
prints `2 true`. Such a literal now builds as a checked-dynamic literal that
keeps the array, and an unannotated binding of it takes the checked-dynamic
type. This covers shorthand properties (`{ xs }`), nested literals, spreads
of a literal holder, aliases of a holder and names destructured from one.
Element reads through an alias or holder (`rows[0]`, `alias[1]`) read the
checked-dynamic value instead of checking it against the checker's type,
which lists only the elements written before the alias or holder was taken.

A shorthand property now counts as a read of the array when its reads are
compared, so `{ xs }` before a write of another element type makes the
array checked-dynamic too.

Fixes #1641.
This commit is contained in:
Chris Tate
2026-10-22 21:23:57 -05:00
parent d62d12e9c3
commit 33cf76d36e
5 changed files with 175 additions and 24 deletions
@@ -65,6 +65,7 @@ import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { isClassCallback } from "../class-callbacks.js";
import { isKeyedFieldAccess } from "./keyed-field-targets.js";
import { lowerFieldCompoundValue } from "./updates.js";
import { readsHeldEvolvingArray } from "../lower-stmts.js";
/** `a[i]` reads. Only f64 indices into array receivers are modeled; JS
* string-key element access (`a["length"]`) and string indexing (`s[0]`
@@ -751,12 +752,16 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
: lowerer.maybeNarrow(arr, expr.expression);
}
// An alias or container of an evolving array whose reads disagree
// (`rows[0]` after `const rows = [xs]`) holds elements the checker's type
// does not list, so its element read stays checked-dynamic.
const heldEvolving = arr.type.kind === "dyn" && readsHeldEvolvingArray(lowerer, expr.expression);
// The checker sees an array but the VALUE lowered checked-dynamic (a
// jsdoc-typed default export from a .js module — signature 14): the
// read rides the dynCheck boundary when the array shape is one the checked-dynamic tree
// can validate, and fences by name when it is not — an arrayGet over a
// dyn receiver is never emitted (the validator ICE).
if (arr.type.kind === "dyn") {
if (arr.type.kind === "dyn" && !heldEvolving) {
if (
canDynCheckTo(
receiverIr,
@@ -782,6 +787,14 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
locOf(expr.argumentExpression),
expr.argumentExpression,
);
if (heldEvolving)
return {
kind: "dynKeyGet",
value: arr,
key: lowerer.coerceToExpected(index, DYN),
type: DYN,
loc: locOf(expr),
};
if (
arr.type.kind === "array" &&
(index.type.kind === "dyn" ||
@@ -37,6 +37,7 @@ import { fenceSymbolFieldCopy } from "../symbol-fields.js";
import { planUnionRetag } from "../union-retag.js";
import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js";
import { builtinGlobalValue } from "./property-access.js";
import { holdsEvolvingArray } from "../lower-stmts.js";
/** `{ a: 1, b: "x" }` → recordLit. The record type comes from the
* contextual type when tsc has one (annotated declarations, arguments,
@@ -397,7 +398,10 @@ export function lowerDynObjectLiteral(
? lowerer.lowerLambda(prop, true)
: ts.isShorthandPropertyAssignment(prop) && shorthandBuiltinValue(lowerer, prop)
? shorthandBuiltinValue(lowerer, prop)!
: !boxValue && ts.isObjectLiteralExpression(valueExpr)
: !boxValue &&
(ts.isObjectLiteralExpression(valueExpr) ||
(ts.isArrayLiteralExpression(valueExpr) &&
holdsEvolvingArray(lowerer, valueExpr)))
? lowerer.lowerExprExpecting(valueExpr, DYN)
: lowerer.lowerExpr(valueExpr as ts.Expression);
raw = fenceClosureProbe(lowerer, valueExpr, undefined, lowerValue) ?? lowerValue();
@@ -4920,12 +4920,16 @@ export function bindPatternTarget(
}
// Preserve checked-value pattern bindings when the inferred type
// cannot represent them without a refusal or an alias-breaking copy.
// A name destructured from a container of an evolving array
// (evolvingArrayHold) may hold the array, which the checker types by
// the elements written before it.
const dynStays =
value.type.kind === "dyn" &&
(() => {
((() => {
const mapped = lowerer.mapTypeOf(lowerer.typeOf(name));
return mapped === null || jsBindingKeepsCheckedObject(lowerer, name, mapped);
})();
})() ||
readsHeldEvolvingArray(lowerer, name));
let type = dynStays
? DYN
: (lowerer.runtimeOptionalBindingType(name, lowerer.irTypeOf(name)) ??
@@ -5054,13 +5058,16 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
// type once the reads settle it.
const written: ts.Expression[] = [];
// The file's identifiers are indexed by name once (identifiersNamed), so
// each binding resolves only the uses spelled like it.
// each binding resolves only the uses spelled like it. A shorthand
// property (`{ xs }`) is a use too.
for (const node of identifiersNamed(lowerer, ts.getSourceFileOfNode(decl), decl.name.text)) {
if (
node === decl.name ||
node.pos < scope.pos ||
node.end > scope.end ||
lowerer.checker.getSymbolAtLocation(node) !== symbol
(ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent)
: lowerer.checker.getSymbolAtLocation(node)) !== symbol
)
continue;
let target: ts.Node = node;
@@ -5118,28 +5125,105 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
return result;
}
/** An unannotated alias of an evolving array binding whose reads disagree
* (`const alias = xs` between `xs.push(1)` and `xs.push("two")`): the
* checker types the alias by the elements written before it, but both names
* hold one array, so the alias takes the binding's checked-dynamic type and
* sees the later writes. Aliases of such an alias follow it. Null for other
* bindings. */
function evolvingArrayAliasType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null {
let current = decl;
for (let depth = 0; depth < 8; depth++) {
let initializer = current.initializer;
while (initializer !== undefined && ts.isParenthesizedExpression(initializer))
initializer = initializer.expression;
if (initializer === undefined || !ts.isIdentifier(initializer)) return null;
const source = lowerer.checker.getSymbolAtLocation(initializer)?.valueDeclaration;
/** How a TypeScript expression holds an evolving array binding whose reads
* disagree (evolvingArrayType answers checked-dynamic): "evolving" for the
* binding itself, "alias" for an unannotated binding initialized with it or
* with an alias of it (`const alias = xs`), and "container" for an object or
* array literal with it as a property, element or spread source at any
* depth (`{ items: xs }`, `[xs]`), an unannotated binding initialized with
* or destructured from such a literal, and a property or element read of a
* container (`box.items`). The checker types an alias or container by the
* elements written before it was taken, but it holds the same array. Null
* for other expressions. */
function evolvingArrayHold(
lowerer: Lowerer,
expr: ts.Expression,
depth = 0,
): "evolving" | "alias" | "container" | null {
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
if (depth >= 8) return null;
const binding = (symbol: ts.Symbol | undefined): "evolving" | "alias" | "container" | null => {
const source = symbol?.valueDeclaration;
// A name destructured from a container (`const { items } = box`) may
// be the array or another container.
if (source !== undefined && ts.isBindingElement(source) && ts.isIdentifier(source.name)) {
const host = hostVariableDeclarationOf(source.name);
return host !== null &&
!host.type &&
host.initializer !== undefined &&
evolvingArrayHold(lowerer, host.initializer, depth + 1) === "container"
? "container"
: null;
}
if (source === undefined || !ts.isVariableDeclaration(source) || source.type) return null;
const evolved = evolvingArrayType(lowerer, source);
if (evolved !== null) return evolved.kind === "dyn" ? evolved : null;
current = source;
if (evolved !== null) return evolved.kind === "dyn" ? "evolving" : null;
if (source.initializer === undefined) return null;
const held = evolvingArrayHold(lowerer, source.initializer, depth + 1);
return held === "evolving" ? "alias" : held;
};
if (ts.isIdentifier(expr)) return binding(lowerer.checker.getSymbolAtLocation(expr));
if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr))
return evolvingArrayHold(lowerer, expr.expression, depth + 1) === "container"
? "container"
: null;
// A spread of the array itself (`{ ...xs }`, `[...xs]`) copies its
// elements, as in Node; a spread of a container copies its references.
if (ts.isObjectLiteralExpression(expr)) {
for (const property of expr.properties.nodes) {
const held = ts.isPropertyAssignment(property)
? evolvingArrayHold(lowerer, property.initializer, depth + 1)
: ts.isShorthandPropertyAssignment(property)
? binding(lowerer.checker.getShorthandAssignmentValueSymbol(property))
: ts.isSpreadAssignment(property) &&
evolvingArrayHold(lowerer, property.expression, depth + 1) === "container"
? "container"
: null;
if (held !== null) return "container";
}
return null;
}
if (ts.isArrayLiteralExpression(expr)) {
for (const element of expr.elements.nodes) {
const held = ts.isSpreadElement(element)
? evolvingArrayHold(lowerer, element.expression, depth + 1) === "container"
? "container"
: null
: evolvingArrayHold(lowerer, element, depth + 1);
if (held !== null) return "container";
}
}
return null;
}
/** Whether a TypeScript expression is an alias or container of an evolving
* array binding whose reads disagree (evolvingArrayHold). Its value is
* checked-dynamic, and the checker's type for it lists only the elements
* written before it was taken, so element reads through it stay
* checked-dynamic instead of checking against that type. */
export function readsHeldEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false;
const held = evolvingArrayHold(lowerer, expr);
return held === "alias" || held === "container";
}
/** Whether a TypeScript object or array literal holds an evolving array
* binding whose reads disagree (evolvingArrayHold), so it builds as a
* checked-dynamic literal that keeps the array. */
export function holdsEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false;
return evolvingArrayHold(lowerer, expr) === "container";
}
/** An unannotated binding initialized with an evolving array binding whose
* reads disagree, or with an alias or container of one (evolvingArrayHold):
* it takes the array's checked-dynamic type, so it sees the later writes.
* Null for other bindings. */
function evolvingArrayHolderType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null {
if (decl.initializer === undefined) return null;
return evolvingArrayHold(lowerer, decl.initializer) === null ? null : DYN;
}
/** A single static type for an initializer-less JavaScript `let` whose
* later plain writes all agree. An evolving JS local reads as `undefined`
* until control flow proves a write, so a scalar slot is sound only when
@@ -5834,7 +5918,7 @@ export function lowerVarDecl(
!decl.type &&
!isJsSourceFile(ts.getSourceFileOfNode(decl))
) {
const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayAliasType(lowerer, decl);
const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayHolderType(lowerer, decl);
if (evolved !== null) {
type = evolved;
init = lowerer.lowerExprExpecting(decl.initializer, evolved);
@@ -614,6 +614,7 @@ import {
lowerForStatement,
matchGroupsPatternSource,
isGuardedRecordRead,
holdsEvolvingArray,
} from "./lower-stmts.js";
import {
type FieldTarget,
@@ -10655,7 +10656,8 @@ export class Lowerer {
builtinOption("UrlObject") ||
builtinOption("URLFormatOptions") ||
!this.mapTypeOf(this.typeOf(x)) ||
this.excessJsContextualProperty(x, parts)
this.excessJsContextualProperty(x, parts) ||
holdsEvolvingArray(this, x)
) {
return lowerDynObjectLiteral(this, x);
}
@@ -77,3 +77,51 @@ function spreadSame(): number {
return xs.reduce((sum, v) => sum + v, 0);
}
console.log(spreadSame());
// An object or array literal that holds the array between the writes
// (`{ items: xs }`, `[xs]`, a shorthand property, a nested literal), a
// binding of such a literal, and a name destructured from one hold the
// same array, so they see the later writes.
function objectHolder(): string {
const xs = [];
xs.push(1);
const box = { items: xs, label: "box" };
xs.push("two");
box.items.push(3);
return `${box.items.length} ${box.items === xs} ${box.items[1]} ${box.label} ${xs.join(",")}`;
}
function arrayHolder(): string {
const xs = [];
xs.push(1);
const rows = [xs, [5]];
xs.push("two");
let total = 0;
for (const row of rows) total += row.length;
return `${rows[0].length} ${rows[0] === xs} ${rows[0][1]} ${total}`;
}
function shorthandHolder(): string {
const xs = [];
xs.push(1);
const box = { xs, count: 3 };
xs.push("two");
return `${box.xs.length} ${box.xs === xs} ${box.count}`;
}
function nestedHolder(): string {
const xs = [];
xs.push(1);
const box = { inner: [xs], pair: [{ items: xs }] };
const outer = { box };
xs.push("two");
return `${box.inner[0].length} ${box.pair[0].items === xs} ${outer.box.inner[0][1]}`;
}
function holderAliases(): string {
const xs = [];
xs.push(1);
const box = { items: xs };
const other = box;
const { items } = box;
xs.push("two");
return `${other.items.length} ${items.length} ${items === xs} ${JSON.stringify(other)}`;
}
console.log(objectHolder(), "|", arrayHolder(), "|", shorthandHolder());
console.log(nestedHolder(), "|", holderAliases());