mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Throw Node's TypeError when an asserted callee holds no function
`f!()`, `t[k]!()` and `o.cb!()` on a value that holds undefined threw
scriptc's checked-extraction message ("undefined is not representable in
the target union …"). The non-null assertion is erased in JavaScript, so
Node throws the call's own "<callee> is not a function". The checked
extraction behind an asserted callee now carries that message, with
V8's spelling of the callee: parentheses and non-null assertions dropped
and call results printed as `f(...)`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cf399bc1bf
commit
c1c4daed82
@@ -12,7 +12,10 @@ export class CoercionState {
|
||||
// Provenance for checked extraction; consumers must not infer it from names.
|
||||
readonly checkedNarrows = new Set<string>();
|
||||
// The extracted arm of each checked (non-defaulting) extraction helper.
|
||||
readonly checkedNarrowArms = new Map<string, { unionId: string; tag: number; target: IrType }>();
|
||||
readonly checkedNarrowArms = new Map<
|
||||
string,
|
||||
{ unionId: string; tag: number; target: IrType; message?: string }
|
||||
>();
|
||||
readonly retags = new Map<string, string>();
|
||||
readonly narrows = new Map<string, string>();
|
||||
}
|
||||
|
||||
@@ -394,18 +394,21 @@ export function unionRetagHelper(
|
||||
* the catchable TypeError — divergence 38's lying-assertion stance (an
|
||||
* unchecked unionNarrow would misread the payload where JS lets the
|
||||
* impossible value flow on). Null when the target isn't a non-unit arm
|
||||
* of the union — those uses keep their erasure/fences. */
|
||||
* of the union — those uses keep their erasure/fences. `message` replaces
|
||||
* the failure message (the TypeError of the operation consuming the
|
||||
* value, such as a call's "f is not a function"). */
|
||||
export function narrowedArmHelper(
|
||||
lowerer: Lowerer,
|
||||
fromId: string,
|
||||
target: IrType,
|
||||
loc: SrcLoc,
|
||||
message?: string,
|
||||
): string | null {
|
||||
const from = lowerer.unions.get(fromId);
|
||||
if (!from || isUnitType(target)) return null;
|
||||
const tag = lowerer.armTag(fromId, target);
|
||||
if (tag < 0) return null;
|
||||
const key = `${fromId}:${tag}`;
|
||||
const key = message === undefined ? `${fromId}:${tag}` : `${fromId}:${tag}:${message}`;
|
||||
const existing = lowerer.coercions.narrows.get(key);
|
||||
if (existing) {
|
||||
lowerer.coercions.checkedNarrows.add(existing);
|
||||
@@ -414,9 +417,22 @@ export function narrowedArmHelper(
|
||||
const name = keyedHelperName(lowerer, "%union.narrow", key);
|
||||
lowerer.coercions.narrows.set(key, name);
|
||||
lowerer.coercions.checkedNarrows.add(name);
|
||||
const fn = buildUnionNarrow(name, from, target, loc, (type) => lowerer.fmt(type));
|
||||
const fn = buildUnionNarrow(
|
||||
name,
|
||||
from,
|
||||
target,
|
||||
loc,
|
||||
(type) => lowerer.fmt(type),
|
||||
null,
|
||||
message ?? null,
|
||||
);
|
||||
if (!fn) throw new InternalCompilerError("lowerer bug: invalid checked union extraction");
|
||||
lowerer.coercions.checkedNarrowArms.set(name, { unionId: fromId, tag, target: from.arms[tag]! });
|
||||
lowerer.coercions.checkedNarrowArms.set(name, {
|
||||
unionId: fromId,
|
||||
tag,
|
||||
target: from.arms[tag]!,
|
||||
...(message === undefined ? {} : { message }),
|
||||
});
|
||||
lowerer.liftedFns.push(fn);
|
||||
return name;
|
||||
}
|
||||
@@ -458,7 +474,8 @@ export function checkedNarrowCall(
|
||||
let failure: IrExpr | null = null;
|
||||
for (let tag = from.arms.length - 1; tag >= 0; tag--) {
|
||||
if (tag === arm.tag) continue;
|
||||
const message = unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
|
||||
const message =
|
||||
arm.message ?? unionNarrowFailureMessage(from.arms[tag]!, (t) => lowerer.fmt(t));
|
||||
const thrown = nodeThrowExpr(1, "", message, type, loc);
|
||||
failure =
|
||||
failure === null
|
||||
|
||||
@@ -875,8 +875,15 @@ function checkedStaleArm(
|
||||
|
||||
/** V8's spelling of a callee in "is not a function": a string-literal
|
||||
* element key reads as a property name (`o["a"]` is `o.a`), other keys
|
||||
* keep their brackets. */
|
||||
* keep their brackets, parentheses and non-null assertions disappear, and
|
||||
* a call result reads `f(...)`. */
|
||||
export function v8CalleeText(node: ts.Node): string {
|
||||
// `!` is type-only syntax: the running JavaScript has no trace of it.
|
||||
if (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node))
|
||||
return v8CalleeText(node.expression);
|
||||
// A call result prints with its arguments elided.
|
||||
if (ts.isCallExpression(node) && !node.questionDotToken)
|
||||
return `${v8CalleeText(node.expression)}(...)`;
|
||||
if (ts.isElementAccessExpression(node)) {
|
||||
const key = node.argumentExpression;
|
||||
const object = v8CalleeText(node.expression);
|
||||
|
||||
@@ -89,7 +89,7 @@ import { classSymbolKeyOf } from "./symbol-fields.js";
|
||||
import { lowerTypeofExpr } from "./expressions/typeof.js";
|
||||
import { lowerIdentifierExpr } from "./expressions/identifiers.js";
|
||||
import { lowerFieldCompoundValue, lowerIncDec } from "./expressions/updates.js";
|
||||
import { lowerTernary } from "./expressions/narrowing.js";
|
||||
import { lowerTernary, v8CalleeText } from "./expressions/narrowing.js";
|
||||
import {
|
||||
classFieldTarget,
|
||||
lowerPropertyAccessExpr,
|
||||
@@ -433,7 +433,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
const operand = assertionOperand(lowerer, expr, inner, target ?? null, loc);
|
||||
if (operand) return operand;
|
||||
if (target && target.kind !== "union" && !typeEquals(target, inner.type)) {
|
||||
const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, loc);
|
||||
const helper = lowerer.narrowedArmHelper(
|
||||
inner.type.unionId,
|
||||
target,
|
||||
loc,
|
||||
assertedCalleeMessage(expr, target),
|
||||
);
|
||||
if (helper) {
|
||||
return lowerer.checkedNarrowCall(helper, inner, target, loc);
|
||||
}
|
||||
@@ -1160,6 +1165,19 @@ const NUMERIC_COMPOUNDS = new Set<ts.SyntaxKind>([
|
||||
ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
||||
]);
|
||||
|
||||
/** The TypeError Node raises when an asserted callee (`t[k]!(…)`, `o.f!()`)
|
||||
* holds no function: `!` is erased, so the call itself throws "<callee> is
|
||||
* not a function" in V8's spelling of the callee. */
|
||||
function assertedCalleeMessage(expr: ts.NonNullExpression, target: IrType): string | undefined {
|
||||
if (target.kind !== "func") return undefined;
|
||||
let child: ts.Expression = expr;
|
||||
while (ts.isParenthesizedExpression(child.parent)) child = child.parent;
|
||||
const call = child.parent;
|
||||
if (!ts.isCallExpression(call) || call.expression !== child || call.questionDotToken)
|
||||
return undefined;
|
||||
return `${v8CalleeText(expr.expression)} is not a function`;
|
||||
}
|
||||
|
||||
/** Whether the use of `t[i]!` tells undefined from NaN: a converting use
|
||||
* that takes the value itself (see convertingUseOf), or a destination that
|
||||
* holds a missing element (see assertedElementKeepsUndefined) other than a
|
||||
|
||||
@@ -10525,8 +10525,8 @@ export class Lowerer {
|
||||
return unionRetagHelper(this, fromId, toId, loc, trappable);
|
||||
}
|
||||
|
||||
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc): string | null {
|
||||
return narrowedArmHelper(this, fromId, target, loc);
|
||||
narrowedArmHelper(fromId: string, target: IrType, loc: SrcLoc, message?: string): string | null {
|
||||
return narrowedArmHelper(this, fromId, target, loc, message);
|
||||
}
|
||||
|
||||
checkedNarrowCall(helper: string, value: IrExpr, type: IrType, loc: SrcLoc): IrExpr {
|
||||
|
||||
@@ -97,3 +97,22 @@ test("a deferred read requires a real undefined tag", () => {
|
||||
const value: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
|
||||
expect(buildUnionNarrow("bad", from, BOOL, loc, () => "", value)).toBeNull();
|
||||
});
|
||||
|
||||
test("a failure message replaces every other tag's TypeError", () => {
|
||||
const fn = buildUnionNarrow(
|
||||
"call",
|
||||
union,
|
||||
STRING,
|
||||
loc,
|
||||
(type) => type.kind,
|
||||
null,
|
||||
"f is not a function",
|
||||
)!;
|
||||
const thrown = branches(fn).flatMap((branch) => {
|
||||
const statement = branch.then[0]!;
|
||||
return statement.kind === "throw" ? [statement.value] : [];
|
||||
});
|
||||
expect(thrown).toHaveLength(4);
|
||||
for (const value of thrown)
|
||||
expect(value).toMatchObject({ kind: "libCall", args: [{ value: "f is not a function" }] });
|
||||
});
|
||||
|
||||
@@ -20,7 +20,10 @@ export function unionNarrowFailureMessage(
|
||||
*
|
||||
* Deferred scalar fields can supply the established undefined default:
|
||||
* false for booleans or NaN for numbers. No other tag receives that default,
|
||||
* and reference-bearing targets always use checked extraction. */
|
||||
* and reference-bearing targets always use checked extraction.
|
||||
*
|
||||
* `failureMessage` replaces the TypeError message of every other tag (the
|
||||
* error Node raises for the operation consuming the value). */
|
||||
export function buildUnionNarrow(
|
||||
name: string,
|
||||
from: IrUnionDef,
|
||||
@@ -28,6 +31,7 @@ export function buildUnionNarrow(
|
||||
loc: SrcLoc,
|
||||
formatType: (type: IrType) => string,
|
||||
undefinedDefault: IrExpr | null = null,
|
||||
failureMessage: string | null = null,
|
||||
): IrFunction | null {
|
||||
if (isUnitType(target) || target.kind === "void") return null;
|
||||
const targetTag = from.arms.findIndex((arm) => typeEquals(arm, target));
|
||||
@@ -66,7 +70,7 @@ export function buildUnionNarrow(
|
||||
args: [
|
||||
{
|
||||
kind: "strLit",
|
||||
value: unionNarrowFailureMessage(arm, formatType),
|
||||
value: failureMessage ?? unionNarrowFailureMessage(arm, formatType),
|
||||
type: STRING,
|
||||
loc,
|
||||
},
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8240-asserted-callee-errors.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// A non-null assertion is erased in JavaScript, so calling an asserted callee
|
||||
// that holds undefined throws the call's own TypeError, with V8's spelling of
|
||||
// the callee: element keys, literal keys as names, parentheses and asserted
|
||||
// links dropped, call results as f(...).
|
||||
type F = (x: number) => number;
|
||||
const table: Record<string, F> = { inc: (x) => x + 1 };
|
||||
const obj: { cb?: F; inner?: { cb?: F } } = { inner: {} };
|
||||
const arr: (F | undefined)[] = [undefined];
|
||||
let maybe: F | undefined;
|
||||
function get(): F | undefined { return undefined; }
|
||||
const key = process.argv.length > 99 ? "inc" : "missing";
|
||||
const cases: [string, () => number][] = [
|
||||
["computed", () => table[key]!(1)],
|
||||
["literal key", () => table["missing"]!(1)],
|
||||
["dotted", () => obj.cb!(1)],
|
||||
["nested", () => obj.inner!.cb!(1)],
|
||||
["element", () => arr[0]!(1)],
|
||||
["local", () => maybe!(1)],
|
||||
["parens", () => (table[key])!(1)],
|
||||
["call result", () => get()!(1)],
|
||||
["parens outside", () => (table[key]!)(1)],
|
||||
];
|
||||
for (const [name, run] of cases) {
|
||||
try { console.log(name, run()); } catch (e) { console.log(name, (e as Error).name + ": " + (e as Error).message); }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3000, "san": 3600 }
|
||||
Reference in New Issue
Block a user