mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
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:
co-authored by
Claude Opus 5.5
parent
e324c07a66
commit
34eef83ef4
@@ -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;
|
||||
|
||||
@@ -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"] })));
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare function run(raw: string): string;
|
||||
Generated
Vendored
+12
@@ -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 };
|
||||
Generated
Vendored
+1
@@ -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" }
|
||||
@@ -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"));
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function skipSpaces(str: string, ptr: number): number; export declare function countUntilBracket(str: string): number;
|
||||
+24
@@ -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 };
|
||||
Generated
Vendored
+1
@@ -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" }
|
||||
Reference in New Issue
Block a user