From 6cd96eec491f4593bf455a7d16fcbf73c75ecff3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 14 Oct 2026 16:52:11 -0700 Subject: [PATCH] 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. --- .../src/frontend/lowering/lower-exprs.ts | 152 ++++++++++++------ .../corpus/5341-compound-any-operand.ts.json | 6 + .../corpus/5342-compound-any-operand.cjs.json | 6 + .../corpus/5343-any-flow-compound.ts.json | 6 + .../retired-engine-763-any-flow.ts.json | 6 - tests/corpus/5341-compound-any-operand.ts | 86 ++++++++++ tests/corpus/5342-compound-any-operand.cjs | 44 +++++ .../5343-any-flow-compound.ts} | 5 +- .../retired-engine-760-any-arithmetic.ts.txt | 2 +- .../retired-engine-763-any-flow.ts.txt | 13 -- tests/harness/dyncheck.test.ts | 33 ++++ 11 files changed, 292 insertions(+), 67 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5341-compound-any-operand.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5342-compound-any-operand.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5343-any-flow-compound.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-763-any-flow.ts.json create mode 100644 tests/corpus/5341-compound-any-operand.ts create mode 100644 tests/corpus/5342-compound-any-operand.cjs rename tests/{diagnostics/retired-engine-763-any-flow.ts => corpus/5343-any-flow-compound.ts} (73%) delete mode 100644 tests/harness/__snapshots__/retired-engine-763-any-flow.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index efc1969db..585d992b0 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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 diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5341-compound-any-operand.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5341-compound-any-operand.ts.json new file mode 100644 index 000000000..e54529e69 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5341-compound-any-operand.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5341-compound-any-operand.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5342-compound-any-operand.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5342-compound-any-operand.cjs.json new file mode 100644 index 000000000..04faa2dc9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5342-compound-any-operand.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5342-compound-any-operand.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5343-any-flow-compound.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5343-any-flow-compound.ts.json new file mode 100644 index 000000000..514fe3a5b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5343-any-flow-compound.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5343-any-flow-compound.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-763-any-flow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-763-any-flow.ts.json deleted file mode 100644 index aaa3e68bb..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/retired-engine-763-any-flow.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/diagnostics/retired-engine-763-any-flow.ts" - ], - "diags": [] -} diff --git a/tests/corpus/5341-compound-any-operand.ts b/tests/corpus/5341-compound-any-operand.ts new file mode 100644 index 000000000..13dddf6e5 --- /dev/null +++ b/tests/corpus/5341-compound-any-operand.ts @@ -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); diff --git a/tests/corpus/5342-compound-any-operand.cjs b/tests/corpus/5342-compound-any-operand.cjs new file mode 100644 index 000000000..ea7eeabfa --- /dev/null +++ b/tests/corpus/5342-compound-any-operand.cjs @@ -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); diff --git a/tests/diagnostics/retired-engine-763-any-flow.ts b/tests/corpus/5343-any-flow-compound.ts similarity index 73% rename from tests/diagnostics/retired-engine-763-any-flow.ts rename to tests/corpus/5343-any-flow-compound.ts index 4a1156d65..4265b01f6 100644 --- a/tests/diagnostics/retired-engine-763-any-flow.ts +++ b/tests/corpus/5343-any-flow-compound.ts @@ -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; } diff --git a/tests/harness/__snapshots__/retired-engine-760-any-arithmetic.ts.txt b/tests/harness/__snapshots__/retired-engine-760-any-arithmetic.ts.txt index 0acd30cf9..fbe72aa8d 100644 --- a/tests/harness/__snapshots__/retired-engine-760-any-arithmetic.ts.txt +++ b/tests/harness/__snapshots__/retired-engine-760-any-arithmetic.ts.txt @@ -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}`); diff --git a/tests/harness/__snapshots__/retired-engine-763-any-flow.ts.txt b/tests/harness/__snapshots__/retired-engine-763-any-flow.ts.txt deleted file mode 100644 index 3df3e1ef3..000000000 --- a/tests/harness/__snapshots__/retired-engine-763-any-flow.ts.txt +++ /dev/null @@ -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}`); \ No newline at end of file diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 11af327ed..32594701e 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -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(