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>
This commit is contained in:
Chris Tate
2026-10-21 03:04:59 -05:00
co-authored by ejc3
parent 1f554fbc75
commit b237cf1ff0
6 changed files with 74 additions and 5 deletions
@@ -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",
@@ -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",
@@ -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 }
@@ -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");
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/optional-index-element-writes.ts"
],
"diags": []
}
@@ -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(","));