Split lower-exprs.ts into expression families (#1431)

This commit is contained in:
Chris Tate
2026-10-19 21:03:52 -05:00
committed by GitHub
10 changed files with 14874 additions and 14469 deletions
@@ -0,0 +1,767 @@
/* Array literals. */
import * as ts from "../../ts7/adapter.js";
import { type Lowerer, neverTaintedJsType } from "../lowerer.js";
import {
BOOL,
DYN,
F64,
type IrExpr,
type IrRecordShape,
type IrStmt,
type IrType,
STRING,
type SrcLoc,
UNDEFINED_T,
VOID,
arrayOf,
isSupportedArrayElem,
typeEquals,
} from "../../../ir/ir.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { underConstAssertion, unitOnlyUnion } from "../../type-mapper.js";
import { dateJsonType, isDateJsonSource } from "../builtins/dates.js";
import { npmStaticPackageOfPath } from "../../npm-static.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { maybePromiseUnionOf } from "../maybe-promise.js";
import { lowerTernary } from "./narrowing.js";
import { lowerCollectionSpread } from "../containers/collection-methods.js";
import { strCharsCall } from "../containers/array-construction.js";
import { generatorDrain } from "../iterator-adapters.js";
import { isSearchParamsIterable } from "../builtins/url.js";
import { varRef } from "../../../ir/build.js";
import { arrayValueStore } from "../array-values.js";
/** JavaScript: whether `expr` is or holds an existing checked-dynamic
* object — a binding stored as a dyn value whose checker type is an
* object, or an array or object literal with such an element. Building
* the enclosing literal with a static element type would copy it. */
export function jsHoldsDynObject(lowerer: Lowerer, expr: ts.Expression): boolean {
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
if (ts.isIdentifier(expr)) {
const stored = lowerer.peekLocal(expr)?.type ?? lowerer.globalOf(expr)?.type;
return stored?.kind === "dyn" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Object) !== 0;
}
if (ts.isArrayLiteralExpression(expr))
return expr.elements.nodes.some(
(element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element),
);
if (ts.isObjectLiteralExpression(expr))
return expr.properties.nodes.some(
(property) =>
(ts.isPropertyAssignment(property) && jsHoldsDynObject(lowerer, property.initializer)) ||
(ts.isShorthandPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
jsHoldsDynObject(lowerer, property.name)),
);
return false;
}
/** `[a, b, c]`. The element type comes from the contextual type when tsc
* has one (`const a: number[] = []`, arguments, nested literals) and from
* the literal's own inferred type otherwise. A bare `[]` with no context
* is `never[]` — unmappable, rejected with the component fence (SC2009). `expected` overrides
* the contextual lookup where the caller knows the slot's array type and
* tsc's API doesn't surface it (ternary arms under an array context —
* tsc accepts the arm covariantly, but a tagged element representation
* must be BUILT as the slot's element type). */
export function lowerArrayLiteral(
lowerer: Lowerer,
expr: ts.ArrayLiteralExpression,
expected?: (IrType & { kind: "array" }) | (IrType & { kind: "record" }),
): IrExpr {
const loc = locOf(expr);
const ctxType = lowerer.checker.getContextualType(expr);
// `as const satisfies readonly T[]` checks against an array but keeps
// the literal's inferred tuple type. Build that shape; an actual array
// destination can use the ordinary tuple-to-array coercion afterward.
const tsType = underConstAssertion(expr)
? lowerer.typeOf(expr)
: (ctxType ?? lowerer.typeOf(expr));
let mapped = expected ?? lowerer.mapTypeOf(tsType);
// Inferred JavaScript arrays can contain unrelated constructors with
// structurally equal checker types. Their native class ABIs still differ.
if (
!expected &&
isJsSourceFile(expr.getSourceFile()) &&
mapped?.kind === "array" &&
mapped.elem.kind === "classval"
)
mapped = DYN;
// An inferred destructuring pattern supplies a contextual [any, ...]
// tuple. Its positions do not erase the literal's inferred element
// types; explicit destinations already arrive through expected.
if (
expected === undefined &&
ctxType &&
lowerer.checker.isTupleType(ctxType) &&
lowerer.checker
.getTypeArguments(ctxType as ts.TypeReference)
.some((type) => (type.flags & ts.TypeFlags.Any) !== 0)
) {
const inferred = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (
inferred?.kind === "array" ||
(inferred?.kind === "record" && lowerer.shapes.get(inferred.shapeId)?.tuple)
)
mapped = inferred;
}
// A JS literal whose OWN inferred type is never-tainted
// (neverTaintedJsType — the evolving `const gb = []`, the mixed
// command tuple `['pwd', []]`) carries no element information: route
// it to the checked-dynamic tree fallback below rather than letting never's f64
// representation build a static number array (a later dyn push would
// throw "expected number at $, got string"; the tuple's union arm
// would re-tag as number[] and fence). A REAL slot still wins:
// `expected`, a contextual array/tuple, or a contextual union's
// single array arm all provide element information first.
// (The contextual lookup can answer the binding's own tainted
// inference back — `const cmd = ['pwd', []]` — so the slot test is a
// taint test on whichever type mapped, not a presence test.)
const neverTaintedOwnJs =
expected === undefined && neverTaintedJsType(lowerer, expr, lowerer.typeOf(expr));
if (expected === undefined && neverTaintedJsType(lowerer, expr, tsType)) mapped = null;
// A union-typed slot (`const x: string[] | number = [..]`) contextually
// types the literal as the union; build the literal as its OWN type and
// let the slot's coercion wrap it into the union. An unknown-typed slot
// (`JSON.stringify([1, 2])`) likewise: build the literal's own type. An
// UNMAPPABLE context falls back the same way — a destructuring pattern
// without annotations contextually types its initializer `[any]`, while
// the literal's own inferred type is the real tuple.
if (!mapped || mapped.kind === "union" || mapped.kind === "dyn") {
const ctxUnion = mapped?.kind === "union" ? mapped : null;
mapped = lowerer.mapTypeOf(lowerer.typeOf(expr));
// The never-tainted own type carries no element information here
// either (the JS story above) — only a contextual union's single
// array arm below may still supply a static home.
if (neverTaintedOwnJs) mapped = null;
// An EMPTY literal in a union slot (`const t: string[] | undefined =
// []`, the `[]` default argument against `string[] | undefined`) has
// no useful own type — tsc infers `never[]`, whose f64-element
// mapping is a representation for the uninhabited, not the slot's
// arm. When the contextual union has exactly ONE array arm there is
// no ambiguity: build the literal as that arm and let the slot's
// coercion wrap it. Two array arms would need tsc's own best-fit
// choice — none arises in practice; the fence stands.
if (ctxUnion && expr.elements.nodes.length === 0) {
const def = lowerer.unions.get(ctxUnion.unionId);
const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? [];
if (arrayArms.length === 1) mapped = arrayArms[0]!;
}
// A NON-EMPTY literal whose own type has no static home (the JS dyn
// fallback — a null/dyn mapping, or an inference that degraded to a
// unit-only-element array over non-unit elements) under a union
// with exactly ONE array-family arm — an array, or an
// arity-matching tuple (the option-table `default: [{ value: [] }]`
// shape): build AS that arm, exactly the empty-literal rule above;
// the slot's coercion wraps it.
if (ctxUnion && expr.elements.nodes.length > 0) {
// A unit-only-element array (`(null | undefined)[]`) cannot hold
// non-unit elements — neither as the literal's own mapping nor as
// a competing union arm.
const nonUnitElems = expr.elements.nodes.some(
(el) =>
!ts.isOmittedExpression(el) &&
el.kind !== ts.SyntaxKind.NullKeyword &&
!(ts.isIdentifier(el) && el.text === "undefined"),
);
const ownUnhelpful =
mapped === null ||
mapped.kind === "dyn" ||
// The checker may echo the context union rather than infer this literal.
mapped.kind === "union" ||
(mapped.kind === "array" && nonUnitElems && lowerer.unitOnlyElem(mapped.elem));
if (ownUnhelpful) {
const def = lowerer.unions.get(ctxUnion.unionId);
const arms = (def?.arms ?? []).filter(
(a) =>
(a.kind === "array" && !(nonUnitElems && lowerer.unitOnlyElem(a.elem))) ||
(a.kind === "record" &&
!!lowerer.shapes.get(a.shapeId)?.tuple &&
lowerer.shapes.get(a.shapeId)!.fields.length === expr.elements.nodes.length),
);
if (arms.length === 1) mapped = arms[0]!;
}
}
}
// A Date's toJSON() element keeps its null in a literal with no declared
// destination: the lib types say string.
if (
!expected &&
mapped?.kind === "array" &&
mapped.elem.kind === "string" &&
expr.elements.nodes.some((element) => isDateJsonSource(lowerer, element))
)
mapped = { kind: "array", elem: dateJsonType(lowerer) };
// An array literal inside an npm-static implicit-any instance can
// inherit the checker's poisoned any[] context even after the
// active binding gives every element one concrete lowering. Recover a
// static array only when every spelled element lowers to the same
// supported type. An explicit checked-dynamic destination still owns
// the later slot coercion; heterogeneous literals, holes, and spreads
// keep the checked-dynamic fallback below.
if (
lowerer.implicitParamTypes !== null &&
npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null &&
(mapped === null ||
mapped.kind === "dyn" ||
(mapped.kind === "array" && mapped.elem.kind === "dyn")) &&
expr.elements.nodes.length > 0 &&
expr.elements.nodes.every(
(element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element),
)
) {
const elements = expr.elements.nodes.map((element) => tryLowerExpression(lowerer, element));
const first = elements[0];
if (
first !== undefined &&
first !== null &&
isSupportedArrayElem(first.type) &&
elements.every((element) => element !== null && typeEquals(element.type, first.type))
) {
mapped = arrayOf(first.type);
}
}
if (!expected && isJsSourceFile(expr.getSourceFile()) && mapped?.kind === "array") {
const elem = mapped.elem;
const arms = elem.kind === "union" ? lowerer.unions.get(elem.unionId)?.arms : [elem];
if (arms?.length && arms.every((arm) => arm.kind === "classval")) mapped = DYN;
// Inferred JavaScript literals mixing promises and plain values
// (`[Promise.resolve(1), 2]`) keep the checked-dynamic array: their
// consumers (Readable.from, Promise.all over iterators) inspect each
// element at run time.
if (maybePromiseUnionOf(lowerer, elem)) mapped = DYN;
}
if (
!expected &&
isJsSourceFile(expr.getSourceFile()) &&
expr.elements.nodes.some(
(element) =>
ts.isSpreadElement(element) &&
tryLowerExpression(lowerer, element.expression)?.type.kind === "dyn",
)
)
mapped = DYN;
// An element that is an existing checked-dynamic object (a file-scope
// object literal binding, or a literal holding one) keeps its identity
// only in a checked-dynamic array: a static element type would copy it.
if (
!expected &&
isJsSourceFile(expr.getSourceFile()) &&
mapped?.kind !== "dyn" &&
expr.elements.nodes.some(
(element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element),
)
)
mapped = DYN;
// An EMPTY literal under a CONST ASSERTION whose type queries panicked
// (tsgo's readonly-[] TupleType conversion) uses a unit-element array,
// mapType's own `[] as const` rule.
if (
mapped === null &&
expr.elements.nodes.length === 0 &&
(tsType.flags & ts.TypeFlags.Any) !== 0 &&
underConstAssertion(expr)
) {
mapped = arrayOf(unitOnlyUnion(lowerer.unions));
}
// Heterogeneous JavaScript object arrays must retain each literal's
// real keys. A structural union can otherwise drop descriptor fields.
if (
!expected &&
isJsSourceFile(expr.getSourceFile()) &&
mapped?.kind === "array" &&
mapped.elem.kind === "union" &&
expr.elements.nodes.some(ts.isObjectLiteralExpression)
)
mapped = DYN;
// A TUPLE-typed slot (`const t: [string, number] = ["a", 1]`): the
// literal constructs the tuple's record shape — one positional field
// per element, source order (which IS index order, so evaluation order
// is JS-exact). tsc has already checked the arity; the recount below
// backstops `as` smuggling. Fixed tuple spreads have known positions.
if (mapped?.kind === "record") {
const shape = lowerer.shapes.get(mapped.shapeId);
if (shape?.tuple) {
const spread = expr.elements.nodes.find(ts.isSpreadElement);
if (spread) {
return lowerTupleSpreadLiteral(lowerer, expr, mapped, shape);
}
if (expr.elements.nodes.length !== shape.fields.length) {
// tsc padded an UNDER-LENGTH literal against an optional-element
// tuple context (`options || []` with `options?: [string?,
// number?]` — the instantiated type spells every position, the
// literal spells fewer): no fixed shape holds it.
lowerer.badType(expr, tsType);
}
const byName = new Map(shape.fields.map((f) => [f.name, f.type]));
const fields = expr.elements.nodes.map((el, i) => {
const fieldType = byName.get(String(i));
if (!fieldType) lowerer.badType(el, lowerer.typeOf(el));
return { name: String(i), value: lowerer.lowerExprExpecting(el, fieldType) };
});
return { kind: "recordLit", fields, type: mapped, loc };
}
}
if (!mapped || mapped.kind !== "array") {
// The JS declaration fallback, literal-side: an element type with no
// static home (a string | string[] mixed command tuple, an evolving
// []) builds as a dyn ARRAY — one dyn value whose elements each
// convert through the usual boundary (dynFrom's JSON-safe domain);
// an element that cannot convert fences per element. length/index
// reads and dynamic consumers ride the keyed-dyn paths. TS literals
// take the same build when the slot ITSELF is checked-dynamic — an
// `unknown[]` annotation or a collapsed `(string | object)[]` maps
// to DYN wholesale now (mapType's dyn-element array rule), so the
// literal IS the dyn array.
if (
isJsSourceFile(expr.getSourceFile()) ||
mapped?.kind === "dyn" ||
lowerer.checkerAnyArray(expr)
) {
if (expr.elements.nodes.some(ts.isSpreadElement)) {
// Native collection drains return vectors of retained dyn
// elements. Reuse the ordinary spread builder, then expose the
// resulting array through the established unknown[] representation.
const vector = lowerArrayLiteral(lowerer, expr, arrayOf(DYN) as IrType & { kind: "array" });
return lowerer.coerceInto(expr, vector, DYN);
}
const elems = expr.elements.nodes.map((el): IrExpr => {
if (ts.isSpreadElement(el)) {
lowerer.unsupported(
"SC1090",
el,
"spread elements in a dynamic (unknown[]) array literal",
);
}
const v = lowerer.coerceToExpected(lowerer.lowerExpr(el), DYN);
if (v.type.kind !== "dyn") {
lowerer.unsupported(
"SC1101",
el,
`holding '${lowerer.fmt(v.type)}' values in a dynamic (unknown[]) array literal`,
);
}
return v;
});
return { kind: "dynArrLit", elems, type: DYN, loc };
}
lowerer.badType(expr, tsType);
}
let type = mapped as IrType & { kind: "array" };
// An inferred array of records takes the layout of an element literal
// whose fields widened for missing-element reads; the other elements
// build that layout directly. An explicit destination keeps its own.
if (!expected && type.elem.kind === "record") {
let variant: IrType | null = null;
for (const candidate of expr.elements.nodes) {
let x: ts.Expression = candidate;
while (ts.isParenthesizedExpression(x)) x = x.expression;
if (!ts.isObjectLiteralExpression(x)) continue;
const probe = tryLowerExpression(lowerer, x);
if (!probe || !lowerer.isRuntimeOptionalRecordVariant(probe.type, type.elem)) continue;
if (variant !== null && !typeEquals(variant, probe.type)) {
variant = null;
break;
}
variant = probe.type;
}
if (variant !== null) type = arrayOf(variant) as IrType & { kind: "array" };
}
// Keep the array payload type fixed. Optional reads are stored through
// arrayValueStore, which records UNDEFINED in the state byte while the
// payload remains number/string/etc.; widening the array element here
// would force boxed union storage and change aliases.
const spreads: number[] = [];
const holes = new Set<number>();
const runtimeOptionalElements = new Set<number>();
let needsStateBuild = false;
for (let i = 0; i < expr.elements.nodes.length; i++) {
const candidate = expr.elements.nodes[i]!;
if (ts.isSpreadElement(candidate) || ts.isOmittedExpression(candidate)) continue;
const probe = tryLowerExpression(lowerer, candidate);
if (probe && lowerer.runtimeOptionalWidening(probe.type, type.elem) !== null) {
runtimeOptionalElements.add(i);
needsStateBuild = true;
}
}
const elems: IrExpr[] = expr.elements.nodes.map((el, i): IrExpr => {
if (ts.isSpreadElement(el)) {
// `[...xs, b]`: xs must be an array of the literal's own element
// type — its elements copy in at construction (a fresh array,
// JS-exact). Iterables that aren't arrays (strings, Sets, Maps)
// and mismatched element types stay fenced. A TERNARY source gets
// the literal's own type as its expected type (the conditional-
// spread idiom — tsc surfaces no contextual type through spreads,
// and the arm literals must BUILD as this element type).
let srcNode: ts.Expression = el.expression;
while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression;
lowerer.fenceStaticHeadersIteration(el.expression);
let src = ts.isConditionalExpression(srcNode)
? lowerTernary(lowerer, srcNode, type)
: lowerer.lowerExpr(el.expression);
src = lowerCollectionSpread(lowerer, src, el.expression) ?? src;
// `[...new SymbolIterator]`: a CLASS ITERABLE drains through its
// own protocol into a fresh element array (classIteratorDrainCall
// — an infinite iterator loops forever, exactly Node), and the
// spread machinery copies like any array source.
if (src.type.kind === "object") {
const drained = lowerer.classIteratorDrainCall(src, locOf(el), type.elem);
if (drained) src = drained;
}
// `[...s]` on a STRING spreads its code-point characters (the
// string iterator's walk — astral chars whole) through the same
// interned helper as Array.from(s); the result rides the array
// machinery below like any string[] source.
if (src.type.kind === "string") {
src = strCharsCall(lowerer, src, locOf(el));
}
// `[...typedArray]`: represented typed arrays are dense numeric
// iterables, so drain their elements into the fresh number[] that
// the surrounding array literal will copy. In particular this is
// the Uint8Array-to-Array bridge (`[...u8]`), with element values
// read rather than backing bytes for the wider typed-array kinds.
if (src.type.kind === "bytes") {
src = {
kind: "bytesIntrinsic",
method: "toArray",
receiver: src,
args: [],
type: arrayOf(F64),
loc: locOf(el),
};
}
// `[...it]` over a generator (or an iterator adapted into one):
// drain it once, in order, into a fresh element array.
if (src.type.kind === "generator") {
const drained = generatorDrain(lowerer, src, type.elem, locOf(el));
if (drained) src = drained;
}
// `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its
// checked-dynamic view iterates the live pair list, and the drained
// [name, value] pairs convert to the literal's tuple elements.
const searchParamsSource = isSearchParamsIterable(lowerer, el.expression);
if (src.type.kind === "searchParams") src = lowerer.coerceInto(el.expression, src, DYN);
// A native checked iterable can supply scalar elements even when
// the checker inferred a typed array from its producer. Drain once
// and validate the elements before the ordinary spread copy.
if (
src.type.kind === "dyn" &&
(type.elem.kind === "dyn" ||
type.elem.kind === "f64" ||
type.elem.kind === "string" ||
type.elem.kind === "bool" ||
searchParamsSource)
) {
src = lowerer.coerceInto(el.expression, checkedIteratorPack(lowerer, src, locOf(el)), type);
}
// A same-family array whose ELEMENT lifts (string[] into a
// (string | symbol)[] literal — per-element wrap/width copy):
// the interned width helper reshapes before the spread copies.
if (src.type.kind === "array" && !typeEquals(src.type, type)) {
const w = lowerer.widthCoerce(src, type);
if (w) src = w;
}
if (!typeEquals(src.type, type)) {
lowerer.unsupported(
"SC1090",
el,
`spreading '${lowerer.fmt(src.type)}' into a '${lowerer.fmt(type)}' literal (only a same-element-type array spreads)`,
);
}
spreads.push(i);
return src;
}
// A HOLE (`[,]` — an elision) remains absent. Ordinary reads still
// answer undefined, while presence-sensitive methods and concat keep
// the hole. The state-building path below advances length without
// writing a value into this slot.
if (ts.isOmittedExpression(el)) {
needsStateBuild = true;
holes.add(i);
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(el) };
}
// An ARRAY-LITERAL element under a UNION element slot whose own type
// has no lift into the slot builds against the union's single array
// arm instead (`['pwd', []]` as (string | string[])[] — tsc pushes no
// contextual type through the unannotated chain, so the empty nested
// literal types never[], whose f64-element representation re-tags
// into nothing). One array arm is unambiguous — the union-slot rule's
// stance; several keep the fence.
if (type.elem.kind === "union" || type.elem.kind === "array") {
let x: ts.Expression = el;
while (ts.isParenthesizedExpression(x)) x = x.expression;
if (ts.isArrayLiteralExpression(x)) {
const ownT = lowerer.mapTypeOf(lowerer.checker.getContextualType(x) ?? lowerer.typeOf(x));
if (ownT === null || lowerer.widthLiftPlan(ownT, type.elem) === null) {
// An ARRAY-typed slot takes the literal directly; a union slot
// routes through its single array arm and wraps.
if (type.elem.kind === "array") {
return lowerer.lowerArrayLiteral(x, type.elem);
}
const def = lowerer.unions.get(type.elem.unionId);
const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? [];
if (arrayArms.length === 1) {
const built = lowerer.lowerArrayLiteral(x, arrayArms[0] as IrType & { kind: "array" });
return lowerer.coerceInto(el, built, type.elem);
}
}
}
}
// Elements flow into the element slot like an assignment: union
// elements wrap plain arm values (`[1, "a"]` as (number | string)[]),
// holes reject inside lowerExpr.
let lowered: IrExpr;
const directOptionalCandidate =
runtimeOptionalElements.has(i) ||
ts.isElementAccessExpression(el) ||
(ts.isCallExpression(el) &&
ts.isPropertyAccessExpression(el.expression) &&
(el.expression.name.text === "pop" || el.expression.name.text === "shift"));
if (directOptionalCandidate) {
const raw = lowerer.lowerExpr(el);
if (!typeEquals(raw.type, type.elem)) {
needsStateBuild = true;
return raw;
}
lowered = raw;
} else {
lowered = lowerer.lowerExprExpecting(el, type.elem);
}
if (!typeEquals(lowered.type, type.elem)) lowerer.badType(el, lowerer.typeOf(el));
return lowered;
});
if (!needsStateBuild) {
return { kind: "arrayLit", elems, ...(spreads.length > 0 ? { spreads } : {}), type, loc };
}
const out = lowerer.declareHiddenLocal("%arrayLit", type);
const outRef = varRef(out.id, type, loc);
// Dense capacity for the elements the statements below store.
const capacity = Math.min(elems.length - spreads.length, 1024);
const body: IrStmt[] = [
{
kind: "varDecl",
localId: out.id,
init: {
kind: "arrayLit",
elems: [],
...(capacity > 0 ? { capacity } : {}),
type,
loc,
},
loc,
},
];
const spreadSet = new Set(spreads);
const currentLength = (): IrExpr => ({
kind: "arrIntrinsic",
method: "length",
receiver: outRef,
args: [],
type: F64,
loc,
});
const nextLength = (): IrExpr => ({
kind: "bin",
op: "+",
left: currentLength(),
right: { kind: "numLit", value: 1, type: F64, loc },
type: F64,
loc,
});
for (let i = 0; i < elems.length; i++) {
const el = elems[i]!;
if (spreadSet.has(i)) {
body.push({
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "pushSpread",
receiver: outRef,
args: [el],
type: F64,
loc,
},
loc,
});
continue;
}
if (holes.has(i)) {
body.push({ kind: "arraySetLength", arr: outRef, length: nextLength(), loc });
continue;
}
let value = el;
if (!typeEquals(value.type, type.elem)) {
const temp = lowerer.declareHiddenLocal("%arrayValue", value.type);
body.push({ kind: "varDecl", localId: temp.id, init: value, loc });
value = varRef(temp.id, temp.type, loc);
}
body.push(arrayValueStore(lowerer, outRef, currentLength(), value, type.elem, loc));
}
return {
kind: "seqExpr",
stmts: body,
result: outRef,
type,
loc,
};
}
/** Build a fixed tuple from positional elements and fixed tuple spreads.
* Capture each value in source order: deferring the reads until recordLit
* would observe a later element's mutations of the spread source. */
function lowerTupleSpreadLiteral(
lowerer: Lowerer,
expr: ts.ArrayLiteralExpression,
type: IrType & { kind: "record" },
shape: IrRecordShape,
): IrExpr {
const loc = locOf(expr);
const byName = new Map(shape.fields.map((field) => [field.name, field.type]));
const stmts: IrStmt[] = [];
const fields: { name: string; value: IrExpr }[] = [];
const append = (node: ts.Expression, value: IrExpr): void => {
const name = String(fields.length);
const expected = byName.get(name);
if (!expected) lowerer.badType(expr, lowerer.typeOf(expr));
const coerced = lowerer.coerceInto(node, value, expected);
const temp = lowerer.declareHiddenLocal("%tupleElement", expected);
const at = locOf(node);
stmts.push({ kind: "varDecl", localId: temp.id, init: coerced, loc: at });
fields.push({ name, value: varRef(temp.id, expected, at) });
};
for (const element of expr.elements.nodes) {
if (!ts.isSpreadElement(element)) {
const expected = byName.get(String(fields.length));
if (!expected) lowerer.badType(element, lowerer.typeOf(element));
append(element, lowerer.lowerExprExpecting(element, expected));
continue;
}
const source = lowerer.lowerExpr(element.expression);
const sourceShape =
source.type.kind === "record" ? lowerer.shapes.get(source.type.shapeId) : undefined;
if (!sourceShape?.tuple) {
// Empty tuples use a zero-length array representation. Preserve a
// producing call's effects even though there are no positions to copy.
const sourceTs = lowerer.typeOf(element.expression);
if (
source.type.kind === "array" &&
lowerer.checker.isTupleType(sourceTs) &&
lowerer.checker.getTypeArguments(sourceTs as ts.TypeReference).length === 0
) {
stmts.push({ kind: "exprStmt", expr: source, loc: locOf(element) });
continue;
}
lowerer.unsupported(
"SC1090",
element,
"spreading a variable-length value into a fixed tuple literal",
);
}
const temp = lowerer.declareHiddenLocal("%tupleSpread", source.type);
const at = locOf(element);
stmts.push({ kind: "varDecl", localId: temp.id, init: source, loc: at });
const receiver = varRef(temp.id, source.type, at);
const positions = [...sourceShape.fields].sort((a, b) => Number(a.name) - Number(b.name));
for (const field of positions) {
append(element.expression, {
kind: "recordGet",
obj: receiver,
shapeId: sourceShape.id,
field: field.name,
type: field.type,
loc: at,
});
}
}
if (fields.length !== shape.fields.length) lowerer.badType(expr, lowerer.typeOf(expr));
return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc };
}
/** Drain using checked dispatch so native class and generator iterators keep their methods. */
function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, loc: SrcLoc): IrExpr {
const name = "%iterator.pack.checked";
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: DYN, loc });
const property = (key: string): IrExpr => ({
kind: "dynKeyGet",
value: ref("step"),
key: { kind: "strLit", value: key, type: STRING, loc },
type: DYN,
loc,
});
lowerer.liftedFns.push({
name,
params: [{ localId: "source", name: "source", type: DYN }],
returnType: DYN,
locals: ["source", "iterator", "step", "result"].map((id) => ({
id,
name: id,
type: DYN,
mutable: id !== "source",
})),
body: [
{
kind: "varDecl",
localId: "iterator",
init: {
kind: "libCall",
fn: "dyn.iterator",
args: [ref("source"), { kind: "strLit", value: "value", type: STRING, loc }],
type: DYN,
loc,
},
loc,
},
{
kind: "varDecl",
localId: "result",
init: { kind: "dynArrLit", elems: [], type: DYN, loc },
loc,
},
{
kind: "while",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
body: [
{
kind: "varDecl",
localId: "step",
init: {
kind: "dynInvoke",
recv: ref("iterator"),
method: "next",
calleeName: "iterator.next",
args: [],
type: DYN,
loc,
},
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "truthy", value: property("done"), type: BOOL, loc },
then: [{ kind: "return", value: ref("result"), loc }],
else_: null,
loc,
},
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.packPush",
args: [ref("result"), property("value")],
type: VOID,
loc,
},
loc,
},
],
loc,
},
{ kind: "return", value: ref("result"), loc },
],
loc,
});
}
return { kind: "call", callee: name, args: [source], type: DYN, loc };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,922 @@
/* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */
import * as ts from "../../ts7/adapter.js";
import * as posix from "node:path/posix";
import { type Lowerer, PoisonError } from "../lowerer.js";
import {
BOOL,
BYTES_ELEMENT_NAME,
DYN,
F64,
type IrExpr,
type IrType,
STRING,
type SrcLoc,
UNDEFINED_T,
arrayOf,
funcOf,
isUnitType,
recordTextCodecClass,
} from "../../../ir/ir.js";
import { lowerNodeModuleIdentifier } from "../lower-node-module.js";
import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js";
import { captureContextArguments } from "../function-context.js";
import { isCjsExportTableLiteral, isJsSourceFile, isNodeEsmFile, locOf } from "../../program.js";
import { builtinClassAliasOwnsNoSlot, runtimeClassValueOf } from "../builtins/class-values.js";
import {
cjsAccessorCall,
primitiveCtorClosure,
runtimeOptionalElementKey,
runtimeOptionalUseOf,
} from "../lower-exprs.js";
import { runtimeOptionalReceiverRead } from "./narrowing.js";
import { varRef } from "../../../ir/build.js";
import {
ambientUndefReadType,
ambientUndefinedFnSymbolOf,
contextualUndefReadType,
fenceEarlyAliasUse,
lowerNsIdentifierValue,
nsUndefRead,
} from "../lower-namespaces.js";
import {
blockedBindingUseDiag,
unsupportedDiag,
unsupportedPackageValueDiag,
} from "../../../diagnostics/diagnostic.js";
import { dirname } from "node:path";
import { funcTypeFromParamShapes } from "../call-signatures.js";
import { threadsImportOf } from "../../threads-import.js";
import { experimentalImportOf } from "../../experimental-import.js";
import { lowerWorkerMetadata } from "../builtins/workers.js";
import { esmImportBindingKind } from "../builtins/module-bindings.js";
import {
builtinConstLit,
builtinFenceHintOf,
builtinModuleConstOf,
builtinModuleFnOf,
builtinModulesArrayLit,
stdlibGlobalNameOf,
} from "../surfaces.js";
import { builtinConstantsObject } from "../lower-builtin-values.js";
import { lowerTlsRootCertificates } from "../lower-server.js";
import { builtinFunctionFenceValue } from "../builtin-function-fences.js";
import { unsupportedModuleFeatureOf } from "../../builtin-modules.js";
import { lowerGlobalValue } from "../lower-global-value.js";
import { fenceEnumObjectValue } from "../lower-enums.js";
import { mixinFnOfCallee } from "../lower-mixins.js";
import { isNavigatorGlobal, refuseNavigator } from "../builtins/navigator.js";
import { wasiGuestPath } from "../../../wasi-paths.js";
export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr {
if (lowerer.isStdlibGlobal(expr, "console")) {
return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc };
}
const moduleValue = lowerNodeModuleIdentifier(lowerer, expr);
if (moduleValue) return moduleValue;
if (expr.text === "exports") {
const exportsValue = lowerCjsExportsObjectValue(lowerer, expr);
if (exportsValue) return exportsValue;
}
if (lowerer.isSelfReference(expr)) {
return { kind: "selfRef", type: lowerer.ctx.selfType!, loc };
}
// `arguments` in a variadic JS function (the rest-marked form): the
// synthetic trailing dyn-array param — lambdaSignature marked the
// type and lowerLambda declared the local.
if (expr.text === "arguments") {
const binding = captureContextArguments(lowerer.fnStack);
if (binding) return { kind: "varRef", localId: binding.id, type: DYN, loc };
}
// A compiler-projected adapter as a VALUE: the adapter has no
// runtime function object (its calls rewrite directly) — call it
// through its immutable binding instead.
{
const sym = lowerer.resolveValueSymbol(expr);
const projection = sym ? lowerer.staticCallables.get(sym) : undefined;
if (
projection?.kind === "promisified-exec-file" ||
projection?.kind === "promisified-exec" ||
projection?.kind === "promisified-builtin" ||
projection?.kind === "promisified-zlib" ||
projection?.kind === "promisified-crypto" ||
projection?.kind === "promisified-dns"
) {
lowerer.unsupported(
"SC1090",
expr,
`a promisified builtin as a value (call '${expr.text}' directly)`,
);
}
if (projection?.kind === "builtin-function") {
if (!isJsSourceFile(expr.getSourceFile())) {
const callable = lowerer.lowerBuiltinCallableValue(
{ module: projection.module, member: projection.member },
loc,
);
if (callable) return callable;
}
lowerer.unsupported(
"SC1090",
expr,
`a builtin function alias as an escaping value (call '${expr.text}' directly)`,
);
}
// `EventEmitter`, `Readable` or `TypeError` (or a const alias of
// one) as a value in a TypeScript file: the runtime class's class
// object. JavaScript files keep the identity token.
if (!isJsSourceFile(expr.getSourceFile())) {
const runtimeClass = runtimeClassValueOf(lowerer, expr);
if (runtimeClass) return lowerer.classValueRef(runtimeClass, expr);
}
// `const E = EventEmitter` owns no runtime slot: construction,
// instanceof, typeof and .name lower at their use sites.
if (sym && builtinClassAliasOwnsNoSlot(lowerer, sym)) {
lowerer.unsupported(
"SC1090",
expr,
`a builtin class alias as an escaping value (use '${expr.text}' with new, instanceof, typeof or .name)`,
);
}
}
// Union-typed bindings read through tsc's control-flow narrowing:
// when the checker types this USE as a single arm, maybeNarrow
// bridges the tagged representation with a unionNarrow.
const local = lowerer.resolveLocal(expr);
if (local) {
// A switch can dispatch past a lexical declaration. Keep this
// previously refused codec form out until switch-scope TDZ boxes
// model that skipped initialization, including value aliases.
if (
local.type.kind === "record" &&
recordTextCodecClass(lowerer.shapes.get(local.type.shapeId)!) !== null
) {
const symbol = lowerer.resolveValueSymbol(expr);
const decl = symbol ? symbol.valueDeclaration : undefined;
if (
decl &&
ts.isVariableDeclaration(decl) &&
ts.isVariableDeclarationList(decl.parent) &&
(decl.parent.flags & ts.NodeFlags.BlockScoped) !== 0 &&
ts.isVariableStatement(decl.parent.parent)
) {
const clause = decl.parent.parent.parent;
if (
(ts.isCaseClause(clause) || ts.isDefaultClause(clause)) &&
!(expr.getStart() >= clause.getStart() && expr.end <= clause.end)
) {
lowerer.unsupported(
"SC1090",
expr,
"codec bindings read from another switch clause before initialization is proven",
);
}
}
}
if (local.type.kind === "caught") return lowerer.caughtRead(expr, local, loc);
const symbol = lowerer.resolveValueSymbol(expr);
const runtimeOptionalRoot = lowerer.runtimeOptionalRootOf(local);
const active = lowerer.runtimeOptionalLocals.has(runtimeOptionalRoot);
const arithmetic = lowerer.runtimeOptionalArithmeticLocals.has(runtimeOptionalRoot);
const captured =
!!symbol &&
lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) &&
(local.boxed === true ||
lowerer.ctx.captureBySymbol.get(symbol) === local ||
runtimeOptionalRoot !== local);
if (arithmetic && !runtimeOptionalUseOf(expr)) {
return { kind: "varRef", localId: local.id, type: local.type, loc };
}
if (active || captured) {
const use = runtimeOptionalUseOf(expr);
if (use?.complex) {
lowerer.unsupported(
"SC1090",
expr,
"a logical or conditional receiver containing a runtime-optional capture",
);
}
if (use?.optional) return { kind: "varRef", localId: local.id, type: local.type, loc };
if (use?.kind === "property")
return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc);
if (use?.kind === "element") {
const key = runtimeOptionalElementKey(use.access.argumentExpression);
if (key === null) {
return varRef(local.id, local.type, loc);
}
return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc);
}
if (use?.kind === "call") {
if (use.comma && !use.optional) {
lowerer.unsupported(
"SC1090",
use.access,
"a direct call through a comma-wrapped runtime-optional capture",
);
}
return runtimeOptionalReceiverRead(lowerer, expr, local, null, loc);
}
if (runtimeOptionalObjectWalkerArg(lowerer, expr)) {
const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (local.type.kind === "union" && narrowed) {
const helper =
narrowed.kind === "union"
? lowerer.narrowedRetagHelper(expr, local.type.unionId, narrowed.unionId, loc)
: !isUnitType(narrowed)
? lowerer.narrowedArmHelper(local.type.unionId, narrowed, loc)
: null;
if (helper) {
return lowerer.checkedNarrowCall(
helper,
varRef(local.id, local.type, loc),
narrowed,
loc,
);
}
}
}
return { kind: "varRef", localId: local.id, type: local.type, loc };
}
const narrowed = lowerer.maybeNarrow(
{ kind: "varRef", localId: local.id, type: local.type, loc },
expr,
);
if (
narrowed.type.kind === "union" &&
lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) &&
lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "record"
) {
// A presence guard can be followed by a predicate strengthening
// the record's fields. Its refined shape is not a stored union
// arm: extract the original layout before reading a member.
const use = runtimeOptionalUseOf(expr);
if (use && !use.complex && !use.optional) {
if (use.kind === "property")
return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc);
if (use.kind === "element") {
const key = runtimeOptionalElementKey(use.access.argumentExpression);
if (key !== null) return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc);
}
}
}
return narrowed;
}
// `import x = N.y` aliases resolve transparently through globalOf/
// fnSigOf below; their source-order guards live here (a no-op for
// every non-import= binding — see lower-namespaces.ts).
fenceEarlyAliasUse(lowerer, expr, expr);
const g = lowerer.globalOf(expr);
if (g) {
// A global typed by a class that never REGISTERED (its collection
// fenced): the initializing assignment never lowered, so the read
// can only observe garbage — and the emitter would name a struct
// that does not exist. The blocked-binding cascade names the use;
// without this the validator's registration check ICEs on the live
// reference (`@this {T}` inference — signature 07).
const unregistered = lowerer.unregisteredClassOf(g.type, new Set());
if (unregistered !== null) {
// The class's own rejection is the root cause the cascade names.
lowerer.flushDeferredClass(unregistered);
lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc));
throw new PoisonError();
}
const ref: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc };
if (lowerer.isRuntimeOptionalArithmeticGlobal(g) && !runtimeOptionalUseOf(expr)) {
return ref;
}
const storageHasUndefined =
g.type.kind === "union" && lowerer.armTag(g.type.unionId, UNDEFINED_T) >= 0;
if (lowerer.isRuntimeOptionalGlobal(g) || storageHasUndefined) {
// A checker-narrowed global receiver (`if (tags !== undefined)
// tags[tags.length] = ...`, or `a ? Object.keys(a) : ...`) is
// still stored as the runtime optional union. Extract the proven
// value arm before routing the member/element operation so dynamic
// keys and builtin record walkers do not see a union receiver.
const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr));
const arithmeticUnion =
g.type.kind === "union" &&
lowerer.unions.get(g.type.unionId)?.arms.length === 2 &&
lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "f64") &&
lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "string");
const symbol = lowerer.resolveValueSymbol(expr);
if (
!arithmeticUnion &&
!(symbol !== null && lowerer.earlyReadVars.has(symbol)) &&
g.type.kind === "union" &&
narrowed &&
narrowed.kind !== "union" &&
!isUnitType(narrowed) &&
!(
narrowed.kind === "record" &&
recordTextCodecClass(lowerer.shapes.get(narrowed.shapeId)!) !== null
) &&
narrowed.kind !== "f64" &&
narrowed.kind !== "string" &&
narrowed.kind !== "bool" &&
lowerer.armTag(g.type.unionId, narrowed) >= 0
) {
const helper = lowerer.narrowedArmHelper(g.type.unionId, narrowed, loc);
if (helper) return lowerer.checkedNarrowCall(helper, ref, narrowed, loc);
}
const use = runtimeOptionalUseOf(expr);
// The enclosing logical/conditional expression consumes the union
// before any member operation runs (`(value ?? fallback).length`).
// Keep the tagged value intact and let that lazy expression decide
// its branch; extracting here would evaluate or throw too early.
if (use?.complex) return ref;
if (use?.optional) return ref;
if (use?.kind === "property")
return runtimeOptionalReceiverRead(lowerer, expr, g, use.access.name.text, loc);
if (use?.kind === "element") {
const key = runtimeOptionalElementKey(use.access.argumentExpression);
if (key === null)
lowerer.unsupported(
"SC1090",
use.access,
"a computed read from a runtime-optional global (use a literal key)",
);
return runtimeOptionalReceiverRead(lowerer, expr, g, key, loc);
}
if (use?.kind === "call") return runtimeOptionalReceiverRead(lowerer, expr, g, null, loc);
return ref;
}
return lowerer.maybeNarrow(ref, expr);
}
// `undefined` — tsc's intrinsic global (a local shadowing the name
// resolved above): a unit literal, exactly like the `null` keyword.
if (expr.text === "undefined" && lowerer.typeOf(expr).flags & ts.TypeFlags.Undefined) {
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc };
}
// `__dirname` / `__filename` — the CommonJS module globals: per-
// MODULE compile-time constants, the containing file's location
// (Node's exact values when the same tree runs in place; WASI uses
// the equivalent guest-visible path; locals shadowing the names
// resolved above). In a file with ESM syntax Node never defines them
// (ReferenceError) — fence rather than invent a value there.
if (
(expr.text === "__dirname" || expr.text === "__filename") &&
lowerer.isStdlibGlobal(expr, expr.text)
) {
const sf = expr.getSourceFile();
if (isNodeEsmFile(sf, lowerer.program)) {
lowerer.unsupported(
"SC1090",
expr,
`'${expr.text}' in an ES module (Node defines the CJS module globals only in CommonJS modules)`,
);
}
// The WASI runner mounts the host cwd at guest `/` and the host temp
// directory at `/tmp`. Bake the matching GUEST spelling so resource
// lookups relative to these CommonJS globals stay inside the exposed
// capability instead of naming an unreachable host-absolute path.
const fileName = moduleFileName(lowerer, sf);
const value =
expr.text === "__dirname"
? lowerer.targetPlatform === "wasi"
? posix.dirname(fileName)
: dirname(fileName)
: fileName;
return { kind: "strLit", value, type: STRING, loc };
}
const sig = lowerer.fnSigOf(expr);
if (sig && lowerer.isTopLevelFnSymbol(expr)) {
// A declared function used as a value: a zero-capture closure. The
// backend interns one per function so `f === f` holds (JS identity).
// The value's type is the completed ABI signature; optional/default
// slots and typed rest arrays pass the value fence first.
lowerer.noteEdge(sig.name);
// dynRest slots stay out of the VALUE type's param list (fn.length
// semantics); the rest marker carries the trailing dyn-array ABI.
const funcType: IrType = funcTypeFromParamShapes(sig.params, sig.returnType);
lowerer.requireExactArityValue(expr, expr, sig.params, funcType);
return { kind: "closure", fnName: sig.name, captures: [], type: funcType, loc };
}
{
// A generic function as a VALUE: monomorphized by flow — the
// reference's pinned concrete signature (contextual type or an
// instantiation expression) names the instance; unpinned
// references fence inside (lowerGenericFnValue).
const gfn = lowerer.genericFnOf(expr);
if (gfn) return lowerer.lowerGenericFnValue(expr, gfn);
}
// A CJS export-table ACCESSOR read (`tmpdir.path`, or a destructured
// binding aliasing one): the getter is a real function of module
// scope — its body reads module globals, so it lifts like a lambda
// (interned per declaration) and every read is a call. Node runs the
// getter per read; so does this.
{
const acc = cjsExportAccessorRead(lowerer, expr);
if (acc) return acc;
}
// A binding destructured from a baked constants object
// (http2.constants / crypto.constants): the literal — the
// declaration emitted nothing (builtinConstantsDestructureDecl).
{
const h2c = lowerer.builtinConstantBindingOf(expr);
if (h2c) return h2c;
}
// Builtin-module import bindings: constants (path.sep, os.EOL) read
// as interned string literals; functions have no closure
// representation (they lower to libCall at call sites only); members
// with no lowering at all fence with the module-qualified name.
{
const member = threadsImportOf(lowerer.program, expr);
// scriptc delivers published graphs by reference.
if (member === "sharesPublishedGraphs")
return { kind: "boolLit", value: true, type: BOOL, loc };
if (member === "publish")
lowerer.noLowering(
"publish (@scriptc/threads) as a value",
expr,
"call publish(value) directly",
);
if (experimentalImportOf(lowerer.program, expr) === "LifetimeRegion")
lowerer.noLowering(
"LifetimeRegion (@scriptc/experimental) as a value",
expr,
"construct regions with new LifetimeRegion()",
);
if (experimentalImportOf(lowerer.program, expr) === "InternTable")
lowerer.noLowering(
"InternTable (@scriptc/experimental) as a value",
expr,
'open tables with new InternTable("name")',
);
if (experimentalImportOf(lowerer.program, expr) === "ThreadSlots")
lowerer.noLowering(
"ThreadSlots (@scriptc/experimental) as a value",
expr,
'open slots with new ThreadSlots("name")',
);
}
{
const bi = lowerer.builtinImportOf(expr);
if (bi) {
if (bi.module === "worker_threads") {
const value = lowerWorkerMetadata(bi.member, loc);
if (value) return value;
}
// A named import of defaultMaxListeners reads the ES module
// namespace's copy. A CommonJS destructured or member binding holds
// the value read when its declaration ran, which these bindings do
// not store.
if (bi.module === "events" && bi.member === "defaultMaxListeners") {
if (esmImportBindingKind(lowerer, expr) === "named") {
return { kind: "libCall", fn: "emitter.esmDefaultMax", args: [], type: F64, loc };
}
lowerer.noLowering(
"events.defaultMaxListeners through a CommonJS destructured or member binding",
expr,
"read require('events').defaultMaxListeners or EventEmitter.defaultMaxListeners where the value is needed",
);
}
const c = builtinModuleConstOf(lowerer, bi.module, bi.member);
if (c !== undefined) return builtinConstLit(c, loc);
// module.builtinModules — the baked Node v24 list, a fresh
// string[] per read.
if (bi.module === "module" && bi.member === "builtinModules") {
return builtinModulesArrayLit(loc);
}
if (bi.module === "http" && bi.member === "METHODS") {
return { kind: "libCall", fn: "http.methods", args: [], type: arrayOf(STRING), loc };
}
if ((bi.module === "http" || bi.module === "https") && bi.member === "globalAgent") {
return {
kind: "libCall",
fn: "http.globalAgent",
args: [{ kind: "boolLit", value: bi.module === "https", type: BOOL, loc }],
type: DYN,
loc,
};
}
if (bi.module === "http" && bi.member === "STATUS_CODES") {
return { kind: "libCall", fn: "http.statusCodes", args: [], type: DYN, loc };
}
if (bi.module === "os" && bi.member === "constants") {
return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc };
}
if (bi.module === "fs" && bi.member === "constants")
return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc };
// `import { O_RDONLY } from "constants"`: the member of the frozen
// module object.
if (bi.module === "constants") {
return {
kind: "dynKeyGet",
value: builtinConstantsObject(lowerer, loc),
key: { kind: "strLit", value: bi.member, type: STRING, loc },
type: DYN,
loc,
};
}
// tls.rootCertificates: a runtime-valued module constant (the
// cached bundled-CA array) — the one member read that lowers
// to a libCall instead of a baked literal.
{
const roots = lowerTlsRootCertificates(lowerer, bi, loc);
if (roots) return roots;
}
{
const callable = lowerer.lowerBuiltinCallableValue(bi, loc);
if (callable) return callable;
}
// JavaScript sources: an otherwise unsupported builtin VALUE is
// the same identity-token story as stdlib globals above (the
// harness adds worker_threads.Worker to its identity Set).
if (isJsSourceFile(expr.getSourceFile())) {
// A function member is a function value that fences when called.
if (builtinModuleFnOf(lowerer, bi.module, bi.member))
return builtinFunctionFenceValue(lowerer, bi, expr, loc);
return {
kind: "strLit",
value: `[builtin ${bi.module}.${bi.member}]`,
type: STRING,
loc,
};
}
if (builtinModuleFnOf(lowerer, bi.module, bi.member)) {
lowerer.unsupported(
"SC1090",
expr,
`library functions as values (call '${expr.text}' directly)`,
);
}
lowerer.noLowering(
`${bi.module}.${bi.member}`,
expr,
builtinFenceHintOf(bi.module, bi.member),
lowerer.resolveValueSymbol(expr),
);
}
}
// A builtin namespace import used as a bare VALUE (`const f = fs`):
// the namespace object has no representation — members lower at
// their access sites only. The CommonJS namespace binding
// (`const lib = require("./lib.js")`) gets the same fence.
const builtinNamespace = lowerer.builtinNamespaceModuleOf(expr);
if (builtinNamespace === "constants") return builtinConstantsObject(lowerer, loc);
if (builtinNamespace !== null) {
const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNamespace}` };
return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc };
}
if (lowerer.cjsLocalModuleBindingOf(expr)) {
lowerer.unsupported(
"SC1090",
expr,
`module namespace objects as values (access '${expr.text}' members directly)`,
);
}
if (lowerer.stdlibGlobalFnOf(expr)) {
lowerer.unsupported(
"SC1090",
expr,
`library functions as values (call '${expr.text}' directly)`,
);
}
// A declaration-only npm binding has no compiled runtime value.
{
const pkg = lowerer.npmPackageOfSymbol(lowerer.resolveValueSymbol(expr) ?? undefined);
if (pkg) {
lowerer.pushDiag(unsupportedPackageValueDiag(pkg, loc));
throw new PoisonError();
}
}
// Bindings imported from an UNSUPPORTED builtin module: coverage
// analyzes past import fences, so uses of `spawn` from
// child_process reach lowering — the use site reports the same
// "the '<module>' module" diagnostic as the import line, and the
// report groups them into one blocker. (Builds never get here: the
// import already failed preflight.)
{
const spec = lowerer.fencedBuiltinImportOf(expr);
if (spec !== null) {
lowerer.pushDiag(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec)));
throw new PoisonError();
}
}
// The globals `Infinity` and `NaN` (lib-declared, provenance-checked
// like every stdlib name): non-finite numLits — `-Infinity` arrives
// through the unary-minus literal fold. Arithmetic, comparisons, and
// formatting are IEEE-exact in C (String(Infinity) is "Infinity" and
// String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds
// because f64 compares are IEEE compares).
if (expr.text === "Infinity" || expr.text === "NaN") {
const sym = lowerer.checker.getSymbolAtLocation(expr);
if (lowerer.isStdlibSymbol(sym)) {
return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc };
}
}
// The primitive constructors as VALUES (`const f = String`, an
// option table's `type: Boolean` field, `opt.type === Number`): the
// interned coercion closure — one synthesized module function per
// constructor per program, so every reference is the SAME zero-
// capture closure and `===` is JS identity (the type mapping in
// type-mapper.ts pins the one concrete signature `(value: string) =>
// primitive`; direct calls `String(x)` never reach here — the call
// lowering intercepts them with the wider static coercions).
const jsPrimitiveCtorSelector = isJsSourceFile(expr.getSourceFile());
const primitiveName = stdlibGlobalNameOf(lowerer, expr);
if (
primitiveName === "String" ||
primitiveName === "Number" ||
primitiveName === "Boolean" ||
primitiveName === "BigInt"
) {
return primitiveCtorClosure(lowerer, primitiveName, loc, jsPrimitiveCtorSelector);
}
if (lowerer.isStdlibGlobal(expr, "ArrayBuffer"))
return { kind: "libCall", fn: "arrayBuffer.constructor", args: [], type: DYN, loc };
if (isJsSourceFile(expr.getSourceFile()) && lowerer.isStdlibGlobal(expr, "Array"))
return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc };
if (
Object.values(BYTES_ELEMENT_NAME).includes(expr.text) &&
lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr))
) {
return {
kind: "libCall",
fn: "bytes.constructor",
args: [{ kind: "strLit", value: expr.text, type: STRING, loc }],
type: DYN,
loc,
};
}
if (
(expr.text === "encodeURIComponent" ||
expr.text === "encodeURI" ||
expr.text === "decodeURIComponent" ||
expr.text === "decodeURI") &&
lowerer.isStdlibGlobal(expr, expr.text)
) {
const name = `%builtin.${expr.text}`;
const type = funcOf([DYN], STRING);
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const value: IrExpr = {
kind: "libCall",
fn: "dyn.toStringCoerce",
args: [varRef("value", DYN, loc)],
type: STRING,
loc,
};
const fn =
expr.text === "encodeURIComponent"
? "str.encodeUriComponent"
: expr.text === "encodeURI"
? "str.encodeUri"
: expr.text === "decodeURI"
? "str.decodeUri"
: "str.decodeUriComponent";
lowerer.liftedFns.push({
name,
params: [{ localId: "value", name: "value", type: DYN }],
locals: [{ id: "value", name: "value", type: DYN, mutable: false }],
returnType: STRING,
body: [
{
kind: "return",
value: { kind: "libCall", fn, args: [value], type: STRING, loc },
loc,
},
],
loc,
});
}
return { kind: "closure", fnName: name, captures: [], type, loc };
}
if (lowerer.isStdlibGlobal(expr, "crypto"))
return { kind: "libCall", fn: "crypto.native", args: [], type: DYN, loc };
const nativeAlias = stdlibGlobalNameOf(lowerer, expr);
if (nativeAlias === "Buffer" && expr.text !== "Buffer") {
return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc };
}
if (lowerer.isStdlibGlobal(expr, "fetch")) {
return { kind: "libCall", fn: "fetch.function", args: [], type: DYN, loc };
}
// The lib fence's IDENTIFIER chokepoint: the real standard library
// resolves names the old minimal ambient world never declared
// (Symbol, Reflect, Infinity, Date, ...) — and the adopted
// @types/node resolves its whole global surface (Buffer, fetch,
// setInterval, URL, ...). Reaching one that no lowering above
// claimed is SC2020, never an ICE — EXCEPT in JavaScript sources,
// where a stdlib global taken as a VALUE (never called through this
// path — call sites lower earlier) becomes an opaque IDENTITY TOKEN:
// an interned string naming the global. Identity flows (the
// harness's knownGlobals Set, === comparisons) are exact — one
// global, one token; what a token cannot do (be called, answer
// typeof "function") meets per-site fences/divergences, and
// SEMANTICS.md documents the stance.
if (stdlibGlobalNameOf(lowerer, expr) === "globalThis") {
return lowerGlobalValue(lowerer, expr);
}
{
const sym = lowerer.checker.getSymbolAtLocation(expr);
if (lowerer.isStdlibSymbol(sym) || expr.text === "globalThis") {
if (isJsSourceFile(expr.getSourceFile())) {
const canonical = stdlibGlobalNameOf(lowerer, expr) ?? expr.text;
return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc };
}
// The families with a WHY: each hint states what makes the
// surface genuinely non-static (or what to use instead).
const globalHints: Record<string, string | undefined> = {
Proxy:
"Proxy constructor values have no native lowering; use a direct new Proxy with checked-native plain objects",
Reflect:
"reflective property access has no static lowering — read and call members directly",
Intl: 'native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported',
SharedArrayBuffer: "use direct new SharedArrayBuffer(length) for fixed shared storage",
ArrayBuffer:
"ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage",
WeakRef:
"deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead",
FinalizationRegistry:
"finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead",
eval: "runtime code evaluation cannot be compiled ahead of time",
};
lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined);
}
}
// An ambient global nothing defines compiles to exactly what Node does
// at the access: the catchable ReferenceError "<name> is not defined".
// Stdlib/@types/node ambients never reach here (their chokepoint
// is above); only user-file declares do.
{
const sym = lowerer.resolveValueSymbol(expr);
const decl = sym ? sym.declarations[0] : undefined;
if (
decl !== undefined &&
ts.isVariableDeclaration(decl) &&
decl.initializer === undefined &&
ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Ambient
) {
const declared = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (declared && declared.kind !== "void") {
return {
kind: "libCall",
fn: "global.undefRead",
args: [{ kind: "strLit", value: expr.text, type: STRING, loc }],
type: declared,
loc,
};
}
}
}
// An ambient `declare function` nothing defines, taken as a VALUE
// (call sites lower earlier, in lowerCall): the same story as the
// ambient `declare const` above — Node erases the declaration and
// the read throws the catchable ReferenceError at the access.
{
if (ambientUndefinedFnSymbolOf(lowerer, expr)) {
const t = ambientUndefReadType(lowerer, expr);
if (t) return nsUndefRead(lowerer, expr.text, expr, t);
}
}
// A read of a TRAP binding — a declaration whose own initializer
// provably threw (module init unwound there), so this reference can
// never execute: any lowering is sound, and the trap keeps the
// shape honest. Typed by the use site when it maps, the F64 dummy
// otherwise (never observed — never even reached).
{
const sym = lowerer.resolveValueSymbol(expr);
if (sym !== null && lowerer.trapBindings.has(sym)) {
const t =
ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr) ?? F64;
return nsUndefRead(lowerer, expr.text, expr, t);
}
}
// A program CLASS NAME as a value: the classRef over the class's
// immortal class object (member accesses and construction never
// reach here — their hooks claim the property/new forms first).
{
const sym = lowerer.resolveValueSymbol(expr);
const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined;
// A GENERIC class name whose CONTEXTUAL type pins one instantiation
// (`const B: new (v: string) => Counter<string> = Counter`): the
// value is that instantiation's class object — the generic-fn
// pinning rule on the static side. Unpinned references fall
// through to classValueRef's family fence.
if (classInfo?.generic) {
const ctxT = lowerer.checker.getContextualType(expr);
const mapped = ctxT ? lowerer.mapTypeOf(ctxT) : null;
const instInfo =
mapped?.kind === "classval" ? lowerer.classes.get(mapped.className) : undefined;
if (instInfo && instInfo.genericInstance?.family === classInfo) {
return lowerer.classValueRef(instInfo, expr);
}
}
if (classInfo) {
// A decorated name a replacing decorator can REBIND: the value
// is the decoration result — the mutable classval global %init
// assigned at the class statement (TC39's name binding), never
// the declaration's own class object.
const decoratedGlobal = classInfo.classDecorators?.valueGlobalId;
if (decoratedGlobal !== undefined) {
return {
kind: "varRef",
localId: decoratedGlobal,
type: { kind: "classval", className: classInfo.def.name },
loc,
};
}
return lowerer.classValueRef(classInfo, expr);
}
// The enum OBJECT as a value (member reads never reach here — the
// access hooks fold them): iteration, storage, reverse lookups
// through variables all land on this pointed fence.
if (sym) fenceEnumObjectValue(lowerer, expr, sym);
// A MIXIN function as a first-class value: no runtime function
// exists — calls instantiate a class per call site
// (lower-mixins.ts). Generic mixins took the generic-fn value
// fence above; this names the non-generic spelling.
if (mixinFnOfCallee(lowerer, expr)) {
lowerer.unsupported(
"SC1090",
expr,
`the mixin function '${expr.text}' as a value (mixin calls compile per call site — call it directly)`,
);
}
}
// A NAMESPACE object as a first-class value: members lower at their
// qualified access sites; the object itself has no runtime
// representation (ambient namespaces compile to Node's
// ReferenceError instead — the object never exists at runtime).
{
const moduleNs = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (moduleNs?.kind === "moduleNs") {
return { kind: "moduleNsRef", moduleId: moduleNs.moduleId, type: moduleNs, loc };
}
const builtinNs = lowerer.builtinNamespaceModuleOf(expr);
if (builtinNs === "constants") return builtinConstantsObject(lowerer, loc);
if (builtinNs !== null) {
const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNs}` };
return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc };
}
const ns = lowerNsIdentifierValue(lowerer, expr);
if (ns) return ns;
}
if (isNavigatorGlobal(lowerer, expr)) {
refuseNavigator(
lowerer,
expr,
"the navigator object as a value",
lowerer.checker.getSymbolAtLocation(expr),
);
}
// Preflight guarantees no unresolved identifiers; anything else here
// is a blocked declaration's binding (the SC2004 cascade) or a
// binding form we don't model yet.
lowerer.rejectUnresolved(
expr,
`the reference to '${expr.text}' (a binding form with no lowering)`,
);
}
function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string {
return lowerer.targetPlatform === "wasi"
? (wasiGuestPath(sf.fileName) ?? sf.fileName.replace(/\\/g, "/"))
: sf.fileName;
}
/** Object.keys/Object.entries require an object receiver, but their first
* argument is not syntactically a member receiver. Recognize that one
* standard-library position so a runtime-optional loop element can be
* checked against the checker-narrowed record arm before builtin lowering. */
function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean {
let arg = expr;
while (
(ts.isParenthesizedExpression(arg.parent) ||
ts.isAssertionExpression(arg.parent) ||
ts.isSatisfiesExpression(arg.parent) ||
ts.isNonNullExpression(arg.parent)) &&
arg.parent.expression === arg
) {
arg = arg.parent;
}
const call = arg.parent;
if (
!ts.isCallExpression(call) ||
call.arguments.nodes[0] !== arg ||
!ts.isPropertyAccessExpression(call.expression)
)
return false;
const access = call.expression;
return (
(access.name.text === "keys" || access.name.text === "entries") &&
lowerer.isStdlibMember(access)
);
}
/** A read that resolves to a CJS export-table ACCESSOR property
* (`module.exports = { get path() {...}, set path(v) {...} }`): the
* getter lifts as a module-level function (module globals need no
* captures) — interned per declaration so every read site calls ONE
* function — and the read IS the call, Node's per-read evaluation
* exactly. Null when the identifier doesn't resolve to such a property
* (or it has no getter: a setter-only read answers undefined in Node —
* rare enough to keep fenced). */
function cjsExportAccessorRead(lowerer: Lowerer, ident: ts.Identifier): IrExpr | null {
const symbol = lowerer.resolveValueSymbol(ident);
const getter = symbol ? symbol.declarations.find(ts.isGetAccessorDeclaration) : undefined;
if (!getter) return null;
if (!ts.isObjectLiteralExpression(getter.parent) || !isCjsExportTableLiteral(getter.parent)) {
return null;
}
return cjsAccessorCall(lowerer, getter, locOf(ident));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,528 @@
/* typeof expressions and the typeof tests they lower to. */
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import {
BOOL,
CAUGHT,
DYN,
type IrExpr,
type IrStmt,
STRING,
type SrcLoc,
UNDEFINED_T,
} from "../../../ir/ir.js";
import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js";
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { lowerPerfHooksTypeof } from "../builtins/performance.js";
import { isNodeModuleValue } from "../lower-node-module.js";
import { builtinClassRefOf } from "../builtins/class-values.js";
import { STRING_INDEX_METHODS, builtinModuleConstOf, stdlibGlobalNameOf } from "../surfaces.js";
import {
constructorGlobalTypeof,
deferredSlotOr,
errorRootedObjectOf,
lowerPromiseThenPresence,
operandPairs,
runtimeOptionalStorageOperand,
typeofAnswer,
} from "../lower-exprs.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { isJsSourceFile } from "../../program.js";
import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
import { varRef } from "../../../ir/build.js";
import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js";
import { InternalCompilerError } from "../../../errors.js";
export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr {
const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression);
if (webAssembly) return webAssembly;
if (lowerer.isStdlibGlobal(expr.expression, "process"))
return { kind: "strLit", value: "object", type: STRING, loc };
if (lowerer.isStdlibGlobal(expr.expression, "fetch"))
return { kind: "strLit", value: "function", type: STRING, loc };
if (isNavigatorGlobal(lowerer, expr.expression))
return { kind: "strLit", value: "object", type: STRING, loc };
const performance = lowerPerfHooksTypeof(lowerer, expr.expression);
if (performance) return performance;
if (isNodeModuleValue(lowerer, expr.expression)) {
return { kind: "strLit", value: "object", type: STRING, loc };
}
// `typeof EventEmitter`, `typeof zlib.Gzip`, `typeof C` over a
// builtin class alias.
if (builtinClassRefOf(lowerer, expr.expression) !== null) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
if (ts.isPropertyAccessExpression(expr.expression)) {
const member = expr.expression;
const prototype = member.expression;
if (
STRING_INDEX_METHODS.has(member.name.text) &&
ts.isPropertyAccessExpression(prototype) &&
prototype.name.text === "prototype" &&
ts.isIdentifier(prototype.expression) &&
lowerer.isStdlibGlobal(prototype.expression, "String") &&
lowerer.isStdlibMember(member)
) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
if (
member.name.text === "at" &&
ts.isPropertyAccessExpression(prototype) &&
prototype.name.text === "prototype" &&
ts.isIdentifier(prototype.expression) &&
lowerer.isStdlibGlobal(prototype.expression, "Array") &&
lowerer.isStdlibMember(member)
) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
const presence = lowerPromiseThenPresence(lowerer, expr.expression, {
kind: "strLit",
value: "function",
type: STRING,
loc,
});
if (presence !== null) return presence;
}
// `typeof queueMicrotask` / `typeof DOMException` on a STDLIB global
// whose declared type is callable or constructable: folds to
// "function" BEFORE the operand lowers — the identity-token story
// (JS files) deliberately represents these values as strings, and
// the TS-file fence would name a value the program never needs; an
// identifier read has no side effects to preserve. Node's answer for
// every function and constructor global is "function" (the harness's
// `typeof queueMicrotask === 'function'` probes). Shadowing locals
// have non-stdlib symbols and keep the ordinary path.
let tested = expr.expression;
while (ts.isParenthesizedExpression(tested)) tested = tested.expression;
// `typeof zlib.Inflate` / `typeof zlib.createGzip`: an export of a
// Node builtin module read through a plain binding (an import or
// require result). The read has no side effects, and Node's answer for
// every exported function and class is "function".
if (
ts.isPropertyAccessExpression(tested) &&
ts.isIdentifier(tested.expression) &&
lowerer.isStdlibMember(tested)
) {
const sym = lowerer.checker.getSymbolAtLocation(tested.name);
const decls = sym ? sym.declarations : [];
if (
decls.some(isBuiltinModuleValueDecl) &&
decls.every((decl) => builtinModuleExportParent(decl) !== undefined)
) {
const t = lowerer.typeOf(tested);
if (
lowerer.checker.getCallSignatures(t).length > 0 ||
lowerer.checker.getConstructSignatures(t).length > 0
) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
}
}
// `typeof globalThis.AbortSignal` / `typeof Headers`: Node 24's Web
// platform constructor globals are functions. JavaScript sources
// represent such a value as an identity token, and the fallback
// declarations type some (AbortSignal) without a construct signature,
// so neither the operand nor the type can answer; the read has no
// effects.
if (constructorGlobalTypeof(lowerer, tested) !== null)
return { kind: "strLit", value: "function", type: STRING, loc };
// `typeof globalThis.AbortSignal.any` (ky's feature probe): a static
// member of a standard-library global, declared callable, whose value
// has no representation. The read has no effects and Node's answer
// for every such function is "function"; members that lower as values
// keep their own answer.
if (ts.isPropertyAccessExpression(tested) && !tested.questionDotToken) {
const owner = stdlibGlobalNameOf(lowerer, tested.expression);
if (owner !== null && lowerer.isStdlibMember(tested)) {
const t = lowerer.typeOf(tested);
if (
(lowerer.checker.getCallSignatures(t).length > 0 ||
lowerer.checker.getConstructSignatures(t).length > 0) &&
tryLowerExpression(lowerer, tested) === null
)
return { kind: "strLit", value: "function", type: STRING, loc };
}
}
if (ts.isIdentifier(tested)) {
const sym = lowerer.checker.getSymbolAtLocation(tested);
if (!sym) {
return { kind: "strLit", value: "undefined", type: STRING, loc };
}
// `const { inspect } = require("util")` / `import { inspect } from
// "node:util"`: a binding of a builtin module's exported function,
// class or object. JavaScript sources represent such a value without
// its own lowering as an identity token (a string), so the operand's
// representation cannot answer. Node's answer follows the export's
// kind, and the binding read has no effects.
const imported = isJsSourceFile(tested.getSourceFile())
? lowerer.builtinImportOf(tested)
: null;
if (imported && builtinModuleConstOf(lowerer, imported.module, imported.member) === undefined) {
const t = lowerer.typeOf(tested);
if (
lowerer.checker.getCallSignatures(t).length > 0 ||
lowerer.checker.getConstructSignatures(t).length > 0
) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
if ((t.flags & ts.TypeFlags.Object) !== 0) {
return { kind: "strLit", value: "object", type: STRING, loc };
}
}
if (lowerer.isStdlibSymbol(sym)) {
const t = lowerer.typeOf(expr.expression);
if (
lowerer.checker.getCallSignatures(t).length > 0 ||
lowerer.checker.getConstructSignatures(t).length > 0
) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
}
}
// A known static result type fixes typeof's answer, but its producer
// still evaluates: calls, getters, and checked reads can have effects
// or throw. Only trivial operands may disappear.
let operand = deferredSlotOr(
lowerer,
runtimeOptionalStorageOperand(lowerer, expr.expression) ?? lowerer.lowerExpr(expr.expression),
);
// A checker-narrowed dyn read arrives as a VALIDATED extraction
// (maybeNarrow's dynCheck bridge). typeof needs no extraction — the
// dyn kind table answers the question directly, and answers it even
// where the flow type lied (the extraction would throw instead) —
// so unwrap back to the dyn value and take the dyn.typeof path.
if (operand.kind === "dynCheck" && operand.value.type.kind === "dyn") {
operand = operand.value;
}
const FOLD: Partial<Record<string, string>> = {
f64: "number",
bigint: "bigint",
string: "string",
bool: "boolean",
func: "function",
array: "object",
object: "object",
record: "object",
symbol: "symbol",
map: "object",
set: "object",
promise: "object",
bytes: "object",
regex: "object",
generator: "object",
classval: "function",
moduleNs: "object",
undefinedT: "undefined",
nullT: "object",
};
const folded = FOLD[operand.type.kind];
if (folded) {
const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc };
return isSafeToDiscard(operand)
? result
: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: operand, loc }],
result,
type: STRING,
loc,
};
}
// A union operand: every arm's typeof answer is static, so the value
// form is a ternary chain over runtime TAG tests (arms grouped by
// answer; the last group needs no test). The operand rides several
// tests, so only side-effect-free reads compose — and when every arm
// agrees the whole expression folds to that one string (dropping only
// the pure read, the trust-the-checker bet lowerUnitComparison makes).
// An effectful producer (a getter) evaluates once into a hidden local.
const prelude: IrStmt[] = [];
if (operand.type.kind === "union" && !isSafeToRepeat(operand)) {
const local = lowerer.declareHiddenLocal("%typeofOperand", operand.type);
prelude.push({ kind: "varDecl", localId: local.id, init: operand, loc });
operand = varRef(local.id, operand.type, loc);
}
if (operand.type.kind === "union" && isSafeToRepeat(operand)) {
const def = lowerer.unions.get(operand.type.unionId);
const answers = def?.arms.map(typeofAnswer);
if (def && answers && answers.every((s): s is string => s !== null)) {
const groups = new Map<string, number[]>();
answers.forEach((s, i) => groups.set(s, [...(groups.get(s) ?? []), i]));
const ordered = [...groups.entries()];
let result: IrExpr = {
kind: "strLit",
value: ordered[ordered.length - 1]![0],
type: STRING,
loc,
};
for (const [answer, tags] of ordered.slice(0, -1).reverse()) {
let test: IrExpr = {
kind: "unionIsTag",
unionId: operand.type.unionId,
tag: tags[0]!,
negated: false,
value: operand,
type: BOOL,
loc,
};
for (const t of tags.slice(1)) {
test = {
kind: "logical",
op: "||",
left: test,
right: {
kind: "unionIsTag",
unionId: operand.type.unionId,
tag: t,
negated: false,
value: operand,
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
result = {
kind: "ternary",
cond: test,
then: { kind: "strLit", value: answer, type: STRING, loc },
else_: result,
type: STRING,
loc,
};
}
return prelude.length > 0
? { kind: "seqExpr", stmts: prelude, result, type: STRING, loc }
: result;
}
}
if (operand.type.kind === "dyn") {
// Bare typeof on a dyn value: the runtime's kind→string table
// (null answers "object", boxed closures "function" — JS-exact
// for every dyn kind; "bigint"/"symbol" have no producers).
return { kind: "libCall", fn: "dyn.typeof", args: [operand], type: STRING, loc };
}
lowerer.unsupported("SC1090", expr, "typeof expressions on statically-typed values");
}
/** `typeof e.code === "string"` / `"undefined"` (and the `!==` forms)
* where e — through parens and as-casts — is a catch binding or a value
* of %Error-rooted type: the runtime error's code slot is a string
* exactly when present, so the typeof test IS the presence test (the
* isErrnoException predicate's third conjunct,
* `typeof (err as Record<string, unknown>).code === "string"`). The
* as-cast changes the expression's TYPE, never the value — peeled like
* lowerProcessStreamProperty's receiver match. Null for every other
* shape, so the sibling typeof lowerings keep trying. */
export function lowerErrorCodeTypeofTest(
lowerer: Lowerer,
expr: ts.BinaryExpression,
loc: SrcLoc,
): IrExpr | null {
const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken;
for (const [a, b] of operandPairs(expr)) {
if (!ts.isTypeOfExpression(a)) continue;
let prop: ts.Expression = a.expression;
while (ts.isParenthesizedExpression(prop)) prop = prop.expression;
if (!ts.isPropertyAccessExpression(prop) || prop.questionDotToken) continue;
if (prop.name.text !== "code") continue;
let recv: ts.Expression = prop.expression;
while (
ts.isParenthesizedExpression(recv) ||
ts.isAsExpression(recv) ||
ts.isTypeAssertion(recv)
)
recv = recv.expression;
const errT = errorRootedObjectOf(lowerer, lowerer.typeOf(recv));
if (!errT) continue;
if (isExecExceptionType(lowerer, lowerer.typeOf(recv))) refuseExecExceptionCode(lowerer, prop);
const caught = lowerer.caughtLocalOf(recv);
let receiver: IrExpr;
if (caught) {
// The checker proved the Error narrowing (errT above), so the
// trusted extraction is sound — caughtRead itself would fence on
// the intersection spelling.
receiver = {
kind: "caughtNarrow",
value: { kind: "varRef", localId: caught.id, type: CAUGHT, loc },
type: errT,
loc,
};
} else {
receiver = lowerer.lowerExpr(recv);
if (receiver.type.kind !== "object") continue;
}
if (!ts.isStringLiteral(b)) continue;
if (b.text !== "string" && b.text !== "undefined") continue;
const codeType = lowerer.envValueType();
if (codeType.kind !== "union")
throw new InternalCompilerError("lowerer bug: error code type is not a union");
const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T);
const read: IrExpr = {
kind: "libCall",
fn: "error.code",
args: [receiver],
type: codeType,
loc,
};
// typeof === "string" ⇔ the code slot is present (NOT the undefined
// arm); === "undefined" ⇔ absent. `!==` flips either.
const isNotUndef = b.text === "string" ? !negated : negated;
return {
kind: "unionIsTag",
unionId: codeType.unionId,
tag: undefTag,
...(isNotUndef ? { negated: true as const } : {}),
value: read,
type: BOOL,
loc,
};
}
return null;
}
/** `typeof v === "lit"` / `typeof v !== "lit"` where v is `unknown`-typed
* (dyn): the runtime kind test over the dyn node's tag. Only the kinds a
* later read can bridge narrow ("string"/"number"/"boolean") plus
* "undefined" (no payload to read); "object"/"function" tests keep the
* fence — an object-narrowed `unknown` read has no lowering anyway (a
* checked cast `v as T` is the supported extraction). Null when neither
* side is a typeof over a dyn-typed operand (not this pattern). */
export function lowerDynTypeofTest(
lowerer: Lowerer,
expr: ts.BinaryExpression,
loc: SrcLoc,
): IrExpr | null {
const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken;
for (const [a, b] of operandPairs(expr)) {
if (!ts.isTypeOfExpression(a)) continue;
if (lowerer.isStdlibGlobal(a.expression, "fetch")) continue;
// Non-literal comparisons use the ordinary typeof string value,
// preserving both operands' evaluation order without a narrowing test.
if (!ts.isStringLiteral(b)) continue;
const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression));
let value: IrExpr;
if (declared?.kind === "dyn") {
value = lowerer.lowerExpr(a.expression);
if (value.type.kind !== "dyn") continue; // defensive: an already-narrowed use
} else if (lowerer.caughtLocalOf(a.expression)) {
continue; // the caught lowering's territory (it ran first and declined)
} else {
// `typeof (value as T).field === "string"` — the raw-object
// validation idiom (routes.ts's isValidRoute): the `as` is erasure
// in JS, so the READ is the dyn KEYED read of the un-cast value —
// a missing key answers undefined and the guard goes false,
// Node-exact — never the checked cast (validating T here would
// throw on exactly the values the guard exists to reject).
const unparen = (e: ts.Expression): ts.Expression => {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
};
let keyed: IrExpr | null = null;
const pn = unparen(a.expression);
if (ts.isPropertyAccessExpression(pn) && !pn.questionDotToken && ts.isIdentifier(pn.name)) {
const recv = unparen(pn.expression);
if (ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) {
const inner = unparen(recv.expression);
const obj = tryLowerExpression(lowerer, inner);
if (obj?.type.kind === "dyn") {
keyed = {
kind: "dynKeyGet",
key: { kind: "strLit", value: pn.name.text, type: STRING, loc },
value: obj,
type: DYN,
loc,
};
}
}
}
if (keyed) {
value = keyed;
} else {
// A narrowing residue of `unknown`: a `!== undefined` (or truthy)
// guard ahead of the typeof test spells the operand `{}`/`{} |
// null` — no useful static mapping, but the READ is still the dyn
// value (`if (obj.name !== undefined) { if (typeof obj.name !==
// "string") ... }`, the validateConfig idiom). Probe the lowering
// and claim exactly the dyn results; anything else keeps its
// sibling lowerings and fences untouched.
const probed = tryLowerExpression(lowerer, a.expression);
if (probed?.type.kind !== "dyn") continue;
value = probed;
}
}
if (
b.text === "string" ||
b.text === "number" ||
b.text === "boolean" ||
b.text === "undefined" ||
b.text === "bigint" ||
b.text === "symbol"
) {
return {
kind: "dynTest",
test: b.text,
...(negated ? { negated: true as const } : {}),
value,
type: BOOL,
loc,
};
}
// `typeof v === "object"`: the dyn answers exactly (object, array,
// bytes, and null kinds — JS's oldest wart preserved). tsc narrows
// the branch to `object | null`; reads past the narrow take the
// checked-cast path per site.
if (b.text === "object") {
return {
kind: "dynTest",
test: "object",
...(negated ? { negated: true as const } : {}),
value,
type: BOOL,
loc,
};
}
// `typeof v === "function"`: a REAL runtime test since the checked-dynamic tree's
// function kind exists (boxed closures — the mustCall guard
// `if (typeof fn !== 'function') throw` answers honestly).
if (b.text === "function") {
return {
kind: "dynTest",
test: "function",
...(negated ? { negated: true as const } : {}),
value,
type: BOOL,
loc,
};
}
// Native checked values do not yet carry symbols.
if (b.text === "symbol") {
return { kind: "boolLit", value: negated, type: BOOL, loc };
}
lowerer.unsupported(
"SC1090",
b,
`'typeof' tests against "${b.text}" on 'unknown' values (only "string"/"number"/` +
`"boolean"/"undefined"/"object" narrow; use a checked cast — 'v as T' — to extract)`,
);
}
return null;
}
/** The `declare module "..."` body (or nested namespace body) a module
* export's declaration sits directly in, or undefined. */
function builtinModuleExportParent(decl: ts.Node): ts.ModuleBlock | undefined {
const parent = ts.isVariableDeclaration(decl) ? decl.parent?.parent?.parent : decl.parent;
return parent !== undefined && ts.isModuleBlock(parent) ? parent : undefined;
}
/** A function, class or variable exported by a builtin module's body. */
function isBuiltinModuleValueDecl(decl: ts.Node): boolean {
return (
builtinModuleExportParent(decl) !== undefined &&
(ts.isFunctionDeclaration(decl) ||
ts.isClassDeclaration(decl) ||
ts.isVariableDeclaration(decl))
);
}
@@ -0,0 +1,955 @@
/* Increment, decrement and prefix unary operators, and compound field updates. */
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import {
type FieldTarget,
bigintCompoundValue,
compoundUnsupported,
concatOperand,
deferredSlotOr,
dynCompoundValue,
lowerOptionalNumber,
operatorUnsupported,
runtimeOptionalStorageOperand,
symbolFieldTarget,
} from "../lower-exprs.js";
import {
BIGINT_T,
BOOL,
DYN,
F64,
type IrExpr,
type IrStmt,
type IrType,
STRING,
type SrcLoc,
UNDEFINED_T,
VOID,
} from "../../../ir/ir.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { classToNumberConversion } from "../class-to-primitive.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js";
import { fenceNodeModuleMutation } from "../lower-node-module.js";
import { staticFieldWriteTarget } from "../lower-classes.js";
import { expandoWritableTarget } from "../lower-expando.js";
import { nsWritableTarget } from "../lower-namespaces.js";
import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-values.js";
import { isSafeToRepeat } from "./evaluation-safety.js";
import { errorPropertyWrite } from "../error-methods.js";
import type { CompoundOp } from "../surfaces.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { hasRuntimeStatics } from "../class-runtime-statics.js";
import { declareContextLocal } from "../function-context.js";
/** Read-modify-write updates read the member first: an unassigned
* deferred-init receiver throws Node's member-read TypeError. */
function checkDeferredMemberReceiver(
lowerer: Lowerer,
target: FieldTarget,
access: ts.Expression,
): void {
if (
target.container !== "class" ||
!ts.isPropertyAccessExpression(access) ||
target.obj.type.kind !== "object" ||
lowerer.deferredFieldSlot(target.obj) === null
)
return;
target.obj =
lowerer.runtimeOptionalPropertyReceiver(
access.expression,
target.obj,
target.obj.type,
target.field,
) ?? target.obj;
}
/** Whether evaluating `expr` cannot store anything or run other code:
* reads, arithmetic, tests and checked extractions (which at most throw). */
function writeFree(expr: IrExpr): boolean {
switch (expr.kind) {
case "numLit":
case "strLit":
case "boolLit":
case "unitLit":
case "varRef":
return true;
case "fieldGet":
case "recordGet":
return writeFree(expr.obj);
case "bin":
case "logical":
return writeFree(expr.left) && writeFree(expr.right);
case "unary":
case "toBool":
return writeFree(expr.operand);
case "ternary":
return writeFree(expr.cond) && writeFree(expr.then) && writeFree(expr.else_);
case "unionIsTag":
case "unionNarrow":
case "unionWrap":
return writeFree(expr.value);
case "libCall":
return expr.fn === "error.nodeThrow";
default:
return false;
}
}
export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpression): IrExpr {
const loc = locOf(expr);
switch (expr.operator) {
case ts.SyntaxKind.MinusToken: {
const raw = lowerer.lowerExpr(expr.operand);
if (raw.type.kind === "bigint") {
return { kind: "libCall", fn: "bigint.neg", args: [raw], type: BIGINT_T, loc };
}
// Match the checked numeric boundary used by JS binary arithmetic.
const operand =
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") operatorUnsupported(lowerer, expr, "-", [raw.type]);
if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc };
return { kind: "unary", op: "-", operand, type: F64, loc };
}
case ts.SyntaxKind.PlusToken: {
const raw = lowerer.lowerExpr(expr.operand);
if (raw.type.kind === "dyn") {
return { kind: "libCall", fn: "dyn.toNumberCoerce", args: [raw], type: F64, loc };
}
// Unary + is ToNumber; on an already-number operand it's identity,
// and a STRING operand runs the runtime's ECMA-exact StringToNumber
// (num.fromString — Number(aString)'s lowering, scr_string.c).
if (raw.type.kind === "string") {
return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc };
}
const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus");
if (classNumber) return classNumber;
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]);
return operand;
}
case ts.SyntaxKind.ExclamationToken: {
// `!x` is ToBoolean-then-negate: f64/string operands go through toBool.
const operand = lowerer.ensureBool(
deferredSlotOr(
lowerer,
runtimeOptionalStorageOperand(lowerer, expr.operand) ?? lowerer.lowerExpr(expr.operand),
),
expr.operand,
);
return { kind: "unary", op: "!", operand, type: BOOL, loc };
}
case ts.SyntaxKind.TildeToken: {
// `~x`: ToInt32, complement, back to f64 (JS-exact, incl. NaN → -1).
const raw = lowerer.lowerExpr(expr.operand);
if (raw.type.kind === "bigint") {
return { kind: "libCall", fn: "bigint.not", args: [raw], type: BIGINT_T, loc };
}
if (raw.type.kind === "dyn")
return {
kind: "libCall",
fn: "dyn.bitwise",
args: [raw, dynUndefinedExpr(loc), { kind: "strLit", value: "~", type: STRING, loc }],
type: DYN,
loc,
};
// A checked-dynamic value retains enough JavaScript structure to
// run exact ToNumber, including the object valueOf/toString
// protocol. This is the static `any`/JS-lane answer for idioms
// 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") operatorUnsupported(lowerer, expr, "~", [raw.type]);
return { kind: "unary", op: "~", operand, type: F64, loc };
}
case ts.SyntaxKind.PlusPlusToken:
case ts.SyntaxKind.MinusMinusToken:
// `++x` / `--x` in expression position: yields the NEW value.
return lowerIncDec(lowerer, expr, true);
}
lowerer.unsupported("SC1090", expr, `syntax '${ts.syntaxKindName(expr.kind)}'`);
}
/** Member updates evaluate the reference once, convert its old value with
* ToNumeric, store the new value, then yield the old or new numeric value. */
export function lowerIncDec(
lowerer: Lowerer,
expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression,
prefix: boolean,
): IrExpr {
const loc = locOf(expr);
const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-";
if (!ts.isIdentifier(expr.operand)) {
const access = expr.operand;
if (
(!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) ||
access.questionDotToken
) {
lowerer.unsupported("SC1045", expr, "increment/decrement of this target");
}
fenceNodeModuleMutation(lowerer, access, "assignment");
if (ts.isPropertyAccessExpression(access)) {
const target =
staticFieldWriteTarget(lowerer, access) ??
expandoWritableTarget(lowerer, access) ??
nsWritableTarget(lowerer, access);
if (target) {
return lowerIncDecTarget(lowerer, expr, target, prefix);
}
}
const stmts: IrStmt[] = [];
const save = (value: IrExpr, name: string): IrExpr => {
const local = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: local.id, init: value, loc });
return varRef(local.id, value.type, loc);
};
const finish = (read: IrExpr, write: (value: IrExpr) => IrStmt): IrExpr => {
if (read.type.kind === "bigint") {
const old = save(read, "%incrementOld");
const next = save(bigintStep(op, old, loc), "%incrementNext");
stmts.push(write(next));
const result = prefix ? next : old;
return { kind: "seqExpr", stmts, result, type: result.type, loc };
}
const old = save(
read.type.kind === "f64"
? read
: {
kind: "libCall",
fn: "dyn.toNumeric",
args: [lowerer.coerceInto(access, read, DYN)],
type: DYN,
loc,
},
"%incrementOld",
);
const next = save(
old.type.kind === "f64"
? {
kind: "bin",
op,
left: old,
right: { kind: "numLit", value: 1, type: F64, loc },
type: F64,
loc,
}
: {
kind: "libCall",
fn: "dyn.increment",
args: [old, { kind: "boolLit", value: op === "+", type: BOOL, loc }],
type: DYN,
loc,
},
"%incrementNext",
);
stmts.push(write(next));
const result = prefix ? next : old;
return { kind: "seqExpr", stmts, result, type: result.type, loc };
};
const raw = lowerer.lowerExpr(access.expression);
// Keep native array/byte storage: boxing a native array can create a
// checked view, which is not a substitute for updating its slot.
if (
ts.isElementAccessExpression(access) &&
(raw.type.kind === "array" || raw.type.kind === "bytes")
) {
const receiver = save(raw, "%incrementArray");
const rawIndex = lowerer.lowerExpr(access.argumentExpression);
const index = save(
rawIndex.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())
? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc }
: rawIndex,
"%incrementIndex",
);
if (index.type.kind !== "f64")
lowerer.unsupported("SC1090", access, "incrementing non-number array keys");
const type = raw.type;
const read: IrExpr =
type.kind === "array"
? type.elem.kind === "f64"
? arrayNumberRead(receiver, index, loc)
: arrayValueRead(lowerer, receiver, index, type.elem, loc)
: {
kind: "bytesIntrinsic",
method: "get",
receiver,
args: [index],
type: F64,
loc,
invalidNaN: true,
};
return finish(read, (value) =>
type.kind === "array"
? arrayValueStore(
lowerer,
receiver,
index,
lowerer.coerceInto(access, value, type.elem),
type.elem,
loc,
)
: {
kind: "bytesSet",
arr: receiver,
index,
value: lowerer.coerceInto(access, value, F64),
loc,
},
);
}
// A TS member statically typed number on a checked-dynamic receiver (a
// class-backed protocol view): the expanded form `o.v = o.v + 1`, its
// read checked into number like any other read of the member.
const checkedNumber =
!isJsSourceFile(expr.getSourceFile()) &&
raw.type.kind === "dyn" &&
lowerer.mapTypeOf(lowerer.typeOf(access))?.kind === "f64";
if ((isJsSourceFile(expr.getSourceFile()) || checkedNumber) && raw.type.kind === "dyn") {
const receiver = save(raw, "%incrementReceiver");
const key = save(
ts.isElementAccessExpression(access)
? lowerer.lowerExprExpecting(access.argumentExpression, DYN)
: lowerer.coerceInto(
access,
{ kind: "strLit", value: access.name.text, type: STRING, loc },
DYN,
),
"%incrementKey",
);
const read: IrExpr = { kind: "dynKeyGet", value: receiver, key, type: DYN, loc };
return finish(checkedNumber ? lowerer.coerceInto(access, read, F64) : read, (value) => ({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySetComputed",
args: [receiver, key, lowerer.coerceInto(access, value, DYN)],
type: VOID,
loc,
},
loc,
}));
}
const target = ts.isPropertyAccessExpression(access)
? lowerer.fieldTarget(access)
: symbolFieldTarget(lowerer, access);
if (target && lowerer.dynConvertible(target.fieldType)) {
checkDeferredMemberReceiver(lowerer, target, access);
target.obj = save(target.obj, "%incrementReceiver");
return finish(lowerer.fieldGetExpr(target, loc, access), (value) =>
lowerer.fieldSetStmt(
target,
lowerer.coerceInto(access, value, target.fieldType),
loc,
access,
),
);
}
if (isJsSourceFile(expr.getSourceFile()) && lowerer.dynConvertible(raw.type)) {
const receiver = save(lowerer.coerceInto(access.expression, raw, DYN), "%incrementReceiver");
const key = save(
ts.isElementAccessExpression(access)
? lowerer.lowerExprExpecting(access.argumentExpression, DYN)
: lowerer.coerceInto(
access,
{ kind: "strLit", value: access.name.text, type: STRING, loc },
DYN,
),
"%incrementKey",
);
return finish({ kind: "dynKeyGet", value: receiver, key, type: DYN, loc }, (value) => ({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySetComputed",
args: [receiver, key, lowerer.coerceInto(access, value, DYN)],
type: VOID,
loc,
},
loc,
}));
}
lowerer.unsupported("SC1045", expr, "increment/decrement of this member target");
}
const target = lowerer.resolveWritable(expr.operand);
if (!target) {
lowerer.rejectUnresolved(
expr.operand,
`increment/decrement of '${expr.operand.text}' (not a writable local or module global)`,
);
}
return lowerIncDecTarget(lowerer, expr, target, prefix);
}
/** Numeric updates read the represented slot once and write the computed
* value back in that same representation. In particular, an unchecked
* indexed initializer can leave undefined in an otherwise numeric binding. */
export function lowerIncDecTarget(
lowerer: Lowerer,
expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression,
target: { id: string; type: IrType },
prefix: boolean,
): IrExpr {
const loc = locOf(expr);
const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-";
if (target.type.kind === "dyn") {
const old = lowerer.declareHiddenLocal("%numericOld", DYN);
const next = lowerer.declareHiddenLocal("%numericNext", DYN);
const read = varRef(target.id, target.type, loc);
return {
kind: "seqExpr",
stmts: [
{
kind: "varDecl",
localId: old.id,
init: { kind: "libCall", fn: "dyn.toNumeric", args: [read], type: DYN, loc },
loc,
},
{
kind: "varDecl",
localId: next.id,
init: {
kind: "libCall",
fn: "dyn.increment",
args: [
varRef(old.id, DYN, loc),
{ kind: "boolLit", value: op === "+", type: BOOL, loc },
],
type: DYN,
loc,
},
loc,
},
{ kind: "assign", localId: target.id, value: varRef(next.id, DYN, loc), loc },
],
result: varRef(prefix ? next.id : old.id, DYN, loc),
type: DYN,
loc,
};
}
if (target.type.kind === "union") {
const read = lowerOptionalNumber(
lowerer,
varRef(target.id, target.type, loc),
loc,
expr.operand,
);
if (read.type.kind === "f64") {
const old = lowerer.declareHiddenLocal("%numericOld", F64);
const next = lowerer.declareHiddenLocal("%numericNext", F64);
const result = varRef(prefix ? next.id : old.id, F64, loc);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: old.id, init: read, loc },
{
kind: "varDecl",
localId: next.id,
init: {
kind: "bin",
op,
left: varRef(old.id, F64, loc),
right: numLit(1, loc),
type: F64,
loc,
},
loc,
},
{
kind: "assign",
localId: target.id,
value: lowerer.coerceInto(expr.operand, varRef(next.id, F64, loc), target.type),
loc,
},
],
result,
type: F64,
loc,
};
}
}
if (target.type.kind === "bigint") {
const old = lowerer.declareHiddenLocal("%bigintOld", BIGINT_T);
const next = lowerer.declareHiddenLocal("%bigintNext", BIGINT_T);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: old.id, init: varRef(target.id, BIGINT_T, loc), loc },
{
kind: "varDecl",
localId: next.id,
init: bigintStep(op, varRef(old.id, BIGINT_T, loc), loc),
loc,
},
{ kind: "assign", localId: target.id, value: varRef(next.id, BIGINT_T, loc), loc },
],
result: varRef(prefix ? next.id : old.id, BIGINT_T, loc),
type: BIGINT_T,
loc,
};
}
if (target.type.kind !== "f64")
operatorUnsupported(lowerer, expr, op === "+" ? "++" : "--", [target.type]);
return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc };
}
/** `++`/`--` on a bigint value: old ± 1n. */
function bigintStep(op: "+" | "-", old: IrExpr, loc: SrcLoc): IrExpr {
const one: IrExpr = {
kind: "libCall",
fn: "bigint.parse",
args: [{ kind: "strLit", value: "1", type: STRING, loc }],
type: BIGINT_T,
loc,
};
return {
kind: "libCall",
fn: op === "+" ? "bigint.add" : "bigint.sub",
args: [old, one],
type: BIGINT_T,
loc,
};
}
/** The write statement for a field target (fieldSet / recordSet / setter
* call). A write to a getter-only property never gets here in a clean
* program (tsc's TS2540 is the fence); the rejection is the backstop. */
export function fieldSetStmt(
lowerer: Lowerer,
target: FieldTarget,
value: IrExpr,
loc: SrcLoc,
blame: ts.Node,
): IrStmt {
// A member write through an unassigned deferred-init field: the value
// still evaluates first, then the write throws Node's TypeError.
const deferredSlot =
target.container === "class" &&
!(
(target.field === "message" || target.field === "name") &&
(target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error"))
)
? lowerer.deferredFieldSlot(target.obj)
: null;
if (target.container === "class" && deferredSlot?.type.kind === "union") {
const unionId = deferredSlot.type.unionId;
const undefTag = lowerer.armTag(unionId, UNDEFINED_T);
const presentTag = lowerer.armTag(unionId, target.obj.type);
if (undefTag >= 0 && presentTag >= 0) {
// A value that cannot write anything leaves the field unchanged, so
// the field is read after it (no snapshot of the slot).
const late = isSafeToRepeat(deferredSlot) && writeFree(value);
const receiver = late
? null
: lowerer.declareHiddenLocal("%deferredWriteObject", deferredSlot.type);
const stored = lowerer.declareHiddenLocal("%deferredWriteValue", value.type);
const receiverRef = receiver ? varRef(receiver.id, receiver.type, loc) : deferredSlot;
const result = varRef(stored.id, stored.type, loc);
const write: IrStmt = {
kind: "fieldSet",
obj: {
kind: "unionNarrow",
unionId,
tag: presentTag,
value: receiverRef,
type: target.obj.type,
loc,
},
className: target.className,
field: target.field,
value: result,
loc,
};
return {
kind: "exprStmt",
expr: {
kind: "seqExpr",
stmts: [
...(receiver
? [{ kind: "varDecl" as const, localId: receiver.id, init: deferredSlot, loc }]
: []),
{ kind: "varDecl", localId: stored.id, init: value, loc },
],
result: {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId,
tag: undefTag,
value: receiverRef,
negated: false,
type: BOOL,
loc,
},
then: nodeThrowExpr(
1,
"",
`Cannot set properties of undefined (setting '${target.field}')`,
result.type,
loc,
),
else_: { kind: "seqExpr", stmts: [write], result, type: result.type, loc },
type: result.type,
loc,
},
type: result.type,
loc,
},
loc,
};
}
}
if (target.container === "dynamic")
return {
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySet",
args: [
target.obj,
{ kind: "strLit", value: target.field, type: STRING, loc },
lowerer.coerceToExpected(value, DYN),
],
type: VOID,
loc,
},
loc,
};
if (
target.container === "class" &&
(target.field === "message" || target.field === "name") &&
(target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error"))
)
return errorPropertyWrite(lowerer, target.obj, value, target.field);
if (target.container === "errorStackLimit") {
return {
kind: "exprStmt",
expr: { kind: "libCall", fn: "error.stackLimitSet", args: [value], type: VOID, loc },
loc,
};
}
if (target.container === "errorCause") {
return {
kind: "exprStmt",
expr: { kind: "libCall", fn: "error.setCause", args: [target.obj, value], type: VOID, loc },
loc,
};
}
// A record-shaped CHECKER target whose receiver VALUE lives in the checked-dynamic tree:
// the checked-dynamic keyed write — dyn.keySet (later writes win,
// insertion order; Node's TypeErrors on non-object receivers), the
// value converting into the checked-dynamic tree.
if (
(target.container === "record" || target.container === "recordOvf") &&
target.obj.type.kind === "dyn"
) {
const v = lowerer.coerceToExpected(value, DYN);
if (v.type.kind !== "dyn") {
lowerer.unsupported(
"SC1101",
blame,
`storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`,
);
}
return {
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySet",
args: [target.obj, { kind: "strLit", value: target.field, type: STRING, loc }, v],
type: VOID,
loc,
},
loc,
};
}
if (target.container === "accessor") {
const setF = lowerer.findMethodOn(
lowerer.classes.get(target.className) ?? null,
`set:${target.field}`,
);
if (!setF)
lowerer.unsupported(
"SC1090",
blame,
`assignment to the getter-only property '${target.field}'`,
);
return {
kind: "exprStmt",
expr: lowerer.accessorCall(
target.className,
`set:${target.field}`,
target.obj,
[value],
VOID,
loc,
),
loc,
};
}
// Record accessor properties: the write calls the %set: closure with
// the coerced value. A getter-only write never gets here in a clean
// program (tsc's TS2540); the rejection is the backstop.
if (target.container === "recordAccessor") {
if (!target.setType) {
lowerer.unsupported(
"SC1090",
blame,
`assignment to the getter-only property '${target.field}'`,
);
}
const closure: IrExpr = {
kind: "recordGet",
obj: target.obj,
shapeId: target.shapeId,
field: `%set:${target.field}`,
type: target.setType,
loc,
};
return {
kind: "exprStmt",
expr: { kind: "callValue", callee: closure, args: [value], type: VOID, loc },
loc,
};
}
// Overflow dot writes: the bracket write with a literal key — a pure
// overflow insert (recordKeySet, overflowOnly: the name declares no
// field, so no declared collision exists to validate). The value was
// coerced into the index-value slot type by the caller.
if (target.container === "recordOvf") {
return {
kind: "recordKeySet",
obj: target.obj,
shapeId: target.shapeId,
key: { kind: "strLit", value: target.field, type: STRING, loc },
value,
overflowOnly: true,
loc,
};
}
return target.container === "class"
? {
kind: "fieldSet",
obj: target.obj,
className: target.className,
field: target.field,
value,
loc,
}
: {
kind: "recordSet",
obj: target.obj,
shapeId: target.shapeId,
field: target.field,
value,
loc,
};
}
export function lowerFieldCompoundValue(
lowerer: Lowerer,
access: ts.PropertyAccessExpression | ts.ElementAccessExpression,
op: CompoundOp,
rhsNode: ts.Expression | null,
loc: SrcLoc,
): IrExpr {
if (access.expression.kind === ts.SyntaxKind.SuperKeyword) {
lowerer.unsupported(
"SC1090",
access,
"compound assignment through 'super' (read and write separately)",
);
}
const body: IrStmt[] = [];
const save = (value: IrExpr, name: string): IrExpr => {
const local = lowerer.declareHiddenLocal(name, value.type);
body.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc });
return varRef(local.id, value.type, value.loc);
};
// A CHECKED-DYNAMIC receiver (`context.actual++` — test/common's call
// accounting; dot spelling only — symbol-keyed element targets are
// static fields): read the member (dynKeyGet), VALIDATE it as a
// number (dynCheck — a non-number member throws the catchable
// TypeError where JS would ToNumber-coerce; it throws instead of
// producing NaN — SEMANTICS.md), combine, write back (dyn.keySet). The
// receiver and member read are saved before evaluating the RHS.
if (ts.isPropertyAccessExpression(access)) {
let probed = tryLowerExpression(lowerer, access.expression);
const staticClass =
probed?.type.kind === "classval" ? lowerer.classes.get(probed.type.className) : undefined;
if (probed && staticClass && hasRuntimeStatics(staticClass))
probed = lowerer.coerceToExpected(probed, DYN);
if (probed?.type.kind === "dyn") {
const receiver = save(probed, "%compoundReceiver");
const key: IrExpr = {
kind: "strLit",
value: access.name.text,
type: STRING,
loc: locOf(access.name),
};
const read = save(
{ kind: "dynKeyGet", key, value: receiver, type: DYN, loc },
"%compoundOld",
);
const cur: IrExpr = { kind: "dynCheck", value: read, type: F64, loc };
const rhs = save(
rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc },
"%compoundRhs",
);
if (isJsSourceFile(access.getSourceFile()) && lowerer.dynConvertible(rhs.type)) {
const numeric = ["-", "*", "/", "%", "**"].includes(op);
const right = lowerer.coerceToExpected(rhs, DYN);
const value = save(
{
kind: "libCall",
fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise",
args:
op === "+"
? [read, right]
: [read, right, { kind: "strLit", value: op, type: STRING, loc }],
type: DYN,
loc,
},
"%compoundResult",
);
body.push({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySet",
args: [receiver, key, value],
type: VOID,
loc,
},
loc,
});
return { kind: "seqExpr", stmts: body, result: value, type: DYN, loc };
}
const numericRhs: IrExpr =
rhs.type.kind === "dyn"
? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc }
: lowerOptionalNumber(lowerer, rhs, loc);
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",
);
const boxed: IrExpr = { kind: "dynFrom", value, type: DYN, loc };
body.push({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.keySet",
args: [receiver, { ...key }, boxed],
type: VOID,
loc,
},
loc,
});
return { kind: "seqExpr", stmts: body, result: value, type: value.type, loc };
}
}
const targetOf = (): FieldTarget | null =>
ts.isPropertyAccessExpression(access)
? lowerer.fieldTarget(access)
: symbolFieldTarget(lowerer, access);
const target = targetOf();
if (!target)
lowerer.unsupported("SC1090", access, "compound assignment to unsupported field targets");
checkDeferredMemberReceiver(lowerer, target, access);
target.obj = save(target.obj, "%compoundReceiver");
// Accessors observe getter, RHS side effects, then setter, all through
// the saved receiver.
const fieldRead = lowerer.fieldGetExpr(target, locOf(access), access);
// `+=` on a stored string field reads it into a mutable local, so the
// concat below can append to that local in place.
const storedString =
op === "+" &&
fieldRead.type.kind === "string" &&
target.fieldType.kind === "string" &&
(target.container === "class" || target.container === "record");
let read: IrExpr;
if (storedString) {
const old = declareContextLocal(
lowerer.ctx,
"%compoundOld",
STRING,
true,
undefined,
undefined,
);
body.push({ kind: "varDecl", localId: old.id, init: fieldRead, loc: fieldRead.loc });
read = varRef(old.id, STRING, fieldRead.loc);
} else {
read = save(fieldRead, "%compoundOld");
}
const rhs = save(
rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc },
"%compoundRhs",
);
const numericRhs: IrExpr =
rhs.type.kind === "dyn" && isJsSourceFile(access.getSourceFile())
? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc }
: lowerOptionalNumber(lowerer, rhs, loc);
let value: IrExpr;
let appended: IrExpr | null = null;
const numericRead = lowerOptionalNumber(lowerer, read, loc, access);
const bigintValue = rhsNode ? bigintCompoundValue(op, read, rhs, loc) : null;
if (op === "+" && read.type.kind === "string") {
appended = concatOperand(lowerer, rhs, rhsNode ?? access);
value = { kind: "strConcat", left: read, right: appended, type: STRING, loc };
} else if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") {
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())) ||
// 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 {
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
// between: both operands are already strings), and the concat is the
// self-append `old = old + rhs`, which hands the local's reference to
// the runtime: the old string is then uniquely owned unless something
// else holds it, and is appended to in place. Repeated `this.text += s`
// is linear rather than a copy per append.
const oldId = read.localId;
const oldLoc = read.loc;
const oldRef = (): IrExpr => varRef(oldId, STRING, oldLoc);
const concat: IrExpr = {
kind: "strConcat",
left: oldRef(),
right: appended,
type: STRING,
loc,
};
body.push(
lowerer.fieldSetStmt(target, { kind: "strLit", value: "", type: STRING, loc }, loc, access),
{ kind: "assign", localId: oldId, value: concat, loc },
lowerer.fieldSetStmt(target, oldRef(), loc, access),
);
return { kind: "seqExpr", stmts: body, result: oldRef(), type: STRING, loc };
}
const result = save(value, "%compoundResult");
body.push(
lowerer.fieldSetStmt(target, lowerer.coerceInto(access, result, target.fieldType), loc, access),
);
return { kind: "seqExpr", stmts: body, result, type: result.type, loc };
}
File diff suppressed because it is too large Load Diff