Lower compound assignment with untyped operands like the expanded form

`checksum += parsed[i].score` (a number local plus an `any` value) failed
with SC1043 "comparing non-number, non-string values" in TypeScript, while
the expanded `checksum = checksum + parsed[i].score` compiled: the
compound lowering only routed checked-dynamic operands through the
runtime operators in JavaScript files.

Locals, record fields and array elements now lower `op=` with an untyped
right operand (or an untyped local target) exactly like `x = x op rhs`:
dyn.add / dyn.arithmetic / dyn.bitwise, then the checked conversion into
the target's declared type. Numeric operators keep Node's ToNumber
semantics; a `+=` that produces a string for a `number` target throws the
same catchable TypeError as the expanded assignment and leaves the target
unchanged (pinned in tests/harness/dyncheck.test.ts).

Remaining SC1043 refusals for arithmetic, unary, update and compound
operators now name the operator and operand types instead of the
comparison wording.

The retired-engine-763 diagnostics fixture compiles now and matches Node,
so it moves to the corpus as 5343.
This commit is contained in:
Malte Ubl
2026-10-16 06:31:51 -07:00
parent a09990192e
commit 6cd96eec49
11 changed files with 292 additions and 67 deletions
@@ -7492,16 +7492,27 @@ export function lowerElementCompound(
} else if (valueType.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) {
const left = lowerer.coerceToExpected(oldRef(), DYN),
right = lowerer.coerceToExpected(rhs, DYN);
const numeric = ["-", "*", "/", "%", "**"].includes(op);
computed = {
kind: "libCall",
fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise",
args: op === "+" ? [left, right] : [left, right, strLit(op, loc)],
type: DYN,
loc,
};
computed = dynCompoundValue(op, left, right, loc);
} else if (
rhs.type.kind === "dyn" &&
receiver.type.kind === "array" &&
lowerer.dynConvertible(oldValue.type) &&
lowerer.dynConvertible(valueType)
) {
// An untyped right operand: the expanded `a[i] = a[i] op rhs`, checked
// back into the element type.
computed = lowerer.coerceInto(
expr,
dynCompoundValue(
op,
lowerer.coerceToExpected(oldRef(), DYN),
lowerer.coerceToExpected(rhs, DYN),
loc,
),
valueType,
);
} else {
lowerer.unsupported("SC1043", expr);
compoundUnsupported(lowerer, expr, op, oldValue.type, rhs.type);
}
const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type);
const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc);
@@ -8725,7 +8736,7 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
raw.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())
? { kind: "dynCheck" as const, value: raw, type: F64, loc }
: lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") lowerer.unsupported("SC1043", expr);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]);
if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc };
return { kind: "unary", op: "-", operand, type: F64, loc };
}
@@ -8742,7 +8753,7 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc };
}
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") lowerer.unsupported("SC1043", expr);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]);
return operand;
}
case ts.SyntaxKind.ExclamationToken: {
@@ -8776,7 +8787,7 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
// such as `while (~index)`, where inference may have widened an
// otherwise numeric slot to the checked-dynamic tree.
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") lowerer.unsupported("SC1043", expr);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "~", [raw.type]);
return { kind: "unary", op: "~", operand, type: F64, loc };
}
case ts.SyntaxKind.PlusPlusToken:
@@ -9096,7 +9107,8 @@ export function lowerIncDecTarget(
loc,
};
}
if (target.type.kind !== "f64") lowerer.unsupported("SC1043", expr);
if (target.type.kind !== "f64")
operatorUnsupported(lowerer, expr, op === "+" ? "++" : "--", [target.type]);
return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc };
}
@@ -9214,28 +9226,75 @@ export function lowerCompoundValueToTarget(
target.type,
);
}
// An untyped operand: exactly the expanded `x = x op rhs` — the runtime
// operator on both values, then the checked conversion into the
// target's declared type (a string result for a number target throws the
// catchable TypeError, as the expanded assignment does).
if (
(target.type.kind === "dyn" || rhs.type.kind === "dyn") &&
isJsSourceFile(expr.getSourceFile())
(isJsSourceFile(expr.getSourceFile()) ||
(lowerer.dynConvertible(read.type) && lowerer.dynConvertible(rhs.type)))
) {
const left = lowerer.coerceInto(expr.left, read, DYN);
const right = lowerer.coerceInto(expr.right, rhs, DYN);
const numeric = ["-", "*", "/", "%", "**"].includes(compound);
const computed: IrExpr = {
kind: "libCall",
fn: compound === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise",
args:
compound === "+"
? [left, right]
: [left, right, { kind: "strLit", value: compound, type: STRING, loc }],
type: DYN,
loc,
};
return lowerer.coerceInto(expr, computed, target.type);
return lowerer.coerceInto(expr, dynCompoundValue(compound, left, right, loc), target.type);
}
const bigint = bigintCompoundValue(compound, read, rhs, loc);
if (bigint) return bigint;
lowerer.unsupported("SC1043", expr);
compoundUnsupported(lowerer, expr, compound, read.type, rhs.type);
}
/** The runtime operator of a compound assignment over two checked-dynamic
* operands (Node's ToPrimitive/ToNumeric order lives in the runtime). */
function dynCompoundValue(op: CompoundOp, left: IrExpr, right: IrExpr, loc: SrcLoc): IrExpr {
const numeric = ["-", "*", "/", "%", "**"].includes(op);
return {
kind: "libCall",
fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise",
args: op === "+" ? [left, right] : [left, right, strLit(op, loc)],
type: DYN,
loc,
};
}
/** An operand type in an SC1043 message: checked-dynamic values (`any`,
* `unknown`) are "untyped", everything else its quoted type. */
function operandText(lowerer: Lowerer, t: IrType): string {
return t.kind === "dyn" ? "untyped" : `'${lowerer.fmt(t)}'`;
}
/** SC1043 for a compound assignment whose operand types have no lowering,
* naming the operator and both types rather than the comparison wording. */
function compoundUnsupported(
lowerer: Lowerer,
node: ts.Node,
op: CompoundOp,
target: IrType,
value: IrType,
): never {
lowerer.unsupported(
"SC1043",
node,
`compound '${op}=' assignments of ${operandText(lowerer, value)} values to ${operandText(lowerer, target)} targets`,
);
}
/** SC1043 for an arithmetic, unary or update operator whose operand types
* have no lowering (comparisons keep the registry wording). */
function operatorUnsupported(
lowerer: Lowerer,
node: ts.Node,
op: string,
operands: readonly IrType[],
): never {
const [first, second] = operands;
lowerer.unsupported(
"SC1043",
node,
second === undefined
? `'${op}' operations on ${operandText(lowerer, first!)} values`
: `'${op}' operations on ${operandText(lowerer, first!)} and ${operandText(lowerer, second)} values`,
);
}
/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may
@@ -10204,14 +10263,15 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
};
}
}
lowerer.unsupported("SC1043", expr);
operatorUnsupported(lowerer, expr, "+", [left.type, right.type]);
break;
case ts.SyntaxKind.MinusToken:
case ts.SyntaxKind.AsteriskToken:
case ts.SyntaxKind.SlashToken:
case ts.SyntaxKind.PercentToken:
case ts.SyntaxKind.AsteriskAsteriskToken: {
if (!bothNum) lowerer.unsupported("SC1043", expr);
if (!bothNum)
operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]);
const binOp =
op === ts.SyntaxKind.MinusToken
? "-"
@@ -10241,7 +10301,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
case ts.SyntaxKind.LessThanLessThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanToken:
case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: {
if (!bothNum) lowerer.unsupported("SC1043", expr);
if (!bothNum)
operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]);
const bitOp =
op === ts.SyntaxKind.AmpersandToken
? "&"
@@ -15041,7 +15102,8 @@ function lowerFieldCompoundValue(
rhs.type.kind === "dyn"
? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc }
: lowerOptionalNumber(lowerer, rhs, loc);
if (numericRhs.type.kind !== "f64") lowerer.unsupported("SC1043", access);
if (numericRhs.type.kind !== "f64")
compoundUnsupported(lowerer, access, op, F64, numericRhs.type);
const value = save(
{ kind: "bin", op, left: cur, right: numericRhs, type: F64, loc },
"%compoundResult",
@@ -15114,22 +15176,22 @@ function lowerFieldCompoundValue(
value = { kind: "bin", op, left: numericRead, right: numericRhs, type: F64, loc };
} else if (bigintValue) {
value = bigintValue;
} else if (target.fieldType.kind === "dyn" && isJsSourceFile(access.getSourceFile())) {
const numeric = ["-", "*", "/", "%", "**"].includes(op);
const left = lowerer.coerceToExpected(read, DYN);
const right = lowerer.coerceToExpected(rhs, DYN);
value = {
kind: "libCall",
fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise",
args:
op === "+"
? [left, right]
: [left, right, { kind: "strLit", value: op, type: STRING, loc }],
type: DYN,
} else if (
(target.fieldType.kind === "dyn" && isJsSourceFile(access.getSourceFile())) ||
// An untyped right operand: the expanded `o.f = o.f op rhs`; the field
// write below checks the result into the declared field type.
(rhs.type.kind === "dyn" &&
lowerer.dynConvertible(read.type) &&
lowerer.dynConvertible(target.fieldType))
) {
value = dynCompoundValue(
op,
lowerer.coerceToExpected(read, DYN),
lowerer.coerceToExpected(rhs, DYN),
loc,
};
);
} else {
lowerer.unsupported("SC1043", access);
compoundUnsupported(lowerer, access, op, read.type, rhs.type);
}
if (storedString && rhs.type.kind === "string" && read.kind === "varRef" && appended !== null) {
// The field gives up its value before the concat (no code runs in
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5341-compound-any-operand.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5342-compound-any-operand.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5343-any-flow-compound.ts"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/retired-engine-763-any-flow.ts"
],
"diags": []
}
+86
View File
@@ -0,0 +1,86 @@
// Compound assignment with an untyped (`any`) right operand lowers exactly
// like the expanded `x = x op rhs`: number and string locals, record fields,
// array elements, and `any` targets, across every compound operator.
const d: any = JSON.parse('{"n":5,"s":"x","f":2.5,"neg":-3,"big":"12","arr":[1,2,3]}');
const rows: any = JSON.parse('[{"score":3},{"score":4},{"score":"7"}]');
// number locals: arithmetic and bitwise operators
let a: number = 10;
a += d.n;
console.log("+=", a);
a -= d.f;
console.log("-=", a);
a *= d.neg;
console.log("*=", a);
a /= d.n;
console.log("/=", a);
a %= d.n;
console.log("%=", a);
a **= d.n;
console.log("**=", a);
let bits: number = 0xff;
bits &= d.n;
bits |= d.arr[2];
bits ^= d.neg;
console.log("bitwise", bits);
bits <<= d.n;
bits >>= d.arr[0];
bits >>>= d.arr[1];
console.log("shifts", bits);
// numeric operators coerce a string operand with ToNumber, exactly as Node
let coerced: number = 100;
coerced -= d.big;
coerced *= d.s;
console.log("coerced", coerced);
// the benchmark shape: summing a field of parsed rows into a number local
let checksum: number = 0;
for (let i: number = 0; i < 2; i++) checksum += rows[i].score;
console.log("checksum", checksum);
// string locals concatenate every operand
let label: string = "n=";
label += d.n;
label += d.arr;
label += d.missing;
console.log(label);
// `any` targets keep Node's dynamic + (concatenation once a string appears)
let acc: any = 1;
acc += d.n;
acc += d.s;
console.log(acc, typeof acc);
let num: any = "8";
num -= d.n;
console.log(num, typeof num);
// record fields
const totals = { count: 1, name: "t" };
totals.count += d.n;
totals.count *= d.f;
totals.name += d.n;
console.log(totals.count, totals.name);
// array elements, including a missing element (NaN in, like Node)
const nums: number[] = [1, 2];
nums[0] += d.n;
nums[1] **= d.arr[1];
nums[2] += d.n;
console.log(nums);
const words: string[] = ["w"];
words[0] += d.n;
words[0] += d.s;
console.log(words);
// the value of a compound assignment expression
let v: number = 1;
const result = (v += d.n);
console.log(result, v);
// bigint targets with a bigint-valued `any`
let big: bigint = 2n;
const three: any = 3n;
big += three;
big *= three;
console.log(big);
@@ -0,0 +1,44 @@
// Compound assignment with JSON.parse operands in JavaScript: numeric and
// string locals, fields, and elements keep Node's dynamic semantics,
// including a number local that becomes a string through `+=`.
const d = JSON.parse('{"n":5,"s":"x","f":2.5,"arr":[1,2,3]}');
const rows = JSON.parse('[{"score":3},{"score":4},{"score":"7"}]');
let a = 10;
a += d.n;
a -= d.f;
a *= d.n;
a /= d.arr[1];
a %= d.n;
a **= d.arr[1];
console.log(a);
let bits = 0xff;
bits &= d.n;
bits |= d.arr[2];
bits <<= d.n;
bits >>>= d.arr[0];
console.log(bits);
let checksum = 0;
for (let i = 0; i < rows.length; i++) checksum += rows[i].score;
console.log(checksum, typeof checksum);
let becomes = 1;
becomes += d.s;
console.log(becomes, typeof becomes);
let label = "n=";
label += d.n;
label += d.arr;
console.log(label);
const totals = { count: 1, name: "t" };
totals.count += d.n;
totals.name += d.s;
console.log(totals.count, totals.name);
const nums = [1, 2];
nums[0] += d.n;
nums[1] -= d.s;
console.log(nums);
@@ -1,6 +1,7 @@
// `any` values flow through static functions, logical operators keep JS
// value semantics through the island, compound assignment works, and a
// churn loop pressures the RC/engine boundary (the SAN lane's real test).
// value semantics, compound assignment on an `any` local works (formerly a
// diagnostics fixture from the retired engine), and a churn loop pressures
// checked-value reference counting (the SAN lane's real test).
function pipe(v: any): any {
return v;
}
@@ -1,4 +1,4 @@
retired-engine-760-any-arithmetic.ts:8:67 - error SC1043: comparing non-number, non-string values are not supported yet
retired-engine-760-any-arithmetic.ts:8:67 - error SC1043: '-' operations on untyped values are not supported yet
7 | const coerced: any = "10";
8 | console.log(`${coerced * 2}`, `${coerced + 5}`, `${+coerced}`, `${-coerced}`);
@@ -1,13 +0,0 @@
retired-engine-763-any-flow.ts:15:1 - error SC1043: comparing non-number, non-string values are not supported yet
14 | let acc: any = 1;
15 | acc += 4;
| ^~~~~~~~
16 | acc *= 3;
retired-engine-763-any-flow.ts:16:1 - error SC1043: comparing non-number, non-string values are not supported yet
15 | acc += 4;
16 | acc *= 3;
| ^~~~~~~~
17 | console.log(`${acc}`);
+33
View File
@@ -1131,6 +1131,39 @@ console.log(has(eng));
expect(r.stdout).toBe("true\n");
});
test("compound assignment of an untyped string into a number throws like the expanded form", async () => {
// Node turns `n += "x"` into the string "1x"; a `number` local cannot
// hold it, so the compound form takes the same checked conversion as
// `n = n + value` and leaves the local unchanged.
const r = await compileAndRun(
"compound-any-string",
`const d: any = JSON.parse('{"s":"x"}');
let expanded: number = 1;
try { expanded = expanded + d.s; } catch (e) { console.log("expanded", (e as Error).message, expanded); }
let compound: number = 1;
try { compound += d.s; } catch (e) { console.log("compound", (e as Error).message, compound); }
const field = { n: 1 };
try { field.n += d.s; } catch (e) { console.log("field", (e as Error).message, field.n); }
const items: number[] = [1];
try { items[0] += d.s; } catch (e) { console.log("element", (e as Error).message, items[0]); }
let uncaught: number = 1;
uncaught += d.s;
console.log("unreachable", uncaught);
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe(
[
"expanded expected number at $, got string 1",
"compound expected number at $, got string 1",
"field expected number at $, got string 1",
"element expected number at $, got string 1",
"",
].join("\n"),
);
expect(r.stderr).toContain("Uncaught TypeError: expected number at $, got string");
});
test("a checked value RC-balances through catch-and-continue", async () => {
// The sanitizer lane audits releases while checked values unwind.
const r = await compileAndRun(