Decide concat spreading of dynamic values at run time; keep JS lets assigned from index reads unchecked

Three JavaScript lowering bugs threw a checked conversion where Node answers:

- concat with a checked-dynamic argument lowered it as an element, so an
  array argument threw. The argument now spreads when it is an array at run
  time and is appended otherwise.
- `let c; while ((c = str[i++]) !== "]" && c)`: the checker types c from the
  index read, without the undefined a read past the end answers. Evolving-let
  inference ignores index-read writes, and reads of a JS `let x;` statement
  binding keep the actual value.
- Strict equality on a flow-narrowed dynamic JS name compares unchecked.

npm cases concat-dynamic-args and evolving-let-index match Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 09:19:53 -07:00
co-authored by Claude Opus 5.5
parent e324c07a66
commit 34eef83ef4
15 changed files with 169 additions and 7 deletions
@@ -3,6 +3,7 @@ import { identityPreservingWidening } from "../coercions/identity.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
import {
BOOL,
DYN,
F64,
arrayOf,
typeEquals,
@@ -18,15 +19,18 @@ import type { Lowerer } from "../lowerer.js";
/** Copy into fresh storage after every argument has evaluated. Array
* arguments retain their own element ABI; only copied values are widened.
* Missing slots stay holes and present undefined stays present. */
* Missing slots stay holes and present undefined stays present. A shape
* entry is an array argument's element type, null for an element value, or
* "dyn" for a checked-dynamic argument, which JS spreads when it is an
* array at run time (IsConcatSpreadable) and appends otherwise. */
export function arrayConcatHelper(
lowerer: Lowerer,
elem: IrType,
shape: (IrType | null)[],
shape: (IrType | null | "dyn")[],
node: ts.CallExpression,
loc: SrcLoc,
): string {
const key = `concat:${typeKey(elem)}:${shape.map((type) => (type ? `a:${typeKey(type)}` : "e")).join(";")}`;
const key = `concat:${typeKey(elem)}:${shape.map((type) => (type === "dyn" ? "d" : type ? `a:${typeKey(type)}` : "e")).join(";")}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.concat", key);
@@ -36,7 +40,7 @@ export function arrayConcatHelper(
...shape.map((type, i) => ({
localId: `x.${i}`,
name: `x${i}`,
type: type ? arrayOf(type) : elem,
type: type === "dyn" ? DYN : type ? arrayOf(type) : elem,
})),
];
const locals: IrLocal[] = params.map((p) => ({
@@ -146,6 +150,43 @@ export function arrayConcatHelper(
},
];
};
// A checked-dynamic argument: an array spreads its elements, converted
// to the element type; any other value is appended as one element.
const appendDyn = (source: IrExpr, site: ts.Node): IrStmt[] => [
{
kind: "if",
cond: { kind: "dynTest", test: "array", value: source, type: BOOL, loc },
then: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "concatSpread",
receiver: out,
args: [lowerer.coerceInto(site, source, arrT)],
type: F64,
loc,
},
loc,
},
],
else_: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: out,
args: [lowerer.coerceInto(site, source, elem)],
type: F64,
loc,
},
loc,
},
],
loc,
},
];
const body: IrStmt[] = [
{
kind: "varDecl",
@@ -155,11 +196,17 @@ export function arrayConcatHelper(
},
...append(varRef("a.0", arrT, loc), elem, node.expression),
...shape.flatMap((type, i) =>
append(varRef(`x.${i}`, type ? arrayOf(type) : elem, loc), type, node.arguments.nodes[i]!),
type === "dyn"
? appendDyn(varRef(`x.${i}`, DYN, loc), node.arguments.nodes[i]!)
: append(
varRef(`x.${i}`, type ? arrayOf(type) : elem, loc),
type,
node.arguments.nodes[i]!,
),
),
{ kind: "return", value: out, loc },
];
if (shape.some((type) => type && !typeEquals(type, elem))) {
if (shape.some((type) => type && type !== "dyn" && !typeEquals(type, elem))) {
for (const id of ["i.0", "offset.0", "count.0"])
locals.push({ id, name: id, type: F64, mutable: true });
}
@@ -924,7 +924,7 @@ export function lowerArrayMethodCall(
// an array-of-arrays receiver given a bare inner array, where TS
// types it as an element but JS would SPREAD it — is fenced.
const receiver = lowerer.lowerExpr(access.expression);
const shape: (IrType | null)[] = [];
const shape: (IrType | null | "dyn")[] = [];
const args: IrExpr[] = [];
for (const argNode of call.arguments.nodes) {
if (ts.isSpreadElement(argNode)) {
@@ -958,6 +958,16 @@ export function lowerArrayMethodCall(
args.push(arg);
continue;
}
// A checked-dynamic value: whether JS spreads it is decided at run
// time (an array spreads one level, anything else is one element).
if (
(argIr === null || argIr.kind === "dyn") &&
(probedArg ?? tryLowerExpression(lowerer, argNode))?.type.kind === "dyn"
) {
shape.push("dyn");
args.push(lowerer.lowerExpr(argNode));
continue;
}
// An element value — union elements wrap exactly like a push.
shape.push(null);
args.push(lowerer.lowerExprExpecting(argNode, elem));
@@ -2280,6 +2280,33 @@ export function lowerStrictEquality(
negated: boolean,
loc: SrcLoc,
): IrExpr {
// JavaScript: a checked-dynamic name the checker's flow analysis narrowed
// (`let c; ... (c = s[i]) === " "`) is compared unchecked. Strict
// equality never throws, and the flow type is the checker's inference
// (an indexed read past the end is undefined whatever it says).
const flowNarrowed = (n: ts.Node, e: IrExpr): IrExpr | null =>
ts.isIdentifier(n) &&
e.kind === "dynCheck" &&
e.value.type.kind === "dyn" &&
isJsSourceFile(ts.getSourceFileOfNode(n))
? e.value
: null;
const scalar = (e: IrExpr): boolean =>
e.type.kind === "string" || e.type.kind === "f64" || e.type.kind === "bool";
{
const l = flowNarrowed(leftNode, left);
const r = flowNarrowed(rightNode, right);
if ((l !== null && (scalar(right) || r !== null)) || (r !== null && scalar(left))) {
const cmp: IrExpr = {
kind: "dynScalarEq",
left: l ?? lowerer.coerceInto(leftNode, left, DYN),
right: r ?? lowerer.coerceInto(rightNode, right, DYN),
type: BOOL,
loc,
};
return negated ? { kind: "unary", op: "!", operand: cmp, type: BOOL, loc } : cmp;
}
}
const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64";
const bothStr = left.type.kind === "string" && right.type.kind === "string";
const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc);
@@ -513,6 +513,27 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
)
)
return expr;
// A `let c;` binding without a type takes its flow types from what
// the code assigns, and the checker's types for those values can omit
// undefined (`c = s[i]` past the end): reads keep the actual value.
if (
symbol &&
symbol.declarations.some(
(decl) =>
ts.isVariableDeclaration(decl) &&
decl.initializer === undefined &&
decl.type === undefined &&
ts.isIdentifier(decl.name) &&
decl.parent?.parent !== undefined &&
ts.isVariableStatement(decl.parent.parent) &&
!/@type\b/.test(
ts
.getSourceFileOfNode(decl)
.text.slice(decl.parent?.parent?.pos ?? decl.pos, ts.tokenStartOfNode(decl)),
),
)
)
return expr;
if (
symbol &&
symbol.declarations.some(
@@ -5367,6 +5367,14 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration)
invalid = true;
return "stop";
}
// An index read's checker type omits the undefined a read past the
// end (or of an absent key) answers.
let right = node.right;
while (ts.isParenthesizedExpression(right)) right = right.expression;
if (ts.isElementAccessExpression(right)) {
invalid = true;
return "stop";
}
const mapped = lowerer.mapTypeOf(lowerer.typeOf(node.right));
if (!mapped || mapped.kind === "dyn" || mapped.kind === "void" || isUnitType(mapped)) {
invalid = true;
+3
View File
@@ -0,0 +1,3 @@
// concat of checked-dynamic values decides spreading at run time.
import { run } from "concat-dynamic";
console.log(run(JSON.stringify({ text: "desc", list: ["a", "b"] })));
@@ -0,0 +1 @@
export declare function run(raw: string): string;
@@ -0,0 +1,12 @@
'use strict';
// Array.prototype.concat spreads an argument that is an array at run time and
// appends any other value, whatever the static type says.
function run(raw) {
const parsed = JSON.parse(raw);
let output = ['Usage:', ''];
output = output.concat([parsed.text, '']);
output = output.concat(parsed.list);
output = output.concat(parsed.text);
return output.join('|') + ' ' + output.length;
}
module.exports = { run };
@@ -0,0 +1 @@
{ "name": "concat-dynamic", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "concat-dynamic-args-case", "private": true, "type": "module" }
+4
View File
@@ -0,0 +1,4 @@
// Reads of a `let` binding whose checker type comes from string index reads
// see undefined past the end.
import { countUntilBracket, skipSpaces } from "evolving-let";
console.log(skipSpaces("ab ", 2), skipSpaces("a b", 1), countUntilBracket("abc"), countUntilBracket("ab]c"));
@@ -0,0 +1 @@
export declare function skipSpaces(str: string, ptr: number): number; export declare function countUntilBracket(str: string): number;
@@ -0,0 +1,24 @@
'use strict';
// `let c;` takes what an index read past the end answers: undefined.
/**
* @param {string} str
* @param {number} ptr
* @returns {number}
*/
function skipSpaces(str, ptr) {
let c;
while ((c = str[ptr]) === ' ' || c === '\t') ptr++;
return ptr;
}
/**
* @param {string} str
* @returns {number}
*/
function countUntilBracket(str) {
let c;
let ptr = 0;
let n = 0;
while ((c = str[ptr++]) !== ']' && c) n++;
return n;
}
module.exports = { skipSpaces, countUntilBracket };
@@ -0,0 +1 @@
{ "name": "evolving-let", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "evolving-let-index-case", "private": true, "type": "module" }