From b237cf1ff0d048c764e91e2f2cd7c8ee028c944f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 03:04:59 -0500 Subject: [PATCH] Convert possibly undefined indexes of element writes with ToNumber An element write whose index the checker types as a number but whose storage may hold undefined (a for...of variable over an array that may have holes) converted the index with ToNumber only on plain-array writes. Typed-array writes, compound and update writes, element assignments used as values and element targets in destructuring assignment now convert it the same way, as the element reads already do, instead of refusing it as a non-number key. Fixes #1622 Co-authored-by: ejc3 <1694850+ejc3@users.noreply.github.com> --- .../frontend/lowering/expressions/binary.ts | 7 +++- .../lowering/expressions/element-access.ts | 16 +++++++-- .../frontend/lowering/expressions/updates.ts | 7 +++- .../src/frontend/lowering/lower-stmts.ts | 8 ++++- .../optional-index-element-writes.ts.json | 6 ++++ tests/corpus/optional-index-element-writes.ts | 35 +++++++++++++++++++ 6 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-index-element-writes.ts.json create mode 100644 tests/corpus/optional-index-element-writes.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 9bb74e72b..fafbbf695 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1154,7 +1154,12 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } if (recv?.type.kind === "array" || recv?.type.kind === "bytes") { const receiver = lowerer.declareHiddenLocal("%setArray", recv.type); - const index = lowerer.lowerExpr(expr.left.argumentExpression); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.left.argumentExpression), + locOf(expr.left.argumentExpression), + expr.left.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 0c4e89f87..74f0be955 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1493,7 +1493,12 @@ export function lowerElementCompoundParts( "compound assignment through a non-native array or byte view", ); } - const index = lowerer.lowerExpr(target.argumentExpression); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); @@ -1836,7 +1841,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): const recv = lowerer.lowerExpr(target.expression); // The checker's flow type is a typed array, but the storage is wider. if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); - const index = lowerer.lowerExpr(target.argumentExpression); + // An index the checker types as a number but whose storage may hold + // undefined (a hole read) converts with ToNumber, as the reads do. + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind === "dyn" && recv.type.kind === "bytes") { return { kind: "exprStmt", diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 104057906..7269f6b52 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -261,7 +261,12 @@ export function lowerIncDec( (raw.type.kind === "array" || raw.type.kind === "bytes") ) { const receiver = save(raw, "%incrementArray"); - const rawIndex = lowerer.lowerExpr(access.argumentExpression); + const rawIndex = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(access.argumentExpression), + locOf(access.argumentExpression), + access.argumentExpression, + ); const index = save( rawIndex.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) ? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc } diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index df055aed4..14dadfc4d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -194,6 +194,7 @@ import { lowerElementCompoundStatement, lowerIncDec, lowerIncDecTarget, + lowerOptionalNumber, lowerGroupsProjection, matchResultNamedGroupsOf, runtimeOptionalGuardIds, @@ -8835,7 +8836,12 @@ function lowerAssignTargetInto( if (arr.type.kind === "array") { const recv = lowerer.declareHiddenLocal("%dtRecv", arr.type); out.push({ kind: "varDecl", localId: recv.id, init: arr, loc }); - const index = lowerer.lowerExpr(t.argumentExpression); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(t.argumentExpression), + locOf(t.argumentExpression), + t.argumentExpression, + ); if (index.type.kind !== "f64") { lowerer.unsupported("SC1090", t.argumentExpression, "indexing with non-number keys"); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-index-element-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-index-element-writes.ts.json new file mode 100644 index 000000000..e748f8e75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/optional-index-element-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/optional-index-element-writes.ts" + ], + "diags": [] +} diff --git a/tests/corpus/optional-index-element-writes.ts b/tests/corpus/optional-index-element-writes.ts new file mode 100644 index 000000000..6f55d07a5 --- /dev/null +++ b/tests/corpus/optional-index-element-writes.ts @@ -0,0 +1,35 @@ +// Element writes whose index comes from for...of over a number[] field the +// class also writes by index (so the loop variable's storage may be +// undefined, a hole read): typed-array writes, compound and update writes, +// a write used as a value, and a destructuring target all convert the +// index with ToNumber, as the element reads do. +class Heap { + private k: number[] = []; + push(v: number): void { + const i = this.k.length; + this.k.push(v); + this.k[i] = v; + } + all(): number[] { + return this.k; + } +} +const h = new Heap(); +h.push(2); +h.push(0); +h.push(1); +const cost = new Float64Array(4); +for (const i of h.all()) cost[i] = i * 1.5; +console.log(Array.from(cost).join(",")); +const counts = new Int32Array(3); +for (const i of h.all()) counts[i] += 10; +for (const i of h.all()) counts[i]++; +console.log(Array.from(counts).join(",")); +const marks = new Uint8Array(3); +let last = 0; +for (const i of h.all()) last = marks[i] = i + 1; +console.log(Array.from(marks).join(","), last); +const plain = [0, 0, 0]; +for (const i of h.all()) [plain[i]] = [i * 2]; +for (const i of h.all()) plain[i] += 1; +console.log(plain.join(","));