From e07f1be5c297a17769d7a2b69dcea17b6f78c2f5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 07:07:34 -0500 Subject: [PATCH 1/2] Keep aliases of evolving empty arrays on the array they alias An unannotated `const xs = []` inside a function takes the element type its reads agree on, and is checked-dynamic when they disagree. An alias taken between two writes of different element types (`const alias = xs` after `xs.push(1)`, before `xs.push("two")`) took the element type the checker gives it at that point, so it held a copy and missed the later writes: Node prints `1,two 2` for `xs.join(","), alias.length`, the compiled program printed `1,two 1`. An unannotated alias of such a binding, and an alias of that alias, now takes the binding's checked-dynamic type, so every name holds the one array. A write that stores a value the agreed element type does not hold (`xs.push("t")` after reads typed `boolean[]`, or `xs[1] = "one"`) now makes the binding checked-dynamic too. Those programs were refused with SC1090 "'string' values where 'boolean' is expected". Fixes #1641. --- .../src/frontend/lowering/lower-stmts.ts | 59 ++++++++++++++-- .../7006-evolving-array-aliases.ts.json | 6 ++ tests/corpus/7006-evolving-array-aliases.ts | 70 +++++++++++++++++++ .../7006-evolving-array-aliases.ts.json | 1 + 4 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7006-evolving-array-aliases.ts.json create mode 100644 tests/corpus/7006-evolving-array-aliases.ts create mode 100644 tests/harness/corpus-costs/7006-evolving-array-aliases.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 213abfec6..29086d326 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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[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,36 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy return DYN; result = mapped; } - return result ?? DYN; + if (result === null || result.kind !== "array") return DYN; + for (const value of written) { + if (ts.isSpreadElement(value)) return DYN; + const t = lowerer.mapTypeOf(lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(value))); + 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 +5614,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); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7006-evolving-array-aliases.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7006-evolving-array-aliases.ts.json new file mode 100644 index 000000000..cb3d36498 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7006-evolving-array-aliases.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7006-evolving-array-aliases.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7006-evolving-array-aliases.ts b/tests/corpus/7006-evolving-array-aliases.ts new file mode 100644 index 000000000..4810c440e --- /dev/null +++ b/tests/corpus/7006-evolving-array-aliases.ts @@ -0,0 +1,70 @@ +// 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()); diff --git a/tests/harness/corpus-costs/7006-evolving-array-aliases.ts.json b/tests/harness/corpus-costs/7006-evolving-array-aliases.ts.json new file mode 100644 index 000000000..f34efd332 --- /dev/null +++ b/tests/harness/corpus-costs/7006-evolving-array-aliases.ts.json @@ -0,0 +1 @@ +{ "plain": 2292, "san": 3445 } From 0078dc2c46c93e6443fae1e3296456129ba190e0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:10:59 -0500 Subject: [PATCH 2/2] Check the elements a spread push stores into an evolving empty array The evolving-array check made the binding checked-dynamic for any spread write, so `xs.push(...values)` of an array whose elements match the binding's agreed element type turned a typed `number[]` into a checked-dynamic array, and the spread call through it was refused with SC1090 "spread arguments in calls through 'unknown' values". A spread now stores the source array's element type, or each of a tuple's element types, and the binding stays typed when they match. A spread of another iterable still makes it checked-dynamic. --- .../src/frontend/lowering/lower-stmts.ts | 27 ++++++++++++++----- tests/corpus/7006-evolving-array-aliases.ts | 9 +++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 29086d326..cc01d7401 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4800,9 +4800,9 @@ export function lowerVarDeclList( * 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, a write (`xs.push(v)`, `xs.unshift(v)`, - * `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. */ + * `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; @@ -4870,11 +4870,24 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy result = mapped; } 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) { - if (ts.isSpreadElement(value)) return DYN; - const t = lowerer.mapTypeOf(lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(value))); - if (!t || (!typeEquals(t, result.elem) && lowerer.widthLiftPlan(t, result.elem) === null)) - return DYN; + 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; } diff --git a/tests/corpus/7006-evolving-array-aliases.ts b/tests/corpus/7006-evolving-array-aliases.ts index 4810c440e..215b3f3c5 100644 --- a/tests/corpus/7006-evolving-array-aliases.ts +++ b/tests/corpus/7006-evolving-array-aliases.ts @@ -68,3 +68,12 @@ 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());