Keep aliases of evolving empty arrays on the array they alias (#1812)

This commit is contained in:
Chris Tate
2026-10-22 17:12:17 -05:00
committed by GitHub
4 changed files with 153 additions and 5 deletions
@@ -4799,9 +4799,10 @@ export function lowerVarDeclList(
* `never[]` or `any[]` and each later read by the elements written before
* it, and reads inside closures as the unsettled array. When every read
* with a settled element type maps to the same array type, the binding has
* that type. When reads disagree or the binding is reassigned, it is
* checked-dynamic, as the same binding is at module scope. Null for other
* bindings. */
* that type. When reads disagree, a write (`xs.push(v)`, `xs.unshift(v)`,
* `xs.push(...values)`, `xs[i] = v`) stores a value that type's element does
* not hold, or the binding is reassigned, it is checked-dynamic, as the same
* binding is at module scope. Null for other bindings. */
function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null;
let initializer: ts.Expression = decl.initializer;
@@ -4823,6 +4824,9 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
};
const scope = owner(decl);
let result: IrType | null = null;
// The values the binding's writes store, checked against the element
// 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.
for (const node of identifiersNamed(lowerer, ts.getSourceFileOfNode(decl), decl.name.text)) {
@@ -4842,6 +4846,22 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
if (reassigned) return DYN;
if (
ts.isPropertyAccessExpression(parent) &&
parent.expression === target &&
(parent.name.text === "push" || parent.name.text === "unshift") &&
ts.isCallExpression(parent.parent) &&
parent.parent.expression === parent
)
written.push(...parent.parent.arguments.nodes);
else if (
ts.isElementAccessExpression(parent) &&
parent.expression === target &&
ts.isBinaryExpression(parent.parent) &&
parent.parent.left === parent &&
parent.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
)
written.push(parent.parent.right);
const t = lowerer.typeOf(node);
if (unsettled(t)) continue;
const mapped = lowerer.mapTypeOf(t);
@@ -4849,7 +4869,49 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
return DYN;
result = mapped;
}
return result ?? DYN;
if (result === null || result.kind !== "array") return DYN;
// The element types a write stores: its value's, or for a spread
// (`xs.push(...values)`) the source array's element or tuple's elements.
// Another spread source stores elements this check cannot see.
const storedTypes = (value: ts.Expression): ts.Type[] | null => {
if (!ts.isSpreadElement(value)) return [lowerer.typeOf(value)];
const source = lowerer.typeOf(value.expression);
if (lowerer.checker.isArrayType(source) || lowerer.checker.isTupleType(source))
return [...lowerer.checker.getTypeArguments(source as ts.TypeReference)];
return null;
};
for (const value of written) {
const stored = storedTypes(value);
if (stored === null) return DYN;
for (const type of stored) {
const t = lowerer.mapTypeOf(lowerer.checker.getBaseTypeOfLiteralType(type));
if (!t || (!typeEquals(t, result.elem) && lowerer.widthLiftPlan(t, result.elem) === null))
return DYN;
}
}
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;
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;
}
return null;
}
/** A single static type for an initializer-less JavaScript `let` whose
@@ -5565,7 +5627,7 @@ export function lowerVarDecl(
!decl.type &&
!isJsSourceFile(ts.getSourceFileOfNode(decl))
) {
const evolved = evolvingArrayType(lowerer, decl);
const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayAliasType(lowerer, decl);
if (evolved !== null) {
type = evolved;
init = lowerer.lowerExprExpecting(decl.initializer, evolved);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7006-evolving-array-aliases.ts"
],
"diags": []
}
@@ -0,0 +1,79 @@
// An unannotated `const xs = []` inside a function whose writes store values
// of more than one element type: an alias taken between the writes
// (`const alias = xs`) and an alias of that alias name the same array, so
// they see every later write, as in Node. A write of another element type
// after reads that agree on one type also keeps the array untyped.
function chain(): string {
const xs = [];
xs.push(1);
const a = xs;
const b = a;
xs.push("two");
b.push(3);
return `${xs.join(",")} ${a.length} ${b.length} ${a === xs} ${b === xs}`;
}
function viaAlias(): string {
const xs = [];
xs.push("a");
const alias = xs;
alias.push("b");
xs.push(2);
return `${xs.join(",")} ${alias.join(",")}`;
}
function letAlias(): string {
const xs = [];
xs.push(true);
let alias = xs;
xs.push("t");
const seen = alias.length;
alias = [];
return `${seen} ${alias.length} ${xs.length}`;
}
function nested(flag: boolean): string {
const xs = [];
xs.push(1);
if (flag) {
const inner = xs;
xs.push("n");
return `${inner.length} ${inner.join(":")}`;
}
return `${xs.length}`;
}
function elementWrite(): string {
const xs = [];
xs.push(1);
const alias = xs;
xs[1] = "one";
return `${alias.length} ${alias.join("+")}`;
}
function agreeing(): string {
const xs = [];
xs.push(1);
const alias = xs;
xs.push(2);
return `${alias.length} ${alias === xs} ${alias.reduce((sum, v) => sum + v, 0)}`;
}
function total(values: number[]): number {
return values.length;
}
function passed(): string {
const xs = [];
xs.push(1);
const alias = xs;
const n = total(alias);
xs.push("z");
return `${n} ${alias.length}`;
}
console.log(chain());
console.log(viaAlias(), "|", letAlias());
console.log(nested(true), "|", nested(false), "|", elementWrite());
console.log(agreeing(), "|", passed());
function spreadSame(): number {
const xs = [];
xs.push(1);
const more = [2, 3];
xs.push(...more);
return xs.reduce((sum, v) => sum + v, 0);
}
console.log(spreadSame());
@@ -0,0 +1 @@
{ "plain": 2292, "san": 3445 }