mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-21 05:50:55 +08:00
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:
@@ -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
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5341-compound-any-operand.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5342-compound-any-operand.cjs"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5343-any-flow-compound.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/diagnostics/retired-engine-763-any-flow.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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);
|
||||
+3
-2
@@ -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}`);
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user