Files
scriptc/packages/compiler/src/frontend/lowering/lower-calls.ts
T
Chris Tate 81091a1a49 Preserve effectful toFixed defaults
- Evaluate undefined precision expressions before substituting zero.
- Cover effectful optional precision with a differential corpus case.
2026-07-26 21:21:07 -05:00

8615 lines
422 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Call lowering: the lowerCall dispatch chain, parameter-shape analysis and
* argument completion (optional/default/rest, explicit-undefined ≡ omission),
* function/lambda lowering and signature collection, and monomorphizing
* generic instantiation (bounded by MAX_GENERIC_INSTANCES). */
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { lowerGenMethodCall } from "./lower-generators.js";
import { BOOL, BYTES_U8, CAUGHT, DYN, F64, IrExpr, IrFunction, IrLocal, IrParam, IrStmt, IrType, JSVAL, STRING, SYMBOL_T, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, canMarshalTypedFuncIntoIsland, funcOf, isUnitType, shapeHasAccessorSlots, typeEquals } from "../../ir/nodes.js";
import type { IrFfiImport } from "../../ir/nodes.js";
import { isJsSourceFile, locOf } from "../program.js";
import { isGenericCallableMemberType, typeKey } from "../types.js";
import { PoisonError, dynFallbackType, dynUndefinedExpr, importCallHandleType, jsFuncNameOf, newFnCtx, nodeThrowExpr } from "./lowerer.js";
import { enforceLibBoundary } from "./lib-boundary.js";
import { NARROW_FIRST, builtinFenceHintOf, builtinModuleFnOf } from "./surfaces.js";
import { ffiBindingDiag, ffiSignatureDiag, requiresDynamicDiag } from "../../diagnostics/diagnostic.js";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import { mixinFnShapeOf } from "./lower-mixins.js";
import { bufEncoding, dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerRegexMethodCall, lowerStringMethodCall, lowerTupleReadMethodCall } from "./lower-containers.js";
import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerDirentMethodCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerWatcherMethodCall } from "./lower-builtins.js";
import { droppableStatic, lowerPromiseAllTupleCall, lowerPromiseRejectCall, probeLower, templateRawTextOf } from "./lower-exprs.js";
import { httpClientFnBindingOf, isStreamUndefCallExpr, lowerHttpClientFnCall } from "./lower-server.js";
import { EMITTER_API_MEMBERS, exactInstanceClassOf, findGenericMethodOn, lowerClassGenericMethodCall, lowerStaticMethodCall, type ClassInfo } from "./lower-classes.js";
import { emitterRooted, lowerEmitterMethodCall } from "./lower-emitter.js";
import { lowerConsoleInspectArg, lowerFormatCall } from "./lower-inspect.js";
import { STREAM_API_MEMBERS, lowerStreamMethodCall, lowerStreamModuleCall, lowerStreamStaticCall, streamSidesOf } from "./lower-stream.js";
import { ambientNsRootOf, ambientUndefReadType, ambientUndefVarRootOf, ambientUndefinedFnSymbolOf, contextualUndefReadType, fenceEarlyAliasUse, fenceEarlyNsMemberRef, nsMemberIdentOf, nsPathPrefix, nsUndefRead } from "./lower-namespaces.js";
import { declSymbolOf } from "./lower-modules.js";
import { expandoMemberRead } from "./lower-expando.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
/** How a parameter participates in CALL-SITE COMPLETION (the frontend
* completes every call to the one full signature, so the IR and backends
* stay count-exact — see docs/ir.md). `required` params must be passed;
* `omittable` params (declared `x?: T` or `x: T = e`) may be omitted by a
* trailing-suffix call, and the frontend appends the interned undefined arm;
* `rest` (always last) receives the surplus arguments packed into one array
* literal at each call site. */
export type ParamMode = "required" | "omittable" | "rest" | "dynRest" | "islandRest";
/** One parameter of a signature, as call sites and callee prologues see it.
* `type` is the ABI type — what the emitted C parameter carries: the
* checker's `T | undefined` union for `x?: T`, a synthesized `T | undefined`
* union for `x: T = e`, `T[]` for `...xs: T[]`, the plain declared type
* otherwise. `bodyType` is present exactly for DEFAULTED params: the plain T
* the body sees after the prologue applies the default (see declareParams). */
export interface ParamShape {
type: IrType;
mode: ParamMode;
bodyType?: IrType;
}
export interface FnSig {
name: string;
params: ParamShape[];
/** Call-site result type — Promise<inner> for async functions, the
* generator type for generator functions. */
returnType: IrType;
/** Async: the IrFunction's returnType is the promise's INNER type. */
isAsync?: boolean;
/** Generator: the IrFunction's returnType is the TReturn channel; the
* yield/next channels ride here (IrFunction.generator's exact shape). */
generator?: { yieldT: IrType; nextT: IrType };
}
/** Instantiation cap per generic function: same-key recursion (`len<T>`
* calling itself) converges, but POLYMORPHIC recursion (`f<T>` calling
* `f<T[]>`) would request new instances forever — the cap turns that into a
* diagnostic instead of a hang. */
export const MAX_GENERIC_INSTANCES = 100;
/** A generic function-like declaration, collected instead of an FnSig —
* top-level generic function declarations, class GENERIC METHODS (own type
* parameters, instance and static), and object-literal generic methods.
* The body is NOT lowered at collection: each call site's checker-resolved
* signature (type arguments substituted) becomes an instantiation key, and
* the body is lowered once per distinct key (monomorphization). */
export interface GenericFnInfo {
decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction;
/** Unqualified source name, for diagnostics. */
baseName: string;
/** Program-wide qualified name; instance `n` is named `<qualified>%<n>`
* ('%' cannot appear in a TS identifier, so instance names can never
* collide with user functions). */
qualifiedName: string;
/** Declaration-order type parameter symbols. */
typeParams: ts.Symbol[];
/** Lazily computed (keyofConstrainedTypeParams): the type parameters
* declared `K extends keyof …`. Their bound LITERAL keys are semantic —
* the body's `o[k]` reads the named field — so instances key on the
* literal (no cross-literal sharing) and keep the checker types
* (tsBindings) the body resolves through. */
keyofTps?: Set<ts.Symbol>;
/** Instantiation key (comma-joined typeKeys of the mapped param types +
* `=>` + return typeKey) → instance. Key identity IS signature identity:
* two call sites whose inferred types map to the same IR types share one
* native function. */
instances: Map<string, GenericInstance>;
/** CLASS-member generic methods: the declaring ClassInfo and flavor.
* Instance methods take `this` (object:<declarer>) as param 0 and lower
* under the declarer's instantiation bindings (generic-class receivers)
* MERGED with the method instantiation's own; statics lower as plain
* module functions with the static-method this/super fence. Absent for
* top-level functions and object-literal methods. */
member?: { cls: ClassInfo; kind: "method" | "static" };
/** Object-literal generic methods (`{ m<T>(x: T) {...} }` and generic
* arrow/function-expression properties): lowered as plain module
* functions — `this` inside is fenced (rejectThisInObjectMethod) and the
* defining literal must sit at module scope (no enclosing frame to
* capture). */
objectLiteral?: true;
/** IMPLICIT-ANY monomorphization (npm-static JS): parallel to
* decl.parameters — the param's own symbol when the slot is a BINDABLE
* implicit-any parameter (untyped, identifier-named, never written in
* the body), null for typed or unbindable slots. Present ⇔ this info
* monomorphizes over its implicit-any params instead of declared type
* parameters (typeParams stays empty): each call site's WIDENED argument
* checker types key an instantiation, exactly the generic machinery —
* the untyped params ARE the type parameters (see implicitCallInstance). */
implicitParams?: (ts.Symbol | null)[];
}
export interface GenericInstance {
name: string;
/** 0 for the first instance of a base function — the only one whose
* statements count toward coverage stats (re-instantiations re-visit the
* same source lines). */
ordinal: number;
params: ParamShape[];
returnType: IrType;
/** Type-parameter symbol → concrete IR type, consulted by mapType (via
* typeParamResolver) while the instance body lowers. */
bindings: Map<ts.Symbol, IrType>;
/** Call-keyed instances: type-parameter symbol → the bound CHECKER type
* (pre-widening), consulted while the body lowers where the IrType
* binding has already lost what the body needs — `T[K]` and `o[k]` reads
* whose K is bound to one literal key (typeParamTsBindings). */
tsBindings?: Map<ts.Symbol, ts.Type>;
/** Rendered type arguments ("<number, string>") for diagnostics. */
typeArgsText: string;
/** Implicit instances only: param symbol → the call site's (widened)
* checker type, consulted by the Lowerer's typeOf while this instance's
* body lowers (the implicit twin of `bindings`). */
implicitArgTypes?: Map<ts.Symbol, ts.Type>;
/** Implicit instances only: eager-lowering lifecycle. "lowering" while
* the body builds (a re-demand is same-key recursion: the caller uses
* the PINNED fallback returnType and returnPinned locks it); "done" once
* returnType holds the inferred (or pinned) truth. */
implicitState?: "lowering" | "done";
/** Same-key recursion observed the fallback return type mid-lowering, so
* the ABI is locked to it — the return post-pass coerces every return
* value to the pinned type instead of adopting the inferred one. */
returnPinned?: boolean;
/** Implicit instances only: the declared return did not map (the
* any-params poisoned it) — the body lowers in return-INFERENCE mode
* (returnType holds the DYN recursion pin until the post-pass settles). */
implicitInferReturn?: true;
}
/** One parameter's ParamShape — the shared signature-shaped collection
* point for function declarations, methods, constructors, and lambdas
* (generic declarations defer to their call sites, where the resolved
* types exist; see lowerGenericCall).
*
* - `x?: T`: the checker already types the param `T | undefined` under
* strictNullChecks, so the ABI type IS that union and the body narrows
* with `!== undefined` like any union local.
* - `x: T = e`: the ABI type is a synthesized `T | undefined` union (the
* caller may omit the arg or pass undefined — both trigger the default,
* JS-exact); the body sees plain T through the two-local prologue
* (declareParams). A single-arm T narrows in the prologue; a UNION T
* re-tags through the interned retag helper (the undefined arm is the
* one stranded case, unreachable from the else-branch by construction).
* - `...xs: T[]`: the ABI type is the array; call sites pack the surplus.
*/
export function paramShape(L: Lowerer, param: ts.ParameterDeclaration): ParamShape {
// Island-handle params (a then-handler receiving a dynamic import's
// namespace handle — markJsvalHandlerParams): jsval, whatever the
// contextual type spelled.
if (ts.isIdentifier(param.name) && L.jsvalParamOverrides.has(param)) {
return { type: JSVAL, mode: param.questionToken ? "omittable" : "required" };
}
if (!ts.isIdentifier(param.name)) {
// A destructuring pattern parameter — `([label, value]) => ...`,
// `({ x }) => ...`. The ABI slot carries the SOURCE value (the
// tuple/array/record itself); the callee prologue desugars the reads
// through the declaration-destructuring machinery (declareParams →
// lowerBindingPattern), so the fences inside patterns (computed
// keys, class-instance sources, union sources) are the declaration
// fences verbatim. A rest parameter bound to a pattern would need
// the packing machinery on top — fenced.
if (param.questionToken) {
L.unsupported("SC1031", param, "optional destructuring pattern parameters");
}
if (param.dotDotDotToken) {
// A REST parameter bound to a pattern (`(...[[k1, v1]]: [string,
// number][])`): the ABI packs the surplus arguments into one
// array exactly like an identifier rest param; the prologue then
// destructures the packed array through the declaration
// machinery (declareParams → lowerBindingPattern).
const type = L.irTypeOf(param.name);
const tupleRest = type.kind === "record" && L.shapes.get(type.shapeId)?.tuple === true;
if (type.kind !== "array" && !tupleRest) L.badType(param.name, L.typeOf(param.name));
return { type, mode: "rest" };
}
if (param.initializer) {
// A WHOLE-PATTERN default (`({ x } = { x: 1 }) => ...`): the ABI
// slot arms the pattern's type with undefined, exactly the
// identifier-param default below; the callee prologue picks the
// default when the argument was omitted or undefined, then the
// pattern destructures the picked value (declareParams).
const raw = L.irTypeOf(param.name);
// A DYNAMIC-TIER pattern source (`function f({} = a)` with
// `a: any` — jsval for island values, dyn for the checked-dynamic
// dyn): the slot holds its tier's undefined DIRECTLY, so the ABI
// is the slot itself — no synthesized union; the prologue tests
// undefined at runtime (declareParams).
if (raw.kind === "dyn" || raw.kind === "jsval") {
return { type: raw, mode: "omittable", bodyType: raw };
}
const bodyType = L.stripUndefinedArm(raw);
L.checkDefaultParamBodyType(param, bodyType);
const abi = bodyType.kind === "union" ? L.withUndefinedArmOf(bodyType) : L.withUndefinedArm(bodyType);
if (!abi) {
L.badType(param.name, L.typeOf(param.name)); // defensive: unknown union id
}
return { type: abi, mode: "omittable", bodyType };
}
return { type: L.irTypeOf(param.name), mode: "required" };
}
if (param.dotDotDotToken) {
// A JS rest param with no static element type (`(...args)` — any[]):
// the VARIADIC dyn form. The lifted function takes one trailing dyn
// ARRAY param the dyn call thunk fills with the call's surplus
// arguments; the binding is that array (dynRest — funcType marks
// `rest`, and the value only ever calls through the boxed thunk).
if (isJsSourceFile(param.getSourceFile())) {
const restMapped = L.mapTypeOf(L.typeOf(param.name));
// `any[]` under --dynamic maps to an island-element array — that
// is inference residue, not element information; the binding is
// the ENGINE's own arguments array (an island handle) and the
// value crosses as a REST host function (the withPlugins
// `async (...args) =>` shape). Static builds keep the variadic
// dyn form for every unmappable JS rest.
if (restMapped?.kind === "array" && restMapped.elem.kind === "jsval" && L.dynamic) {
return { type: JSVAL, mode: "islandRest" };
}
if (restMapped?.kind !== "array") {
return { type: DYN, mode: "dynRest" };
}
}
const type = L.irTypeOf(param.name);
// Tuple-typed rest params don't map to an array; generic rest is the
// generic path's business. Anything non-array here is unmappable.
if (type.kind !== "array") L.badType(param.name, L.typeOf(param.name));
return { type, mode: "rest" };
}
if (param.initializer) {
const raw = L.irTypeOf(param.name);
// A DYNAMIC-TIER defaulted param (`function f(x = a)` with `a: any`
// — tsc types x any; jsval for island values, dyn for the checked-
// dynamic dyn): the slot holds its tier's undefined directly, so
// the ABI is the slot itself and the prologue's default test is the
// runtime undefined test (declareParams).
if (raw.kind === "dyn" || raw.kind === "jsval") {
return { type: raw, mode: "omittable", bodyType: raw };
}
// A default that may ITSELF be undefined (`x = process.env.FOO`):
// tsc keeps undefined in the body's type, so there is nothing to
// narrow — the ABI union IS the body type and the prologue passes a
// present argument through unchanged (declareParams's pass-through
// branch). The generic strip-and-narrow below would demand a
// `string`-typed default and fence on the union re-tag.
if (L.bareUndefinedArmedUnion(raw)) {
const initT = L.mapTypeOf(L.typeOf(param.initializer));
if (initT && (initT.kind === "undefinedT" || L.bareUndefinedArmedUnion(initT))) {
return { type: raw, mode: "omittable", bodyType: raw };
}
}
const bodyType = L.stripUndefinedArm(raw);
L.checkDefaultParamBodyType(param, bodyType);
// A UNION body type (`tlds: string | string[] = "localhost"`) arms
// the ABI with undefined ON TOP of the body's arms; the prologue
// re-tags a present argument back into the body union (undefined
// sorts last among arm typeKeys in practice, so the mapping is
// usually the identity prefix — the interned retag helper handles
// any order).
const abi = bodyType.kind === "union" ? L.withUndefinedArmOf(bodyType) : L.withUndefinedArm(bodyType);
if (!abi) {
L.badType(param.name, L.typeOf(param.name)); // defensive: unknown union id
}
return { type: abi, mode: "omittable", bodyType };
}
const type = L.irTypeOf(param.name);
if (param.questionToken && !L.bareUndefinedArmedUnion(type) && type.kind !== "dyn" && type.kind !== "jsval") {
// `x?: unknown` where unknown came from an annotation: undefined is
// absorbed into the hole type, so no undefined ARM exists — but a
// checked-dynamic slot holds the dyn undefined directly (`bar?: any`
// — an omitted call passes it, undefinedArgFor), and an island slot
// the engine's own undefined likewise (`options?: [string?]` — an
// optional-tuple param, jsval-mapped), so dyn and jsval params stay
// omittable.
L.unsupported("SC1090", param, `optional parameters of type '${L.fmt(type)}'`);
}
return { type, mode: param.questionToken ? "omittable" : "required" };
}
/** ParamShapes for a whole parameter list. */
/** A `this` PARAMETER declaration (`function f(this: void, x: {}) ...`)
* — type-world only: tsc types the receiver with it, callers never pass
* it, and signature.getParameters() excludes it. The syntactic walks
* (paramShapes, declareParams) skip it with this predicate so ABI slots
* and call completion stay aligned with what JS actually passes. */
export function isThisParameter(param: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(param.name) && param.name.text === "this";
}
export function paramShapes(L: Lowerer, params: readonly ts.ParameterDeclaration[]): ParamShape[] {
return params.filter((p) => !isThisParameter(p)).map((param) => L.paramShape(param));
}
/** The fences on a defaulted parameter's body type: it becomes the value
* arm of the synthesized `T | undefined` ABI union, so it must be a valid
* single arm. func and Set ARE valid here: the ABI union's only test is
* the prologue's own undefined-tag check (never a user narrowing, which
* is what keeps map/set out of general unions), so `runner: Runner =
* defaultRunner` and `skip: Set<string> = new Set()` arm like any ref
* kind — the nullable-callback union shape, built by the compiler. */
export function checkDefaultParamBodyType(L: Lowerer, param: ts.ParameterDeclaration, bodyType: IrType): void {
if (
bodyType.kind === "void" ||
bodyType.kind === "map" ||
bodyType.kind === "regex" ||
bodyType.kind === "dyn" ||
bodyType.kind === "jsval" ||
isUnitType(bodyType)
) {
L.unsupported(
"SC1090",
param,
`parameter default values on '${L.fmt(bodyType)}'-typed parameters`,
);
}
}
/** CALL-SITE COMPLETION — the frontend half of the one-signature contract
* (docs/ir.md): every call lowers to exactly the callee's full ABI
* parameter list, so backends and the validator stay count-exact and no
* runtime arity machinery exists. Omitted trailing args for omittable
* params become the interned undefined arm (which is also what an
* explicitly-passed `undefined` wraps to — both trigger a default, JS-
* exact); a rest param packs the surplus args (possibly zero) into one
* array literal, evaluated in source order at the call site. */
export function completeArgs(L: Lowerer, argNodes: readonly ts.Expression[],
shapes: readonly ParamShape[],
loc: SrcLoc,
blame: ts.Node,
/** Pre-lowered values virtually PREPENDED to the argument list — the
* tagged-template strings object, which has no ts.Expression to lower
* (lowerTaggedTemplate builds it). Each rides the same slot-directed
* coercion an ordinary argument gets (coerceInto against its shape,
* DYN conversion in a dyn rest, element coercion in a typed rest). */
leading?: readonly IrExpr[],): IrExpr[] {
type ArgSource = ts.Expression | { ir: IrExpr };
const isIr = (s: ArgSource | undefined): s is { ir: IrExpr } =>
s !== undefined && !("kind" in s);
const sources: readonly ArgSource[] =
leading && leading.length > 0 ? [...leading.map((ir) => ({ ir })), ...argNodes] : argNodes;
const restAt = shapes.findIndex((s) => s.mode === "rest" || s.mode === "dynRest" || s.mode === "islandRest");
const positional = restAt >= 0 ? shapes.slice(0, restAt) : [...shapes];
const out: IrExpr[] = positional.map((shape, i) => {
const src = sources[i];
if (isIr(src)) return L.coerceInto(blame, src.ir, shape.type);
const arg = src;
if (arg && ts.isSpreadElement(arg)) {
// A spread landing on FIXED parameter positions would need the
// array's length to decide arity at runtime — the compile-time
// completion has no home for that. Spreads fill REST slots only.
L.unsupported(
"SC1090",
arg,
"spread arguments into fixed parameter positions (a spread can only fill a rest parameter)",
);
}
if (arg) return L.lowerExprExpecting(arg, shape.type);
if (shape.mode !== "omittable") {
// A missing argument for a CHECKED-DYNAMIC param (an implicit-any
// JS signature called short — `mustCall(fn)` with `expected`
// omitted): JS fills undefined, and the dyn slot holds exactly
// that — the undefined dyn value. tsc's arity families don't gate
// .js builds (SEMANTICS.md 116), so the completion lands here.
if (shape.type.kind === "dyn") {
return { kind: "dynFrom", value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: DYN, loc };
}
// tsc's arity checking admits omitting only the omittable suffix;
// reaching here means a call form we don't model — defensive.
L.unsupported("SC1090", blame, "this call form");
}
return L.undefinedArgFor(shape.type, loc, blame);
});
if (restAt >= 0 && shapes[restAt]!.mode === "islandRest") {
// The ISLAND variadic pack: surplus arguments marshal into one
// fresh ENGINE array — exactly what the REST host-call adapter
// hands the closure for indirect calls.
const elems = sources.slice(restAt).map((a): IrExpr => {
if (isIr(a)) return L.coerceInto(blame, a.ir, JSVAL);
if (ts.isSpreadElement(a)) {
L.unsupported("SC1090", a, "spread arguments into an island rest parameter");
}
return L.lowerExprExpecting(a, JSVAL);
});
out.push({ kind: "jsOp", op: "arrLit", args: elems, type: JSVAL, loc });
} else if (restAt >= 0 && shapes[restAt]!.mode === "dynRest") {
// The VARIADIC dyn pack (a JS `...args` with no static element
// type, or the synthetic `arguments` slot): surplus arguments
// convert through the dyn boundary into one fresh dyn array —
// exactly what the boxed call thunk builds for indirect calls.
const elems = sources.slice(restAt).map((a): IrExpr => {
if (isIr(a)) return L.coerceInto(blame, a.ir, DYN);
if (ts.isSpreadElement(a)) {
L.unsupported("SC1090", a, "spread arguments into a dynamic rest parameter");
}
return L.lowerExprExpecting(a, DYN);
});
out.push({ kind: "dynArrLit", elems, type: DYN, loc });
} else if (restAt >= 0) {
const restType = shapes[restAt]!.type;
// A TUPLE-typed rest (`(...[x, y]: [number, number])` — the pattern
// rest form): tsc pins the call to exactly the tuple's arity, so
// the pack is a positional record literal. Spreads stay fenced —
// their length is a runtime fact the fixed shape cannot take.
if (restType.kind === "record") {
const tupleShape = L.shapes.get(restType.shapeId);
if (tupleShape?.tuple) {
const rest = sources.slice(restAt);
if (rest.some((a) => !isIr(a) && ts.isSpreadElement(a)) || rest.length !== tupleShape.fields.length) {
L.unsupported("SC1090", blame, "spread or arity-mismatched arguments into a tuple-typed rest parameter");
}
out.push({
kind: "recordLit",
fields: rest.map((a, i) => {
const f = tupleShape.fields.find((x) => x.name === String(i))!;
return { name: f.name, value: isIr(a) ? L.coerceInto(blame, a.ir, f.type) : L.lowerExprExpecting(a, f.type) };
}),
type: restType,
loc,
});
return out;
}
}
if (restType.kind !== "array") L.unsupported("SC1090", blame, "this call form");
// The rest pack is a fresh array per call; surplus SPREADS copy
// their elements in (JS-exact — `f(a, ...xs, b, ...ys)` packs in
// order, sources untouched).
const spreads: number[] = [];
const elems = sources.slice(restAt).map((a, i) => {
if (isIr(a)) return L.coerceInto(blame, a.ir, restType.elem);
if (ts.isSpreadElement(a)) {
let src = L.lowerExpr(a.expression);
// A same-element Set spread drains first (setIntrinsic toArray).
if (src.type.kind === "set" && typeEquals(src.type.elem, restType.elem)) {
src = { kind: "setIntrinsic", method: "toArray", receiver: src, args: [], type: arrayOf(src.type.elem), loc: locOf(a) };
}
// A CLASS ITERABLE spread (`foo(...new SymbolIterator)`) drains
// through its protocol into a fresh array (classIteratorDrainCall).
if (src.type.kind === "object") {
const drained = L.classIteratorDrainCall(src, locOf(a), restType.elem);
if (drained) src = drained;
}
// Same-family arrays whose element lifts reshape through the
// interned width helper (the array-literal spread rule).
if (src.type.kind === "array" && !typeEquals(src.type, restType)) {
const w = L.widthCoerce(src, restType);
if (w) src = w;
}
if (!typeEquals(src.type, restType)) {
L.unsupported(
"SC1090",
a,
`spreading '${L.fmt(src.type)}' into a '${L.fmt(restType)}' rest parameter (only a same-element-type array spreads)`,
);
}
spreads.push(i);
return src;
}
return L.lowerExprExpecting(a, restType.elem);
});
out.push({ kind: "arrayLit", elems, ...(spreads.length > 0 ? { spreads } : {}), type: restType, loc });
} else {
// Surplus args without a rest param: JS evaluates them in order and
// DROPS them (tsc's arity families don't gate .js builds —
// SEMANTICS.md 116, so `f(a, b, c, d)` against `function f(a, b, c)`
// reaches here). The completed call has no slot for them — pushing
// them through would break the one-signature contract (the validator
// catches exactly that). Effect-free lowerings (literals, plain
// reads, closures — the recordLit drop-field list) drop at compile
// time, JS-exact; an EFFECTFUL surplus (a call, an await, an
// assignment) has no evaluation slot in an expression-position
// completion, so it fences by name rather than silently not running.
for (let i = positional.length; i < sources.length; i++) {
const a = sources[i]!;
if (isIr(a)) continue; // pre-lowered leading values are effect-free
if (ts.isSpreadElement(a)) {
L.unsupported(
"SC1090",
a,
"spread arguments into fixed parameter positions (a spread can only fill a rest parameter)",
);
}
const v = L.lowerExpr(a);
if (
v.kind !== "unitLit" && v.kind !== "numLit" && v.kind !== "strLit" &&
v.kind !== "boolLit" && v.kind !== "varRef" && v.kind !== "closure"
) {
L.unsupported(
"SC1090",
a,
"surplus arguments with side effects (JS evaluates surplus arguments to a function without a rest parameter, then drops them; only effect-free surplus arguments compile)",
);
}
}
}
return out;
}
/** The undefined arm of an undefined-armed union `type`, wrapped (a
* unitLit under a unionWrap) — the value every "absent" slot holds: an
* omitted optional argument, an omitted optional record field. Null when
* `type` has no undefined arm to wrap into. */
export function wrappedUndefined(L: Lowerer, type: IrType, loc: SrcLoc): IrExpr | null {
const unit: IrExpr = { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc };
const wrapped = L.coerceToExpected(unit, type);
return wrapped.kind === "unionWrap" ? wrapped : null;
}
/** The synthesized argument for an omitted omittable param: the interned
* undefined arm of the param's `T | undefined` ABI union, or the checked-dynamic tree
* undefined for a checked-dynamic param (`bar?: any`). */
/** The "absent argument" value for a param SLOT type, or null when the
* slot cannot hold one: the interned undefined arm for undefined-armed
* unions, the dyn undefined for checked-dynamic slots, the engine's own
* undefined for island slots. Shared by every call-completion loop
* (direct calls and calls through func-typed values). */
export function omittedArgFor(L: Lowerer, type: IrType, loc: SrcLoc): IrExpr | null {
if (type.kind === "dyn") return dynUndefinedExpr(loc);
if (type.kind === "jsval") return { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc };
return L.wrappedUndefined(type, loc);
}
export function undefinedArgFor(L: Lowerer, type: IrType, loc: SrcLoc, blame: ts.Node): IrExpr {
if (type.kind === "dyn") return dynUndefinedExpr(loc);
// An omitted argument for an ISLAND-typed omittable param (`f()` where
// f's `x = a` default is jsval-shaped): the engine's own undefined.
if (type.kind === "jsval") return { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc };
const wrapped = L.wrappedUndefined(type, loc);
if (!wrapped) {
// Omittable params always carry an undefined-armed union (paramShape
// guarantees it) — defensive.
L.unsupported("SC1090", blame, "this call form");
}
return wrapped;
}
/** DECISION (docs/ir.md): function VALUES keep exact-arity semantics — a
* func-typed IrType spells one completed signature, so a function whose
* declaration has optional/default/rest parameters can become a value only
* where the target type spells that exact signature with required
* parameters (`x?: T` / `x: T = e` params appear as literal `T | undefined`
* unions; a rest signature is never spellable without `...`, which func
* types reject). Direct calls get the full feature. */
export function requireExactArityValue(L: Lowerer, blame: ts.Node,
contextual: ts.Expression | null,
shapes: readonly ParamShape[],
funcType: IrType,): void {
// dynRest params ride the boxed thunk (JS arity — no completed-ABI
// spelling exists or is needed); they don't gate the value form.
// Dynamic-tier omittable params (`{} = a` with `a: any` — jsval/dyn
// slots) don't either: their ABI slot IS the declared param type (no
// synthesized union), so every func-type spelling of the signature
// already matches and short calls through the value complete with the
// tier's undefined (omittedArgFor).
if (
shapes.every(
(s) =>
s.mode === "required" ||
s.mode === "dynRest" ||
s.mode === "islandRest" ||
(s.mode === "omittable" && (s.type.kind === "dyn" || s.type.kind === "jsval")),
)
) {
return;
}
if (shapes.some((s) => s.mode === "rest")) {
L.unsupported("SC1090", blame, "functions with rest parameters as values (call them directly)");
}
// The type the value FLOWS under must spell the completed ABI: the
// contextual (target) type when one exists, otherwise the expression's
// OWN inferred type — the unannotated-const case (`const f = (x = 5) =>
// ...`), where every later read types the value by that inference and
// optional/defaulted params spell their `T | undefined` slots (mapType's
// completed-signature contract), so omitted trailing args complete with
// the undefined arm like any direct call.
const target = contextual ? L.checker.getContextualType(contextual) : undefined;
const mapped = target
? L.mapTypeOf(target)
: contextual
? L.mapTypeOf(L.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// A union-typed slot (`runner || defaultRunner` under a
// `CommandRunner | undefined` context): the value can only inhabit
// the union's one func arm — judge by it.
let mappedFn: IrType | null =
mapped?.kind === "union"
? (() => {
const arms = L.unions.get(mapped.unionId)?.arms.filter((a) => a.kind === "func") ?? [];
return arms.length === 1 ? arms[0]! : null;
})()
: mapped;
// A contextual type that maps to something non-functional (`picked ||
// defaultRunner` — tsc's contextual answer for the rhs is not the
// slot): judge by the expression's OWN completed type; the slot's
// coercion still enforces (or adapts) the flow it lands in.
if (mappedFn?.kind !== "func" && contextual) {
mappedFn = L.mapTypeOf(L.typeOf(contextual));
}
if (mappedFn && typeEquals(mappedFn, funcType)) return;
// A target signature that agrees on the completed parameters and
// differs only by RETURNING the structural spawnSync-result record
// (the CommandRunner shape): the slot coercion bridges with the
// interned runner-value adapter, so the value passes here.
if (
mappedFn?.kind === "func" &&
funcType.kind === "func" &&
L.spawnResFnAdapterPlan(funcType, mappedFn) !== null
) {
return;
}
// An 'any'-typed slot is the ISLAND boundary: the host-function
// trampoline already implements JS call semantics over the completed
// signature — a missing engine argument arrives as undefined and takes
// the omittable param's undefined arm (which is what triggers the
// default), surplus arguments drop. So a function with optional/
// defaulted params may flow into a package API whenever the completed
// signature can cross at all (jsvalIn re-checks and speaks otherwise) —
// commander's `.option(flags, desc, collector, [])` pattern.
if (mapped?.kind === "jsval" &&
canMarshalTypedFuncIntoIsland(funcType, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
return;
}
L.unsupported(
"SC1090",
blame,
"functions with optional or defaulted parameters as values, except where the " +
"target type spells the completed signature with required parameters " +
"(a '(x?: T) => R' function flows into a '(x: T | undefined) => R' slot, " +
"and a package/'any' slot takes any signature that can cross the island " +
"boundary; otherwise call the function directly)",
);
}
/** The BODY-facing return type of a (possibly async) function: an async
* body's `return v` fulfills its promise with v, so the body returns the
* promise's INNER type while call sites keep Promise<T>. The declared
* type of an async function is always a promise (collectSignature /
* lowerLambda reject anything else before calling this). */
export function bodyReturnType(L: Lowerer, isAsync: boolean, declared: IrType): IrType {
return isAsync && declared.kind === "promise" ? declared.inner : declared;
}
/** A union-returning body may complete WITHOUT returning — JS yields
* undefined then (`(): string | undefined => { if (c) return "x"; }`), so
* an undefined-armed union return gets a trailing `return <undefined
* arm>` appended unless the body's last statement already returns or
* throws (deeper always-returning control flow keeps the appended return
* as dead code — harmless).
*
* Every OTHER non-void body gets a trailing UNREACHABLE trap instead:
* tsc's reachability can prove completions the validator's conservative
* alwaysReturns cannot (an exhaustive `switch (typeof x)` with a return
* in every case — signature 16), and those bodies end without a terminal
* statement of their own. The trap satisfies the must-return rule as the
* dead code it is; it can only fire if the checker's proof was violated,
* which would be a lowering bug — hence the please-report wording. */
export function appendImplicitUndefinedReturn(L: Lowerer, body: IrStmt[],
bodyReturn: IrType, loc: SrcLoc,): void {
if (bodyReturn.kind === "void") return;
const last = body[body.length - 1];
if (last && (last.kind === "return" || last.kind === "throw" || last.kind === "rethrow" || last.kind === "runtimeFence")) {
return;
}
// A DYN body that can complete without returning (a JS function whose
// guarded return may not run — mustSucceed's `if (typeof fn ===
// 'function') return fn.apply(...)`): JS completes with undefined —
// the undefined dyn value.
if (bodyReturn.kind === "dyn") {
body.push({
kind: "return",
value: { kind: "dynFrom", value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: DYN, loc },
loc,
});
return;
}
if (bodyReturn.kind === "union") {
const value = L.wrappedUndefined(bodyReturn, loc);
if (value) {
body.push({ kind: "return", value, loc });
return;
}
// no undefined arm: the trap below stands in, exactly like non-unions
}
body.push({
kind: "runtimeFence",
code: "SC9002",
message:
"unreachable: a non-void function completed without returning " +
"(the checker proved every path returns) — please report this",
loc,
});
}
/** A declaration's checker-derived IR return type. The unmappable-type
* diagnostic points at `blame` (the name for top-level declarations, the
* whole node for lambda-likes — preserving each caller's historical loc). */
export function declaredReturnType(L: Lowerer, decl: ts.SignatureDeclaration, blame: ts.Node): IrType {
const sig = L.checker.getSignatureFromDeclaration(decl);
if (!sig) L.unsupported("SC1090", decl, "this function form");
const retTsType = L.checker.getReturnTypeOfSignature(sig);
// A body that always throws infers `never` — as a RETURN type that is
// void with a stronger guarantee (`() => never` is assignable to
// `() => void`), and throw-only callbacks are ordinary code
// (`.action(() => { throw ... })`). `never` VALUES stay unmapped.
if (retTsType.flags & ts.TypeFlags.Never) return VOID;
// A JS function whose UNANNOTATED return infers a FUNCTION type
// (test/common's mustCall — tsc infers `() => any` from the wrapper
// it returns): the inferred arity is the wrapper's spelling, not a
// contract — JS callers call the result however they like, and a
// static func slot would force an arity-narrowing adapter that DROPS
// arguments. Function-valued results stay checked-dynamic (dyn): the
// value rides its own box, calls go through the boxed thunk (JS
// arity), and typed slots re-check with dynCheck as usual.
if (
isJsSourceFile(decl.getSourceFile()) &&
decl.type === undefined &&
L.mapTypeOf(retTsType)?.kind === "func"
) {
return DYN;
}
// The RECORD twin (the tracing suite's traced closures — `function ()
// { return expectedResult; }` infers `{ foo: string }`): JS object
// literals are checked-dynamic VALUES, so a record-typed return would
// copy the dyn value into a struct at the return and copy it back out
// at any dyn boundary — identity lost twice (found.result !==
// expectedResult where Node passes the object through). The inferred
// shape is inference, not a contract: the return stays checked-
// dynamic, and typed consumers re-check with dynCheck as usual.
// GATED to the untyped-wrapper shape — every parameter itself
// checked-dynamic (or none): a lambda with RECORD-typed parameters
// (a reduce reducer over a typed array) legitimately returns its
// parameters' records and keeps the static type.
if (
isJsSourceFile(decl.getSourceFile()) &&
decl.type === undefined &&
L.mapTypeOf(retTsType)?.kind === "record" &&
decl.parameters.every((p) => {
const mt = L.mapTypeOf(L.typeOf(p));
return mt === null || mt.kind === "dyn";
})
) {
return DYN;
}
const returnType = L.mapTypeOf(retTsType);
if (!returnType) {
// JS inference residue (an `any` return, an unmappable union): the
// checked-dynamic fallback, exactly the declaration story in
// irTypeOf — callers' typed slots re-check with dynCheck.
const js = dynFallbackType(L, decl, retTsType);
if (js) return js;
fenceGenericSignatureResult(L, blame, retTsType);
L.badType(blame, retTsType);
}
return returnType;
}
/** A RESULT position whose type is itself a generic signature (`const
* satisfies = <T>() => <N extends T>(n: N) => n` — the call's result
* keeps type parameters): the returned value is a fresh generic value
* per call, the pinned/unpinned rule applies at the result, and nothing
* here can pin it — the value would also need the producing call's
* frame, which module-function instances cannot capture. Named fence
* instead of the generic supported-types recitation; a no-op for every
* other unmappable type (the caller's badType reports those). */
function fenceGenericSignatureResult(L: Lowerer, blame: ts.Node, t: ts.Type): void {
const parts = t.isUnionType() ? t.getTypes() : [t];
if (!parts.some((p) => L.checker.getCallSignatures(p).some((s) => (s.typeParameters?.length ?? 0) > 0))) {
return;
}
L.unsupported(
"SC1090",
blame,
`results that are themselves generic functions ('${L.checker.typeToString(t)}' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments)`,
);
}
export function collectSignature(L: Lowerer, decl: ts.FunctionDeclaration): void {
L.collectDeferring(
() => declSymbolOf(L, decl),
() => L.collectSignatureInner(decl),
);
}
export function collectSignatureInner(L: Lowerer, decl: ts.FunctionDeclaration): void {
// The one legal nameless declaration form is `export default function
// () {}` — its symbol is the module's default export (declSymbolOf)
// and it registers under the synthetic "%default" spelling.
if (!decl.name && declSymbolOf(L, decl) === undefined) {
L.unsupported("SC1090", decl, "anonymous function declarations");
}
// A body-less declaration is type-world and lowers to NOTHING: an
// OVERLOAD SIGNATURE when an implementation shares the symbol (the
// implementation's own collection registers the one real ABI — tsc
// resolved every call site against the signatures, and the
// implementation's parameter types are supersets by the
// overload-compatibility rules, so calls flow through that ABI), or an
// AMBIENT `declare function` nothing defines (references compile to
// Node's ReferenceError at the use site — the `declare const` /
// ambient-namespace undefRead stance, ambientUndefinedFnSymbolOf).
if (!decl.body) return;
// A MIXIN function (`function M(Base: T) { return class extends Base
// {…} }`) has no callable signature of its own — its return type is a
// per-call class, so calls instantiate per site (lower-mixins.ts) and
// nothing ever dispatches through an ABI. Recognized here so the
// declaration neither registers a broken signature nor lowers as a
// body (run()/discover() skip by the same test). Generic mixins still
// register their generic signature below: non-mixin-shaped calls
// degrade to the generic machinery's own per-site fences.
if (!decl.typeParameters && mixinFnShapeOf(L, decl)) return;
const isAsync = decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
const isGenerator = decl.asteriskToken !== undefined;
if (isGenerator && isAsync) {
L.unsupported("SC1071", decl, "async generators (async function*)");
}
if (decl.typeParameters) {
// Generic async composes: each monomorphized instance is an async
// IrFunction like any other — its own spawn wrapper, its body
// returning the resolved promise's inner (lowerGenericInstance).
L.collectGenericSignature(decl);
return;
}
// IMPLICIT-ANY monomorphization (npm-static JS): a function whose
// signature carries bindable untyped params registers like a generic
// declaration — no ABI of its own, one instance per call-site type
// tuple (see the implicit-monomorphization section). Everything that
// routes generic declarations (direct calls, namespace/CJS member
// calls, value references) resolves it through genericFnsBySymbol
// unchanged.
if (implicitMonoFile(decl.getSourceFile()) && !isAsync && !isGenerator) {
const implicit = implicitAnyParamSymbolsOf(L, decl);
if (implicit) {
const nameText = decl.name?.text ?? "%default";
const symbol = declSymbolOf(L, decl);
if (!symbol) L.unsupported("SC1090", decl, "this function form");
L.genericFnsBySymbol.set(symbol, {
decl,
baseName: nameText,
qualifiedName: L.qualify(decl.getSourceFile(), nsPathPrefix(decl) + nameText),
typeParams: [],
instances: new Map(),
implicitParams: implicit,
});
return;
}
}
const params = L.paramShapes(decl.parameters);
// The VARIADIC `arguments` form on a DECLARED function: same rule as
// lambdas (lambdaSignature) — zero declared params, the body reads
// `arguments`, a synthetic trailing dynRest shape carries the call's
// arguments (completeArgs packs direct calls; the boxed thunk packs
// indirect ones; lowerFunction declares the `arguments` local).
if (
!params.some((sh) => sh.mode === "dynRest") &&
isJsSourceFile(decl.getSourceFile()) &&
bodyReadsArguments(decl)
) {
if (decl.parameters.length > 0) {
L.unsupported(
"SC1090",
decl,
"'arguments' in functions with declared parameters (use a rest parameter: (...args))",
);
}
params.push({ type: DYN, mode: "dynRest" });
}
const nameBlame: ts.Node = decl.name ?? decl;
const returnType = L.declaredReturnType(decl, nameBlame);
if (isAsync && returnType.kind !== "promise") {
L.badType(nameBlame, L.typeOf(nameBlame));
}
if (isGenerator && returnType.kind !== "generator") {
L.badType(nameBlame, L.typeOf(nameBlame));
}
const symbol = declSymbolOf(L, decl);
if (!symbol) L.unsupported("SC1090", decl, "this function form");
L.fnSigsBySymbol.set(symbol, {
// Namespace-nested functions carry the namespace path (nsPathPrefix)
// so `namespace A { export function f }` and a top-level `f` never
// collide. The anonymous default export takes the synthetic
// "%default" spelling ('%' cannot appear in a user identifier).
name: L.qualify(decl.getSourceFile(), nsPathPrefix(decl) + (decl.name?.text ?? "%default")),
params,
returnType,
isAsync,
...(isGenerator && returnType.kind === "generator"
? { generator: { yieldT: returnType.yieldT, nextT: returnType.nextT } }
: {}),
});
}
/** Registers a top-level generic function. Only the SYNTAX is checked
* here — parameter/return types mention the type parameters and cannot
* map yet; the body is lowered per instantiation, on demand (an unused
* generic function costs nothing, like a C++ template). Called inside
* collectSignature's poison catch. */
export function collectGenericSignature(L: Lowerer, decl: ts.FunctionDeclaration): void {
const typeParams: ts.Symbol[] = [];
for (const tp of decl.typeParameters!) {
// Defaults (`<T = number>`) are supported: call sites receive
// default-substituted types from getResolvedSignature already, and
// inferTypeParamBindings binds any still-unbound parameter from its
// mapped defaultType.
const sym = L.checker.getSymbolAtLocation(tp.name);
if (!sym) L.unsupported("SC1090", decl, "this function form");
typeParams.push(sym);
}
// Only NAME syntax is checkable here; optional/default/rest shapes are
// computed per call site from the resolved signature (lowerGenericCall).
// Binding-PATTERN parameters (`retry<T>(fn, { retries = 3 } = {})`)
// pass: declareParams desugars patterns per instance exactly as it
// does for non-generic functions.
for (const param of decl.parameters) {
if (!ts.isIdentifier(param.name) && !ts.isObjectBindingPattern(param.name) && !ts.isArrayBindingPattern(param.name)) {
L.unsupported("SC1031", param);
}
}
const nameText = decl.name?.text ?? "%default"; // nameless = the default export (checked by collectSignatureInner)
const symbol = declSymbolOf(L, decl);
if (!symbol) L.unsupported("SC1090", decl, "this function form");
L.genericFnsBySymbol.set(symbol, {
decl,
baseName: nameText,
qualifiedName: L.qualify(decl.getSourceFile(), nsPathPrefix(decl) + nameText),
typeParams,
instances: new Map(),
});
}
export function genericFnOf(L: Lowerer, ident: ts.Identifier): GenericFnInfo | null {
const symbol = L.resolveValueSymbol(ident);
return symbol ? (L.genericFnsBySymbol.get(symbol) ?? null) : null;
}
/** Call of a generic top-level function. The checker already inferred (or
* was told, via explicit type arguments) the concrete signature —
* getResolvedSignature returns it with type arguments substituted. The
* mapped param+return IR types form the INSTANTIATION KEY; the first call
* with a new key queues the body for monomorphic lowering as
* `<qualifiedName>%<n>`, and every call lowers to a direct `call` of that
* instance. */
export function lowerGenericCall(L: Lowerer, expr: ts.CallExpression, info: GenericFnInfo): IrExpr {
const loc = locOf(expr);
const instance = info.implicitParams
? implicitCallInstance(L, expr, info)
: genericCallInstance(L, expr, info);
const args = L.completeArgs(expr.arguments, instance.params, loc, expr);
return { kind: "call", callee: instance.name, args, type: instance.returnType, loc };
}
/** The instance a CALL of a generic function-like names: resolved
* signature → mapped param shapes/return → interned instance. Shared by
* top-level generic calls, class generic-method calls (the caller
* prepends the receiver), and object-literal generic-method calls. */
export function genericCallInstance(L: Lowerer, expr: ts.CallExpression, info: GenericFnInfo): GenericInstance {
const rsig = L.checker.getResolvedSignature(expr);
// A GENERIC function with overload signatures: the call resolved to a
// signature that is not the implementation's, so the per-instantiation
// body lowering would type the body against parameter/return types it
// was never checked under. Named fence until generic overloads get an
// honest story (monomorphize per implementation signature with the
// reconcile bridge, like the non-generic path).
{
const rdecl = rsig ? L.checker.signatureDeclaration(rsig) : undefined;
if (rdecl && (ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) && !rdecl.body) {
L.unsupported("SC1090", expr, `calls selecting an overload signature of a generic ${ts.isMethodDeclaration(rdecl) ? "method" : "function"} (only the implementation signature monomorphizes)`);
}
}
if (!rsig || rsig.getParameters().length !== info.decl.parameters.length) {
L.unsupported("SC1090", expr, "this call form");
}
// Per-param shapes from the RESOLVED signature (types substituted) plus
// the declaration's modes: rest stays the resolved array, a default's
// ABI union is synthesized over the resolved body type — exactly the
// paramShape rules, applied to post-substitution types.
const params: ParamShape[] = [];
rsig.getParameters().forEach((p, i) => {
const declParam = info.decl.parameters[i]!;
const pt = L.checker.getTypeOfSymbol(p);
const mapped = L.mapTypeOf(pt);
if (!mapped || mapped.kind === "void") L.badType(expr.arguments[i] ?? expr, pt);
if (declParam.dotDotDotToken) {
if (mapped.kind !== "array") L.badType(expr.arguments[i] ?? expr, pt);
params.push({ type: mapped, mode: "rest" });
} else if (declParam.initializer) {
if (mapped.kind === "dyn" || mapped.kind === "jsval") {
// Dynamic-tier default: the slot holds its tier's undefined
// directly (paramShape's rule — declareParams tests at runtime).
params.push({ type: mapped, mode: "omittable", bodyType: mapped });
} else {
const bodyType = L.stripUndefinedArm(mapped);
L.checkDefaultParamBodyType(declParam, bodyType);
params.push({ type: L.withUndefinedArm(bodyType), mode: "omittable", bodyType });
}
} else {
if (declParam.questionToken && !L.bareUndefinedArmedUnion(mapped)) {
L.unsupported("SC1090", declParam, `optional parameters of type '${L.fmt(mapped)}'`);
}
params.push({ type: mapped, mode: declParam.questionToken ? "omittable" : "required" });
}
});
const retTs = L.checker.getReturnTypeOfSignature(rsig);
const returnType = L.mapTypeOf(retTs);
if (!returnType) {
fenceGenericSignatureResult(L, expr, retTs);
L.badType(expr, retTs);
}
// keyof-constrained type parameters (`K extends keyof T`): the bound
// LITERAL is semantic — the instance body reads the named field — so
// the bindings compute EAGERLY (the key needs them) and each literal
// keys its own instance (`pick(o, "a")` and `pick(o, "b")` map to the
// same IR signature when the fields agree, but their bodies read
// different fields). Non-literal bindings (a key union, plain string)
// share one runtime-keyed instance per IR signature, exactly the
// widened discipline.
const keyofTps = keyofConstrainedTypeParams(info);
if (keyofTps.size > 0) {
const tsBindings = new Map<ts.Symbol, ts.Type>();
const bindings = L.inferTypeParamBindings(expr, info, rsig, tsBindings);
const litKey = info.typeParams
.filter((tp) => keyofTps.has(tp))
.map((tp) => {
const bound = tsBindings.get(tp);
return bound?.isStringLiteralType() ? JSON.stringify(bound.value)
: bound?.isNumberLiteralType() ? String(bound.value)
: "*";
})
.join(",");
return internGenericInstance(L, expr, info, params, returnType, () => bindings, {
extraKey: `@${litKey}`,
tsBindings,
});
}
return internGenericInstance(L, expr, info, params, returnType, (tsBindings) =>
L.inferTypeParamBindings(expr, info, rsig, tsBindings),
);
}
/** The one instance table both instantiation routes share: key identity IS
* signature identity, so a call (`identity(1)`) and a pinned VALUE
* (`const f: (x: number) => number = identity`) reuse one compiled
* instance. `makeBindings` runs only for a NEW key (binding inference
* costs checker walks). */
export function internGenericInstance(L: Lowerer, blame: ts.Node,
info: GenericFnInfo,
params: ParamShape[],
returnType: IrType,
makeBindings: (tsBindings: Map<ts.Symbol, ts.Type>) => Map<ts.Symbol, IrType>,
opts?: { extraKey?: string; tsBindings?: Map<ts.Symbol, ts.Type> },): GenericInstance {
// keyof-constrained instantiations append their literal keys
// (extraKey): the IR signature alone under-discriminates there — two
// literals can map to one IR signature while their bodies read
// different fields.
const key = `${params.map((s) => typeKey(s.type)).join(",")}=>${typeKey(returnType)}${opts?.extraKey ?? ""}`;
let inst = info.instances.get(key);
if (!inst) {
if (info.instances.size >= MAX_GENERIC_INSTANCES) {
L.unsupported(
"SC1090",
blame,
`unbounded generic instantiation ('${info.baseName}' exceeded ` +
`${MAX_GENERIC_INSTANCES} instances — polymorphic recursion?)`,
);
}
const tsBindings = opts?.tsBindings ?? new Map<ts.Symbol, ts.Type>();
const bindings = makeBindings(tsBindings);
const rendered = info.typeParams
.map((tp) => {
// A literal-bound keyof parameter renders its literal — the
// instance is per-literal, and '<…, string>' would misname it.
const tsBound = tsBindings.get(tp);
if (info.keyofTps?.has(tp) && tsBound?.isStringLiteralType()) {
return JSON.stringify(tsBound.value);
}
const bound = bindings.get(tp);
return bound ? L.fmt(bound) : tp.name;
})
.join(", ");
// Deep polymorphic recursion renders unbounded types — keep messages sane.
const typeArgsText = `<${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered}>`;
inst = {
name: `${info.qualifiedName}%${info.instances.size}`,
ordinal: info.instances.size,
params,
returnType,
bindings,
tsBindings,
typeArgsText,
};
info.instances.set(key, inst);
L.instantiationQueue.push({ info, inst });
}
return inst;
}
/** The type parameters of `info` declared with a `keyof` CONSTRAINT
* (`K extends keyof T`) — the parameters whose bound literal is semantic
* (the body's `o[k]` reads the named field), computed once per info from
* the declaration's syntax. */
export function keyofConstrainedTypeParams(info: GenericFnInfo): Set<ts.Symbol> {
if (info.keyofTps) return info.keyofTps;
const out = new Set<ts.Symbol>();
info.decl.typeParameters?.forEach((tpDecl, i) => {
const sym = info.typeParams[i];
if (!sym || tpDecl.constraint === undefined) return;
if (ts.isTypeOperatorNode(tpDecl.constraint) && tpDecl.constraint.operator === ts.SyntaxKind.KeyOfKeyword) {
out.add(sym);
}
});
info.keyofTps = out;
return out;
}
/** Type-parameter symbol → concrete IR type for one instantiation.
* Explicit type arguments bind directly; the rest come from structurally
* matching each DECLARED param/return type (which mentions the type
* parameters) against the checker's INSTANTIATED one — the latter is the
* former with the substitution applied, so the shapes are parallel by
* construction. A type parameter left unbound only matters if the body
* mentions it, where mapType fails and badType names the shape
* (carrying the instantiation context). */
export function inferTypeParamBindings(L: Lowerer, expr: ts.CallExpression,
info: GenericFnInfo,
rsig: ts.Signature,
tsBindings?: Map<ts.Symbol, ts.Type>,): Map<ts.Symbol, IrType> {
const bindings = new Map<ts.Symbol, IrType>();
expr.typeArguments?.forEach((ta, i) => {
const tp = info.typeParams[i];
if (!tp) return;
const taT = L.checker.getTypeFromTypeNode(ta);
const mapped = L.mapTypeOf(taT);
if (mapped && mapped.kind !== "void") {
bindings.set(tp, mapped);
tsBindings?.set(tp, taT);
}
});
unifySignatureBindings(L, info, rsig, bindings, tsBindings);
bindDefaultTypeParams(L, info.typeParams, info.decl.typeParameters, bindings, tsBindings);
return bindings;
}
/** Type parameters still unbound after unification take their declared
* DEFAULT (`<T = number>`), mapped — the checker already substituted the
* default into every resolved signature, so this only fills the bindings
* an instance body's mapType consults. */
export function bindDefaultTypeParams(L: Lowerer, typeParams: readonly ts.Symbol[],
typeParamDecls: readonly ts.TypeParameterDeclaration[] | undefined,
bindings: Map<ts.Symbol, IrType>,
tsBindings?: Map<ts.Symbol, ts.Type>,): void {
typeParamDecls?.forEach((tpDecl, i) => {
const tp = typeParams[i];
if (!tp || bindings.has(tp) || !tpDecl.defaultType) return;
const defT = L.checker.getTypeFromTypeNode(tpDecl.defaultType);
const mapped = L.mapTypeOf(defT);
if (mapped && mapped.kind !== "void") {
bindings.set(tp, mapped);
tsBindings?.set(tp, defT);
}
});
}
/** The structural half of binding inference: unify the DECLARED signature
* (whose types mention the type parameters) against a TARGET signature
* with the substitution applied — a call's resolved signature, or the
* completed signature a VALUE reference is pinned to (the contextual
* type's one call signature). Mutates `bindings`; already-bound
* parameters (explicit type arguments) win. */
export function unifySignatureBindings(L: Lowerer, info: GenericFnInfo,
rsig: ts.Signature,
bindings: Map<ts.Symbol, IrType>,
tsBindings?: Map<ts.Symbol, ts.Type>,): void {
const tpSet = new Set(info.typeParams);
const seen = new Set<ts.Type>(); // recursive declared types must not loop
// The identity-keyed set cannot catch LAZILY INFINITE anonymous types
// (`function rec<T>(x: T) { return { deeper: <U>(y: U) => rec<[T, U]>(...) }; }`
// — every property/signature walk instantiates FRESH type objects, and
// no Reference target exists to shortcut on), so a depth cap bounds the
// walk. Stopping only stops INFERENCE: a type parameter left unbound
// surfaces as an ordinary mapping diagnostic later, never a wrong
// binding — and every practical signature binds its parameters within
// a few levels.
const MAX_UNIFY_DEPTH = 24;
const unify = (declared: ts.Type, inst: ts.Type, depth = 0): void => {
if (depth > MAX_UNIFY_DEPTH) return;
if (declared.flags & ts.TypeFlags.TypeParameter) {
const sym: ts.Symbol | undefined = declared.getSymbol();
if (sym && tpSet.has(sym)) {
// The checker type records even when an explicit type argument
// already bound the IrType: the raw type is the SAME binding
// pre-widening, and first-hit-wins keeps the two maps parallel.
// A generic body FORWARDING its own parameter (`pluck`'s
// `pick(it, key)` binds pick's K to pluck's K) resolves through
// the enclosing instantiation's ts bindings first — the literal
// carries through the chain.
if (tsBindings && !tsBindings.has(sym)) {
tsBindings.set(sym, L.typeParamTsResolver(inst) ?? inst);
}
if (!bindings.has(sym)) {
const mapped = L.mapTypeOf(inst);
if (mapped && mapped.kind !== "void") bindings.set(sym, mapped);
}
}
return;
}
if (seen.has(declared)) return;
seen.add(declared);
// Optional-flavored unions (`x?: T` declares `T | undefined`): strip
// the unit parts from both sides and unify the lone remaining pair.
// Multi-part unions have no positional correspondence — skipped (an
// unbound type parameter surfaces as a mapping diagnostic later).
if (declared.isUnionType()) {
const unitFlags = ts.TypeFlags.Undefined | ts.TypeFlags.Null;
const dParts = declared.getTypes().filter((t) => !(t.flags & unitFlags));
const iParts: readonly ts.Type[] = inst.isUnionType() ? inst.getTypes().filter((t) => !(t.flags & unitFlags)) : [inst];
if (dParts.length === 1 && iParts.length === 1) unify(dParts[0]!, iParts[0]!, depth + 1);
return;
}
// Instantiations of the SAME generic ALIAS (Partial<T> vs
// Partial<Config>) unify by alias arguments: instantiation preserves
// aliasSymbol/aliasTypeArguments, and the two argument lists are
// parallel by construction. Without this, a mapped-type parameter
// leaves T unbound — the declared `Partial<T>` has no resolvable
// members for the property walk below (keyof T is unknown).
const dAlias = declared.getAliasSymbol();
const dAliasArgs = declared.getAliasTypeArguments();
const iAliasArgs = inst.getAliasTypeArguments();
if (
dAlias &&
dAlias === inst.getAliasSymbol() &&
dAliasArgs.length &&
iAliasArgs.length === dAliasArgs.length
) {
dAliasArgs.forEach((da, i) => {
const ia = iAliasArgs[i];
if (ia) unify(da, ia, depth + 1);
});
return;
}
// References to the SAME generic (Promise<T> vs Promise<string>, or
// any interface reference) unify by type ARGUMENTS only. Walking
// members instead diverges: a self-referential member like Promise's
// `then<U>(...): Promise<U>` instantiates a FRESH type object on
// every property read, so an identity-keyed visited set never trips.
const dRef = declared as ts.TypeReference;
const iRef = inst as ts.TypeReference;
if (
declared.flags & ts.TypeFlags.Object &&
inst.flags & ts.TypeFlags.Object &&
(declared as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference &&
(inst as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference &&
dRef.getTarget() === iRef.getTarget()
) {
const dArgs = L.checker.getTypeArguments(dRef);
const iArgs = L.checker.getTypeArguments(iRef);
dArgs.forEach((da, i) => {
const ia = iArgs[i];
if (ia) unify(da, ia, depth + 1);
});
return;
}
if (L.checker.isArrayType(declared) && L.checker.isArrayType(inst)) {
const dElem = L.checker.getTypeArguments(declared as ts.TypeReference)[0];
const iElem = L.checker.getTypeArguments(inst as ts.TypeReference)[0];
if (dElem && iElem) unify(dElem, iElem, depth + 1);
return;
}
const dSigs = L.checker.getCallSignatures(declared);
const iSigs = L.checker.getCallSignatures(inst);
if (dSigs.length === 1 && iSigs.length === 1) {
const ds = dSigs[0]!;
const is = iSigs[0]!;
ds.getParameters().forEach((dp, i) => {
const ip = is.getParameters()[i];
if (ip) unify(L.checker.getTypeOfSymbol(dp), L.checker.getTypeOfSymbol(ip), depth + 1);
});
unify(L.checker.getReturnTypeOfSignature(ds), L.checker.getReturnTypeOfSignature(is), depth + 1);
return;
}
if (declared.flags & ts.TypeFlags.Object) {
for (const dp of L.checker.getPropertiesOfType(declared)) {
const ip = L.checker.getPropertyOfType(inst, dp.name);
if (ip) unify(L.checker.getTypeOfSymbol(dp), L.checker.getTypeOfSymbol(ip), depth + 1);
}
}
};
const declSig = L.checker.getSignatureFromDeclaration(info.decl);
if (declSig) {
declSig.getParameters().forEach((dp, i) => {
const ip = rsig.getParameters()[i];
if (ip) unify(L.checker.getTypeOfSymbol(dp), L.checker.getTypeOfSymbol(ip));
});
unify(
L.checker.getReturnTypeOfSignature(declSig),
L.checker.getReturnTypeOfSignature(rsig),
);
}
}
/** Lowers ONE monomorphic instance of a generic function: the same body
* AST, re-lowered with the type parameters bound (threaded into every
* mapType call via typeParamResolver — the checker keeps reporting the
* unsubstituted `T`s inside the body). Coverage stats count a base
* function's statements once: only the FIRST instance contributes. */
export function lowerGenericInstance(L: Lowerer, info: GenericFnInfo, inst: GenericInstance): IrFunction {
const decl = info.decl;
const cls = info.member?.cls ?? null;
const prevBindings = L.typeParamBindings;
const prevContext = L.instantiationContext;
const prevSuppress = L.suppressStats;
const prevClass = L.currentClass;
const prevImplicit = L.implicitParamTypes;
// A generic METHOD of a generic-class INSTANTIATION lowers under BOTH
// binding sets: the receiver instantiation's class type parameters
// underneath, the method instantiation's own on top (disjoint symbol
// sets — tsc rejects shadowing a class type parameter in a method).
const clsBindings = cls?.genericInstance?.bindings;
L.typeParamBindings = clsBindings
? new Map([...clsBindings, ...inst.bindings])
: inst.bindings;
// The ts-level bindings ride along: `T[K]` and literal-keyed `o[k]`
// reads inside the body resolve through the bound CHECKER types
// (typeParamTsResolver). Generic-class instantiations carry no
// ts-level bindings (their type arguments widened at the reference),
// so only the method instantiation's own map installs.
const prevTsBindings = L.typeParamTsBindings;
L.typeParamTsBindings = inst.tsBindings ?? null;
// Implicit-any instances thread their param bindings through typeOf
// (the checker reports `any` inside the body — there is no T for
// mapType to substitute); see the implicit-monomorphization section.
L.implicitParamTypes =
info.implicitParams !== undefined ? (inst.implicitArgTypes ?? new Map()) : null;
L.instantiationContext = `instantiating '${info.baseName}' with ${inst.typeArgsText}`;
// Coverage counts a generic source body once: re-instantiations of the
// method AND re-instantiations of the declaring generic class re-visit
// the same source lines.
L.suppressStats = inst.ordinal > 0 || (cls?.genericInstance?.ordinal ?? 0) > 0;
// A generic ASYNC instance is an async IrFunction like any other: the
// body returns the resolved promise's INNER type, calls enter through
// the instance's own spawn wrapper (the emitter routes by fn.async),
// and awaits park this instance's fibers.
const isAsync = decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
const nameBlame: ts.Node = (ts.isArrowFunction(decl) ? undefined : decl.name) ?? decl;
if (isAsync && inst.returnType.kind !== "promise") {
L.badType(nameBlame, L.checker.getTypeAtLocation(nameBlame));
}
// A generic GENERATOR instance mirrors async: the body returns the
// resolved TReturn channel, calls enter through the instance's own
// gen-spawn wrapper (the emitter routes by fn.generator).
const isGenerator = decl.asteriskToken !== undefined;
if (isGenerator && isAsync) {
L.unsupported("SC1071", decl, "async generators (async function*)");
}
if (isGenerator && inst.returnType.kind !== "generator") {
L.badType(nameBlame, L.checker.getTypeAtLocation(nameBlame));
}
let bodyReturn = isGenerator
? L.genBodyReturnType(inst.returnType)
: L.bodyReturnType(isAsync, inst.returnType);
const fnCtx = newFnCtx(false, null, null, bodyReturn);
fnCtx.isAsync = isAsync;
// Implicit-any instances whose declared return did not map lower in
// return-INFERENCE mode: `return` statements record here bare, and the
// post-pass (resolveInferredReturn) settles the type and wraps them.
if (inst.implicitInferReturn) fnCtx.inferReturn = { entries: [] };
if (cls && info.member!.kind === "method") L.currentClass = cls;
if (isGenerator && inst.returnType.kind === "generator") {
fnCtx.generator = { yieldT: inst.returnType.yieldT, nextT: inst.returnType.nextT };
}
L.fnStack.push(fnCtx);
try {
// STATIC generic methods: `this`/`super` name the RECEIVER class (a
// dynamic value) — the lowerStaticMethod fence, applied here because
// generic statics have no non-generic lowering pass. Arrow functions
// are transparent (they inherit the method's `this`); this-binding
// function forms are opaque.
if (info.member?.kind === "static" && decl.body) {
const checkThis = (n: ts.Node): void => {
if (
ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) ||
ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) ||
ts.isGetAccessor(n) || ts.isSetAccessor(n) ||
ts.isClassDeclaration(n) || ts.isClassExpression(n)
) {
return;
}
if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword) {
L.unsupported(
"SC1090",
n,
`'${n.kind === ts.SyntaxKind.ThisKeyword ? "this" : "super"}' in static methods (it names the RECEIVER class — a dynamic value; reference the class by name instead)`,
);
}
n.forEachChild(checkThis);
};
decl.body.forEachChild(checkThis);
}
const params: IrParam[] = [];
if (cls && info.member!.kind === "method") {
// Instance methods take `this` as param 0, exactly like plain
// `%C.method` functions (lowerClassMethodMemberInner).
const thisType: IrType = { kind: "object", className: cls.def.name };
const thisLocal = L.declareThis(thisType);
params.push({ localId: thisLocal.id, name: "this", type: thisType });
}
// Default-param initializers lower per instance, with the bindings
// threaded — a default mentioning T resolves like any body expression.
const declared = L.declareParams(decl.parameters, inst.params);
params.push(...declared.params);
const body = [...declared.prologue];
const bodyBlock = blockBodyOf(decl);
if (bodyBlock) {
body.push(...L.lowerStmts(bodyBlock.statements));
if (fnCtx.inferReturn) {
bodyReturn = resolveInferredReturn(L, inst, fnCtx.inferReturn, body, decl);
}
appendImplicitUndefinedReturn(L, body, bodyReturn, locOf(decl));
} else if (ts.isArrowFunction(decl) && decl.body !== undefined && !ts.isBlock(decl.body)) {
// A concise arrow body: the expression IS the return value —
// generic properties (`id: <T>(x: T) => x`), and implicit-any
// local arrows (`(cmd) => [cmd.name()].concat(cmd.aliases())`),
// whose inferred return is simply the expression's own type.
if (fnCtx.inferReturn) {
const value = L.lowerExpr(decl.body);
if (value.type.kind === "void") {
body.push({ kind: "exprStmt", expr: value, loc: locOf(decl.body) });
bodyReturn = resolveInferredReturn(L, inst, fnCtx.inferReturn, body, decl);
appendImplicitUndefinedReturn(L, body, bodyReturn, locOf(decl));
} else {
const stmt: IrStmt = { kind: "return", value, loc: locOf(decl.body) };
fnCtx.inferReturn.entries.push({ stmt, node: decl.body });
body.push(stmt);
bodyReturn = resolveInferredReturn(L, inst, fnCtx.inferReturn, body, decl);
}
} else {
const value = L.lowerExprExpecting(decl.body, bodyReturn);
if (bodyReturn.kind === "void") {
body.push({ kind: "exprStmt", expr: value, loc: locOf(decl.body) });
} else {
body.push({ kind: "return", value, loc: locOf(decl.body) });
}
}
} else {
L.unsupported("SC1090", decl, "function declarations whose block body the frontend cannot locate");
}
const fn: IrFunction = {
name: inst.name,
params,
returnType: bodyReturn,
locals: L.ctx.locals,
body,
loc: locOf(decl),
};
if (isAsync) fn.async = true;
if (fnCtx.generator) fn.generator = fnCtx.generator;
return fn;
} finally {
L.fnStack.pop();
L.currentClass = prevClass;
L.typeParamBindings = prevBindings;
L.typeParamTsBindings = prevTsBindings;
L.implicitParamTypes = prevImplicit;
L.instantiationContext = prevContext;
L.suppressStats = prevSuppress;
}
}
/** The return-inference post-pass of an implicit-any instance: unify the
* recorded return statements' value types into the instance's settled
* return type, then wrap each return to it — arm values wrap into the
* union, dyn-convertible values ride dynFrom, and a value that cannot
* ride the settled type converts ITS return statement into the standard
* per-statement runtime fence (JS sources defer fences to runtime).
*
* The settled type: the one distinct value type when every return
* agrees; `T | undefined` when a bare `return;` or possible fallthrough
* adds JS's undefined; DYN when returns disagree (the checked-dynamic
* result slot — today's shape). Same-key recursion PINNED the fallback
* type mid-lowering (callers already hold it), so a pinned instance
* keeps it and the wrap pass coerces every return to the pin. */
function resolveInferredReturn(L: Lowerer, inst: GenericInstance,
infer: NonNullable<import("./lowerer.js").FnCtx["inferReturn"]>,
body: IrStmt[],
decl: ts.Node,): IrType {
// The conservative completion test appendImplicitUndefinedReturn uses:
// a body whose last statement isn't a terminator may complete without
// returning — JS answers undefined.
const last = body[body.length - 1];
const mayFallThrough =
!last || !(last.kind === "return" || last.kind === "throw" || last.kind === "rethrow" || last.kind === "runtimeFence");
const valued = infer.entries.filter(
(e): e is { stmt: IrStmt & { kind: "return"; value: IrExpr }; node: ts.Expression | null } =>
e.stmt.kind === "return" && e.stmt.value !== null && e.stmt.value !== undefined,
);
const sawBare =
mayFallThrough || infer.entries.some((e) => e.stmt.kind === "return" && (e.stmt.value === null || e.stmt.value === undefined));
let final: IrType;
if (inst.returnPinned) {
final = inst.returnType;
} else {
const distinct: IrType[] = [];
for (const e of valued) {
if (!distinct.some((t) => typeEquals(t, e.stmt.value.type))) distinct.push(e.stmt.value.type);
}
if (distinct.length === 0) {
final = DYN; // no valued return: JS completes with undefined — the dyn undefined, today's slot
} else if (distinct.length === 1) {
const t = distinct[0]!;
final = !sawBare ? t : t.kind === "dyn" ? DYN : (L.withUndefinedArmOf(t) ?? DYN);
} else {
final = DYN; // disagreeing returns: the checked-dynamic join
}
inst.returnType = final;
}
// The wrap pass: settle every recorded return onto `final`, in place.
for (const e of infer.entries) {
if (e.stmt.kind !== "return") continue;
const st = e.stmt as IrStmt & { kind: "return"; value: IrExpr | null };
const diagsBefore = L.diags.length;
try {
if (st.value === null || st.value === undefined) {
if (final.kind === "dyn") st.value = dynUndefinedExpr(st.loc);
else if (final.kind === "union") {
const wrapped = L.wrappedUndefined(final, st.loc);
if (!wrapped) {
L.unsupported("SC1090", e.node ?? decl, `bare 'return' in a function whose inferred return type is '${L.fmt(final)}'`);
}
st.value = wrapped;
}
// void final: bare return stands as-is
} else if (!typeEquals(st.value.type, final)) {
st.value = L.coerceInto(e.node ?? decl, st.value, final);
}
} catch (err) {
if (!(err instanceof PoisonError)) throw err;
// The per-return fence: this value cannot ride the settled type —
// executing THIS return throws the recorded reason (the JS
// per-statement deferral, applied to one return of an instance).
const captured = L.diags.splice(diagsBefore);
const ice = captured.filter((d) => d.code === "SC9001");
if (ice.length > 0) L.diags.push(...ice);
L.runtimeFences.push(...captured.filter((d) => d.code !== "SC9001"));
const first = captured.find((d) => d.code !== "SC9001");
const mutable = st as unknown as Record<string, unknown>;
delete mutable["value"];
mutable["kind"] = "runtimeFence";
mutable["code"] = first?.code ?? "SC1090";
mutable["message"] = first
? `${first.message} [${first.code}]`
: "this return value has no lowering onto the instance's settled return type [SC1090]";
}
}
return final;
}
/** A generic function taken as a VALUE, monomorphized by flow. A function
* value needs ONE concrete signature; tsc pins one at exactly two
* reference shapes — an instantiation EXPRESSION (`identity<number>`,
* whose own checker type is the substituted signature) and a reference
* whose CONTEXTUAL type completes the signature (`const f: (x: number) =>
* number = identity`, `take(identity)`). The declared signature unifies
* against the pinned one to recover the bindings; the instance then
* registers in the SAME table call sites use (one compiled copy per
* signature however it is reached), and the value is the instance's
* zero-capture closure — `f === f` holds within an instantiation, the
* declared-function identity rule. References with no pinning context
* (the slot keeps `<T>(x: T) => T`) fence by name. */
export function lowerGenericFnValue(L: Lowerer, ref: ts.Expression, info: GenericFnInfo): IrExpr {
const loc = locOf(ref);
// An IMPLICIT-ANY function taken as a VALUE: indirect calls carry no
// per-site types to bind, so the value is the all-dyn DEFAULT
// instance's closure — today's compiled body exactly (one interned
// closure per function, so `f === f` holds like any declaration).
if (info.implicitParams) {
const inst = implicitDefaultInstance(L, ref, info);
const funcType: IrType = {
kind: "func",
params: inst.params.filter((p) => p.mode !== "dynRest").map((p) => p.type),
ret: inst.returnType,
...(inst.params.some((p) => p.mode === "dynRest") ? { rest: true as const } : {}),
};
L.requireExactArityValue(ref, ref, inst.params, funcType);
L.noteEdge(inst.name);
return { kind: "closure", fnName: inst.name, captures: [], type: funcType, loc };
}
const fenceUnpinned: () => never = () =>
L.unsupported(
"SC1090",
ref,
`generic functions as values without a pinned concrete signature (annotate the destination — e.g. 'const f: (x: number) => number = ${info.baseName}' — instantiate explicitly ('${info.baseName}<number>'), or call '${info.baseName}' directly)`,
);
// The PINNING type: an instantiation expression's own checker type
// (explicit type arguments applied), else the reference's contextual
// type — the slot or argument the value flows into. Namespace/CJS
// member paths delegate the member NAME here (`lib.tag` hands over
// `tag`), and the checker hangs the contextual type on the whole
// property access — hop to it.
const ctxNode =
ref.parent !== undefined && ts.isPropertyAccessExpression(ref.parent) && ref.parent.name === ref
? ref.parent
: ref;
const pinT = ts.isExpressionWithTypeArguments(ref)
? L.typeOf(ref)
: L.checker.getContextualType(ctxNode);
let target: ts.Signature | null = null;
if (pinT) {
const sigs = L.checker.getCallSignatures(pinT);
if (sigs.length === 1) target = sigs[0]!;
else if (sigs.length === 0 && pinT.isUnionType()) {
// A `Fn | undefined`-flavored slot: the value can only inhabit the
// one callable arm — judge by it (the requireExactArityValue union
// rule).
const callable = pinT.getTypes().map((t) => L.checker.getCallSignatures(t)).filter((s) => s.length === 1);
if (callable.length === 1) target = callable[0]![0]!;
}
}
if (!target) fenceUnpinned();
const bindings = new Map<ts.Symbol, IrType>();
unifySignatureBindings(L, info, target, bindings);
bindDefaultTypeParams(L, info.typeParams, info.decl.typeParameters, bindings);
// A pinning signature that itself keeps type parameters (`let g: <T>(x:
// T) => T = identity` — storing the generic signature as such) binds
// nothing: mapType answers null for an unsubstituted parameter.
if (info.typeParams.some((tp) => !bindings.get(tp))) fenceUnpinned();
const inst = genericValueInstance(L, ref, info, bindings);
// The value's type is the completed ABI signature — exact-arity, the
// declared-function value rule (dynRest slots stay out of the param
// list; the rest marker carries the trailing dyn-array ABI).
const funcType: IrType = {
kind: "func",
params: inst.params.filter((p) => p.mode !== "dynRest").map((p) => p.type),
ret: inst.returnType,
...(inst.params.some((p) => p.mode === "dynRest") ? { rest: true as const } : {}),
};
L.requireExactArityValue(ref, ref, inst.params, funcType);
L.noteEdge(inst.name);
return { kind: "closure", fnName: inst.name, captures: [], type: funcType, loc };
}
/** The instance a pinned VALUE reference names: the declaration's modes
* over the DECLARED types mapped under the bindings (mapType's resolver
* substitutes — the instance-body trick), which is the same result the
* call path computes from the resolved signature, so both routes land on
* one instance per key. */
function genericValueInstance(L: Lowerer, ref: ts.Expression,
info: GenericFnInfo,
bindings: Map<ts.Symbol, IrType>,): GenericInstance {
const prevBindings = L.typeParamBindings;
const prevContext = L.instantiationContext;
const rendered = info.typeParams
.map((tp) => {
const bound = bindings.get(tp);
return bound ? L.fmt(bound) : tp.name;
})
.join(", ");
L.typeParamBindings = bindings;
L.instantiationContext = `instantiating '${info.baseName}' with <${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered}>`;
try {
const declSig = L.checker.getSignatureFromDeclaration(info.decl);
if (!declSig) L.unsupported("SC1090", ref, "this function form");
const params: ParamShape[] = [];
info.decl.parameters.forEach((declParam, i) => {
const p = declSig.getParameters()[i];
const pt = p ? L.checker.getTypeOfSymbol(p) : L.typeOf(declParam.name);
const mapped = L.mapTypeOf(pt);
if (!mapped || mapped.kind === "void") L.badType(declParam.name, pt);
if (declParam.dotDotDotToken) {
if (mapped.kind !== "array") L.badType(declParam.name, pt);
params.push({ type: mapped, mode: "rest" });
} else if (declParam.initializer) {
if (mapped.kind === "dyn" || mapped.kind === "jsval") {
// Dynamic-tier default: the slot holds its tier's undefined
// directly (paramShape's rule).
params.push({ type: mapped, mode: "omittable", bodyType: mapped });
} else {
const bodyType = L.stripUndefinedArm(mapped);
L.checkDefaultParamBodyType(declParam, bodyType);
params.push({ type: L.withUndefinedArm(bodyType), mode: "omittable", bodyType });
}
} else {
if (declParam.questionToken && !L.bareUndefinedArmedUnion(mapped)) {
L.unsupported("SC1090", declParam, `optional parameters of type '${L.fmt(mapped)}'`);
}
params.push({ type: mapped, mode: declParam.questionToken ? "omittable" : "required" });
}
});
const retTs = L.checker.getReturnTypeOfSignature(declSig);
const returnType = L.mapTypeOf(retTs);
if (!returnType) {
fenceGenericSignatureResult(L, ref, retTs);
L.badType(ref, retTs);
}
return internGenericInstance(L, ref, info, params, returnType, () => bindings);
} finally {
L.typeParamBindings = prevBindings;
L.instantiationContext = prevContext;
}
}
/* ── implicit-any monomorphization (npm-static JS) ─────────────────────
*
* A JS function whose signature carries UNTYPED parameters is, morally, a
* generic function: the author wrote it for whatever the call sites pass.
* Inside an opted-in npm-static package the frontend treats each bindable
* implicit-any parameter as an implicit TYPE parameter and instantiates
* the body per call site over the WIDENED checker types of the arguments —
* the generic-binding machinery verbatim, with two twists:
*
* 1. The checker reports `any` INSIDE the body (there is no `T` for
* mapType to substitute), so the binding threads through the
* Lowerer's typeOf instead: an identifier reference to a bound param
* whose checker answer is still `any` answers the bound ts.Type, and
* every receiver-typed lowering downstream (field targets, method
* dispatch, narrowing) sees the concrete type. Where tsc's own
* flow analysis DID narrow the `any` (typeof/instanceof guards), a
* narrow CONSISTENT with the binding wins (it is the binding, or an
* arm of it); a contradicting narrow — the statically-dead branch of
* a typeof dispatch this instantiation cannot take — answers the
* bound type, so dead branches fence honestly instead of lowering
* the live value under a lying type.
* 2. The instance's RETURN type cannot come from the checker when the
* params poisoned it to `any`: instances lower EAGERLY at first
* demand (nested body lowering, the lambda discipline) and infer the
* return from the lowered return statements; same-key recursion
* observes the checker-fallback type ("pinned") and the post-pass
* coerces every return to the settled type — per-return fences where
* a value cannot ride it. Bounded: MAX_GENERIC_INSTANCES per
* function, the polymorphic-recursion cap.
*
* Bindings are SOUND by construction: the bound type is the argument's own
* checker type at the call site (never a guess), a param the body ever
* WRITES is not bindable (it stays checked-dynamic — `options = options
* || {}` keeps today's story), and an argument whose type does not map
* statically binds the checked-dynamic DYN — the all-dyn instance IS
* today's compiled body, so nothing regresses where nothing binds. */
/** The npm-static gate: implicit-any monomorphization applies to functions
* DECLARED in an opted-in package's JS files (user JS keeps today's
* checked-dynamic story until the corpus is re-baselined). */
export function implicitMonoFile(sf: ts.SourceFile): boolean {
return isJsSourceFile(sf) && npmStaticPackageOfPath(sf.fileName) !== null;
}
/** True when the body (or a nested function capturing it) ever WRITES the
* parameter symbol — assignment, compound assignment, ++/--, a
* destructuring-assignment target, or a for-in/of cursor. A written
* param's binding could lie after the write, so it stays dyn. */
function paramWrittenInBody(L: Lowerer, body: ts.Node, sym: ts.Symbol, name: string): boolean {
let written = false;
const targetsSym = (e: ts.Expression): boolean => {
let n: ts.Expression = e;
while (ts.isParenthesizedExpression(n)) n = n.expression;
if (ts.isIdentifier(n) && n.text === name) {
return L.checker.getSymbolAtLocation(n) === sym;
}
// Destructuring-assignment patterns ([a] = xs, {a} = o): any
// identifier inside the target literal counts (conservative — a
// nested `a.b` member write through the pattern is a write THROUGH,
// not a rebind, but patterns are rare enough to over-approximate).
if (ts.isArrayLiteralExpression(n) || ts.isObjectLiteralExpression(n)) {
let hit = false;
const scan = (m: ts.Node): void => {
if (hit) return;
if (ts.isIdentifier(m) && m.text === name && L.checker.getSymbolAtLocation(m) === sym) {
hit = true;
return;
}
m.forEachChild(scan);
};
scan(n);
return hit;
}
return false;
};
const walk = (n: ts.Node): void => {
if (written) return;
if (ts.isBinaryExpression(n)) {
const k = n.operatorToken.kind;
const isAssign = k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment;
if (isAssign && targetsSym(n.left)) {
written = true;
return;
}
}
if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken) &&
targetsSym(n.operand)
) {
written = true;
return;
}
if (
(ts.isForInStatement(n) || ts.isForOfStatement(n)) &&
!ts.isVariableDeclarationList(n.initializer) &&
ts.isExpression(n.initializer) &&
targetsSym(n.initializer)
) {
written = true;
return;
}
n.forEachChild(walk);
};
walk(body);
return written;
}
/** The implicit-type-parameter slots of a JS function-like: parallel to
* decl.parameters, the param SYMBOL where the slot is a bindable
* implicit-any param (identifier-named, no annotation/JSDoc type, not
* rest/optional/defaulted, never written), null elsewhere. Null overall
* when nothing qualifies — the declaration keeps today's path. */
export function implicitAnyParamSymbolsOf(L: Lowerer,
decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction,): (ts.Symbol | null)[] | null {
if (!decl.body) return null;
if (decl.asteriskToken) return null;
if (decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return null;
if (decl.typeParameters !== undefined) return null; // real generics own the machinery
if (decl.parameters.length === 0) return null;
// The variadic-`arguments` form keeps its dynRest story whole.
if (bodyReadsArguments(decl)) return null;
let any = false;
const out = decl.parameters.map((param): ts.Symbol | null => {
if (!ts.isIdentifier(param.name)) return null;
if (param.dotDotDotToken || param.questionToken || param.initializer) return null;
if (param.name.text === "this") return null;
const t = L.typeOf(param.name);
if ((t.flags & ts.TypeFlags.Any) === 0) return null;
const sym = L.checker.getSymbolAtLocation(param.name);
if (!sym) return null;
if (paramWrittenInBody(L, decl.body!, sym, param.name.text)) return null;
any = true;
return sym;
});
return any ? out : null;
}
/** The checker-fallback return type an implicit instance PROMISES before
* its body lowers: the declared/inferred return when it maps statically
* (JSDoc @returns, `void`, concrete inference the any-params didn't
* poison) — used as the expected return, no inference — or null, which
* selects return INFERENCE with DYN as the recursion pin. */
function implicitDeclaredReturn(L: Lowerer, info: GenericFnInfo): IrType | null {
try {
const declSig = L.checker.getSignatureFromDeclaration(info.decl);
if (!declSig) return null;
const retTs = L.checker.getReturnTypeOfSignature(declSig);
if (retTs.flags & ts.TypeFlags.Any) return null;
return L.mapTypeOf(retTs);
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
return null;
}
}
/** True when an IR type may BIND an implicit param (a concrete static
* type — the checked-dynamic kinds keep the dyn slot, units have no
* standalone representation). */
function bindableImplicitIr(t: IrType | null): t is IrType {
return (
t !== null &&
t.kind !== "void" && t.kind !== "dyn" && t.kind !== "jsval" &&
t.kind !== "caught" && t.kind !== "undefinedT" && t.kind !== "nullT"
);
}
/** The instance a CALL of an implicit-any function-like names: each
* bindable implicit param takes the call's WIDENED argument checker type
* when it maps statically (DYN otherwise — today's slot), typed params
* keep their declared shapes, and the param-type tuple is the
* instantiation key. New keys lower EAGERLY (return inference — see the
* section comment); a same-key re-demand mid-lowering pins the fallback
* return type. */
export function implicitCallInstance(L: Lowerer, call: ts.CallExpression, info: GenericFnInfo): GenericInstance {
const shapes: ParamShape[] = [];
const argTypes = new Map<ts.Symbol, ts.Type>();
info.decl.parameters.forEach((param, i) => {
const sym = info.implicitParams![i];
if (!sym) {
shapes.push(L.paramShape(param));
return;
}
let bound: IrType = DYN;
const arg = call.arguments[i];
if (arg && !ts.isSpreadElement(arg)) {
// The argument's own checker type, literal-widened ('add' binds
// string) — typeOf consults the ACTIVE instance's bindings, so a
// bound param forwarded into another implicit call transitively
// instantiates it (this._initCommandGroup(command)).
const t = L.checker.getBaseTypeOfLiteralType(L.typeOf(arg));
const mapped = L.mapTypeOf(t);
if (bindableImplicitIr(mapped)) {
bound = mapped;
argTypes.set(sym, t);
}
}
shapes.push({ type: bound, mode: "required" });
});
return internImplicitInstance(L, call, info, shapes, argTypes);
}
/** The all-dyn DEFAULT instance — today's compiled body exactly: what a
* VALUE reference of an implicit-any function names (indirect calls
* carry no per-site types to bind). */
export function implicitDefaultInstance(L: Lowerer, blame: ts.Node, info: GenericFnInfo): GenericInstance {
const shapes: ParamShape[] = info.decl.parameters.map((param, i) =>
info.implicitParams![i] ? { type: DYN, mode: "required" as const } : L.paramShape(param),
);
return internImplicitInstance(L, blame, info, shapes, new Map());
}
function internImplicitInstance(L: Lowerer, blame: ts.Node,
info: GenericFnInfo,
shapes: ParamShape[],
argTypes: Map<ts.Symbol, ts.Type>,): GenericInstance {
const key = shapes.map((s) => typeKey(s.type)).join(",");
let inst = info.instances.get(key);
if (inst) return inst;
if (info.instances.size >= MAX_GENERIC_INSTANCES) {
L.unsupported(
"SC1090",
blame,
`unbounded implicit-any instantiation ('${info.baseName}' exceeded ` +
`${MAX_GENERIC_INSTANCES} instances — polymorphic recursion?)`,
);
}
const rendered = shapes.map((s) => L.fmt(s.type)).join(", ");
const declared = implicitDeclaredReturn(L, info);
inst = {
name: `${info.qualifiedName}%${info.instances.size}`,
ordinal: info.instances.size,
params: shapes,
// The promise callers rely on before the body settles it: the
// declared truth when it maps, else DYN (the recursion pin — and
// exactly today's checked-dynamic result slot).
returnType: declared ?? DYN,
bindings: new Map(),
typeArgsText: `(${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered})`,
implicitArgTypes: argTypes,
implicitState: "lowering",
...(declared === null ? { implicitInferReturn: true as const } : {}),
};
info.instances.set(key, inst);
// EAGER lowering (nested, the lambda discipline): the call site needs
// the settled return type NOW. A body-level poison (a fenced parameter
// form) skips the function like lowerFunction's rule — calls then meet
// the pinned signature over a missing body, which the linker never
// sees because the poison also fenced the call statement.
try {
const fn = L.lowerGenericInstance(info, inst);
L.implicitFns.push(fn);
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
inst.implicitState = "done";
throw e;
}
inst.implicitState = "done";
return inst;
}
/** The implicit-any twin of bindingGenericFnNodeOf, for LOCAL and module
* bindings alike (`const knownBy = (cmd) => [cmd.name()].concat(...)`
* inside a method body — commander's _registerCommand shape): the
* initializer function-like when the WHOLE declaration qualifies for
* implicit monomorphization, else null — non-qualifying shapes keep
* today's closure story silently (never a fence: the flag must not make
* working code worse). Qualification: an npm-static JS file, a const (or
* never-reassigned, never-redeclared) identifier binding, an
* arrow/function-expression initializer with bindable implicit-any
* params, and a body with NO captures — no `this`/`super`, and no
* reference to a function-scoped declaration outside itself (compiled
* instances are module functions; module-scope references are fine).
* Cached per declaration on L.implicitLocalFns. */
export function implicitLocalFnNodeOf(L: Lowerer, decl: ts.VariableDeclaration): ts.FunctionExpression | ts.ArrowFunction | null {
const cached = L.implicitLocalFns.get(decl);
if (cached !== undefined) return cached ? (cached.decl as ts.FunctionExpression | ts.ArrowFunction) : null;
const probe = (): ts.FunctionExpression | ts.ArrowFunction | null => {
if (!implicitMonoFile(decl.getSourceFile())) return null;
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null;
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (!ts.isArrowFunction(init) && !ts.isFunctionExpression(init)) return null;
if (init.typeParameters !== undefined || init.body === undefined) return null;
if (!implicitAnyParamSymbolsOf(L, init)) return null;
const sym = L.checker.getSymbolAtLocation(decl.name);
if (!sym) return null;
const isConst = (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0;
const redeclared = L.checker
.declarationsOf(sym)
.some((d) => d !== decl && ts.isVariableDeclaration(d) && d.initializer !== undefined);
if (redeclared) return null;
if (!isConst && !bindingNeverReassigned(L, sym, decl)) return null;
// The capture scan: instances are module functions with no frame.
let captures = false;
const scan = (n: ts.Node): void => {
if (captures) return;
if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword) {
// Arrow bodies see the ENCLOSING this; function expressions
// rebind their own — but a bare `this` there is untyped JS
// dynamism either way. Reject both, cheaply and soundly.
captures = true;
return;
}
if (ts.isIdentifier(n)) {
const s = L.checker.getSymbolAtLocation(n);
const d = s ? L.checker.valueDeclarationOf(s) : undefined;
if (d && d.getSourceFile() === decl.getSourceFile() && !(d.pos >= init.pos && d.end <= init.end)) {
// Declared outside the initializer, in this file: a capture
// exactly when some enclosing FUNCTION scope declares it —
// module-scope declarations are reachable from any module
// function.
for (let p: ts.Node | undefined = d.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (ts.isFunctionLike(p)) {
captures = true;
return;
}
}
}
}
n.forEachChild(scan);
};
scan(init.body);
return captures ? null : init;
};
const node = probe();
if (node === null) {
L.implicitLocalFns.set(decl, null);
return null;
}
return node;
}
/** Registers (or returns) the GenericFnInfo of a qualifying implicit-any
* function-value binding — implicitLocalFnNodeOf's companion, the
* bindingGenericFnInfoOf shape: the info enters genericFnsBySymbol under
* the binding's symbol (and a named function expression's inner name),
* so calls and value references resolve through genericFnOf; the
* declaration statement emits nothing and the binding has no runtime
* value. The declaration's source position joins the qualified name —
* two same-named locals in one file stay distinct. */
export function implicitLocalFnInfoOf(L: Lowerer, decl: ts.VariableDeclaration,
fnNode: ts.FunctionExpression | ts.ArrowFunction,): GenericFnInfo {
const existing = L.implicitLocalFns.get(decl);
if (existing) return existing;
const name = (decl.name as ts.Identifier).text;
const sym = L.checker.getSymbolAtLocation(decl.name);
if (!sym) L.unsupported("SC1090", decl.name, "this binding form");
const implicit = implicitAnyParamSymbolsOf(L, fnNode);
if (!implicit) L.unsupported("SC1090", fnNode, "this function form"); // defensive: the probe proved it
const stmt = decl.parent.parent;
const info: GenericFnInfo = {
decl: fnNode,
baseName: name,
qualifiedName: L.qualify(decl.getSourceFile(), nsPathPrefix(stmt, decl) + `${name}%l${decl.getStart()}`),
typeParams: [],
instances: new Map(),
implicitParams: implicit,
};
L.implicitLocalFns.set(decl, info);
L.genericFnsBySymbol.set(sym, info);
if (ts.isFunctionExpression(fnNode) && fnNode.name !== undefined) {
const inner = L.checker.getSymbolAtLocation(fnNode.name);
if (inner) L.genericFnsBySymbol.set(inner, info);
}
return info;
}
/** An island call result the .d.ts DECLARES as a primitive exits eagerly
* to that static type — the member-read rule's call sibling (see the
* getProp lowering in lower-exprs.ts): primitives copy by value, every
* static consumer works on the result, and a lying declaration throws the
* catchable TypeError. Chain-handled forms stay jsval (the optChain's
* unit path is the engine's undefined). */
export function islandPrimitiveExit(L: Lowerer, call: ts.CallExpression, result: IrExpr): IrExpr {
if (call.questionDotToken) return result;
if (ts.isPropertyAccessExpression(call.expression) && call.expression.questionDotToken) return result;
const declared = L.mapTypeOf(L.typeOf(call));
if (declared && (declared.kind === "f64" || declared.kind === "bool" || declared.kind === "string")) {
return { kind: "jsExit", value: result, type: declared, loc: result.loc };
}
return result;
}
/** setTimeout invokes its callback with NO arguments, but @types/node's
* generic signature admits callbacks DECLARED with parameters — the
* `setTimeout(resolve, ms)` sleep idiom, where Promise<unknown>'s
* resolve is (value: unknown) => void, i.e. func(dyn)=>void. That one
* shape adapts through an interned wrapper closure that calls the
* callback with the dyn undefined — exactly what JS's zero-argument
* invocation delivers (resolve(undefined) fulfills with undefined).
* Zero-param callbacks pass through; any other parameterized callback
* fences (a value for its parameter would have to be invented). */
function adaptZeroArgTimerCallback(L: Lowerer, cb: IrExpr, node: ts.Node, loc: SrcLoc): IrExpr {
// A REST-marked callback is not the zero-param ABI even with an empty
// fixed-param list — `setTimeout(function(){ arguments }, 0)` infers
// func(...dyn[])=>void (the variadic `arguments` form), and passing it
// through unadapted hands the libCall a shape it does not accept (the
// 12-settimeout-arguments ICE). It adapts below like any other
// parameterized callback: boxed through the checked-dynamic boundary
// when boxable, the named fence otherwise.
if (cb.type.kind !== "func" || (cb.type.params.length === 0 && !cb.type.rest && cb.type.ret.kind === "void")) return cb;
// A zero-param callback whose RETURN isn't void (`setTimeout(push, 1)`
// where push answers boolean|undefined; async callbacks — func()=>
// promise): JS ignores a timer callback's return value, so the shape
// adapts through an interned return-dropping wrapper that calls the
// callback and discards the result (a returned promise is Node's own
// fire-and-forget — rejections take the unhandled-rejection path,
// exactly as if the async callback ran under the timer directly).
if (cb.type.params.length === 0 && !cb.type.rest) {
const fromT = cb.type;
const toT: IrType = { kind: "func", params: [], ret: VOID };
const key = `timer.dropret:${typeKey(fromT)}`;
const existing = L.arrHofHelpers.get(key);
const name = existing ?? `%timer.dropret.${L.arrHofHelpers.size}`;
if (!existing) {
L.arrHofHelpers.set(key, name);
const impl = `${name}.impl`;
L.liftedFns.push({
name: impl,
params: [],
returnType: VOID,
captures: [{ localId: "f.0", name: "f", type: fromT }],
locals: [{ id: "f.0", name: "f", type: fromT, mutable: false, boxed: true }],
body: [
{
kind: "exprStmt",
expr: {
kind: "callValue",
callee: { kind: "varRef", localId: "f.0", type: fromT, loc },
args: [],
type: fromT.ret,
loc,
},
loc,
},
],
loc,
});
L.liftedFns.push({
name,
params: [{ localId: "f.0", name: "f", type: fromT }],
returnType: toT,
locals: [{ id: "f.0", name: "f", type: fromT, mutable: false, boxed: true }],
body: [
{ kind: "return", value: { kind: "closure", fnName: impl, captures: ["f.0"], type: toT, loc }, loc },
],
loc,
});
}
return { kind: "call", callee: name, args: [cb], type: toT, loc };
}
const fromT = cb.type;
const toT0: IrType = { kind: "func", params: [], ret: VOID };
if (fromT.rest || fromT.params.length !== 1 || fromT.params[0]!.kind !== "dyn" || fromT.ret.kind !== "void") {
// Any other BOXABLE signature rides the checked-dynamic function
// boundary instead: box the closure (dynFrom), adapt to () => void
// (dynCheck) — the thunk delivers JS's zero-argument invocation
// (each param sees undefined; a param type undefined fails checks
// throws the catchable TypeError, the SEMANTICS.md 117 stance).
// The JS-inferred mustCall wrapper (func(dyn,dyn)=>dyn) lands here.
if (canBoxFuncIntoDyn(fromT, (id) => L.shapes.get(id), (id) => L.unions.get(id))) {
const boxed: IrExpr = { kind: "dynFrom", value: cb, type: DYN, loc };
return { kind: "dynCheck", value: boxed, type: toT0, loc };
}
L.noLowering(
"setTimeout with a callback that takes arguments",
node,
"the callback is invoked with no arguments — wrap it: setTimeout(() => cb(...), ms)",
);
}
const toT: IrType = { kind: "func", params: [], ret: VOID };
const key = `timer.droparg:${typeKey(fromT)}`;
const existing = L.arrHofHelpers.get(key);
const name = existing ?? `%timer.droparg.${L.arrHofHelpers.size}`;
if (!existing) {
L.arrHofHelpers.set(key, name);
const impl = `${name}.impl`;
L.liftedFns.push({
name: impl,
params: [],
returnType: VOID,
captures: [{ localId: "f.0", name: "f", type: fromT }],
locals: [{ id: "f.0", name: "f", type: fromT, mutable: false, boxed: true }],
body: [
{
kind: "exprStmt",
expr: {
kind: "callValue",
callee: { kind: "varRef", localId: "f.0", type: fromT, loc },
args: [{ kind: "dynFrom", value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, type: DYN, loc }],
type: VOID,
loc,
},
loc,
},
],
loc,
});
L.liftedFns.push({
name,
params: [{ localId: "f.0", name: "f", type: fromT }],
returnType: toT,
locals: [{ id: "f.0", name: "f", type: fromT, mutable: false, boxed: true }],
body: [
{ kind: "return", value: { kind: "closure", fnName: impl, captures: ["f.0"], type: toT, loc }, loc },
],
loc,
});
}
return { kind: "call", callee: name, args: [cb], type: toT, loc };
}
/** The trailing-argument timer forms — `setTimeout(cb, ms, ...args)`,
* `setInterval(cb, ms, ...args)`, `setImmediate(cb, ...args)` — invoke
* the callback WITH those arguments (Node passes them through). The
* callback and every argument box into dyn and an interned per-arity
* thunk delivers the dynCall at fire time: JS's exact call semantics
* (per-argument checks against the callee's declared signature, extras
* ignored, a non-function callee throwing the catchable TypeError).
* Non-boxable callbacks fence. */
export function timerStyleCallback(L: Lowerer, callArgs: readonly ts.Expression[], what: string, loc: SrcLoc): IrExpr {
// The shared callback adaptation for timer-shaped surfaces whose
// trailing arguments start right after the callback (setImmediate,
// process.nextTick): zero-arg callbacks pass through, boxable
// parameterized ones ride the checked-dynamic boundary, trailing
// call arguments ride the interned per-arity dyn thunk.
return callArgs.length > 1
? makeTimerArgsThunk(L, callArgs[0]!, callArgs.slice(1), what, loc)
: adaptZeroArgTimerCallback(L, L.lowerExpr(callArgs[0]!), callArgs[0]!, loc);
}
function makeTimerArgsThunk(L: Lowerer, cbNode: ts.Expression, argNodes: readonly ts.Expression[], what: string, loc: SrcLoc): IrExpr {
const cbLowered = L.lowerExpr(cbNode);
let boxedCb: IrExpr;
if (cbLowered.type.kind === "dyn") {
boxedCb = cbLowered;
} else if (
cbLowered.type.kind === "func" &&
canBoxFuncIntoDyn(cbLowered.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
boxedCb = { kind: "dynFrom", value: cbLowered, type: DYN, loc };
} else {
L.noLowering(
`${what} with trailing arguments and a '${L.fmt(cbLowered.type)}' callback`,
cbNode,
"the callback must be a boxable function (or wrap it: () => cb(...))",
);
}
const args = argNodes.map((a) => L.lowerExprExpecting(a, DYN));
const n = args.length;
const toT: IrType = { kind: "func", params: [], ret: VOID };
const key = `timer.argsthunk:${n}`;
const existing = L.arrHofHelpers.get(key);
const name = existing ?? `%timer.argsthunk.${L.arrHofHelpers.size}`;
if (!existing) {
L.arrHofHelpers.set(key, name);
const impl = `${name}.impl`;
const capIds = ["f.0", ...args.map((_, i) => `a${i}.0`)];
const capNames = ["f", ...args.map((_, i) => `a${i}`)];
L.liftedFns.push({
name: impl,
params: [],
returnType: VOID,
captures: capIds.map((id, i) => ({ localId: id, name: capNames[i]!, type: DYN })),
locals: capIds.map((id, i) => ({ id, name: capNames[i]!, type: DYN, mutable: false, boxed: true })),
body: [
{
kind: "exprStmt",
expr: {
kind: "dynCall",
callee: { kind: "varRef", localId: "f.0", type: DYN, loc },
calleeName: "callback",
args: args.map((_, i) => ({ kind: "varRef", localId: `a${i}.0`, type: DYN, loc }) as IrExpr),
type: DYN,
loc,
},
loc,
},
],
loc,
});
L.liftedFns.push({
name,
params: capIds.map((id, i) => ({ localId: id, name: capNames[i]!, type: DYN })),
returnType: toT,
locals: capIds.map((id, i) => ({ id, name: capNames[i]!, type: DYN, mutable: false, boxed: true })),
body: [
{ kind: "return", value: { kind: "closure", fnName: impl, captures: capIds, type: toT, loc }, loc },
],
loc,
});
}
return { kind: "call", callee: name, args: [boxedCb, ...args], type: toT, loc };
}
/** The timer surface's member names — the ambient globals AND the
* node:timers module's exports (one set: Node's timers module re-exports
* the globals). */
export const TIMER_MODULE_MEMBERS: ReadonlySet<string> = new Set([
"setTimeout", "clearTimeout", "setInterval", "clearInterval", "setImmediate", "clearImmediate",
]);
/** Node tolerates clearTimeout/clearInterval/clearImmediate of anything
* that is not a live handle — null, undefined, plain objects, or no
* argument at all are silent no-ops. The SYNTACTICALLY side-effect-free
* spellings of those (the shapes Node's own tests use) lower to the
* dropped VOID no-op; an expression that must evaluate keeps the typed
* path. Null when the argument might be a real handle. */
function tolerantClearNoop(L: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr | null {
const noop: IrExpr = { kind: "libCall", fn: "timers.clearNoop", args: [], type: VOID, loc };
if (expr.arguments.length === 0) return noop;
if (expr.arguments.length !== 1) return null;
let arg = expr.arguments[0]!;
// `{} as never` / parenthesized spellings: the cast changes no value.
while (ts.isAsExpression(arg) || ts.isTypeAssertion(arg) || ts.isParenthesizedExpression(arg)) arg = arg.expression;
if (arg.kind === ts.SyntaxKind.NullKeyword) return noop;
if (ts.isObjectLiteralExpression(arg) && arg.properties.length === 0) return noop;
if (ts.isIdentifier(arg)) {
const t = L.mapTypeOf(L.typeOf(arg));
if (t && (t.kind === "nullT" || t.kind === "undefinedT")) return noop;
if (arg.text === "undefined") return noop;
}
return null;
}
/** One timer call by MEMBER NAME — the shared lowering behind the ambient
* globals, the node:timers named/destructured imports, and the namespace
* form (`timers.setTimeout(...)`). Null when the member isn't a lowered
* timer function (the caller's fence machinery takes over). */
export function lowerTimersMemberCall(L: Lowerer, expr: ts.CallExpression, member: string, loc: SrcLoc): IrExpr | null {
// setTimeout: the loop-owned one-shot. The one-argument form defaults
// the delay to 1ms (Node coerces an absent delay to 1); trailing
// arguments beyond the delay pass to the callback at fire time via
// the interned dyn thunk.
if (member === "setTimeout") {
if (expr.arguments.length === 0) {
L.noLowering("setTimeout with 0 arguments", expr, "the supported form is setTimeout(callback, ms?, ...args)");
}
const cb = expr.arguments.length > 2
? makeTimerArgsThunk(L, expr.arguments[0]!, expr.arguments.slice(2), "setTimeout", loc)
: adaptZeroArgTimerCallback(L, L.lowerExpr(expr.arguments[0]!), expr.arguments[0]!, loc);
const ms: IrExpr = expr.arguments.length >= 2
? L.lowerExpr(expr.arguments[1]!)
: { kind: "numLit", value: 1, type: F64, loc };
// The use position decides the shape: a Timeout handle (mapped to
// f64) when the call is USED (assigned, `.unref()`d, cleared) — the
// clearable-handle timer; plain void in statement position (the
// historic fire-and-forget setTimeout, no clear surface). Both ride
// the same heap; only the handle form can be unref'd/cleared.
const resultT = L.mapTypeOf(L.typeOf(expr));
if (resultT?.kind === "f64" && !ts.isExpressionStatement(expr.parent)) {
return { kind: "libCall", fn: "timers.setTimeoutHandle", args: [cb, ms], type: F64, loc };
}
return { kind: "libCall", fn: "timers.setTimeout", args: [cb, ms], type: VOID, loc };
}
// clearTimeout(handle): shares the interval clear (the handle ids
// share one space). A `Timeout | null` handle narrows first, like
// clearInterval.
if (member === "clearTimeout") {
const noop = tolerantClearNoop(L, expr, loc);
if (noop) return noop;
if (expr.arguments.length !== 1) {
L.noLowering(`clearTimeout with ${expr.arguments.length} arguments`, expr);
}
const handle = L.lowerExpr(expr.arguments[0]!);
if (handle.type.kind !== "f64") {
L.noLowering(
`clearTimeout of '${L.fmt(handle.type)}' handles`,
expr.arguments[0]!,
"the handle is the Timeout setTimeout returned (narrow 'Timeout | null' first)",
);
}
return { kind: "libCall", fn: "timers.clearTimeout", args: [handle], type: VOID, loc };
}
// setInterval/clearInterval: the repeating pair. The Timeout handle
// maps to the f64 interval id; a live interval keeps the event loop
// alive and clearInterval releases it, like Node. The callback adapts
// exactly like setTimeout's: zero-param passes through, the
// one-dyn-param sleep idiom drops its argument, any other boxable
// shape (the JS-inferred mustCall wrapper) rides the checked-dynamic
// function boundary; trailing arguments beyond the delay pass to the
// callback each tick via the interned dyn thunk.
if (member === "setInterval") {
if (expr.arguments.length === 0) {
L.noLowering("setInterval with 0 arguments", expr, "the supported form is setInterval(callback, ms?, ...args)");
}
const cb = expr.arguments.length > 2
? makeTimerArgsThunk(L, expr.arguments[0]!, expr.arguments.slice(2), "setInterval", loc)
: adaptZeroArgTimerCallback(L, L.lowerExpr(expr.arguments[0]!), expr.arguments[0]!, loc);
const ms: IrExpr = expr.arguments.length >= 2
? L.lowerExpr(expr.arguments[1]!)
: { kind: "numLit", value: 1, type: F64, loc };
return { kind: "libCall", fn: "timers.setInterval", args: [cb, ms], type: F64, loc };
}
if (member === "clearInterval") {
const noop = tolerantClearNoop(L, expr, loc);
if (noop) return noop;
if (expr.arguments.length !== 1) {
L.noLowering(`clearInterval with ${expr.arguments.length} arguments`, expr);
}
const handle = L.lowerExpr(expr.arguments[0]!);
if (handle.type.kind !== "f64") {
L.noLowering(
`clearInterval of '${L.fmt(handle.type)}' handles`,
expr.arguments[0]!,
"the handle is the number setInterval returned (narrow `number | null` first)",
);
}
return { kind: "libCall", fn: "timers.clearInterval", args: [handle], type: VOID, loc };
}
// setImmediate/clearImmediate: Node's check-phase pair. The handle is
// the f64 immediate id (its own space — clearTimeout of an Immediate
// no-ops, like Node); the callback adapts like setTimeout's.
if (member === "setImmediate") {
if (expr.arguments.length === 0) {
L.noLowering("setImmediate with 0 arguments", expr, "the supported form is setImmediate(callback, ...args)");
}
const cb = expr.arguments.length > 1
? makeTimerArgsThunk(L, expr.arguments[0]!, expr.arguments.slice(1), "setImmediate", loc)
: adaptZeroArgTimerCallback(L, L.lowerExpr(expr.arguments[0]!), expr.arguments[0]!, loc);
return { kind: "libCall", fn: "timers.setImmediate", args: [cb], type: F64, loc };
}
if (member === "clearImmediate") {
const noop = tolerantClearNoop(L, expr, loc);
if (noop) return noop;
if (expr.arguments.length !== 1) {
L.noLowering(`clearImmediate with ${expr.arguments.length} arguments`, expr);
}
const handle = L.lowerExpr(expr.arguments[0]!);
if (handle.type.kind !== "f64") {
L.noLowering(
`clearImmediate of '${L.fmt(handle.type)}' handles`,
expr.arguments[0]!,
"the handle is the Immediate setImmediate returned (narrow 'Immediate | undefined' first)",
);
}
return { kind: "libCall", fn: "timers.clearImmediate", args: [handle], type: VOID, loc };
}
return null;
}
function ffiTypeForClass(
cls: IrFfiImport["params"][number] | IrFfiImport["returns"],
): IrType {
switch (cls) {
case "bool":
return BOOL;
case "string":
return STRING;
case "bytes":
return BYTES_U8;
case "void":
return VOID;
default:
return F64;
}
}
/** The declaration half of an outbound FFI binding. Kept independent of
* call-site argument checks so the whole manifest can be validated even
* when a configured function is never called. */
function ffiDeclarationDiagnostic(
L: Lowerer,
binding: IrFfiImport,
symbol: ts.Symbol,
loc: SrcLoc,
): ScrDiagnostic | null {
const declarations = L.checker.declarationsOf(symbol);
const functionDecls = declarations.filter(ts.isFunctionDeclaration);
if (
functionDecls.length === 0 ||
declarations.some((decl) => !ts.isFunctionDeclaration(decl)) ||
functionDecls.some((decl) => decl.body !== undefined)
) {
return ffiBindingDiag(
binding.name,
"the configured name does not resolve exclusively to signature-only function declarations",
loc,
);
}
if (functionDecls.some((decl) => (decl.typeParameters?.length ?? 0) > 0)) {
return ffiSignatureDiag(
binding.name,
"generic ambient declarations cannot describe one fixed C ABI",
loc,
);
}
const signatures = L.checker.getCallSignatures(L.checker.getTypeOfSymbol(symbol));
if (signatures.length !== 1) {
return ffiSignatureDiag(
binding.name,
`the ambient binding has ${signatures.length} call signatures; exactly one non-overloaded signature is required`,
loc,
);
}
const signature = signatures[0]!;
const params = signature.getParameters();
if (params.length !== binding.params.length) {
return ffiSignatureDiag(
binding.name,
`the TypeScript declaration has ${params.length} parameter(s), but the manifest declares ${binding.params.length}`,
loc,
);
}
const expectedParams = binding.params.map(ffiTypeForClass);
for (let i = 0; i < params.length; i++) {
const paramType = L.checker.getTypeOfSymbol(params[i]!);
// `mapType` deliberately gives uninhabited value positions a cheap f64
// slot because no TypeScript value can ever reach them. An FFI
// declaration is different: it is a callable external contract, so a
// `never` slot cannot truthfully describe any native parameter.
if ((paramType.flags & ts.TypeFlags.Never) !== 0) {
return ffiSignatureDiag(
binding.name,
`parameter ${i + 1} is 'never', an uninhabited TypeScript type that cannot describe a native ABI parameter`,
loc,
);
}
const mapped = L.mapTypeOf(paramType);
const expected = expectedParams[i]!;
if (mapped === null || !typeEquals(mapped, expected)) {
return ffiSignatureDiag(
binding.name,
`parameter ${i + 1} maps to '${mapped === null ? L.checker.typeToString(paramType) : L.fmt(mapped)}', ` +
`which does not fit manifest class '${binding.params[i]}'`,
loc,
);
}
}
const returnType = L.checker.getReturnTypeOfSignature(signature);
// A native function is allowed to return. Accepting `never` here would
// let tsc erase all control flow after the call while the linked function
// continues, making the generated program disagree with TypeScript.
if ((returnType.flags & ts.TypeFlags.Never) !== 0) {
return ffiSignatureDiag(
binding.name,
"the return type is 'never', but a native ABI return cannot uphold TypeScript's non-returning contract",
loc,
);
}
const declaredReturn = L.mapTypeOf(returnType);
const expectedReturn = ffiTypeForClass(binding.returns);
if (declaredReturn === null || !typeEquals(declaredReturn, expectedReturn)) {
return ffiSignatureDiag(
binding.name,
`the return maps to '${declaredReturn === null ? L.checker.typeToString(returnType) : L.fmt(declaredReturn)}', ` +
`which does not fit manifest class '${binding.returns}'`,
loc,
);
}
return null;
}
export interface FfiValidationResult {
diagnostics: ScrDiagnostic[];
symbolsByName: ReadonlyMap<string, ReadonlySet<ts.Symbol>>;
}
/** Resolve and validate every configured outbound binding before emit.
* Candidate declarations are signature-only functions bearing the manifest
* name anywhere in the program. Multiple scoped declarations are all native
* bindings under the existing name-based call surface, so every candidate
* must fit the one manifest ABI. */
export function validateFfiImports(L: Lowerer): FfiValidationResult {
const diagnostics: ScrDiagnostic[] = [];
const symbolsByName = new Map<string, ReadonlySet<ts.Symbol>>();
const configuredNames = new Set(L.ffiImports.map((binding) => binding.name));
const candidates = new Map<string, Map<ts.Symbol, ts.FunctionDeclaration>>();
if (configuredNames.size === 0) return { diagnostics, symbolsByName };
for (const file of L.program.getSourceFiles()) {
ts.walkPreorder(file, (node) => {
if (ts.isFunctionDeclaration(node)) {
if (
node.body === undefined &&
node.name !== undefined &&
configuredNames.has(node.name.text)
) {
const symbol = L.checker.getSymbolAtLocation(node.name);
if (symbol !== undefined) {
let bySymbol = candidates.get(node.name.text);
if (bySymbol === undefined) {
bySymbol = new Map();
candidates.set(node.name.text, bySymbol);
}
if (!bySymbol.has(symbol)) bySymbol.set(symbol, node);
}
}
return "skip";
}
if (ts.isFunctionLike(node)) return "skip";
});
}
for (const binding of L.ffiImports) {
const bySymbol = candidates.get(binding.name);
if (bySymbol === undefined || bySymbol.size === 0) {
diagnostics.push(
ffiBindingDiag(
binding.name,
"the program has no signature-only function declaration with this name",
{ file: L.entry.fileName, start: 0, end: 0 },
),
);
continue;
}
const validSymbols = new Set<ts.Symbol>();
let valid = true;
for (const [symbol, declaration] of bySymbol) {
const diagnostic = ffiDeclarationDiagnostic(
L,
binding,
symbol,
locOf(declaration),
);
if (diagnostic === null) {
validSymbols.add(symbol);
} else {
diagnostics.push(diagnostic);
valid = false;
}
}
if (valid) symbolsByName.set(binding.name, validSymbols);
}
return { diagnostics, symbolsByName };
}
/** A manifest-bound call of a signature-only ambient declaration. This
* recognition deliberately runs before ambientUndefVarRootOf: without the
* manifest the exact same source keeps Node's ReferenceError semantics;
* with it, only the resolved declaration binding (never a shadowing
* function with a body) becomes a direct native call. */
export function lowerFfiCall(L: Lowerer, expr: ts.CallExpression): IrExpr | null {
if (!ts.isIdentifier(expr.expression)) return null;
const binding = L.ffiImportsByName.get(expr.expression.text);
if (binding === undefined) return null;
const loc = locOf(expr);
const bindingError = (detail: string): never => {
L.pushDiag(ffiBindingDiag(binding.name, detail, loc));
throw new PoisonError();
};
const signatureError = (detail: string): never => {
L.pushDiag(ffiSignatureDiag(binding.name, detail, loc));
throw new PoisonError();
};
const symbol =
L.resolveValueSymbol(expr.expression) ??
bindingError("the call has no resolved TypeScript symbol");
if (L.ffiBindingSymbols !== null) {
const validSymbols = L.ffiBindingSymbols.get(binding.name);
// No entry means the program-level pass already diagnosed this
// binding. Poison the statement without duplicating that diagnostic.
if (validSymbols === undefined) throw new PoisonError();
if (!validSymbols.has(symbol)) {
// TypeScript resolved this call to a distinct local declaration.
// The manifest owns only the exact validated ambient binding; a
// same-named function with a body remains ordinary scriptc code.
return null;
}
} else {
const diagnostic = ffiDeclarationDiagnostic(L, binding, symbol, loc);
if (diagnostic !== null) {
L.pushDiag(diagnostic);
throw new PoisonError();
}
}
if (expr.questionDotToken !== undefined || expr.typeArguments !== undefined) {
signatureError("native bindings support direct, non-generic calls only");
}
if (expr.arguments.some(ts.isSpreadElement)) {
signatureError("spread arguments do not have a fixed native ABI");
}
if (expr.arguments.length !== binding.params.length) {
signatureError(
`this call passes ${expr.arguments.length} argument(s), but the native ABI requires exactly ${binding.params.length}`,
);
}
const expectedParams = binding.params.map(ffiTypeForClass);
const expectedReturn = ffiTypeForClass(binding.returns);
const args = expr.arguments.map((arg, i) =>
L.lowerExprExpecting(arg, expectedParams[i]!)
);
return {
kind: "ffiCall",
import: binding.name,
args,
type: expectedReturn,
loc,
};
}
export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr {
const loc = locOf(expr);
// A call whose chain ROOTS at an ambient-undefined name (`declare
// const value: Y | undefined; value?.foo("a")`, `declare function
// chain...; chain(o).mapValues(f).value()`, a trap binding's read):
// Node evaluates the root FIRST and throws the catchable
// ReferenceError before any member, type argument, or argument runs —
// the whole call IS that throw, typed by the use site (arguments
// never lower; Node never evaluates them). Claimed before every
// intrinsic and dispatch path: no lowering can answer differently
// when the root read itself is the crash.
{
const root = ambientUndefVarRootOf(L, expr);
if (root !== null) {
const mapped = L.mapTypeOf(L.typeOf(expr));
const t =
mapped && mapped.kind !== "void" && !L.typeNamesUnregisteredClass(mapped)
? mapped
: (contextualUndefReadType(L, expr) ?? F64);
return nsUndefRead(L, root.text, expr, t);
}
}
// A method call through a NULLISH binding (`const i: I<A & B> = null
// as any; i.fn(...)` — the receiver provably holds null/undefined
// forever): the member READ throws Node's exact TypeError before any
// argument evaluates — the whole call lowers to that throw. Claimed
// when the receiver's type has no mapping (no other story exists) or
// the member is a generic signature (the alternative is the
// interface-dispatch fence — the runtime truth is this throw).
if (
ts.isPropertyAccessExpression(expr.expression) &&
ts.isIdentifier(expr.expression.expression) &&
expr.expression.questionDotToken === undefined
) {
const recvSym = L.resolveValueSymbol(expr.expression.expression);
const unit = nullishValueUnitOf(L, recvSym);
if (unit !== null) {
const recvUnmappable =
recvSym !== null && L.mapTypeOf(L.checker.getTypeOfSymbol(recvSym)) === null;
const propSym = L.checker.getPropertyOfType(
L.typeOf(expr.expression.expression),
expr.expression.name.text,
);
const genericMember =
propSym !== undefined && propSym !== null &&
isGenericCallableMemberType(L.checker.getTypeOfSymbol(propSym), L.checker);
if (recvUnmappable || genericMember) {
const mapped = L.mapTypeOf(L.typeOf(expr));
const t = mapped && mapped.kind !== "void" && !L.typeNamesUnregisteredClass(mapped) ? mapped : F64;
return nodeThrowExpr(1, "", `Cannot read properties of ${unit} (reading '${expr.expression.name.text}')`, t, loc);
}
}
}
// `require("spec")` through a createRequire binding (and the inline
// `createRequire(import.meta.url)("spec")` spelling — a CallExpression
// callee no other dispatch path serves): the static erasure —
// builtins/json/npm per lowerCreateRequireCall's arms.
{
const crServed = lowerCreateRequireCall(L, expr, loc);
if (crServed) return crServed;
}
// `process.getuid?.()` — intercepted BEFORE the optional-chain
// machinery (the member always exists on a POSIX target, so the
// optional call IS the call; `process.getuid` itself has no value
// lowering for the chain to guard).
const processOptional = L.lowerProcessOptionalMethodCall(expr);
if (processOptional) return processOptional;
// `t.unref?.()` on a Timeout handle — same story: the method always
// exists, so the optional call is the call.
if (expr.questionDotToken && ts.isPropertyAccessExpression(expr.expression)) {
const timeoutOptional = L.lowerTimeoutMethodCall(expr, expr.expression);
if (timeoutOptional) return timeoutOptional;
}
// `req.stream?.on(...)` — the http2 compatibility request's h2-only
// stream member, guarded. The allowHTTP1 lowering serves every
// connection as HTTP/1.1, where Node answers undefined for req.stream:
// the optional chain short-circuits, the arguments never evaluate, and
// the whole statement is a no-op — exactly what lowers here (a VOID
// no-op the emitter drops). The receiver is restricted to an
// identifier so no evaluation is skipped, and to statement position so
// no value is consumed (an unguarded or computed use meets the pointed
// per-member fence in lower-server.ts instead).
if (
ts.isPropertyAccessExpression(expr.expression) &&
ts.isPropertyAccessExpression(expr.expression.expression) &&
!expr.expression.expression.questionDotToken &&
(expr.expression.expression.name.text === "stream" ||
expr.expression.expression.name.text === "session") &&
ts.isIdentifier(expr.expression.expression.expression) &&
L.mapTypeOf(L.typeOf(expr.expression.expression.expression))?.kind === "httpReq" &&
L.isStdlibMember(expr.expression.expression)
) {
const member = expr.expression.expression.name.text;
if (!ts.isExpressionStatement(expr.parent) && !ts.isArrowFunction(expr.parent)) {
L.unsupported(
"SC1090",
expr,
`using the result of the '${member}${expr.expression.questionDotToken ? "?." : "."}${expr.expression.name.text}(...)' call (${member} is always undefined on this HTTP/1.1 lowering — call it as its own statement)`,
);
}
if (!expr.expression.questionDotToken) {
// The UNGUARDED form: on this lowering (and in Node, on every
// HTTP/1.1 connection of an allowHTTP1 server) req.stream is
// undefined — the member read on undefined THROWS Node's exact
// TypeError, catchably (JS evaluates the receiver, throws reading
// the method, and never evaluates the arguments — the identifier
// receiver and unevaluated arguments make that order exact here).
return {
kind: "libCall",
fn: "http2.streamUndefCall",
args: [{ kind: "strLit", value: expr.expression.name.text, type: STRING, loc }],
type: VOID,
loc,
};
}
return { kind: "libCall", fn: "http2.streamNoop", args: [], type: VOID, loc };
}
// Optional-chain call forms: `f?.()` (the token on the call) and
// `a?.m()` (the token on the member access). The handled markers keep
// the chain lowering's re-entrant dispatch from looping.
if (
(expr.questionDotToken && !L.chainHandled.has(expr)) ||
(ts.isPropertyAccessExpression(expr.expression) &&
expr.expression.questionDotToken &&
!L.chainHandled.has(expr.expression))
) {
return L.lowerOptionalChain(expr);
}
// super(...) is handled by the derived-constructor lowering as a
// top-level statement (its field-initializer ordering lives there);
// any other position would misorder initialization — rejected.
if (expr.expression.kind === ts.SyntaxKind.SuperKeyword) {
L.unsupported("SC1090", expr, "super() calls anywhere but as a top-level constructor statement");
}
// super.method(...): a DIRECT (never virtual) call of the base chain's
// implementation over the same `this` — JS's super dispatch exactly.
if (
ts.isPropertyAccessExpression(expr.expression) &&
expr.expression.expression.kind === ts.SyntaxKind.SuperKeyword
) {
return L.lowerSuperMethodCall(expr, expr.expression);
}
const consoleMember = L.consoleCallMember(expr);
if (consoleMember !== null) {
// console.log/info/debug write stdout; console.error and console.warn
// are one stream in Node (warn IS error, info and debug ARE log) and
// write stderr with the exact same formatting. Node's formatter is
// formatWithOptions: string arguments print verbatim, numbers and
// booleans directly, and EVERYTHING else through util.inspect at the
// rest-args depth 2 — which the static inspect machinery renders
// here (arrays, records, unions, Maps/Sets, undefined/null, ...);
// shapes inspect cannot render keep honest per-argument fences.
const surface = `console.${consoleMember}`;
const stdoutMember = consoleMember === "log" || consoleMember === "info" || consoleMember === "debug";
// A LITERAL format string with %-specifiers and further arguments
// (`console.log('Mismatched %s function calls. Expected %s, actual
// %d.', name, seg, n)` — test/common's exit report): Node's console
// formatter IS util.format — route through the format lowering and
// print its one string. Specifier-free first strings keep the
// plain space-joined path below (identical output, cheaper).
if (
expr.arguments.length > 1 &&
expr.arguments[0] !== undefined &&
(ts.isStringLiteral(expr.arguments[0]) || ts.isNoSubstitutionTemplateLiteral(expr.arguments[0])) &&
/%[sdifjoOc%]/.test(expr.arguments[0].text)
) {
const formatted = lowerFormatCall(L, expr, loc, false);
return {
kind: "intrinsic",
name: stdoutMember ? "console.log" : "console.error",
args: [formatted],
type: VOID,
loc,
};
}
const args = expr.arguments.map((a) => {
const lowered = L.lowerExpr(a);
if (lowered.type.kind === "jsval") {
// Node prints objects with util.inspect formatting, which
// String() cannot match — silent divergence is banned. Templates
// are ToString (Node-exact), casts are validated: both honest.
L.unsupported(
"SC1090",
a,
`${surface} of 'any' values (wrap it: ${surface}(\`\${v}\`), or validate with 'as <type>' first)`,
);
}
// Checked-dynamic values carry their own shape, so the runtime
// renders them exactly like Node's console formatter renders a
// non-format argument: strings VERBATIM, everything else through
// inspect at the rest-args depth 2 (formatWithOptions) — scalar
// kinds byte-exactly, boxed functions as [Function: name] /
// [Function (anonymous)], composites through the dyn walk
// (insp.dyn). Never throws — Node's console.log never does.
if (lowered.type.kind === "dyn") {
return {
kind: "libCall",
fn: "insp.dynS",
args: [lowered, { kind: "numLit", value: 2, type: F64, loc }],
type: STRING,
loc,
} satisfies IrExpr;
}
// A function VALUE prints Node's [Function: name] form by boxing
// across the checked-dynamic boundary (the box carries the
// best-effort reference-site name — the documented naming stance)
// and rendering through the same dyn arm.
if (
lowered.type.kind === "func" &&
canBoxFuncIntoDyn(lowered.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
const name = jsFuncNameOf(a);
const boxed: IrExpr = {
kind: "dynFrom",
value: lowered,
type: DYN,
...(name !== null ? { fnName: name } : {}),
loc,
};
return {
kind: "libCall",
fn: "insp.dynS",
args: [boxed, { kind: "numLit", value: 2, type: F64, loc }],
type: STRING,
loc,
} satisfies IrExpr;
}
// number/string/boolean ride the ScrLogArg protocol directly (the
// runtime formats them Node-exactly — including -0).
if (lowered.type.kind === "f64" || lowered.type.kind === "string" || lowered.type.kind === "bool") {
return lowered;
}
// Everything else renders through the static inspect machinery at
// the rest-args depth 2 (formatWithOptions): arrays, records,
// unions (a string arm prints VERBATIM — the console.log vs
// inspect distinction, per arm), Maps/Sets, plain undefined/null,
// regexes, symbols, error values, Buffers. Shapes inspect cannot
// render fence honestly with the reason.
return lowerConsoleInspectArg(L, a, lowered, surface, loc);
});
return {
kind: "intrinsic",
name: stdoutMember ? "console.log" : "console.error",
args,
type: VOID,
loc,
};
}
// The timer globals — setTimeout/clearTimeout, setInterval/
// clearInterval, setImmediate/clearImmediate. Provenance-checked (a
// user function shadowing the name has a different, non-ambient
// symbol); the shared member dispatch also serves the node:timers
// module forms (Node's timers module re-exports the globals).
if (
ts.isIdentifier(expr.expression) &&
TIMER_MODULE_MEMBERS.has(expr.expression.text) &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined) &&
// A named/destructured node:timers/promises import shares the
// spelling but is the PROMISIFIED surface (`await setTimeout(1)`)
// — its own builtin-module lowering owns it below.
L.builtinImportOf(expr.expression)?.module !== "timers/promises"
) {
const served = lowerTimersMemberCall(L, expr, expr.expression.text, loc);
if (served) return served;
}
// queueMicrotask: the callback enters the SAME FIFO promise
// continuations ride (one microtask order), and a throw surfaces as
// an UNCAUGHT exception, like Node. A checked-dynamic argument (the
// mustCall wrapper, the suite's invalid-input probes) routes to the
// runtime form that throws Node's ERR_INVALID_ARG_TYPE synchronously
// on non-functions; extra arguments are Node-ignored (evaluated
// nowhere — a documented residue: Node evaluates them). Provenance-
// checked like setTimeout.
if (
ts.isIdentifier(expr.expression) &&
expr.expression.text === "queueMicrotask" &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
if (expr.arguments.length === 0) {
// Node: queueMicrotask() throws ERR_INVALID_ARG_TYPE at runtime
// (the undefined callback) — the Dyn form delivers exactly that.
return { kind: "libCall", fn: "timers.queueMicrotaskDyn", args: [dynUndefinedExpr(loc)], type: VOID, loc };
}
const raw = expr.arguments[0]!;
const cb = L.lowerExpr(raw);
if (cb.type.kind === "dyn") {
return { kind: "libCall", fn: "timers.queueMicrotaskDyn", args: [cb], type: VOID, loc };
}
if (cb.type.kind !== "func" && (cb.kind === "unitLit" || L.dynConvertible(cb.type))) {
// A statically-typed non-function (the invalid-input probes'
// scalars and unions): Node's synchronous ERR_INVALID_ARG_TYPE,
// through the Dyn form.
return {
kind: "libCall",
fn: "timers.queueMicrotaskDyn",
args: [{ kind: "dynFrom", value: cb, type: DYN, loc }],
type: VOID,
loc,
};
}
const adapted = adaptZeroArgTimerCallback(L, cb, raw, loc);
if (adapted.type.kind !== "func") {
L.noLowering(
`queueMicrotask with a '${L.fmt(cb.type)}' argument`,
raw,
"a zero-parameter function is the lowered form",
);
}
return { kind: "libCall", fn: "timers.queueMicrotask", args: [adapted], type: VOID, loc };
}
// structuredClone: the JSON-safe + bytes subset over the checked-dynamic tree, deep;
// %DOMException clones through WebIDL serialization (name/message,
// the code re-derives). Functions/handles throw the spec's catchable
// DataCloneError; cycles fence (the checked-dynamic tree cannot represent them — Node
// clones cycles, a documented divergence). Option validation throws
// Node's exact errors; the zero-argument call Node's
// ERR_MISSING_ARGS. Provenance-checked like setTimeout.
if (
ts.isIdentifier(expr.expression) &&
expr.expression.text === "structuredClone" &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
if (expr.arguments.length === 0) {
return { kind: "libCall", fn: "dyn.cloneMissing", args: [], type: DYN, loc };
}
if (expr.arguments.length > 2) {
L.noLowering(`structuredClone with ${expr.arguments.length} arguments`, expr);
}
const toDynArg = (a: ts.Expression | undefined): IrExpr => {
if (!a) return dynUndefinedExpr(loc);
const v = L.lowerExpr(a);
const conv = L.coerceToExpected(v, DYN);
if (conv.type.kind !== "dyn") {
L.noLowering(
`structuredClone with a '${L.fmt(v.type)}' argument`,
a,
"JSON-safe data, bytes, and DOMException values are the cloneable subset",
);
}
return conv;
};
const valueNode = expr.arguments[0]!;
// A NON-EMPTY transfer array of static values: nothing static is
// transferable, so the call is Node's DataCloneError — decided here
// (the list's values need no dyn representation to fail). An EMPTY
// literal transfer list is a no-op member and drops.
{
let optNode = expr.arguments[1];
while (optNode && ts.isParenthesizedExpression(optNode)) optNode = optNode.expression;
if (optNode && ts.isObjectLiteralExpression(optNode)) {
const tr = optNode.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) && p.name !== undefined &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === "transfer",
);
if (tr && ts.isArrayLiteralExpression(tr.initializer) && tr.initializer.elements.length > 0) {
return { kind: "libCall", fn: "dyn.cloneTransferFail", args: [], type: DYN, loc };
}
}
}
const optsArg = toDynArg(expr.arguments[1]);
// A DOMException value clones through its own runtime arm — the
// typed result keeps the class (instanceof, .code, throwability).
const valueT = L.mapTypeOf(L.typeOf(valueNode));
if (valueT?.kind === "object" && valueT.className === "%DOMException") {
const recv = L.lowerExpr(valueNode);
return {
kind: "libCall",
fn: "error.domClone",
args: [recv, optsArg],
type: { kind: "object", className: "%DOMException" },
loc,
};
}
const value = toDynArg(valueNode);
const cloned: IrExpr = { kind: "libCall", fn: "dyn.structuredClone", args: [value, optsArg], type: DYN, loc };
// The declared result is the value's own type (the generic's T):
// validate the dyn copy back into it when the type can be checked;
// dyn-typed and unmappable results stay dyn values (JS files).
const resultT = L.mapTypeOf(L.typeOf(expr));
if (
resultT !== null && resultT.kind !== "dyn" && resultT.kind !== "void" &&
canDynCheckTo(resultT, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
return { kind: "dynCheck", value: cloned, type: resultT, loc };
}
return cloned;
}
// comptime: compile-time evaluation. Provenance-checked like setTimeout —
// a user function named `comptime` has a different, non-ambient symbol
// and takes the ordinary call paths.
if (
ts.isIdentifier(expr.expression) &&
expr.expression.text === "comptime" &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
return L.lowerComptime(expr);
}
// The lib constructors-as-functions with STATIC conversion semantics:
// String(x) is exactly the template-literal ToString, Boolean(x) is
// exactly the condition ToBoolean (union arms included), Number(x) is
// ToNumber where it lowers exactly: numbers pass through, booleans
// become 1/0, and strings run the runtime's ECMA-exact
// StringToNumber (num.fromString — the full StringNumericLiteral
// grammar, scr_string.c). Other argument types (unions included —
// narrow first) keep the fence.
// Provenance-checked like setTimeout; zero-arg forms are the JS
// constants ("", false, 0). `new String(...)` (wrapper objects) stays
// on the SC2020 fence.
if (
ts.isIdentifier(expr.expression) &&
(expr.expression.text === "String" ||
expr.expression.text === "Boolean" ||
expr.expression.text === "Number") &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
const name = expr.expression.text;
if (expr.arguments.length > 1) {
L.noLowering(`${name} with ${expr.arguments.length} arguments`, expr);
}
const argNode = expr.arguments[0];
if (!argNode) {
if (name === "String") return { kind: "strLit", value: "", type: STRING, loc };
if (name === "Boolean") return { kind: "boolLit", value: false, type: BOOL, loc };
return { kind: "numLit", value: 0, type: F64, loc };
}
// String(e) on a catch binding: the snapshot's own ToString —
// intercepted before lowerExpr (caughtRead would fence the raw read).
if (name === "String") {
const caught = L.caughtToString(argNode);
if (caught) return caught;
}
// Boolean(x) IS condition position: route through lowerCondition so
// `&&`/`||` operands descend as ToBoolean'd conditions (JS-exact —
// `Boolean(a && b)` ≡ `Boolean(a) && Boolean(b)`, short-circuit
// preserved). This also admits mixed-kind operands with no VALUE
// representation (`Boolean(rec && list.some(f))` — a record and a
// bool) that a value lowering of the `&&` would fence on.
if (name === "Boolean") return L.lowerCondition(argNode);
const arg = L.lowerExpr(argNode);
if (name === "String") return L.ensureString(arg, argNode);
if (arg.type.kind === "f64") return arg;
if (arg.type.kind === "bool") {
return {
kind: "ternary",
cond: arg,
then: { kind: "numLit", value: 1, type: F64, loc },
else_: { kind: "numLit", value: 0, type: F64, loc },
type: F64,
loc,
};
}
if (arg.type.kind === "string") {
return { kind: "libCall", fn: "num.fromString", args: [arg], type: F64, loc };
}
L.noLowering(
`Number of ${L.fmt(arg.type)} values`,
argNode,
arg.type.kind === "union"
? "numbers, booleans, and strings lower (the full ToNumber string grammar included) — narrow the union first"
: undefined,
);
}
// __island_eval: the internal island testing hook (eval in the embedded
// engine, String(result) back). Provenance-checked like setTimeout.
// Only meaningful when the engine is linked: without --dynamic it is a
// clean requires-dynamic diagnostic, never an ICE or a link error.
if (
ts.isIdentifier(expr.expression) &&
expr.expression.text === "__island_eval" &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
if (!L.dynamic) {
L.pushDiag(requiresDynamicDiag("'__island_eval'", loc));
throw new PoisonError();
}
const code = L.lowerExprExpecting(expr.arguments[0]!, STRING);
return { kind: "libCall", fn: "island.eval", args: [code], type: STRING, loc };
}
// Island calls. A property-access callee whose receiver is an 'any'
// value is an engine method call (this = receiver, JS-exact); any other
// 'any'-typed callee is an engine function call. Arguments marshal in;
// results stay island values.
// A questionDotToken here is always chain-handled (the gate at the top
// of lowerCall routed unhandled ones to the chain lowering), so
// `x?.y(...)` re-dispatches into this same method-call form with the
// receiver reading back as the chain's bound handle.
if (
ts.isPropertyAccessExpression(expr.expression) &&
L.isIslandExpr(expr.expression.expression)
) {
const receiver = L.lowerExpr(expr.expression.expression);
// A checker-`any` receiver whose VALUE lives in the checked-dynamic tree (a
// checked-dynamic local behind the any-typed spelling — the JS
// WeakSet placeholder, rest-args arrays): the checked-dynamic
// method machinery owns it — receiver-kind dispatch, stored-member
// calls, honest fences — never an engine op over a dyn value.
if (receiver.type.kind === "dyn") {
const served = lowerDynReceiverMethodCall(L, expr, expr.expression);
if (served) return served;
L.unsupported(
"SC1100",
expr,
`'.${expr.expression.name.text}()' calls through 'unknown'-valued receivers in dynamically-executed positions`,
);
}
const args = expr.arguments.map((a) => L.jsvalIn(L.lowerExpr(a), a));
const result: IrExpr = {
kind: "jsOp", op: "callMethod", name: expr.expression.name.text,
args: [receiver, ...args], type: JSVAL, loc,
};
return islandPrimitiveExit(L, expr, result);
}
if (L.isIslandExpr(expr.expression)) {
// `o.m(...)` where o LOWERS checked-dynamic and the checker types
// `o.m` 'any' (a member read behind an 'object'/'unknown'-typed
// bag): METHOD-CALL semantics — receiver-kind dispatch (dynInvoke),
// so `this` binds and a WRAPPED island receiver runs the ENGINE's
// own method (the routed-ops lane). A stored-member dynCall would
// call engine prototype methods receiverless — the this-less
// `list.slice()` ToObject TypeError. Spread arguments keep the
// stored-member path below (the runtime-arity lane owns them).
if (
ts.isPropertyAccessExpression(expr.expression) &&
!expr.expression.questionDotToken &&
!expr.questionDotToken &&
!expr.arguments.some((a) => ts.isSpreadElement(a))
) {
const recvProbe = probeLower(L, expr.expression.expression);
if (recvProbe?.type.kind === "dyn") {
const args = expr.arguments.map((a) => L.lowerExprExpecting(a, DYN));
return {
kind: "dynInvoke",
recv: recvProbe,
method: expr.expression.name.text,
calleeName: expr.expression.getText(),
args,
type: DYN,
loc,
};
}
}
const callee = L.lowerExpr(expr.expression);
// A checker-`any` callee that LOWERED checked-dynamic (a dyn member
// chain's stored function): the checked-dynamic tree's own call — dynCall reads and
// calls the stored member with Node's is-not-a-function TypeError
// on refusal.
if (callee.type.kind === "dyn") {
const args = expr.arguments.map((a) => L.lowerExprExpecting(a, DYN));
const calleeName = ts.isPropertyAccessExpression(expr.expression)
? expr.expression.getText()
: ts.isIdentifier(expr.expression)
? expr.expression.text
: "value";
return { kind: "dynCall", callee, calleeName, args, type: DYN, loc };
}
// `fn(...args)` — a TRAILING spread into an island call: the
// engine's own apply (`fn.apply(undefined, argsArray)`); leading
// plain arguments prepend through `[l1, l2].concat(argsArray)`
// (concat flattens the array argument one level — exactly the
// spread). Other spread shapes keep the syntax fence.
if (
expr.arguments.length > 0 &&
ts.isSpreadElement(expr.arguments[expr.arguments.length - 1]!) &&
expr.arguments.slice(0, -1).every((a) => !ts.isSpreadElement(a))
) {
const spread = expr.arguments[expr.arguments.length - 1] as ts.SpreadElement;
const spreadV = L.jsvalIn(L.lowerExpr(spread.expression), spread.expression);
const leading = expr.arguments.slice(0, -1).map((a) => L.jsvalIn(L.lowerExpr(a), a));
const argsArr: IrExpr =
leading.length === 0
? spreadV
: {
kind: "jsOp",
op: "callMethod",
name: "concat",
args: [{ kind: "jsOp", op: "arrLit", args: leading, type: JSVAL, loc }, spreadV],
type: JSVAL,
loc,
};
const result: IrExpr = {
kind: "jsOp",
op: "callMethod",
name: "apply",
args: [callee, { kind: "jsOp", op: "undefLit", args: [], type: JSVAL, loc }, argsArr],
type: JSVAL,
loc,
};
return islandPrimitiveExit(L, expr, result);
}
const args = expr.arguments.map((a) => L.jsvalIn(L.lowerExpr(a), a));
const result: IrExpr = { kind: "jsOp", op: "callFn", args: [callee, ...args], type: JSVAL, loc };
return islandPrimitiveExit(L, expr, result);
}
// Builtin-module functions (fs, path, os, ...): named imports whose
// binding resolves to a supported builtin specifier lower to `libCall`.
// A user local shadowing an import has a different symbol and never
// lands here. The fallback declarations make unsupported call forms
// type errors; under @types/node the real (much wider) signatures
// typecheck — options objects, omitted encodings, Buffer data — so the
// supported form is fenced here per site. Members with no lowering at
// all (fs.watch, os.cpus, ...) fence with the module-qualified name.
if (ts.isIdentifier(expr.expression)) {
// A call through a `const execFileAsync = promisify(execFile)`
// binding — the one lowered util.promisify shape: the interned
// async-exec helper (Node's promisified execFile behind an
// already-settled promise).
{
const sym = L.resolveValueSymbol(expr.expression);
if (sym && L.promisifiedExecFile.has(sym)) {
return L.lowerExecFileAsyncCall(expr, loc);
}
// A call through a `const requestFn = tls ? https.request :
// http.request` binding (the client-function ternary): the http
// client lowering with the RUNTIME-secure dial.
const rf = sym ? httpClientFnBindingOf(L, sym) : undefined;
if (rf) return lowerHttpClientFnCall(L, expr, rf, loc);
}
const bi = L.builtinImportOf(expr.expression);
if (bi) {
// The timers spoke: the node:timers module's exports ARE the
// timer globals (Node re-exports them), so a named/destructured
// import lands on the same shared lowering. Unknown members fall
// through to the module-qualified fence below.
if (bi.module === "timers") {
const timersServed = lowerTimersMemberCall(L, expr, bi.member, loc);
if (timersServed) return timersServed;
}
// The server-surface spoke (lower-server.ts) owns the net module
// wholesale — call shapes there are all special-cased (closures,
// optional middles), so it never rides the param-table path.
const served = L.lowerNetModuleCall(expr, bi, loc);
if (served) return served;
// The dgram spoke (lower-dgram.ts) owns dgram and dns the same way.
const dgramServed = L.lowerDgramDnsModuleCall(expr, bi, loc);
if (dgramServed) return dgramServed;
// The assert spoke (lower-assert.ts) owns node:assert the same way
// (`import { strictEqual } from "node:assert"` and the destructured
// require twin land here).
const assertServed = L.lowerAssertModuleCall(expr, bi, loc);
if (assertServed) return assertServed;
// The node:test spoke (lower-test.ts) owns node:test the same way
// (`import { test, describe } from "node:test"` and the
// destructured require twin land here).
const testServed = L.lowerNodeTestModuleCall(expr, bi, loc);
if (testServed) return testServed;
// The util spoke (lower-inspect.ts) owns inspect/format —
// `const { inspect } = require('util')` and the named-import
// twin land here.
const utilServed = L.lowerUtilModuleCall(expr, bi, loc);
if (utilServed) return utilServed;
// The stream spoke owns finished/pipeline (the callback forms)
// and getDefaultHighWaterMark the same way.
const streamServed = lowerStreamModuleCall(L, expr, bi, loc);
if (streamServed) return streamServed;
const fsTs = L.lowerFsToUnixTimestampCall(expr, bi, loc);
if (fsTs) return fsTs;
// The fs validation-ladder spoke (checked-dynamic lane): misuse
// of implemented-namespace members throws Node's typed errors
// instead of meeting the table fence.
const fsLadder = L.lowerFsLadderCall(expr, bi, loc);
if (fsLadder) return fsLadder;
// The crypto introspection statics (getFips and the name lists)
// bake at the call site — no runtime entry exists to table.
const cryptoServed = L.lowerCryptoModuleCall(expr, bi, loc);
if (cryptoServed) return cryptoServed;
const builtinFn = builtinModuleFnOf(L, bi.module, bi.member);
if (!builtinFn) {
// Typed by @types/node (the fallback declarations only declare
// what lowers, so this form is a type error there), no lowering:
// the module-qualified member names the gap, and the ALIASED
// symbol (the @types/node declaration) picks the blame wording.
// Buffer-bound members (zlib, crypto.randomBytes) carry their
// specific hint.
L.noLowering(
`${bi.module}.${bi.member}`,
expr,
builtinFenceHintOf(bi.module, bi.member),
L.resolveValueSymbol(expr.expression),
);
}
return L.lowerBuiltinModuleCall(expr, bi, builtinFn, loc);
}
// The assert module binding called DIRECTLY (`assert(x)` — a default
// import or the CJS `const assert = require("assert")`): Node's
// module object IS assert.ok; namespace-import bindings fence inside
// (ES namespace objects are not callable in Node).
{
const direct = L.lowerAssertDirectCall(expr, loc);
if (direct) return direct;
}
// The node:test module binding called DIRECTLY (`test(...)` — a
// default import or the CJS `const test = require('node:test')`):
// Node's module object IS the test function.
{
const direct = L.lowerTestDirectCall(expr, loc);
if (direct) return direct;
}
// `Symbol(desc?)` — the global Symbol factory (provenance like
// parseInt: a user function shadowing the name has a different,
// non-stdlib symbol). A fresh runtime-unique identity per call;
// the optional description must be a string (Node ToStrings other
// values — no static lowering, fenced with the honest hint).
// `new Symbol()` throws in Node and is a checker error — the
// generic new fence keeps it.
if (
expr.expression.text === "Symbol" &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
if (expr.arguments.length > 1) {
L.noLowering(`Symbol with ${expr.arguments.length} arguments`, expr);
}
const argNode = expr.arguments[0];
// A literal `undefined` argument IS the no-description form
// (Symbol(undefined).description is undefined, like Symbol()).
if (
!argNode ||
(ts.isIdentifier(argNode) && argNode.text === "undefined")
) {
return { kind: "libCall", fn: "sym.newAnon", args: [], type: SYMBOL_T, loc };
}
const desc = L.lowerExpr(argNode);
if (desc.type.kind !== "string") {
L.noLowering(
`Symbol with a '${L.fmt(desc.type)}' description`,
argNode,
"only string descriptions lower (Node would ToString the value — convert it explicitly)",
);
}
return { kind: "libCall", fn: "sym.new", args: [desc], type: SYMBOL_T, loc };
}
// STATIC parseInt/parseFloat/isNaN/isFinite (num.parseInt /
// num.parseFloat / num.isNaN / number.isFinite — scr_string.c,
// scr_lib.c; ECMA-exact, Node is the oracle). Provenance like the
// island globals: a user function shadowing the name has a
// different, non-stdlib symbol. parseInt's omitted radix completes
// to 0 — the spec's "undefined" (base 10 with the 0x hex escape);
// parseFloat lowers the STRING form only (Node would ToString other
// values — no static story); isNaN/isFinite's arguments are
// checker-pinned (or checked) to number, where the global's ToNumber
// coercion is the identity and the tests are Number.isNaN /
// Number.isFinite exactly (ms's `isFinite(val)` guard).
if (
(expr.expression.text === "parseInt" || expr.expression.text === "isNaN") &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
const name = expr.expression.text;
const maxArgs = name === "parseInt" ? 2 : 1;
if (expr.arguments.length < 1 || expr.arguments.length > maxArgs) {
L.noLowering(
`${name} with ${expr.arguments.length} argument${expr.arguments.length === 1 ? "" : "s"}`,
expr,
);
}
if (name === "isNaN") {
const x = L.lowerExprExpecting(expr.arguments[0]!, F64);
return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc };
}
const s = L.lowerExprExpecting(expr.arguments[0]!, STRING);
const radix: IrExpr = expr.arguments[1]
? L.lowerExprExpecting(expr.arguments[1], F64)
: { kind: "numLit", value: 0, type: F64, loc };
return { kind: "libCall", fn: "num.parseInt", args: [s, radix], type: F64, loc };
}
// STATIC parseFloat/isFinite over exactly-typed arguments —
// parseInt's siblings (num.parseFloat is ECMA 19.2.4's decimal-
// literal prefix parse in scr_string.c; a number-typed isFinite IS
// Number.isFinite — the global's ToNumber coercion is the identity
// there, ms's `isFinite(val)` guard). Other argument types fall
// through to today's island path (--dynamic) or its SC2012 fence:
// the ToNumber/ToString coercions on arbitrary values stay engine
// territory. The probe never emits — lowering is IR construction.
if (
(expr.expression.text === "parseFloat" || expr.expression.text === "isFinite") &&
expr.arguments.length === 1 &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
const name = expr.expression.text;
const probed = probeLower(L, expr.arguments[0]!);
if (name === "parseFloat" && probed?.type.kind === "string") {
return { kind: "libCall", fn: "num.parseFloat", args: [probed], type: F64, loc };
}
if (name === "isFinite" && probed?.type.kind === "f64") {
return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc };
}
}
// STATIC encodeURIComponent/encodeURI/decodeURIComponent
// (str.encodeUriComponent / str.encodeUri / str.decodeUriComponent —
// scr_string.c; ECMA-exact over the runtime's UTF-8 strings, Node
// is the oracle). Provenance like parseInt: a user function
// shadowing the name has a different, non-stdlib symbol. The
// ENCODERS accept string | number | boolean — the spec ToStrings
// first, which ensureString reproduces exactly for these types;
// they are total (the spec's URIError is the unpaired surrogate,
// which cannot exist in well-formed UTF-8). decode THROWS the
// spec's URIError ("URI malformed") catchably and keeps the
// string-only argument rule.
if (
(expr.expression.text === "encodeURIComponent" ||
expr.expression.text === "encodeURI" ||
expr.expression.text === "decodeURIComponent") &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
const name = expr.expression.text;
if (expr.arguments.length !== 1) {
L.noLowering(
`${name} with ${expr.arguments.length} argument${expr.arguments.length === 1 ? "" : "s"}`,
expr,
);
}
const loc = locOf(expr);
const argNode = expr.arguments[0]!;
if (name === "decodeURIComponent") {
const d = L.lowerExpr(argNode);
if (d.type.kind !== "string") {
L.noLowering(
`${name} with a '${L.fmt(d.type)}' argument`,
argNode,
"only string arguments lower (Node would ToString the value — convert it explicitly)",
);
}
return { kind: "libCall", fn: "str.decodeUriComponent", args: [d], type: STRING, loc };
}
const s = L.ensureString(L.lowerExpr(argNode), argNode);
return {
kind: "libCall",
fn: name === "encodeURIComponent" ? "str.encodeUriComponent" : "str.encodeUri",
args: [s],
type: STRING,
loc,
};
}
// STATIC atob/btoa (str.atob / str.btoa — scr_string.c; WHATWG
// forgiving-base64, Node is the oracle). The argument crosses as a
// dyn value: WebIDL ToString runs in the runtime over the dyn kind
// (Node's atob(null) decodes "null"), a malformed input throws the
// catchable DOMException InvalidCharacterError, and the
// zero-argument call throws Node's TypeError [ERR_MISSING_ARGS].
// Provenance like parseInt: a shadowing user function has a
// different, non-stdlib symbol.
if (
(expr.expression.text === "atob" || expr.expression.text === "btoa") &&
L.isStdlibSymbol(L.resolveValueSymbol(expr.expression) ?? undefined)
) {
const name = expr.expression.text;
if (expr.arguments.length === 0) {
return { kind: "libCall", fn: "str.b64Missing", args: [], type: STRING, loc };
}
if (expr.arguments.length > 1) {
L.noLowering(`${name} with ${expr.arguments.length} arguments`, expr);
}
const argNode = expr.arguments[0]!;
const v = L.lowerExpr(argNode);
let data: IrExpr;
if (v.type.kind === "dyn") {
data = v;
} else if (v.kind === "unitLit" || (v.type.kind !== "jsval" && L.dynConvertible(v.type))) {
data = { kind: "dynFrom", value: v, type: DYN, loc };
} else {
L.noLowering(
`${name} with a '${L.fmt(v.type)}' argument`,
argNode,
"string-convertible arguments lower (Node ToStrings the value — convert it explicitly)",
);
}
return {
kind: "libCall",
fn: name === "atob" ? "str.atob" : "str.btoa",
args: [data],
type: STRING,
loc,
};
}
// Island-backed globals (parseFloat, isFinite): the engine's own
// global function executes — callFn(globalGet(name)) — and the
// result exits to the declared static type. A user function
// shadowing the name has a different, non-stdlib symbol.
const islFn = L.islandGlobalFnOf(expr.expression);
if (islFn && expr.arguments.length !== islFn.args.length) {
const name = expr.expression.text;
L.noLowering(
`${name} with ${expr.arguments.length} argument${expr.arguments.length === 1 ? "" : "s"}`,
expr,
);
}
if (islFn && expr.arguments.length === islFn.args.length) {
L.requireDynamicApi(`'${expr.expression.text}'`, expr);
const callee: IrExpr = {
kind: "jsOp", op: "globalGet", name: expr.expression.text, args: [], type: JSVAL, loc,
};
const args = expr.arguments.map((a) => L.jsvalIn(L.lowerExpr(a), a));
const result: IrExpr = {
kind: "jsOp", op: "callFn", args: [callee, ...args], type: JSVAL, loc,
};
return { kind: "jsExit", value: result, type: islFn.ret, loc };
}
}
// A TYPE-GUARD call on a catch binding (`isErrnoException(err)` —
// `(x: unknown) => x is T` with a single-return body): the caught
// snapshot cannot cross a call boundary (KEEP NARROW), so the
// predicate's return expression inlines HERE with the parameter bound
// to the caught local — every caught lowering (instanceof, `in`,
// typeof tests) applies inside it, tsc's call-site narrowing types
// the guarded branch, and a body construct with no caught lowering
// fences per site at its own location.
if (ts.isIdentifier(expr.expression) && expr.arguments.length === 1) {
const caughtArg = L.caughtLocalOf(expr.arguments[0]!);
if (caughtArg) {
const inlined = lowerCaughtPredicateCall(L, expr, caughtArg);
if (inlined) return inlined;
}
}
// A MIXIN call in value position (`const Thing1 = Tagged(Derived)`,
// an argument, a log): the value is the per-call-site instantiation's
// immortal class object — everything downstream (construction,
// statics, extends, instanceof, identity) rides the classval
// machinery unchanged (lower-mixins.ts). Non-mixin callees fall
// through untouched; a recognized mixin with an unsupported argument
// or position fences by name inside.
if (ts.isIdentifier(expr.expression)) {
const mixinInfo = L.mixinCallClassInfoOf(expr);
if (mixinInfo) return L.classValueRef(mixinInfo, expr);
}
// Direct call of a top-level declared function: the fast path (no
// closure object, plain C call). Generic functions route through
// monomorphization (the call targets a per-instantiation instance).
if (ts.isIdentifier(expr.expression) && !L.isSelfReference(expr.expression)) {
// A JS spread argument the compile-time completion cannot take (a
// fixed position, a dynamic rest slot) sends the call down the
// VALUE path — the runtime-arity lane (lowerSpreadArgsCall) boxes
// the declaration's value and applies through a runtime-built
// argument list. Typed .ts spreads keep completeArgs' rest packing.
const jsSpreadArgs =
expr.arguments.some((a) => ts.isSpreadElement(a)) && isJsSourceFile(expr.getSourceFile());
if (L.isTopLevelFnSymbol(expr.expression) && !L.peekLocal(expr.expression)) {
// `import g = N.f; g()` — the alias's own source-order guards
// (a no-op for every non-import= binding).
fenceEarlyAliasUse(L, expr.expression, expr);
const generic = L.genericFnOf(expr.expression);
// An implicit-any JS function spread-forwarded into: per-site
// monomorphization has no slot for a runtime-length argument
// list — the value path's boxed thunk delivers JS arity instead.
if (generic && !(jsSpreadArgs && generic.implicitParams)) return L.lowerGenericCall(expr, generic);
const sig = generic ? null : L.fnSigOf(expr.expression);
if (sig && !(jsSpreadArgs && spreadNeedsRuntimeArity(sig.params, expr.arguments))) {
L.noteEdge(sig.name);
const args = L.completeArgs(expr.arguments, sig.params, loc, expr);
return reconcileOverloadReturn(L, expr, { kind: "call", callee: sig.name, args, type: sig.returnType, loc });
}
// An ambient `declare function` nothing defines: Node evaluates
// the callee first and throws ReferenceError before any argument
// runs — undefRead reproduces it exactly (the ambient-namespace
// callee stance; arguments never lower, Node never evaluates
// them). The result type is what the use site sees; a VOID,
// unmappable, or unregistered-class result takes the F64 dummy
// (the read always throws first, so the dummy is never observed).
if (ambientUndefinedFnSymbolOf(L, expr.expression)) {
const mapped = L.mapTypeOf(L.typeOf(expr));
const t =
mapped && mapped.kind !== "void" && !L.typeNamesUnregisteredClass(mapped) ? mapped : F64;
return nsUndefRead(L, expr.expression.text, expr, t);
}
}
// Calls through a generic function value BINDING (`const f = <T>(x:
// T) => x; f(1)`): the binding provably holds its initializer
// forever (never-reassigned — bindingGenericFnInfoOf's fences), so
// the call monomorphizes against it exactly like a generic function
// declaration and the binding is never read. Symbol identity does
// the discrimination — a shadowing local has its own symbol, and
// registered bindings never declare locals or globals. Implicit-any
// JS bindings spread-forwarded into skip to the value path (the
// runtime-arity lane), like the declaration form above.
{
const generic = L.genericFnOf(expr.expression);
if (generic && !(jsSpreadArgs && generic.implicitParams)) return L.lowerGenericCall(expr, generic);
}
}
// Expando member calls (`example.isFoo('test')` after `example.isFoo
// = fn`): read the member's global and call through the value —
// lower-expando.ts owns the member storage.
if (
(ts.isPropertyAccessExpression(expr.expression) || ts.isElementAccessExpression(expr.expression)) &&
!expr.expression.questionDotToken
) {
const callee = expandoMemberRead(L, expr.expression);
if (callee) {
if (callee.type.kind !== "func") L.badType(expr.expression, L.typeOf(expr.expression));
const params = callee.type.params;
const args = expr.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
for (let i = args.length; i < params.length; i++) {
const absent = omittedArgFor(L, params[i]!, loc);
if (!absent) {
L.unsupported("SC1090", expr, "calls omitting a non-optional parameter of the callee's type");
}
args.push(absent);
}
return { kind: "callValue", callee, args, type: callee.type.ret, loc };
}
}
// Namespace-qualified calls (`N.f(1)`, `A.B.g()`, calls through
// import= alias chains): the member resolves like a bare identifier —
// the direct path when a signature exists (generic instantiation
// included), the ordinary call-through-value otherwise. Guarded by the
// namespace source-order fences (lower-namespaces.ts).
if (
ts.isPropertyAccessExpression(expr.expression) &&
!expr.expression.questionDotToken &&
ts.isIdentifier(expr.expression.name)
) {
const nsMember = nsMemberIdentOf(L, expr.expression);
if (nsMember) {
// A builtin RE-EXPORT FACADE member (`import * as assert from
// "./facade.js"` over `export { ok } from "node:assert"` —
// a universal re-export facade): builtinMemberOf's alias chase
// resolves the builtin module/member, and the spokes own the call
// exactly as a direct builtin import. Ordinary user-module
// members answer null there and resolve below.
const facadeServed = L.lowerNamespaceBuiltinCall(expr, expr.expression);
if (facadeServed) return facadeServed;
const memberSym = L.checker.getSymbolAtLocation(nsMember);
if (memberSym) fenceEarlyNsMemberRef(L, expr.expression, memberSym);
const generic = L.genericFnOf(nsMember);
if (generic) return L.lowerGenericCall(expr, generic);
const sig = L.fnSigOf(nsMember);
if (sig) {
L.noteEdge(sig.name);
const args = L.completeArgs(expr.arguments, sig.params, loc, expr);
return reconcileOverloadReturn(L, expr, { kind: "call", callee: sig.name, args, type: sig.returnType, loc });
}
let callee = L.lowerExpr(nsMember);
if (callee.type.kind === "record") callee = L.hybridCallUnwrap(callee);
if (callee.type.kind !== "func") L.badType(expr.expression, L.typeOf(expr.expression));
const params = callee.type.params;
const args = expr.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
for (let i = args.length; i < params.length; i++) {
const absent = omittedArgFor(L, params[i]!, loc);
if (!absent) {
L.unsupported("SC1090", expr, "calls omitting a non-optional parameter of the callee's type");
}
args.push(absent);
}
return { kind: "callValue", callee, args, type: callee.type.ret, loc };
}
// An AMBIENT namespace callee (`M.f()` where only `declare
// namespace M` exists): Node evaluates the callee first and throws
// ReferenceError before any argument runs — undefRead reproduces it
// exactly (arguments never lower; Node never evaluates them).
const ambientRoot = ambientNsRootOf(L, expr.expression.expression);
if (ambientRoot !== null) {
// The result type is what the use site sees; a VOID, unmappable,
// or unregistered-class result takes the F64 dummy (the read
// always throws first, so the dummy is never observed — tsc keeps
// void results out of value positions).
const mapped = L.mapTypeOf(L.typeOf(expr));
const t =
mapped && mapped.kind !== "void" && !L.typeNamesUnregisteredClass(mapped) ? mapped : F64;
return nsUndefRead(L, ambientRoot.text, expr, t);
}
}
// CommonJS namespace member calls (`lib.double(5)` where lib is
// `const lib = require("./lib.js")`): the export table is alias
// plumbing, so the member call IS a call of the exporter's declaration
// — the direct path when a signature exists (generic instantiation
// included), the ordinary call-through-value otherwise (func-typed
// export globals).
if (
ts.isPropertyAccessExpression(expr.expression) &&
!expr.expression.questionDotToken &&
L.cjsLocalModuleBindingOf(expr.expression.expression)
) {
// A binding whose dep is a class-expression WHOLE export
// (`module.exports = class {…}`): `C.describe()` is a STATIC call
// on that class — the static machinery answers before the member
// delegation below resolves `describe` as a bare name (which no
// binding form supports).
const viaStatic = lowerStaticMethodCall(L, expr, expr.expression);
if (viaStatic) return viaStatic;
const nameId = expr.expression.name;
if (!ts.isIdentifier(nameId)) {
L.unsupported("SC1090", nameId, "private-named module members");
}
const generic = L.genericFnOf(nameId);
if (generic) return L.lowerGenericCall(expr, generic);
const sig = L.fnSigOf(nameId);
if (sig) {
L.noteEdge(sig.name);
const args = L.completeArgs(expr.arguments, sig.params, loc, expr);
return reconcileOverloadReturn(L, expr, { kind: "call", callee: sig.name, args, type: sig.returnType, loc });
}
let callee = L.lowerExpr(nameId);
if (callee.type.kind === "record") callee = L.hybridCallUnwrap(callee);
if (callee.type.kind !== "func") L.badType(expr.expression, L.typeOf(expr.expression));
const params = callee.type.params;
const args = expr.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
for (let i = args.length; i < params.length; i++) {
const absent = omittedArgFor(L, params[i]!, loc);
if (!absent) {
L.unsupported("SC1090", expr, "calls omitting a non-optional parameter of the callee's type");
}
args.push(absent);
}
return { kind: "callValue", callee, args, type: callee.type.ret, loc };
}
if (ts.isPropertyAccessExpression(expr.expression)) {
const intrinsic =
// Builtin namespace imports first (`fs.readFileSync(...)` where fs
// is `import * as fs from "node:fs"`): the same tables and fences
// as named builtin imports — before anything below tries to lower
// the namespace object itself as a receiver.
L.lowerNamespaceBuiltinCall(expr, expr.expression) ??
// The node:perf_hooks spoke: performance.now() and its
// .bind(performance) function value over the runtime's
// process-start-anchored monotonic clock.
lowerPerfHooksCall(L, expr, expr.expression) ??
// The composed crypto pattern (randomBytes(n).toString(enc))
// — its receiver is a Buffer-typed CALL no other lowering claims.
L.lowerCryptoComposedCall(expr, expr.expression) ??
L.lowerProcessMethodCall(expr, expr.expression) ??
L.lowerJsonMethodCall(expr, expr.expression) ??
// Reflect.apply of a builtin rest-parameter fn (fixtures.js's
// fixturesPath idiom) — before the stdlib member fence claims it.
lowerReflectApplyCall(L, expr, expr.expression) ??
L.lowerNumberStaticCall(expr, expr.expression) ??
L.lowerDateCall(expr, expr.expression) ??
L.lowerTextCodecCall(expr, expr.expression) ??
L.lowerStringStaticCall(expr, expr.expression) ??
L.lowerStringLastIndexOfCall(expr, expr.expression) ??
L.lowerPromiseMethodCall(expr, expr.expression) ??
// Homogeneous promise-tuple literals claim BEFORE the static path
// (whose array bound would fence them); Promise.reject follows it
// (the static path leaves resolve/reject for the member fence).
lowerPromiseAllTupleCall(L, expr, expr.expression) ??
L.lowerPromiseStaticCall(expr, expr.expression) ??
lowerPromiseRejectCall(L, expr, expr.expression) ??
// Before the island path: regex-argument replace/replaceAll/split
// lower STATICALLY; only the string-pattern overloads are island.
L.lowerRegexMethodCall(expr, expr.expression) ??
L.lowerUrlMethodCall(expr, expr.expression) ??
L.lowerSearchParamsMethodCall(expr, expr.expression) ??
L.lowerStatsMethodCall(expr, expr.expression) ??
L.lowerChildMethodCall(expr, expr.expression) ??
// Piped child-output stream receivers — on/once("data" | "end").
lowerChildStreamMethodCall(L, expr, expr.expression) ??
// First-class process-stream receivers — write(data).
lowerProcStreamMethodCall(L, expr, expr.expression) ??
// FSWatcher receivers — close() (fs.watch's handle).
lowerWatcherMethodCall(L, expr, expr.expression) ??
// Atomics.wait — the synchronous-sleep idiom (no threads exist,
// so the compare-then-sleep lowering IS the spec's behavior).
L.lowerAtomicsCall(expr, expr.expression) ??
// StringDecoder receivers — BEFORE the record method paths (the
// decoder maps to its one-field pending record).
L.lowerStringDecoderMethodCall(expr, expr.expression) ??
// Dirent receivers — same story: the type probes read the record's
// hidden %dtype field.
lowerDirentMethodCall(L, expr, expr.expression) ??
// readline Interface receivers — BEFORE the Timeout path (both
// map to f64 handles; the checker symbol discriminates).
L.lowerReadlineMethodCall(expr, expr.expression) ??
// diagnostics_channel Channel receivers — the same f64-handle
// story (publish/subscribe/unsubscribe).
L.lowerDcChannelMethodCall(expr, expr.expression) ??
// AsyncLocalStorage receivers — run/getStore/exit/enterWith over
// the f64 store handle.
L.lowerAlsMethodCall(expr, expr.expression) ??
// TracingChannel receivers — subscribe/unsubscribe/traceSync/
// traceCallback over the f64 tracing handle.
L.lowerDcTracingChannelMethodCall(expr, expr.expression) ??
L.lowerServerMethodCall(expr, expr.expression) ??
L.lowerDgramMethodCall(expr, expr.expression) ??
// node:test — skip/todo/only twins on named import bindings, the
// TestContext surface (t.test/t.skip/t.diagnostic), t.assert.*.
L.lowerTestMethodCall(expr, expr.expression) ??
L.lowerTimeoutMethodCall(expr, expr.expression) ??
L.lowerStringMethodCall(expr, expr.expression) ??
// Typed-array/Buffer receivers and the Buffer statics — before the
// island path (bytes never cross the boundary).
L.lowerBytesMethodCall(expr, expr.expression) ??
L.lowerBufferStaticCall(expr, expr.expression) ??
// URL.revokeObjectURL's zero-argument contract (the one-argument
// form keeps the fence — createObjectURL does too).
lowerUrlStaticCall(L, expr, expr.expression) ??
// Readable.from — the stream classes' one static (before the
// stdlib chokepoint claims the member).
lowerStreamStaticCall(L, expr, expr.expression) ??
// Radix-free n.toString() is the STATIC number formatter (identical
// to `${n}` / String(n)); the explicit-radix form stays island.
lowerNumberToStringCall(L, expr, expr.expression) ??
// Union receivers whose every arm has a text — the ngrok
// `(chunk: Buffer | string) => chunk.toString()` idiom.
lowerUnionToStringCall(L, expr, expr.expression) ??
// Object.prototype.toString's default answer on records and
// override-free program classes — "[object Object]", folded.
lowerDefaultToStringCall(L, expr, expr.expression) ??
// The remaining primitive prototype statics — toExponential(),
// both toFixed() forms, hasOwnProperty over literal keys. Before
// the island path. Optional-chain spellings first enter the chain
// machinery above, then re-enter here with a narrowed chainRecv.
lowerPrimitiveProtoCall(L, expr, expr.expression.expression,
expr.expression.name.text, L.checker.getSymbolAtLocation(expr.expression.name)) ??
// hasOwnProperty on a program class CONSTRUCTOR — own statics are
// compile-time-known, so a literal key folds to a constant.
lowerClassHasOwnPropertyCall(L, expr, expr.expression) ??
L.lowerIslandMethodCall(expr, expr.expression) ??
// Dyn receivers (JSON.parse-derived `unknown`/`any` values) —
// validated-extract, then the static machinery. After the island
// path (jsval receivers belong there), before the fences.
lowerDynReceiverMethodCall(L, expr, expr.expression) ??
// Narrowing filters (inferred predicates, filter(Boolean)) claim
// their calls before the generic array HOF path types the result
// by the receiver's own element.
L.lowerFilterNarrowCall(expr, expr.expression) ??
lowerArrayIsArrayCall(L, expr, expr.expression) ??
lowerSymbolStaticCall(L, expr, expr.expression) ??
lowerSymbolMethodCall(L, expr, expr.expression) ??
lowerRegExpStaticCall(L, expr, expr.expression) ??
// The composed en-US Intl.NumberFormat form — before the member
// fences (the receiver's Intl.NumberFormat type has no mapping).
lowerIntlNumberFormatCall(L, expr, expr.expression) ??
lowerGroupByStaticCall(L, expr, expr.expression) ??
// Iterator-helper chains rooted at arr.values() — before the
// array method paths (the terminal names collide with array
// methods, but only iterator-typed receivers reach this).
lowerIteratorHelperCall(L, expr, expr.expression) ??
lowerIteratorStaticFence(L, expr, expr.expression) ??
lowerObjectStaticCall(L, expr, expr.expression) ??
lowerObjectFromEntriesCall(L, expr, expr.expression) ??
lowerArrayFromCall(L, expr, expr.expression) ??
L.lowerArrayMethodCall(expr, expr.expression) ??
// Read-only array methods (slice/map) on TUPLE receivers — the
// positions snapshot into a fresh array (the for-of stance).
lowerTupleReadMethodCall(L, expr, expr.expression) ??
lowerGenMethodCall(L, expr, expr.expression) ??
L.lowerMapMethodCall(expr, expr.expression) ??
L.lowerSetMethodCall(expr, expr.expression) ??
// Static method calls — on the class name directly (`C.make()`)
// or through a class VALUE (devirtualized; shadowing fences).
lowerStaticMethodCall(L, expr, expr.expression) ??
L.lowerObjectMethodCall(expr, expr.expression) ??
L.lowerRecordFieldCall(expr, expr.expression) ??
// Object-literal GENERIC methods (excluded from record shapes) —
// monomorphized against the defining literal's declaration.
lowerObjLitGenericMethodCall(L, expr, expr.expression);
if (intrinsic) return intrinsic;
// A method call rooted at an initializer-less ambient `declare
// const/var` whose declared type has no mapping: Node throws the
// catchable ReferenceError at the ROOT read before the member, the
// arguments, or the call — the whole call lowers to that throw,
// typed by the use site (or its context; never observed).
{
const ambientRoot = ambientUndefVarRootOf(L, expr.expression);
if (ambientRoot !== null) {
const t = ambientUndefReadType(L, expr) ?? contextualUndefReadType(L, expr);
if (t) return nsUndefRead(L, ambientRoot.text, expr, t);
}
}
// The lib fence's METHOD-CALL chokepoint: a stdlib-declared member
// that every lowering above declined — an unlowered member
// (m.keys(), p.then(f), Object.keys(o)) or an unlowered call FORM of
// a lowered one (Math.min with three arguments, s.padStart(8),
// x.toFixed()).
L.stdlibMemberFence(expr.expression);
// The npm METHOD-CALL chokepoint: a call on a package-typed receiver
// in a static build — attributed to the package.
L.npmMemberFence(expr.expression);
// The chalk shape: a FUNCTION carrying properties
// (`Object.assign(identity, { bold })`, typed `F & { bold: F }`) —
// a callable-record hybrid this representation doesn't model yet.
// Name the shape and the working split instead of the generic
// method fence.
{
const recvT = L.typeOf(expr.expression.expression);
if (recvT.isIntersectionType() && L.checker.getCallSignatures(recvT).length > 0) {
L.unsupported(
"SC1090",
expr,
`calls through function-with-properties values ('${expr.expression.expression.getText()}' is callable AND carries members — the chalk shape; no hybrid representation exists yet: export the base and the property as separate functions)`,
);
}
}
// A GENERIC method no lowering above claimed — an ambient `declare
// class`, an interface-typed receiver, a class whose collection
// fenced: monomorphization needs a declaration WITH A BODY resolved
// statically, and this receiver offers none. Name the shape instead
// of the generic method fence.
{
const propSym = L.checker.getPropertyOfType(L.typeOf(expr.expression.expression), expr.expression.name.text);
if (propSym && isGenericCallableMemberType(L.checker.getTypeOfSymbol(propSym), L.checker)) {
L.unsupported(
"SC1090",
expr,
`calls of the generic method '${expr.expression.name.text}' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize)`,
);
}
}
L.unsupported("SC1090", expr, `method calls like '${expr.expression.getText()}'`);
}
// The ELEMENT spelling of a primitive method call — `x['toString']()`,
// `s['charAt'](0)`: JS resolves it exactly like the dot form, so the
// literal-keyed shapes with a static lowering route there before the
// callee-as-value path could fence on the member read.
if (
ts.isElementAccessExpression(expr.expression) &&
!expr.expression.questionDotToken &&
!expr.questionDotToken &&
ts.isStringLiteralLike(expr.expression.argumentExpression)
) {
const memberName = expr.expression.argumentExpression.text;
// ts7's getSymbolAtLocation does not resolve element accesses; the
// member symbol comes from the receiver's (apparent) type instead —
// same provenance answer as the dot spelling's name symbol.
const recvType = L.typeOf(expr.expression.expression);
const memberSym = L.checker.getPropertyOfType(recvType, memberName);
const prim = lowerPrimitiveProtoCall(
L,
expr,
expr.expression.expression,
memberName,
memberSym,
);
if (prim) return prim;
}
// Everything else: evaluate the callee as a value and call through it
// (func-typed locals/params/captures, self-recursion, IIFEs, results of
// calls). tsc guarantees the callee is callable; anything that lowers to
// a non-func IR type was already rejected while lowering the callee.
// HYBRID (function-with-properties) values call through their %call slot.
let callee = L.lowerExpr(expr.expression);
if (callee.type.kind === "record") callee = L.hybridCallUnwrap(callee);
// A CHECKED-DYNAMIC callee — `fn(a, b)` where fn is an implicit-any
// JS binding (the mustCall body's `fn(...args)`), a dyn capture, or a
// keyed read off a dyn value: the dynCall boundary. Arguments convert
// INTO dyn (typed values through dynFrom — closures box); the boxed
// thunk validates them against the callee's declared signature and a
// non-function callee throws Node's catchable "<name> is not a
// function" TypeError. The result is dyn (checked per use like every
// any-origin value). Spread arguments keep their fence.
if (callee.type.kind === "dyn") {
if (expr.arguments.some((a) => ts.isSpreadElement(a))) {
// The runtime-arity lane: a dyn callee is already boxed — the
// spread-marked dynCall applies through a fresh dyn argument
// array (lowerSpreadArgsCall). Sources outside it keep the fence.
const spreadServed = lowerSpreadArgsCall(L, expr, callee, loc);
if (spreadServed) return spreadServed;
L.unsupported("SC1090", expr, "spread arguments in calls through 'unknown' values");
}
const args = expr.arguments.map((a) => L.lowerExprExpecting(a, DYN));
const calleeName = ts.isPropertyAccessExpression(expr.expression) || ts.isElementAccessExpression(expr.expression)
? expr.expression.getText()
: ts.isIdentifier(expr.expression)
? expr.expression.text
: "value";
return { kind: "dynCall", callee, calleeName, args, type: DYN, loc };
}
if (callee.type.kind !== "func") {
L.badType(expr.expression, L.typeOf(expr.expression));
}
// A SPREAD argument on a func-typed callee — the rest-forwarding
// idiom (`(...args) => from(...args)`): the runtime-arity lane boxes
// or marshals the callee and applies through a runtime-built argument
// list (lowerSpreadArgsCall). Shapes outside its lanes fall through
// to the historical fences.
if (expr.arguments.some((a) => ts.isSpreadElement(a))) {
const spreadServed = lowerSpreadArgsCall(L, expr, callee, loc);
if (spreadServed) return spreadServed;
}
// An ISLAND-REST func value called directly (`f(1, 2)` where f is the
// --dynamic `(...args) =>` lambda): the type SPELLS its trailing
// engine-array param — complete the call exactly like completeArgs'
// island pack (fixed slots positionally, missing ones with the
// engine's undefined, the surplus marshaled into one fresh engine
// array). JS arity, no runtime machinery.
if (
callee.type.rest === true &&
callee.type.restAbi === "jsval" &&
callee.type.params.length >= 1 &&
callee.type.params[callee.type.params.length - 1]!.kind === "jsval" &&
!expr.arguments.some((a) => ts.isSpreadElement(a))
) {
const fixed = callee.type.params.slice(0, -1);
const args: IrExpr[] = fixed.map((p, i) => {
const a = expr.arguments[i];
if (a) return L.lowerExprExpecting(a, p);
const absent = omittedArgFor(L, p, loc);
if (!absent) {
L.unsupported("SC1090", expr, "calls omitting a non-optional parameter of the callee's type");
}
return absent;
});
const restArgs = expr.arguments.slice(fixed.length).map((a) => L.lowerExprExpecting(a, JSVAL));
args.push({ kind: "jsOp", op: "arrLit", args: restArgs, type: JSVAL, loc });
return { kind: "callValue", callee, args, type: callee.type.ret, loc };
}
// A JS call with MORE arguments than the callee's lowered signature
// (`cb(1, 'x')` where the mustCall wrapper's inferred type declared
// fewer params — tsc's JS world doesn't police arity): ride the
// checked-dynamic boundary — box the callee, dynCall — which delivers
// JS arity exactly (the thunk ignores extras). Result dyn, checked
// per use like every any-origin value.
if (
(expr.arguments.length > callee.type.params.length || callee.type.rest === true) &&
isJsSourceFile(expr.getSourceFile()) &&
!expr.arguments.some((a) => ts.isSpreadElement(a)) &&
canBoxFuncIntoDyn(callee.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
const args = expr.arguments.map((a) => L.lowerExprExpecting(a, DYN));
const calleeName = ts.isIdentifier(expr.expression) ? expr.expression.text : "value";
const boxed: IrExpr = { kind: "dynFrom", value: callee, type: DYN, loc };
return { kind: "dynCall", callee: boxed, calleeName, args, type: DYN, loc };
}
const params = callee.type.params;
const args = expr.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
// Optional-param func TYPES map their `x?: T` slots as `T | undefined`
// ABI unions, and tsc admits calls that omit the optional suffix —
// complete the missing trailing args with the interned undefined arm,
// exactly what completeArgs does for direct calls (the ABI stays
// count-exact). A missing arg whose param has no undefined arm means
// the callee value's type spelled a required param tsc let the caller
// skip — not a shape this surface models; fence.
for (let i = args.length; i < params.length; i++) {
// A missing argument completes with the slot's absent value — the
// interned undefined arm, the dyn undefined for checked-dynamic
// slots (a JS-inferred wrapper like mustCall's, called short), or
// the engine undefined for island slots.
const absent = omittedArgFor(L, params[i]!, loc);
if (!absent) {
L.unsupported("SC1090", expr, "calls omitting a non-optional parameter of the callee's type");
}
args.push(absent);
}
return { kind: "callValue", callee, args, type: callee.type.ret, loc };
}
/** The RUNTIME-ARITY spread call — `f(...args)`, the rest-forwarding idiom
* (`const f = (...args) => from(...args)`): a spread whose length is a
* runtime fact has no home in the compile-time completion, so the call
* rides a dynamic boundary instead. Two lanes, picked by the spread
* source's tier:
*
* - CHECKED-DYNAMIC (dyn spread sources — a JS rest binding, a dyn
* value): box the callee (dynFrom; a dyn callee is already boxed),
* convert every argument into dyn, and emit the spread-marked dynCall
* — the emitters build one fresh dyn argument array (spreads flatten
* left-to-right, non-iterables throw V8's TypeError) and apply through
* it; the boxed thunk delivers JS arity exactly. Result dyn, checked
* per use like every any-origin value.
* - ISLAND (a jsval spread source — the --dynamic rest binding is the
* engine's own arguments array): marshal the callee in and emit jsOp
* callSpread — the prelude helper's REAL `f(...pre, ...s)`, so
* iterator protocols and the not-iterable TypeError are the engine's
* own. One trailing spread after the fixed arguments is the modeled
* shape (exactly the forwarding idiom).
*
* Answers null when neither lane fits (typed .ts spreads keep
* completeArgs' rest packing and its fences). JS sources only — the
* same guard as the over-arity dynCall precedent. */
export function lowerSpreadArgsCall(L: Lowerer, expr: ts.CallExpression, callee: IrExpr, loc: SrcLoc): IrExpr | null {
if (!isJsSourceFile(expr.getSourceFile())) return null;
if (!expr.arguments.some((a) => ts.isSpreadElement(a))) return null;
if (callee.type.kind !== "dyn" && callee.type.kind !== "func" && callee.type.kind !== "jsval") return null;
const calleeName =
ts.isPropertyAccessExpression(expr.expression) || ts.isElementAccessExpression(expr.expression)
? expr.expression.getText()
: ts.isIdentifier(expr.expression)
? expr.expression.text
: "value";
// Lower every argument ONCE, in source order (the IR nests them in
// exactly this order, so runtime evaluation order is JS's).
const parts = expr.arguments.map((a) =>
ts.isSpreadElement(a)
? { spreadOf: a.expression, node: null, v: L.lowerExpr(a.expression) }
: { spreadOf: null, node: a as ts.Expression, v: L.lowerExpr(a) },
);
const spreadParts = parts.filter((p) => p.spreadOf !== null);
const getR = (id: string) => L.shapes.get(id);
const getU = (id: string) => L.unions.get(id);
const anyJsvalSpread = spreadParts.some((p) => p.v.type.kind === "jsval");
if (
!anyJsvalSpread &&
(callee.type.kind === "dyn" || (callee.type.kind === "func" && canBoxFuncIntoDyn(callee.type, getR, getU))) &&
spreadParts.every((p) => p.v.type.kind === "dyn" || canConvertToDyn(p.v.type, getR, getU))
) {
const args: IrExpr[] = [];
const spreads: { arg: number; what: string }[] = [];
for (const p of parts) {
if (p.spreadOf !== null) {
// The spelling rides along for V8's nullish spread-call
// TypeError ("v is not iterable (cannot read property ...)").
spreads.push({ arg: args.length, what: p.spreadOf.getText() });
args.push(L.coerceInto(p.spreadOf, p.v, DYN));
} else {
args.push(L.coerceInto(p.node!, p.v, DYN));
}
}
const boxed = callee.type.kind === "dyn" ? callee : L.coerceInto(expr.expression, callee, DYN);
return { kind: "dynCall", callee: boxed, calleeName, args, spreads, type: DYN, loc };
}
if (
spreadParts.length > 0 &&
spreadParts.every((p) => p.v.type.kind === "jsval") &&
(callee.type.kind === "jsval" || callee.type.kind === "func")
) {
if (spreadParts.length !== 1 || parts[parts.length - 1]!.spreadOf === null) {
L.unsupported(
"SC1090",
expr,
"spread arguments before positional arguments in island calls (one trailing spread after the fixed arguments is the supported form)",
);
}
const f = L.coerceInto(expr.expression, callee, JSVAL);
const pre: IrExpr[] = parts
.slice(0, -1)
.map((p) => L.coerceInto(p.node!, p.v, JSVAL));
const preArr: IrExpr = { kind: "jsOp", op: "arrLit", args: pre, type: JSVAL, loc };
const last = parts[parts.length - 1]!;
// The spelling rides in `name` for V8's nullish spread-call
// TypeError ("v is not iterable (cannot read property ...)").
return { kind: "jsOp", op: "callSpread", name: last.spreadOf!.getText(), args: [f, preArr, last.v], type: JSVAL, loc };
}
// No lane fits (a spread source outside both tiers, a callee neither
// boxable nor marshalable, mixed dyn/jsval spreads): fence HERE — the
// arguments are already lowered, and falling back to the historical
// per-site fences would lower them a second time (duplicate lambda
// lifts, duplicated diagnostics).
L.unsupported("SC1090", expr, "spread arguments");
}
/** True when a spread argument lands where the compile-time completion
* cannot take it — a FIXED parameter position, or a dynamic rest slot
* (dynRest/islandRest, whose packs are built per-argument): the shapes
* the runtime-arity lane (lowerSpreadArgsCall) serves. Typed `rest`
* slots keep completeArgs' same-element spread packing. */
export function spreadNeedsRuntimeArity(shapes: readonly ParamShape[], argNodes: readonly ts.Expression[]): boolean {
const restAt = shapes.findIndex((s) => s.mode === "rest" || s.mode === "dynRest" || s.mode === "islandRest");
return argNodes.some(
(a, i) =>
ts.isSpreadElement(a) && (restAt < 0 || i < restAt || shapes[restAt]!.mode !== "rest"),
);
}
/** METHOD calls on dyn receivers (`pkg.name.replace(...)`, `rawName.split`,
* `ws.packages.filter(...)` — JSON.parse-derived values): validate the
* receiver's dyn kind, extract, and ride the STATIC method machinery — the
* dyn boundary's trust-but-verify stance extended to receivers. The
* receiver-kind mismatch throws V8's own catchable TypeErrors (nullish:
* "Cannot read properties of undefined (reading 'replace')"; other kinds:
* "pkg.name.replace is not a function") — though BEFORE the arguments
* evaluate, where JS evaluates them first for the non-nullish case
* (SEMANTICS.md). String methods ride the string/regex intrinsic tables
* through a validated-string receiver; `.filter` runs the predicate over
* the dyn array and validated-extracts the survivors into the element type
* the checker committed the result to. Null when the receiver isn't a dyn
* value or the method isn't claimable (the method-call fence stays). */
function lowerDynReceiverMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.chainBlocked(call, access)) return null;
// Only checker-untyped receivers: `any`/`unknown`, or the `any[]` an
// Array.isArray guard narrows them to (the value is STILL the checked-dynamic tree
// array — scalar narrowings bridge through maybeNarrow's dynCheck and
// take the ordinary typed paths, but there is no static home for an
// any-elemented array). Typed receivers keep their own lowerings.
const recvTs = L.typeOf(access.expression);
const anyArray =
L.checker.isArrayType(recvTs) &&
((L.checker.getTypeArguments(recvTs as ts.TypeReference)[0]?.flags ?? 0) &
(ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
let recv: IrExpr;
if (recvTs.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || anyArray) {
recv = L.lowerExpr(access.expression);
} else {
// A checker-TYPED spelling whose VALUE is still checked-dynamic (an
// evolving `let h = {}` object flowing back out of a JS helper —
// tsc types the return by the evolved shape, the binding lowered
// dyn): probe the lowering and claim exactly the dyn results.
const probed = probeLower(L, access.expression);
if (probed?.type.kind !== "dyn") return null;
recv = probed;
}
// A checker-`any` receiver that already lowered to a real STRING (the
// chained form — `cfg.host.trim().toLowerCase()`, where the first step
// extracted): no validation needed, ride the string tables directly.
if (recv.type.kind === "string") {
return lowerRegexMethodCall(L, call, access, () => recv) ?? lowerStringMethodCall(L, call, access, () => recv);
}
if (recv.type.kind !== "dyn") return null;
// Typed-destination filter first (validated extraction into a real
// T[]); an untyped destination falls through to the runtime dispatch
// below (the survivors stay dyn values).
if (access.name.text === "filter") {
const extracted = lowerDynArrayFilterCall(L, call, access, recv);
if (extracted) return extracted;
}
if (access.name.text === "flatMap") return lowerDynArrayFlatMapCall(L, call, access, recv);
// String methods claim only names NO other dyn-representable kind's
// prototype declares (Array carries includes/indexOf/slice too): for
// these, "the receiver is a string, or the call throws V8's TypeError"
// IS Node's semantics for every possible dyn value. Shared names would
// need a receiver-kind dispatch — they keep the fence.
if (DYN_STRING_ONLY_METHODS.has(access.name.text)) {
const checked = (): IrExpr => dynStringReceiver(L, recv, access);
return lowerRegexMethodCall(L, call, access, checked) ?? lowerStringMethodCall(L, call, access, checked);
}
// toString() is a shared prototype name with its OWN receiver-kind
// dispatched runtime lowering (dyn.toString: Buffer-flavored bytes
// decode per the encoding — a stream chunk's common consumption —
// and strings/numbers/booleans/arrays/objects answer JS-exactly).
// The optional argument is a literal encoding (meaningful for bytes;
// JS ignores extra toString arguments on the other kinds, and so
// does the runtime dispatch).
if (access.name.text === "toString" && call.arguments.length <= 1) {
const enc = call.arguments[0]
? bufEncoding(L, "toString", call.arguments[0])
: "utf8";
// The source spelling rides along for the ONE receiver whose
// prototype lacks toString: a null-prototype dictionary throws
// Node's "<spelling> is not a function" at runtime.
return {
kind: "libCall",
fn: "dyn.toString",
args: [
recv,
{ kind: "strLit", value: enc, type: STRING, loc: locOf(call) },
{ kind: "strLit", value: access.getText(), type: STRING, loc: locOf(call) },
],
type: STRING,
loc: locOf(call),
};
}
// SHARED prototype names with a runtime dispatch (scr_dyn_invoke):
// push/slice/join/forEach/map/apply/... dispatch on the receiver's
// RUNTIME kind — the honest answer for names more than one dyn-
// representable prototype declares (test/common's mustCall internals:
// mustCallChecks.push(context), failed.forEach(fn), fn.apply(this,
// args)). Implemented (kind, name) pairs run JS-exact; real-but-
// unimplemented methods throw a LOUD not-supported Error; names the
// kind's prototype lacks throw Node's "x.y is not a function"; OBJ
// receivers call the own member.
if (DYN_DISPATCH_METHODS.has(access.name.text) && !call.questionDotToken && !access.questionDotToken) {
if (call.arguments.some((a) => ts.isSpreadElement(a))) {
L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values");
}
const args = call.arguments.map((a) => L.lowerExprExpecting(a, DYN));
return {
kind: "dynInvoke",
recv,
method: access.name.text,
calleeName: access.getText(),
args,
type: DYN,
loc: locOf(call),
};
}
// Names NO dyn-representable prototype declares: the member can only
// be an OWN property, so "read the member, call it" IS Node's
// semantics for every possible dyn value — `handlers.onDone(x)` on a
// checked-dynamic object calls the stored function (dynKeyGet answers
// the member or undefined; dynCall throws Node's exact catchable
// "handlers.onDone is not a function" on a non-function). Prototype
// names (map/join/hasOwnProperty/call/...) keep the fence: on a real
// dyn array/string/object Node would run the METHOD, which no stored
// member models. Order note: JS reads the callee before evaluating
// arguments — dynKeyGet's undefined-receiver TypeError fires first,
// exactly Node.
if (DYN_PROTO_METHOD_NAMES.has(access.name.text)) return null;
// Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain
// machinery's short-circuit semantics — not modeled here yet.
if (call.questionDotToken || access.questionDotToken) return null;
const loc = locOf(call);
if (call.arguments.some((a) => ts.isSpreadElement(a))) {
L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values");
}
const member: IrExpr = {
kind: "dynKeyGet",
key: { kind: "strLit", value: access.name.text, type: STRING, loc: locOf(access) },
value: recv,
type: DYN,
loc: locOf(access),
};
const args = call.arguments.map((a) => L.lowerExprExpecting(a, DYN));
return { kind: "dynCall", callee: member, calleeName: access.getText(), args, type: DYN, loc };
}
/** Prototype method names of the checked-dynamic tree-representable kinds (String, Array,
* Object, Function, Number prototypes): a dyn receiver call on one of
* these could be a REAL method on a real value, which a stored-member
* read would silently mis-answer — they keep the fence. Everything else
* is own-property-or-throw for every dyn value (the honest dynCall). */
const DYN_PROTO_METHOD_NAMES = new Set([
// Object.prototype
"hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable", "toLocaleString", "toString", "valueOf",
// Function.prototype
"apply", "bind", "call",
// Array.prototype (less the dyn-claimed filter/flatMap — still listed:
// the claim above runs first)
"at", "concat", "copyWithin", "entries", "every", "fill", "filter", "find", "findIndex", "findLast", "findLastIndex", "flat", "flatMap", "forEach", "includes", "indexOf", "join", "keys", "lastIndexOf", "map", "pop", "push", "reduce", "reduceRight", "reverse", "shift", "slice", "some", "sort", "splice", "toReversed", "toSorted", "toSpliced", "unshift", "values", "with",
// String.prototype (the shared-name remainder — the string-only set
// was claimed above)
"anchor", "big", "blink", "bold", "codePointAt", "fixed", "fontcolor", "fontsize", "isWellFormed", "italics", "link", "localeCompare", "normalize", "small", "strike", "sub", "sup", "toLocaleLowerCase", "toLocaleUpperCase", "toWellFormed",
// Number.prototype
"toExponential", "toFixed", "toPrecision",
]);
/** The SHARED prototype names scr_dyn_invoke dispatches at runtime (the
* subset of DYN_PROTO_METHOD_NAMES with a receiver-kind dispatch): the
* runtime runs the real method for the receiver's kind, throws Node's
* is-not-a-function where the kind's prototype lacks the name, and
* fences LOUDLY on real-but-unimplemented pairs. */
export const DYN_DISPATCH_METHODS = new Set([
"apply", "call",
"push", "pop", "shift", "unshift", "slice", "at",
"indexOf", "lastIndexOf", "includes", "join", "concat", "reverse", "sort",
"forEach", "map", "filter", "some", "every", "find", "findIndex",
// The native-handle receiver surface (SCR_DYN_HANDLE — req/res/socket
// boxed through the checked-dynamic boundary): these names dispatch on
// the runtime kind so a boxed IncomingMessage/ServerResponse/Socket
// routes onto the same entry points the static lowerings use (modeled
// members) or the loud not-supported ladder (real-but-unmodeled ones).
// On every other dyn kind they answer exactly what the stored-member
// path answered (OBJ own members call; the rest throw Node's
// is-not-a-function).
"on", "once", "addListener", "removeListener", "off", "removeAllListeners",
"emit", "prependListener", "prependOnceListener", "listeners", "listenerCount",
"write", "end", "destroy", "pipe", "unpipe", "resume", "pause",
"setEncoding", "setDefaultEncoding", "setTimeout", "read", "isPaused",
"writeHead", "setHeader", "getHeader", "hasHeader", "removeHeader",
"getHeaders", "getHeaderNames", "appendHeader", "flushHeaders",
"writeContinue", "writeEarlyHints", "cork", "uncork", "addTrailers",
"ref", "unref", "address", "setNoDelay", "setKeepAlive", "connect",
"resetAndDestroy", "destroySoon",
// The Agent handle's own member (no other dyn prototype declares it,
// so the remainder keeps the stored-member answers).
"getName",
// The netServer half of the handle surface (`let server; server =
// createServer(...)` — the handle lives in a dyn binding whose
// closures the checker cannot narrow): listen/close dispatch onto the
// server ops; no other dyn prototype declares either name, so the
// remainder keeps the stored-member answers.
"listen", "close",
// Promise.prototype (SCR_DYN_PROMISE receivers): the reaction trio
// rides the fiber machinery (scr_dyn_promise_then); on every other dyn
// kind then/catch/finally answer the stored-member path (OBJ own
// members call, the rest throw Node's is-not-a-function).
"then", "catch", "finally",
// The h2 session/stream half (SCR_DYNH_H2_SESSION/STREAM — boxed
// through a mustCall-wrapped listener's parameter): request/respond
// and the stream/session methods dispatch onto the http2 ops. Names
// shared with the http/net surface (write/end/close/on/...) are
// already above; these are the h2-only additions.
"respond", "respondWithFile", "respondWithFD", "pushStream",
"request", "sendTrailers", "priority", "settings", "goaway", "ping",
"additionalHeaders", "altsvc", "origin",
]);
/** STR_METHODS ∪ the regex-form names, MINUS everything Array (or any
* other dyn kind's prototype) also declares. */
const DYN_STRING_ONLY_METHODS = new Set([
"charCodeAt", "charAt", "startsWith", "endsWith", "substring", "repeat",
"trim", "trimStart", "trimEnd", "split", "padStart", "padEnd",
"toLowerCase", "toUpperCase", "replace", "replaceAll", "match", "matchAll",
"search",
]);
/** `Array.isArray(v)` — a real runtime test on `unknown` values (the checked-dynamic tree's
* array kind: dyn arrays answer true, bytes/objects/scalars false — exactly
* JS, Uint8Array included), a compile-time constant on statically-typed
* ones (an `T[]` value IS an array, every other static kind is not; folded
* only over side-effect-free reads, the `in`-operator discipline; unions
* fence with the narrow-first hint). Null when the callee isn't THE
* stdlib Array.isArray, so the chain keeps trying. */
function lowerArrayIsArrayCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (L.stdlibGlobalMember(access, "Array") !== "isArray") return null;
if (call.arguments.length !== 1) return null; // the stdlib chokepoint fences
const argNode = call.arguments[0]!;
const arg = L.lowerExpr(argNode);
const loc = locOf(call);
if (arg.type.kind === "dyn") {
return { kind: "dynTest", test: "array", value: arg, type: BOOL, loc };
}
if (arg.type.kind === "union") {
// A union answers by its RUNTIME TAG: true iff the active arm is an
// array kind (bytes arms answer false — Array.isArray(new Uint8Array)
// is false in JS too). One array arm compiles to the plain tag test
// (`Array.isArray(tlds)` on `string | readonly string[]` — the
// narrowing test tsc's control flow then builds on); several array
// arms OR their tag tests, and zero arms fold to false — both only
// over side-effect-free reads (the operand re-evaluates/drops, the
// `in`-operator fold discipline). dyn/caught/jsval arms have no
// static tag answer and keep the narrow-first fence.
const def = L.unions.get(arg.type.unionId);
const opaque = !def || def.arms.some((a) => a.kind === "dyn" || a.kind === "caught" || a.kind === "jsval");
const arrayTags = def ? def.arms.flatMap((a, i) => (a.kind === "array" ? [i] : [])) : [];
const freeRead = arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet";
if (!opaque && arrayTags.length === 1) {
return { kind: "unionIsTag", unionId: arg.type.unionId, tag: arrayTags[0]!, negated: false, value: arg, type: BOOL, loc };
}
if (!opaque && freeRead && arrayTags.length === 0) {
return { kind: "boolLit", value: false, type: BOOL, loc };
}
if (!opaque && freeRead && arrayTags.length > 1) {
return arrayTags
.map((tag): IrExpr => ({ kind: "unionIsTag", unionId: (arg.type as { unionId: string }).unionId, tag, negated: false, value: arg, type: BOOL, loc }))
.reduce((left, right) => ({ kind: "logical", op: "||", left, right, type: BOOL, loc }));
}
L.unsupported(
"SC1090",
argNode,
`Array.isArray on '${L.fmt(arg.type)}' values (narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms)`,
);
}
if (arg.type.kind === "jsval" || arg.type.kind === "caught") return null;
if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") {
return { kind: "boolLit", value: arg.type.kind === "array", type: BOOL, loc };
}
L.unsupported(
"SC1090",
call,
"statically-decided Array.isArray on computed arguments (bind the value to a variable first)",
);
}
/** Predicate declarations currently being inlined — re-entrancy guard
* (a self-recursive guard body would otherwise inline forever). */
const inliningPredicates = new Set<ts.Symbol>();
/** `p(err)` where err is a CATCH BINDING and p a top-level type-guard
* `(x: unknown) => x is T` whose body is a single `return <expr>;`: lowers
* <expr> in the caller with the parameter aliased to the caught local.
* Null when the callee isn't that shape (ordinary paths — and their
* caught-argument fences — apply). */
function lowerCaughtPredicateCall(L: Lowerer, call: ts.CallExpression,
caughtLocal: IrLocal,): IrExpr | null {
if (call.questionDotToken) return null;
const callee = call.expression;
if (!ts.isIdentifier(callee)) return null;
const symbol = L.resolveValueSymbol(callee);
const decl = symbol ? L.checker.declarationsOf(symbol).find(ts.isFunctionDeclaration) : undefined;
if (!symbol || !decl || !decl.body) return null;
if (!decl.type || !ts.isTypePredicateNode(decl.type)) return null;
if (decl.parameters.length !== 1) return null;
const param = decl.parameters[0]!;
if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return null;
const paramSymbol = L.checker.getSymbolAtLocation(param.name);
if (!paramSymbol) return null;
const ret = decl.body.statements.length === 1 ? decl.body.statements[0] : undefined;
if (!ret || !ts.isReturnStatement(ret) || !ret.expression) {
L.unsupported(
"SC1090",
call,
`the type-guard '${callee.text}' on a catch binding (only single-'return' guard bodies inline over the caught value)`,
);
}
if (inliningPredicates.has(symbol)) {
L.unsupported("SC1090", call, `the self-recursive type-guard '${callee.text}' on a catch binding`);
}
inliningPredicates.add(symbol);
L.scopes.push(new Map([[paramSymbol, caughtLocal]]));
try {
const result = L.lowerExpr(ret.expression);
return L.ensureBool(result, ret.expression);
} finally {
L.scopes.pop();
inliningPredicates.delete(symbol);
}
}
/** Radix-free `.toString()` on a PRIMITIVE receiver: numbers take the
* STATIC JS-exact number formatter — the same `toString` node templates
* and String(n) lower to (Number::toString with radix 10 IS that
* conversion, per spec) — booleans the "true"/"false" texts, and strings
* the identity read (String.prototype.toString returns `this`). The
* explicit-radix number form keeps its island lowering (ISLAND_SURFACE);
* null for other receivers, argument shapes, or non-lib members (a
* user's own `.toString` takes the ordinary paths). */
export function lowerNumberToStringCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.chainBlocked(call, access)) return null;
if (access.name.text !== "toString" || call.arguments.length !== 0) return null;
const recvKind = L.mapTypeOf(L.typeOf(access.expression))?.kind;
if (recvKind !== "f64" && recvKind !== "bool" && recvKind !== "string") return null;
if (!L.isStdlibMember(access)) return null;
const operand = L.lowerExpr(access.expression);
if (operand.type.kind === "string") return operand; // identity, receiver evaluated
if (operand.type.kind !== "f64" && operand.type.kind !== "bool") return null;
return { kind: "toString", operand, type: STRING, loc: locOf(call) };
}
/** Radix-free `.toString()` on a UNION receiver whose every arm has one
* (string identity, JS-exact number/bool texts, and the Buffer arm's
* utf8 decode — Node's default encoding): the per-union ToString
* helper dispatches on the tag, so `chunk.toString()` over the ngrok
* `Buffer | string` listener param needs no narrowing. Unit-armed
* unions stay out — `(undefined).toString()` THROWS in JS, and
* claiming it here would silently print "undefined" instead. Null for
* other receivers/arms (the narrow-first fences stay). */
export function lowerUnionToStringCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (access.name.text !== "toString" || call.arguments.length !== 0) return null;
const recvT = L.mapTypeOf(L.typeOf(access.expression));
if (recvT?.kind !== "union") return null;
if (!L.isStdlibMember(access)) return null;
const def = L.unions.get(recvT.unionId);
const stringable = def?.arms.every(
(a) =>
a.kind === "string" || a.kind === "f64" || a.kind === "bool" ||
(a.kind === "bytes" && a.elem === "u8"),
);
if (!stringable) return null;
const operand = L.lowerExpr(access.expression);
if (operand.type.kind !== "union") return null;
return { kind: "toString", operand, type: STRING, loc: locOf(call) };
}
/** `x.toString()` resolving to Object.prototype.toString (stdlib
* provenance, zero arguments) on a RECORD or program-class receiver:
* the spec's default answer is the constant "[object Object]". Records
* carry no method storage at all, and a class receiver folds only when
* neither its chain nor ANY subclass declares toString (dynamic
* dispatch could reach an override otherwise — and a resolved override
* is the USER's symbol, which never lands here). Pure receivers elide
* evaluation; effectful ones evaluate through an interned identity
* helper so the receiver's effects keep their place. */
export function lowerDefaultToStringCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (access.name.text !== "toString" || call.arguments.length !== 0) return null;
if (!L.isStdlibMember(access)) return null;
const recvT = L.mapTypeOf(L.typeOf(access.expression));
if (!recvT) return null;
if (recvT.kind === "object") {
const info = L.classes.get(recvT.className);
// Runtime-provided classes (Error, EventEmitter, streams) have real
// toString stories of their own — only source-declared classes fold.
if (!info || !info.decl) return null;
if (L.findMethodOn(info, "toString") !== null) return null;
if (L.overrideBelow(info, "toString")) return null;
if (findGenericMethodOn(L, info, "toString") !== null) return null;
} else if (recvT.kind !== "record") {
return null;
}
const loc = locOf(call);
const constant: IrExpr = { kind: "strLit", value: "[object Object]", type: STRING, loc };
// `(<A>{}).toString()` — assertion-wrapped literals and plain reads
// have nothing to evaluate; pureObjectToStringReceiver widens
// pureReceiverNode with the empty object literal.
if (pureObjectToStringReceiver(access.expression)) return constant;
const recv = L.lowerExpr(access.expression);
const key = `objToStr:${typeKey(recv.type)}`;
let helper = L.widthHelpers.get(key);
if (!helper) {
helper = `%obj.tostr.${L.widthHelpers.size}`;
L.widthHelpers.set(key, helper);
L.liftedFns.push({
name: helper,
params: [{ localId: "o.0", name: "o", type: recv.type }],
returnType: STRING,
locals: [{ id: "o.0", name: "o", type: recv.type, mutable: false }],
body: [{ kind: "return", value: { ...constant }, loc }],
loc,
});
}
return { kind: "call", callee: helper, args: [recv], type: STRING, loc };
}
/** pureReceiverNode plus the empty object literal — the default-toString
* fold's receiver test (an empty literal allocates and nothing more,
* which the discard cannot observe). */
function pureObjectToStringReceiver(node: ts.Expression): boolean {
let e = node;
while (
ts.isParenthesizedExpression(e) || ts.isAsExpression(e) ||
ts.isNonNullExpression(e) || ts.isTypeAssertion(e)
) {
e = e.expression;
}
if (ts.isObjectLiteralExpression(e) && e.properties.length === 0) return true;
return pureReceiverNode(e);
}
/** `A.hasOwnProperty(lit)` on a PROGRAM CLASS constructor: the own
* properties of a class object are compile-time-known — its OWN static
* member names (fields, methods, accessors; inherited statics live on
* the base, not here) plus the function-object trio prototype/name/
* length — so a literal key folds to a constant. Builtin classes and
* non-literal keys keep the fence. */
function lowerClassHasOwnPropertyCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (access.name.text !== "hasOwnProperty" || call.arguments.length !== 1) return null;
if (!ts.isIdentifier(access.expression)) return null;
const argNode = call.arguments[0]!;
if (!ts.isStringLiteralLike(argNode)) return null;
if (!L.isStdlibMember(access)) return null;
const sym = L.resolveValueSymbol(access.expression);
const info = sym ? L.classBySymbol.get(sym) : undefined;
if (!info || !info.decl) return null; // builtin/runtime classes keep the fence
const key = argNode.text;
const own = new Set(["prototype", "name", "length"]);
for (const m of info.decl.members) {
const isStatic = ts.canHaveModifiers(m) &&
(ts.getModifiers(m) ?? []).some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword);
if (!isStatic) continue;
if (
!ts.isPropertyDeclaration(m) && !ts.isMethodDeclaration(m) &&
!ts.isGetAccessorDeclaration(m) && !ts.isSetAccessorDeclaration(m)
) {
continue; // static blocks and constructors carry no own name
}
if (ts.isIdentifier(m.name) || ts.isStringLiteralLike(m.name)) own.add(m.name.text);
else return null; // computed static names — the answer isn't static
}
return { kind: "boolLit", value: own.has(key), type: BOOL, loc: locOf(call) };
}
/** Side-effect-free receiver test for the CONSTANT primitive-prototype
* answers (hasOwnProperty below): the constant elides the receiver's
* evaluation, which is only honest when evaluating it could do nothing —
* identifiers, literals, and parens over those. */
function pureReceiverNode(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e)) {
e = e.expression;
}
return (
ts.isIdentifier(e) ||
ts.isStringLiteralLike(e) ||
ts.isNumericLiteral(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.ThisKeyword
);
}
/** The remaining PRIMITIVE prototype surface with a static story, in both
* member spellings (`x.hasOwnProperty(...)` and `x['hasOwnProperty'](...)`
* — JS resolves the two identically, so the element spelling routes here
* from lowerCall's element-access hook):
* - `n.toExponential()` and both `n.toFixed()` forms — the static
* runtime formatters (num.toExponential's shortest-mantissa form,
* num.toFixed0's ties-up integer fast path, and num.toFixed's exact
* binary-value rounding for an explicit fractionDigits).
* - `hasOwnProperty(lit)` on number/boolean receivers — the boxes own
* NOTHING, so any key answers false (a compile-time constant; the
* receiver must be effect-free since the constant elides it).
* - `hasOwnProperty(lit)` on string receivers — "length" is true,
* a canonical array index answers `index < s.length` (indices ARE
* own properties of the box, per spec), every other literal false.
* - the element-access spellings of `toString()` (the primitive
* lowering above) and `charAt(i)` — the two the element hook needs
* beyond this file's own claims.
* Null elsewhere: non-literal keys, other members, other receivers. */
export function lowerPrimitiveProtoCall(L: Lowerer, call: ts.CallExpression,
recv: ts.Expression, name: string, memberSym: ts.Symbol | undefined,): IrExpr | null {
if (call.questionDotToken) return null;
if (!L.isStdlibSymbol(memberSym)) return null;
const recvKind = L.mapTypeOf(L.typeOf(recv))?.kind;
if (recvKind !== "f64" && recvKind !== "bool" && recvKind !== "string") return null;
const loc = locOf(call);
if (name === "toString" && call.arguments.length === 0) {
const operand = L.lowerExpr(recv);
if (operand.type.kind === "string") return operand; // identity
if (operand.type.kind !== "f64" && operand.type.kind !== "bool") return null;
return { kind: "toString", operand, type: STRING, loc };
}
if ((name === "toExponential" || name === "toFixed") && recvKind === "f64" &&
call.arguments.length === 0) {
const operand = L.lowerExpr(recv);
if (operand.type.kind !== "f64") return null;
const fn = name === "toExponential" ? "num.toExponential" : "num.toFixed0";
return { kind: "libCall", fn, args: [operand], type: STRING, loc };
}
if (name === "toFixed" && recvKind === "f64" && call.arguments.length === 1) {
const operand = L.lowerExpr(recv);
let digits = L.lowerExpr(call.arguments[0]!);
// The optional parameter also admits undefined. Exact unit values
// become the default 0 after preserving any evaluation effects; an
// optional number selects 0 at runtime through the same narrowed
// nullish IR used by `digits ?? 0`.
const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc: digits.loc };
const defaultUnitDigits = (value: IrExpr): IrExpr =>
droppableStatic(value)
? zero
: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: value, loc: value.loc }],
result: zero,
type: F64,
loc: value.loc,
};
if (digits.type.kind === "undefinedT" || digits.type.kind === "void") {
digits = defaultUnitDigits(digits);
} else if (digits.type.kind === "union") {
const def = L.unions.get(digits.type.unionId);
if (def?.arms.every(isUnitType)) {
digits = defaultUnitDigits(digits);
} else if (
def?.arms.length === 2 &&
def.arms.some((arm) => arm.kind === "f64") &&
def.arms.some((arm) => arm.kind === "undefinedT")
) {
digits = { kind: "nullish", left: digits, right: zero, type: F64, loc: digits.loc };
}
}
if (operand.type.kind !== "f64" || digits.type.kind !== "f64") return null;
return { kind: "libCall", fn: "num.toFixed", args: [operand, digits], type: STRING, loc };
}
// Number.prototype.toLocaleString("en-US") — the spec makes it
// NumberFormat(locale).format(this), so the en-US embedded formatter
// answers exactly. The unlowered forms fence by NAME: no locale (the
// host environment's default, which a compiled binary cannot carry),
// other locales (ICU data the binary does not embed), options bags.
if (name === "toLocaleString" && recvKind === "f64") {
if (call.arguments.length === 0) {
L.noLowering(
"Number.prototype.toLocaleString without a locale",
call,
"the default locale is the host environment's, which a compiled binary cannot carry — " +
'pass it explicitly: x.toLocaleString("en-US")',
);
}
if (call.arguments.length > 1) {
L.noLowering(
"Number.prototype.toLocaleString with an options bag",
call,
"the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction " +
'digits, grouping) — x.toLocaleString("en-US")',
);
}
const locNode = call.arguments[0]!;
if (ts.isSpreadElement(locNode) || !ts.isStringLiteralLike(locNode) || locNode.text !== "en-US") {
L.noLowering(
!ts.isSpreadElement(locNode) && ts.isStringLiteralLike(locNode)
? `Number.prototype.toLocaleString at locale "${locNode.text}"`
: "Number.prototype.toLocaleString with a non-literal locale",
locNode,
'"en-US" (Node\'s default-build locale) is the one locale whose data the runtime embeds — ' +
"everything else is ICU data the binary does not carry",
);
}
const operand = L.lowerExpr(recv);
if (operand.type.kind !== "f64") return null;
return { kind: "libCall", fn: "intl.numFormatEnUs", args: [operand], type: STRING, loc };
}
if (name === "charAt" && recvKind === "string" && call.arguments.length === 1 &&
!ts.isSpreadElement(call.arguments[0]!)) {
const receiver = L.lowerExpr(recv);
if (receiver.type.kind !== "string") return null;
const idx = L.lowerExprExpecting(call.arguments[0]!, F64);
return { kind: "strIntrinsic", method: "charAt", receiver, args: [idx], type: STRING, loc };
}
if (name !== "hasOwnProperty" || call.arguments.length !== 1) return null;
const argNode = call.arguments[0]!;
if (!ts.isStringLiteralLike(argNode)) return null;
const key = argNode.text;
if (recvKind === "string") {
if (/^(0|[1-9][0-9]*)$/.test(key) && Number(key) <= 2 ** 32 - 2) {
// A canonical array index: an own property exactly when it is in
// range — `index < s.length` (UTF-16 units, the box's indices).
const receiver = L.lowerExpr(recv);
if (receiver.type.kind !== "string") return null;
const len: IrExpr = { kind: "strIntrinsic", method: "length", receiver, args: [], type: F64, loc };
return { kind: "bin", op: "<", left: { kind: "numLit", value: Number(key), type: F64, loc }, right: len, type: BOOL, loc };
}
if (!pureReceiverNode(recv)) return null; // the constant elides the receiver
return { kind: "boolLit", value: key === "length", type: BOOL, loc };
}
// Number/Boolean boxes own nothing: false for every key.
if (!pureReceiverNode(recv)) return null;
return { kind: "boolLit", value: false, type: BOOL, loc };
}
/** Reconciles a direct call's recorded type with the checker's answer at
* the site when the callee is OVERLOADED: tsc resolved the call against
* one overload SIGNATURE, so every downstream lowering sees that
* overload's return type — but the value arrives through the
* implementation's ABI (the only compiled body). Same mapped type: the
* call stands (overloads differing only in parameters). A union
* implementation return whose resolved type is one ARM: the CHECKED
* extraction (narrowedArmHelper — the `x!` machinery), because nothing
* ever CHECKED the implementation's body against the resolved signature
* (tsc only checks it against the implementation signature), so a lying
* implementation throws the catchable TypeError instead of a misread
* payload. Everything else rides the ordinary coercion path — sub-union
* re-tags bridge (stranded arms trap, the lying-cast stance), and pairs
* with no honest bridge keep coerceInto's exactness fences. Calls that
* resolved to the implementation itself (non-overloaded callees) pass
* through untouched. */
function reconcileOverloadReturn(L: Lowerer, expr: ts.CallExpression | ts.TaggedTemplateExpression, call: IrExpr): IrExpr {
const rsig = L.checker.getResolvedSignature(expr);
const rdecl = rsig ? L.checker.signatureDeclaration(rsig) : undefined;
if (!rsig || !rdecl || !(ts.isFunctionDeclaration(rdecl) || ts.isMethodDeclaration(rdecl)) || rdecl.body) {
return call;
}
const rt = L.mapTypeOf(L.checker.getReturnTypeOfSignature(rsig));
// Unmappable, void, or unit resolved returns keep the implementation's
// type: a discarded result never looks, a USED one meets its use
// site's own mapping (and that site's honest fences). Unit narrowing
// follows maybeNarrow's stance — a unit arm has no payload to extract.
if (!rt || rt.kind === "void" || isUnitType(rt) || typeEquals(rt, call.type)) return call;
// An ISLAND-valued implementation return (`any` under --dynamic): the
// resolved overload's return type is a claim tsc never checked against
// the body — extracting it HERE would throw the boundary TypeError
// where Node just lets the value flow (functionOverloads35: the
// implementation returns its object argument under a number-returning
// overload signature; Node exits clean). The checker-trust trap keeps
// governing edges the checker actually vouches for; this edge it never
// did. The handle stays the value's only story: bindings store it
// (uncheckedOverloadHandleCall's rule at the declaration sites), and
// uses dispatch to engine ops like any island value.
if (call.type.kind === "jsval") return call;
// The CHECKED-DYNAMIC twin of the island rule: an `any`-returning
// implementation under a typed overload signature is the same
// never-vouched-for edge (functionOverloads35's shape without
// --dynamic) — extracting the resolved type HERE would throw the
// boundary TypeError where Node lets the value flow. Uses stay
// checked per read like every any-origin value.
if (call.type.kind === "dyn") return call;
if (call.type.kind === "union" && rt.kind !== "union") {
const helper = L.narrowedArmHelper(call.type.unionId, rt, call.loc);
if (helper) return { kind: "call", callee: helper, args: [call], type: rt, loc: call.loc };
}
return L.coerceInto(expr, call, rt);
}
/** Escape validity of a TAGGED template span's raw text: an invalid
* escape is legal syntax in a tagged template (ES2018) but cooks to
* UNDEFINED — a hole no string[] strings object can carry, so those
* sites keep a named fence. Valid: \x?? (two hex), \u???? (four hex),
* \u{...} (≤ 0x10FFFF), \0 not followed by a digit, and every
* non-digit character escape (identity escapes included). Invalid:
* malformed hex/unicode forms and the legacy octal / \8 \9 family. */
function templateEscapesValid(raw: string): boolean {
for (let i = 0; i < raw.length; i++) {
if (raw[i] !== "\\") continue;
const c = raw[i + 1];
if (c === undefined) return true; // trailing backslash: unreachable (the parser owns delimiters)
if (c === "x") {
if (!/^[0-9a-fA-F]{2}/.test(raw.slice(i + 2))) return false;
i += 3;
continue;
}
if (c === "u") {
if (raw[i + 2] === "{") {
const m = /^\{([0-9a-fA-F]+)\}/.exec(raw.slice(i + 2));
if (!m || parseInt(m[1]!, 16) > 0x10ffff) return false;
i += 1 + m[0].length;
continue;
}
if (!/^[0-9a-fA-F]{4}/.test(raw.slice(i + 2))) return false;
i += 5;
continue;
}
if (c === "0") {
if (/[0-9]/.test(raw[i + 2] ?? "")) return false;
i += 1;
continue;
}
if (/[1-9]/.test(c)) return false;
i += 1; // any other escaped character (identity escapes, \n, line continuations)
}
return true;
}
/** Tagged templates `tag\`a${x}b\`` — ES's call: tag(strings, ...values).
* The strings object is the per-SITE interned cooked array (the
* templateStrings node: one immortal string[] per occurrence, so the
* spec's identity contract holds — the same site evaluated twice hands
* the tag the SAME array; two sites never share). TemplateStringsArray
* maps to string[] (types.ts), so the array rides the ordinary
* slot-directed coercion into whatever the tag's first parameter wants
* — string[] exactly, an `any` slot through the dyn boundary, a rest
* pack's first element. `.raw` does not exist on the lowered object:
* reads fence per member, and String.raw itself lowered above (the raw
* spans splice directly, no array materializes).
*
* Tag forms: a top-level declared function (the direct-call fast path —
* overload sets reconcile through the resolved signature exactly like
* plain calls), an island value under --dynamic (engine method/function
* call: the engine side sees a plain marshaled array — a tag reading
* `.raw` there answers undefined where Node carries the raw spans), and
* a checked-dynamic value (the dynCall boundary — a non-function tag
* throws Node's catchable TypeError). Everything else — generic tags,
* method tags, function-value bindings — fences by name. */
export function lowerTaggedTemplate(L: Lowerer, expr: ts.TaggedTemplateExpression): IrExpr {
const loc = locOf(expr);
const pieces = ts.isNoSubstitutionTemplateLiteral(expr.template)
? [expr.template]
: [expr.template.head, ...expr.template.templateSpans.map((s) => s.literal)];
for (const p of pieces) {
if (!templateEscapesValid(templateRawTextOf(p))) {
L.unsupported(
"SC1090",
p,
"tagged templates with invalid escape sequences (the span cooks to undefined, which the strings array cannot carry)",
);
}
}
const strings: IrExpr = {
kind: "templateStrings",
key: `${loc.file}:${expr.template.getStart()}`,
cooked: pieces.map((p) => p.text),
type: arrayOf(STRING),
loc,
};
const values: readonly ts.Expression[] = ts.isNoSubstitutionTemplateLiteral(expr.template)
? []
: expr.template.templateSpans.map((s) => s.expression);
// Island tags (--dynamic): the engine call forms, mirroring lowerCall's
// island paths — a property-access tag is a method call (this = the
// receiver, JS-exact), any other island tag a function call. The
// strings argument builds ENGINE-NATIVE with its `.raw` property (the
// tplStrings op): a JSON marshal would drop `.raw`, and tags dispatch
// on it (the outdent idiom treats a raw-less argument as its OPTIONS
// form and answers a function). A fresh array per evaluation — tags
// caching by strings identity re-compute per call (SEMANTICS.md).
const islandStrings = (): IrExpr => ({
kind: "jsOp",
op: "tplStrings",
args: [
...pieces.map((p): IrExpr => ({ kind: "jsMarshal", value: { kind: "strLit", value: p.text, type: STRING, loc }, type: JSVAL, loc })),
...pieces.map((p): IrExpr => ({ kind: "jsMarshal", value: { kind: "strLit", value: templateRawTextOf(p), type: STRING, loc }, type: JSVAL, loc })),
],
type: JSVAL,
loc,
});
if (ts.isPropertyAccessExpression(expr.tag) && L.isIslandExpr(expr.tag.expression)) {
const receiver = L.lowerExpr(expr.tag.expression);
const args = [
islandStrings(),
...values.map((a) => L.jsvalIn(L.lowerExpr(a), a)),
];
return {
kind: "jsOp", op: "callMethod", name: expr.tag.name.text,
args: [receiver, ...args], type: JSVAL, loc,
};
}
if (L.isIslandExpr(expr.tag)) {
const callee = L.lowerExpr(expr.tag);
const args = [
islandStrings(),
...values.map((a) => L.jsvalIn(L.lowerExpr(a), a)),
];
return { kind: "jsOp", op: "callFn", args: [callee, ...args], type: JSVAL, loc };
}
// Direct call of a top-level declared function — the plain-call fast
// path with the strings array as the leading completed argument.
if (ts.isIdentifier(expr.tag) && !L.isSelfReference(expr.tag)) {
if (L.isTopLevelFnSymbol(expr.tag) && !L.peekLocal(expr.tag)) {
fenceEarlyAliasUse(L, expr.tag, expr);
if (L.genericFnOf(expr.tag)) {
L.unsupported("SC1090", expr, "tagged templates with generic tag functions");
}
const sig = L.fnSigOf(expr.tag);
if (sig) {
L.noteEdge(sig.name);
const args = completeArgs(L, values, sig.params, loc, expr, [strings]);
return reconcileOverloadReturn(L, expr, { kind: "call", callee: sig.name, args, type: sig.returnType, loc });
}
// An ambient `declare function` nothing defines: Node throws
// ReferenceError reading the tag before the template object is
// built — the plain-call stance (nsUndefRead) reproduces it. An
// `any`-typed result takes the DYN dummy rather than F64 so
// downstream any-shaped consumers (`tag\`...\` as string`) keep
// compiling — the read always throws first, the dummy is never
// observed either way.
if (ambientUndefinedFnSymbolOf(L, expr.tag)) {
const mapped = L.mapTypeOf(L.typeOf(expr));
const t =
mapped && mapped.kind !== "void" && !L.typeNamesUnregisteredClass(mapped)
? mapped
: (L.typeOf(expr).flags & ts.TypeFlags.Any) !== 0
? DYN
: F64;
return nsUndefRead(L, expr.tag.text, expr, t);
}
}
}
// Checked-dynamic tags (`var f: any; f\`abc\``, dyn property chains):
// the dynCall boundary — arguments convert into dyn, a non-function
// tag throws Node's catchable "<name> is not a function" TypeError.
const callee = L.lowerExpr(expr.tag);
if (callee.type.kind === "dyn") {
const args = [
L.coerceInto(expr.template, strings, DYN),
...values.map((a) => L.lowerExprExpecting(a, DYN)),
];
const calleeName = ts.isPropertyAccessExpression(expr.tag) || ts.isElementAccessExpression(expr.tag)
? expr.tag.getText()
: ts.isIdentifier(expr.tag)
? expr.tag.text
: "value";
return { kind: "dynCall", callee, calleeName, args, type: DYN, loc };
}
L.unsupported(
"SC1090",
expr,
"tagged templates with this tag form (top-level functions and dynamic values tag; call the function directly otherwise)",
);
}
/** True when the identifier resolves (through import aliases) to a
* top-level function declaration of ANY program file (not merely a
* same-named local shadowing one). Functions declared directly in a
* FLATTENED namespace block count — splitFiles hoisted them into the
* same collection lists top-level declarations ride. */
export function isTopLevelFnSymbol(L: Lowerer, ident: ts.Identifier): boolean {
const symbol = L.resolveValueSymbol(ident);
const decl = symbol ? L.checker.declarationsOf(symbol)[0] : undefined;
return (
!!decl &&
ts.isFunctionDeclaration(decl) &&
(ts.isSourceFile(decl.parent) || L.nsBlocks.get(decl.parent) === "flattened")
);
}
/** Nested `function name(...) {...}`: lowered as `const name = <lambda>`
* at the declaration's statement position (JS hoists function declarations
* to the top of the enclosing function — calling one before this statement
* is a compile error here, not a silent divergence). Self-references inside
* the body lower to `selfRef`, not a capture: a box holding its own
* closure would be an RC cycle. */
export function lowerNestedFunctionDecl(L: Lowerer, stmt: ts.FunctionDeclaration): IrStmt {
if (!stmt.name) L.unsupported("SC1090", stmt, "anonymous function declarations");
const { funcType } = L.lambdaSignature(stmt);
const local = L.declareLocal(stmt.name, stmt.name.text, funcType, false);
const init = L.lowerLambda(stmt);
return { kind: "varDecl", localId: local.id, init, loc: locOf(stmt) };
}
/** Signature checks + param shapes + IR func type for any lambda-like
* node. The func type's params are the ABI types, so a lambda with
* optional/default params has the same IR type as one spelling the
* `T | undefined` unions with required params — exactly the exact-arity
* value rule (requireExactArityValue decides who may become a value). */
export function lambdaSignature(L: Lowerer, node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration,): { shapes: ParamShape[]; funcType: IrType & { kind: "func" } } {
if (!node.body) L.unsupported("SC1090", node, "function overload signatures");
if (
node.asteriskToken &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
) {
L.unsupported("SC1071", node, "async generators (async function*)");
}
if (node.typeParameters) {
// Generic function-like forms monomorphize only where a static home
// exists: top-level generic function declarations, generic methods
// (class and object-literal), and module-scope never-reassigned
// bindings initialized with a generic arrow/function expression —
// all collected before this path. Everything else lambda-shaped
// (arguments, IIFEs, default exports, nested declarations) has no
// per-instantiation story and stays out.
L.unsupported(
"SC1090",
node,
ts.isMethodDeclaration(node)
? "generic methods"
: ts.isFunctionDeclaration(node)
? "generic nested functions (only top-level generic function declarations are supported)"
: "generic arrow/function expressions outside a never-reassigned module-scope binding (only `const f = <T>(x: T) => ...` bindings and top-level generic function declarations monomorphize)",
);
}
const shapes = L.paramShapes(node.parameters);
// A concise arrow over an h2-only stream/session call (`() =>
// req.stream.destroy()`): the call ALWAYS throws on this lowering
// (stream is undefined — the streamUndefCall precedent), so the body
// is throw-only and the declared return type (ServerHttp2Stream,
// unmappable) must not decide the ABI — void, the `never` stance.
let ret =
ts.isArrowFunction(node) && !ts.isBlock(node.body) && isStreamUndefCallExpr(L, node.body)
? VOID
: L.declaredReturnType(node, node);
// A contextually-typed arrow/function EXPRESSION whose slot signature
// returns a UNION the inferred return doesn't spell adopts the slot's
// return as its ABI: `(n) => work()` (inferring Promise<void>) against
// an `(n) => Promise<void> | void` field must RETURN that union — the
// body's returns coerce into it per return site (arm values wrap;
// width-coercible records rebuild into their arm — the runJobs
// `{ data, id }` literal against `Buffer | string | GeneratedOutput`),
// a void body's implicit completion becomes the undefined arm, and the
// closure VALUE matches the slot exactly (no runtime re-tag exists for
// func returns). tsc vetted the assignability — a return the coercion
// path can't carry fences per site with its own actionable message.
// ASYNC lambdas adopt through the promise: an inferred Promise<record>
// against a Promise<union> slot returns the union promise (the fiber's
// returns coerce; the spawn-wrapper ABI still returns a promise).
// jsval-returning bodies stay out (adoption would force validated
// exits the writer never asked for).
const isAsyncLike =
!ts.isMethodDeclaration(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
const innerRet = isAsyncLike && ret.kind === "promise" ? ret.inner : ret;
// Union-inferred returns adopt too (a mixed-return body inferring a
// SUB-union of the slot's union — adopting is a no-op when the two
// already agree); only jsval stays out.
if (
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
(!isAsyncLike || ret.kind === "promise") &&
innerRet.kind !== "jsval"
) {
// The slot's signature: the contextual type stripped of its nullish
// parts (an OPTIONAL callback field's context is the whole
// `(...) | undefined` union) with exactly one call signature — and
// declared in USER code: stdlib callback slots (flatMap's
// `U | readonly U[]`, sort comparators) have intrinsic lowerings
// that inspect the INFERRED type, so they must not widen.
const ctxType = L.checker.getContextualType(node);
const ctxSigs = ctxType
? L.checker.getCallSignatures(L.checker.getNonNullableType(ctxType))
: [];
const ctxDecl = ctxSigs.length === 1 ? L.checker.signatureDeclaration(ctxSigs[0]!) : undefined;
const ctxRetRaw =
ctxDecl && !ctxDecl.getSourceFile().isDeclarationFile
? L.mapTypeOf(L.checker.getReturnTypeOfSignature(ctxSigs[0]!))
: null;
const ctxRet =
isAsyncLike && ctxRetRaw?.kind === "promise" ? ctxRetRaw.inner : ctxRetRaw;
if (
ctxRet?.kind === "union" &&
(innerRet.kind === "void" ? L.armTag(ctxRet.unionId, UNDEFINED_T) >= 0 : true)
) {
ret = isAsyncLike ? { kind: "promise", inner: ctxRet } : ctxRet;
}
// A VOID slot discards the callback's result (TS's void-returning
// assignability rule; JS ignores the value), so an UNANNOTATED
// sync lambda adopts void regardless of what its body infers —
// `() => socket.destroy()` infers Socket (destroy returns `this`
// for chaining) but the error-listener slot never looks. Stdlib
// slots included: no intrinsic lowering inspects an inferred
// return where its own declared slot is void. Async lambdas stay
// out (the spawn-wrapper ABI must still return a promise), and an
// explicit return annotation keeps its word.
if (
!isAsyncLike &&
!node.type &&
ret.kind !== "void" &&
ctxSigs.length === 1 &&
!!(L.checker.getReturnTypeOfSignature(ctxSigs[0]!).flags & ts.TypeFlags.Void)
) {
ret = VOID;
}
}
// VARIADIC JS functions: a dynRest param (above), or a plain function
// whose body reads `arguments` (test/common's mustCall wrapper —
// `function() { ...; return fn.apply(this, arguments); }`). Both mark
// the func type `rest`: the lifted body takes one trailing dyn-array
// param, filled by the boxed call thunk with the call's arguments
// from index params.length on. `arguments` is only claimed in
// ZERO-param functions (there it IS the surplus array); alongside
// declared params the alias story has no model — the fence says to
// use a rest parameter. Arrows never claim it (JS: an arrow's
// `arguments` is the enclosing function's).
const hasDynRest = shapes.some((s) => s.mode === "dynRest");
const hasIslandRest = shapes.some((s) => s.mode === "islandRest");
const usesArguments =
!hasDynRest &&
!ts.isArrowFunction(node) &&
isJsSourceFile(node.getSourceFile()) &&
bodyReadsArguments(node);
if (usesArguments && node.parameters.length > 0) {
L.unsupported(
"SC1090",
node,
"'arguments' in functions with declared parameters (use a rest parameter: (...args))",
);
}
return {
shapes,
funcType: {
kind: "func",
// dynRest is EXCLUDED (the boxed thunk fills the trailing dyn
// array — no spelled slot); islandRest is INCLUDED (the trailing
// jsval param IS the engine arguments array, the REST host-call
// adapter's one uniform shape).
params: shapes.filter((s) => s.mode !== "dynRest").map((s) => s.type),
ret,
...(hasDynRest || usesArguments || hasIslandRest ? { rest: true as const } : {}),
...(hasIslandRest ? { restAbi: "jsval" as const } : {}),
},
};
}
/** Does this function's OWN body read `arguments`? Nested plain functions
* and methods have their own `arguments` (the walk skips them); arrows
* see the enclosing one (the walk descends). Exported for the lowerer's
* dynFallbackType: tsgo does not synthesize the `arguments` rest
* parameter into inferred signatures (5.9.3 did — its param-count
* mismatch was the detector), so the 7 world asks the BODY directly. */
export function bodyReadsArguments(fn: { body?: ts.Node | undefined }): boolean {
let found = false;
if (fn.body === undefined) return false;
// Iterative walk (walkPreorder): function bodies can hold pathologically
// deep expression chains that a recursive visit would die on.
ts.walkPreorder(fn.body, (n) => {
if (ts.isIdentifier(n) && n.text === "arguments" && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
found = true;
return "stop";
}
if (
(ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n)) &&
n !== fn
) {
return "skip"; // own `arguments` scope
}
return undefined;
});
return found;
}
/** Lifts an arrow function / function expression / nested declaration /
* object-literal shorthand method to a module-level function and yields
* the `closure` expression creating it. */
export function lowerLambda(L: Lowerer, node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration,): IrExpr {
const loc = locOf(node);
const { shapes, funcType } = L.lambdaSignature(node);
// A lambda IS a value: the exact-arity rule applies at birth. The
// contextual (target) type decides — `(x?: number) => void` may flow
// into a slot annotated `(x: number | undefined) => void` (same ABI
// signature), anything else is fenced. Nested function declarations and
// object-literal shorthand methods aren't expressions — always fenced.
L.requireExactArityValue(
node,
ts.isArrowFunction(node) || ts.isFunctionExpression(node) ? node : null,
shapes,
funcType,
);
const nameIdent =
!ts.isArrowFunction(node) && node.name && ts.isIdentifier(node.name) ? node.name : null;
const baseName = nameIdent ? nameIdent.text : "";
const fnName = `%fn${L.lambdaCounter++}${baseName ? `_${baseName}` : ""}`;
// Named function expressions/declarations can self-reference by name; an
// object-literal method's name is a PROPERTY, not a binding — no self.
const selfSymbol =
nameIdent && !ts.isMethodDeclaration(node) && !ts.isAccessor(node)
? (L.checker.getSymbolAtLocation(nameIdent) ?? null)
: null;
// Async lambdas — object-literal async METHODS included (a method in
// an object literal is a function value in a record field; no vtable
// exists to dispatch through): the VALUE's type returns Promise<T>,
// the lifted body returns the inner T (a `return v` fulfills with v).
const isAsync =
!ts.isAccessor(node) &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true;
if (isAsync && funcType.ret.kind !== "promise") L.badType(node, L.typeOf(node));
// Generator lambdas (function* expressions and object-literal
// *methods): the VALUE's type returns the generator; the lifted body
// returns the TReturn channel (a `return v` is the done-value).
const isGenerator = node.asteriskToken !== undefined;
if (isGenerator && funcType.ret.kind !== "generator") L.badType(node, L.typeOf(node));
const bodyReturn = isGenerator
? L.genBodyReturnType(funcType.ret)
: L.bodyReturnType(isAsync, funcType.ret);
const fnCtx = newFnCtx(true, selfSymbol, funcType, bodyReturn);
fnCtx.isAsync = isAsync;
if (isGenerator && funcType.ret.kind === "generator") {
fnCtx.generator = { yieldT: funcType.ret.yieldT, nextT: funcType.ret.nextT };
}
const diagsBefore = L.diags.length;
L.fnStack.push(fnCtx);
try {
const { params, prologue } = L.declareParams(node.parameters, shapes);
// The VARIADIC `arguments` form (rest-marked with no declared rest
// param): a synthetic trailing dyn-array param carries the call's
// arguments; `arguments` reads resolve to it (identifier lowering).
if (funcType.rest && !shapes.some((s) => s.mode === "dynRest" || s.mode === "islandRest")) {
const argsLocal = L.declareHiddenLocal("%arguments", DYN);
params.push({ localId: argsLocal.id, name: "%arguments", type: DYN });
fnCtx.argumentsLocal = argsLocal;
}
let body: IrStmt[];
if (ts.isBlock(node.body!)) {
body = L.lowerStmts(node.body!.statements);
} else {
// Bare-expression arrow body: `x => e` is `x => { return e; }`
// (or an expression statement when the signature returns void — or
// when a union-returning signature wraps a void expression, whose
// value is the implicit undefined arm appended below).
const bodyExpr = node.body as ts.Expression;
if (bodyReturn.kind === "void") {
// `() => undefined` — the return type maps to void (standalone
// undefined IS void in the type mapping) and the body value is a
// bare unit literal: a pure no-op, dropped rather than tripping
// the validator's bare-unitLit rule (typeCheckReturnExpression).
// A `void e` body rides the statement lowering (the value is
// discarded here, so the operand evaluates for effect alone —
// `(name) => void doThing(name)`, the fire-and-forget arrow).
let stripped: ts.Expression = bodyExpr;
while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression;
if (ts.isVoidExpression(stripped)) {
body = [L.lowerExprStatement(stripped)];
} else {
const value = L.lowerExpr(bodyExpr);
body = value.kind === "unitLit" ? [] : [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }];
}
} else {
let value = L.lowerExpr(bodyExpr);
// An async concise body whose value is itself a promise
// (`async () => p`): the async machinery RESOLVES the returned
// thenable into the function's own promise — lowerReturnValue's
// await-through, applied to the implicit return.
if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") {
value = { kind: "awaitExpr", value, type: value.type.inner, loc: value.loc };
}
body =
value.type.kind === "void" && L.wrappedUndefined(bodyReturn, locOf(node.body!))
? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]
: [
{
kind: "return",
value: L.coerceInto(bodyExpr, value, bodyReturn),
loc: locOf(node.body!),
},
];
}
}
body = [...prologue, ...body];
// Bare-expression bodies never pass through lowerStmts, so the
// lib-boundary chokepoint runs here (idempotent for block bodies,
// whose statements were already walked). A fence poisons the
// enclosing statement — the lambda IS part of it.
enforceLibBoundary(L, body);
appendImplicitUndefinedReturn(L, body, bodyReturn, loc);
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params,
returnType: bodyReturn,
locals: ctx.locals,
captures: ctx.captures!,
body,
loc,
};
if (isAsync) lifted.async = true;
if (fnCtx.generator) lifted.generator = fnCtx.generator;
L.liftedFns.push(lifted);
return { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
} catch (e) {
// JS sources defer LAMBDA poisons like function declarations
// (lowerFunction's catch, lambda form — entry the function-level
// deferral): a fenced concise body (`(list) => new Intl.ListFormat
// (...).format(list)` — the error-message list-join idiom) would
// otherwise poison the ENCLOSING statement, stopping module init
// where Node only stops when the lambda is CALLED. The value
// compiles as a capture-free closure over a runtimeFence body —
// calling throws the first captured diagnostic at its source
// position. ICEs (SC9001) stay compile errors, exactly like
// lowerStmts; probe mode (diagSink) keeps the poison.
if (!(e instanceof PoisonError)) throw e;
if (
!isJsSourceFile(node.getSourceFile()) ||
L.diagSink !== null ||
L.diags.length <= diagsBefore ||
L.diags.slice(diagsBefore).some((d) => d.code === "SC9001")
) {
throw e;
}
const captured = L.diags.splice(diagsBefore);
L.runtimeFences.push(...captured);
const first = captured[0]!;
const pos = ts.getLineAndCharacterOfPosition(
L.program.getSourceFile(first.loc.file) ?? node.getSourceFile(),
first.loc.start,
);
const params: IrParam[] = funcType.params.map((t, i) => ({ localId: `%pf${i}`, name: `%pf${i}`, type: t }));
// A REST-MARKED value type hides one synthetic trailing dyn-array
// param in the lifted function (the boxed call thunk fills it) —
// the fence lambda must spell that slot too or the validator's
// closure-signature check trips (SC9001). Island rest types SPELL
// their trailing engine-array param, so funcType.params already
// covers those.
if (funcType.rest === true && funcType.restAbi !== "jsval") {
params.push({ localId: "%pfrest", name: "%pfrest", type: DYN });
}
const lifted: IrFunction = {
name: fnName,
params,
returnType: bodyReturn,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
captures: [],
body: [
{
kind: "runtimeFence",
code: first.code,
message: `${first.message} [${first.code} at ${first.loc.file}:${pos.line + 1}]`,
loc,
},
],
loc,
};
if (isAsync) lifted.async = true;
if (fnCtx.generator) lifted.generator = fnCtx.generator;
L.liftedFns.push(lifted);
return { kind: "closure", fnName, captures: [], type: funcType, loc };
} finally {
L.fnStack.pop();
}
}
/** `p.then(f)` / `p.catch(handler)` / `p.finally(cb)` — fiber-level
* DESUGARS. Each synthesizes a small async wrapper (lifted like a
* lambda) and calls it with the receiver, so promise machinery,
* microtask ordering, and rejection bookkeeping all ride the existing
* await path:
*
* p.then(f) ≡ (async (pp, f) => { return f(await pp); })(p, f)
* p.catch(h) ≡ (async (pp) => { try { return await pp; } catch (e) { <h's body> } })(p)
* p.finally(f) ≡ (async (pp, f) => { try { const v = await pp; f(); return v; } catch (e) { f(); throw e; } })(p, f)
*
* Node-exact by construction: the wrapper's await parks on pending
* receivers and takes the settled-await microtask hop otherwise; the
* catch handler's parameter binds the rejection reason as a CAUGHT
* local — the typed-catch machinery (instanceof/typeof narrowing,
* rethrow) IS the handler's surface; a handler throw rejects the
* result; a handler falling off its end resolves with undefined
* (checker-typed — the result union carries the arm); an unawaited
* rejected result enters the unhandled-rejection ledger. The catch
* HANDLER must be an inline arrow/function expression: its parameter
* becomes the catch binding, and a handler VALUE would need a
* caught-typed closure parameter, which cannot exist. finally takes
* any () => void closure (its callback sees no arguments). then takes
* exactly one FULFILLMENT handler (any closure value of the settled
* value's type — the two-argument onRejected form stays fenced toward
* .catch); a promise-returning handler flattens through the async
* return path, a receiver rejection passes through untouched (the
* wrapper's await re-throws it), and a handler throw rejects the
* result — the spec's onFulfilled rules by construction. Null for
* non-promise receivers and other members. */
/** The storage type behind a promise-valued expression whose CHECKER type
* has no mapping — the dynamic-import receiver rule (--dynamic): a direct
* `import("...")` call is the island promise itself; an identifier bound
* to a promise-of-jsval local or module global answers the binding's
* type. Null everywhere else. */
function islandPromiseStorageTypeOf(L: Lowerer, e: ts.Expression): IrType | null {
const direct = importCallHandleType(e);
if (direct?.kind === "promise") return direct;
if (!ts.isIdentifier(e)) return null;
const local = L.resolveLocal(e);
if (local?.type.kind === "promise" && local.type.inner.kind === "jsval") return local.type;
if (local) return null;
let sym = L.checker.getSymbolAtLocation(e);
if (sym && sym.flags & ts.SymbolFlags.Alias) sym = L.checker.getAliasedSymbol(sym);
const g = sym ? L.globalsBySymbol.get(sym) : undefined;
if (g?.type.kind === "promise" && g.type.inner.kind === "jsval") return g.type;
return null;
}
/** Marks an INLINE then-handler's unannotated identifier parameters for
* the island-handle (jsval) binding type — paramShape's early-out. Only
* the inline arrow/function forms qualify: a handler VALUE keeps its own
* declared signature (and the settled-type equality check below). */
function markJsvalHandlerParams(L: Lowerer, handler: ts.Expression): void {
let e = handler;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isArrowFunction(e) && !ts.isFunctionExpression(e)) return;
for (const p of e.parameters) {
if (ts.isIdentifier(p.name) && p.type === undefined && !p.dotDotDotToken && !p.initializer) {
L.jsvalParamOverrides.add(p);
}
}
}
export function lowerPromiseMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
const member = access.name.text;
if (member !== "then" && member !== "catch" && member !== "finally") return null;
let recvT = L.mapTypeOf(L.typeOf(access.expression));
// A dynamic-import promise under an unmappable checker type
// (`Promise<typeof import("./m")>` — module-namespace types have no
// static mapping): the BINDING holds the island promise
// (importCallHandleType / the island-HANDLE var rules), so the storage
// type is the receiver's truth. Direct `import("./m").then(...)`
// spells the same promise with no binding at all.
if (!recvT && L.dynamic) recvT = islandPromiseStorageTypeOf(L, access.expression);
if (recvT?.kind !== "promise") return null;
if (!L.isStdlibMember(access)) return null;
const loc = locOf(call);
// Handler-less spellings — `p.then()`, `p.catch()`, `p.finally()`,
// and the explicit `undefined`/`null` handler: the spec substitutes
// identity/thrower/no-op, so each is the PASSTHROUGH promise — a
// fresh promise settling exactly as the receiver does (never the
// receiver itself: `p.catch() !== p` in JS). Detected here; built
// after the receiver lowers below.
const isAbsentHandler = (a: ts.Expression | undefined): boolean => {
if (a === undefined) return true;
let e = a;
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e.kind === ts.SyntaxKind.NullKeyword ||
(ts.isIdentifier(e) && e.text === "undefined" &&
(L.typeOf(e).flags & ts.TypeFlags.Undefined) !== 0);
};
const passthrough =
call.arguments.length === 0 ||
(call.arguments.length === 1 && isAbsentHandler(call.arguments[0]));
if (call.arguments.length !== 1 && !passthrough) {
L.noLowering(
`${member} with ${call.arguments.length} arguments`,
call,
member === "then"
? "the supported form takes exactly one fulfillment handler — chain .catch(...) for the rejection half"
: `the supported form takes exactly one ${member === "catch" ? "inline handler" : "callback"}`,
);
}
// The receiver evaluates FIRST, in the enclosing function, like JS.
let receiver = L.lowerExpr(access.expression);
// A PACKAGE-returned promise lowers as an island value (jsval): the
// promise lives in the engine, so bridge it — a static promise the
// engine promise settles (fulfillment = the retained handle or void,
// rejection = the bridged reason) — and desugar over the BRIDGE
// exactly like a native receiver. This is the classic CLI entry line,
// `program.parseAsync(process.argv).catch(handler)`.
if (receiver.type.kind === "jsval") {
receiver = {
kind: "jsBridgePromise",
value: receiver,
type: { kind: "promise", inner: recvT.inner.kind === "void" ? VOID : JSVAL },
loc,
};
}
if (receiver.type.kind !== "promise") {
// mapTypeOf said promise but the value lowered as something else —
// a lowering gap, named rather than ICEd on.
L.unsupported("SC1090", call, `'.${member}' on this receiver`);
}
// The wrapper types follow the RECEIVER's promise type (the bridge's
// promise-of-jsval for package receivers, the mapped type otherwise);
// typed uses of the settled value exit through coerceInto below.
const promT = receiver.type;
const inner = promT.inner;
if (passthrough) {
// The absent-handler forms: a lifted `async (p) => await p` — the
// fresh promise adopts p's settlement exactly (fulfillment value
// through the await, rejection through the await's rethrow), which
// IS the spec's identity/thrower/no-op substitution for all three
// members. An argument expression, when present, is undefined/null
// by construction — nothing to evaluate.
const fnName = `%fn${L.lambdaCounter++}_${member}pass`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT], ret: promT };
const fnCtx = newFnCtx(true, null, funcType, inner);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const awaitE: IrExpr = {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: inner,
loc,
};
const body: IrStmt[] =
inner.kind === "void"
? [{ kind: "exprStmt", expr: awaitE, loc }, { kind: "return", value: null, loc }]
: [{ kind: "return", value: awaitE, loc }];
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [{ localId: pLocal.id, name: pLocal.name, type: promT }],
returnType: inner,
locals: ctx.locals,
captures: ctx.captures!,
body,
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver], type: promT, loc };
} finally {
L.fnStack.pop();
}
}
if (member === "then") {
// The settled value is an island HANDLE: an inline handler's
// unannotated parameter binds it as jsval, whatever the checker's
// contextual type spelled (a module-namespace type has no mapping —
// the handle is the value's only story, isIslandExpr's local rule).
if (inner.kind === "jsval") markJsvalHandlerParams(L, call.arguments[0]!);
let cb = L.lowerExpr(call.arguments[0]!);
// A TYPED handler on a DYN-settling promise (the tracePromise
// result's `.then((value) => ...)` — the checker's generic
// instantiation typed the parameter, but the settled value is a
// dyn value): box the handler and ride the dyn-handler desugar
// below — its call thunk validates the settled value into the
// declared parameter type (the per-arg dynCheck), Node's own
// runtime contract for a value that came off the wire untyped.
if (
inner.kind === "dyn" &&
cb.type.kind === "func" &&
cb.type.params.some((p) => p.kind !== "dyn") &&
canBoxFuncIntoDyn(cb.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
cb = { kind: "dynFrom", value: cb, type: DYN, loc };
}
// A CHECKED-DYNAMIC handler VALUE (`p.then(common.mustCall())` — the
// Node-suite wrapper is an untyped rest-args function): the same
// async desugar with the handler called through the checked-dynamic tree — the
// settled value boxes (dyn passes through; void arrives as JS's
// explicit undefined argument), the result promise settles with the
// handler's dyn result. A receiver rejection passes through the
// await like the typed path; the dyn call's own argument checking
// throws Node's TypeError for non-callables.
if (cb.type.kind === "dyn") {
const settledToDyn = (v: IrExpr): IrExpr => {
if (v.type.kind === "dyn") return v;
if (canConvertToDyn(v.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))) {
return { kind: "dynFrom", value: v, type: DYN, loc };
}
L.unsupported(
"SC1090",
call.arguments[0]!,
`then handlers receiving '${L.fmt(v.type)}' values through an untyped handler (the settled value cannot cross the checked-dynamic tree boundary)`,
);
};
const resultT: IrType & { kind: "promise" } = { kind: "promise", inner: DYN };
const fnName = `%fn${L.lambdaCounter++}_then`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT, DYN], ret: resultT };
const fnCtx = newFnCtx(true, null, funcType, DYN);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const cbLocal = L.declareHiddenLocal("cb", DYN);
const awaitE: IrExpr = {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: inner,
loc,
};
const body: IrStmt[] = [];
let handlerArgs: IrExpr[];
if (inner.kind === "void") {
body.push({ kind: "exprStmt", expr: awaitE, loc });
handlerArgs = [dynUndefinedExpr(loc)];
} else {
const vLocal = L.declareHiddenLocal("v", inner);
body.push({ kind: "varDecl", localId: vLocal.id, init: awaitE, loc });
handlerArgs = [settledToDyn({ kind: "varRef", localId: vLocal.id, type: inner, loc })];
}
body.push({
kind: "return",
value: {
kind: "dynCall",
callee: { kind: "varRef", localId: cbLocal.id, type: DYN, loc },
calleeName: jsFuncNameOf(call.arguments[0]!) ?? "onFulfilled",
args: handlerArgs,
type: DYN,
loc,
},
loc,
});
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [
{ localId: pLocal.id, name: pLocal.name, type: promT },
{ localId: cbLocal.id, name: cbLocal.name, type: DYN },
],
returnType: DYN,
locals: ctx.locals,
captures: ctx.captures!,
body,
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver, cb], type: resultT, loc };
} finally {
L.fnStack.pop();
}
}
if (cb.type.kind !== "func" || cb.type.params.length > 1) {
L.unsupported(
"SC1090",
call.arguments[0]!,
"then handlers with more than one parameter (the two-argument onRejected form has no lowering — chain .catch(...) instead)",
);
}
const param = cb.type.params[0];
if (param !== undefined && !typeEquals(param, inner)) {
L.unsupported(
"SC1090",
call.arguments[0]!,
`then handlers whose parameter is not the settled value's type (expected '${L.fmt(inner)}', got '${L.fmt(param)}')`,
);
}
const resultT = L.mapTypeOf(L.typeOf(call));
if (resultT?.kind !== "promise") {
L.noLowering(
"then with this handler's result type",
call,
"the combined result must be a representable promise",
);
}
const R = resultT.inner;
const fnName = `%fn${L.lambdaCounter++}_then`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT, cb.type], ret: resultT };
const fnCtx = newFnCtx(true, null, funcType, R);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const cbLocal = L.declareHiddenLocal("cb", cb.type);
const awaitE: IrExpr = {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: inner,
loc,
};
const body: IrStmt[] = [];
// The settled value: awaited into a local when the handler wants
// it (a zero-param handler still awaits — the receiver must settle
// before the handler runs, and a rejection must pass through).
let handlerArgs: IrExpr[] = [];
if (param !== undefined && inner.kind !== "void") {
const vLocal = L.declareHiddenLocal("v", inner);
body.push({ kind: "varDecl", localId: vLocal.id, init: awaitE, loc });
handlerArgs = [{ kind: "varRef", localId: vLocal.id, type: inner, loc }];
} else {
body.push({ kind: "exprStmt", expr: awaitE, loc });
}
const handlerCall: IrExpr = {
kind: "callValue",
callee: { kind: "varRef", localId: cbLocal.id, type: cb.type, loc },
args: handlerArgs,
type: cb.type.ret,
loc,
};
// The handler's result: promise returns flatten exactly like
// `return p` in any async body (awaitExpr re-throws rejections —
// the spec's thenable adoption); everything else coerces into R.
if (R.kind === "void") {
if (handlerCall.type.kind === "promise") {
body.push({
kind: "exprStmt",
expr: { kind: "awaitExpr", value: handlerCall, type: handlerCall.type.inner, loc },
loc,
});
} else {
body.push({ kind: "exprStmt", expr: handlerCall, loc });
}
body.push({ kind: "return", value: null, loc });
} else if (handlerCall.type.kind === "promise" && R.kind !== "promise") {
const awaited: IrExpr = { kind: "awaitExpr", value: handlerCall, type: handlerCall.type.inner, loc };
body.push({ kind: "return", value: L.coerceInto(call, awaited, R), loc });
} else {
body.push({ kind: "return", value: L.coerceInto(call, handlerCall, R), loc });
}
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [
{ localId: pLocal.id, name: pLocal.name, type: promT },
{ localId: cbLocal.id, name: cbLocal.name, type: cb.type },
],
returnType: R,
locals: ctx.locals,
captures: ctx.captures!,
body,
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver, cb], type: resultT, loc };
} finally {
L.fnStack.pop();
}
}
if (member === "finally") {
const cb = L.lowerExpr(call.arguments[0]!);
if (cb.type.kind !== "func" || cb.type.params.length !== 0 || cb.type.ret.kind !== "void") {
L.unsupported(
"SC1090",
call.arguments[0]!,
"finally callbacks with parameters or a return value (use () => { ... })",
);
}
const fnName = `%fn${L.lambdaCounter++}_finally`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT, cb.type], ret: promT };
const fnCtx = newFnCtx(true, null, funcType, inner);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const cbLocal = L.declareHiddenLocal("cb", cb.type);
const cbCall = (): IrStmt => ({
kind: "exprStmt",
expr: {
kind: "callValue",
callee: { kind: "varRef", localId: cbLocal.id, type: cb.type, loc },
args: [],
type: VOID,
loc,
},
loc,
});
const awaitE: IrExpr = {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: inner,
loc,
};
const tryBody: IrStmt[] = [];
if (inner.kind === "void") {
tryBody.push({ kind: "exprStmt", expr: awaitE, loc });
tryBody.push(cbCall());
tryBody.push({ kind: "return", value: null, loc });
} else {
const vLocal = L.declareHiddenLocal("v", inner);
tryBody.push({ kind: "varDecl", localId: vLocal.id, init: awaitE, loc });
tryBody.push(cbCall());
tryBody.push({
kind: "return",
value: { kind: "varRef", localId: vLocal.id, type: inner, loc },
loc,
});
}
// catch (e) { cb(); throw e; } — a throwing callback replaces the
// in-flight rejection, exactly the spec's onFinally rule.
const eLocal = L.declareHiddenLocal("e", CAUGHT);
const catchBody: IrStmt[] = [cbCall(), { kind: "rethrow", localId: eLocal.id, loc }];
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [
{ localId: pLocal.id, name: pLocal.name, type: promT },
{ localId: cbLocal.id, name: cbLocal.name, type: cb.type },
],
returnType: inner,
locals: ctx.locals,
captures: ctx.captures!,
body: [{ kind: "tryCatch", tryBody, catchBody, catchLocalId: eLocal.id, finallyBody: null, loc }],
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver, cb], type: promT, loc };
} finally {
L.fnStack.pop();
}
}
// .catch on a DYN-SETTLING promise (the tracePromise result's
// `.catch((e) => ...)`): the rejection reason is a dyn value, so the
// handler runs through the checked-dynamic tree — a lifted async helper awaits the
// receiver, passes fulfillments through as dyn, and on rejection
// calls the boxed handler with caughtToDyn's identity-preserving
// snapshot (the dyn-then desugar's catch twin).
if (member === "catch" && inner.kind === "dyn") {
let cb = L.lowerExpr(call.arguments[0]!);
if (
cb.type.kind === "func" &&
canBoxFuncIntoDyn(cb.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
cb = { kind: "dynFrom", value: cb, type: DYN, loc };
}
if (cb.type.kind === "dyn") {
const resultT: IrType & { kind: "promise" } = { kind: "promise", inner: DYN };
const fnName = `%fn${L.lambdaCounter++}_catchdyn`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT, DYN], ret: resultT };
const fnCtx = newFnCtx(true, null, funcType, DYN);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const cbLocal = L.declareHiddenLocal("cb", DYN);
const eLocal = L.declareHiddenLocal("e", CAUGHT);
const vLocal = L.declareHiddenLocal("v", DYN);
const tryBody: IrStmt[] = [
{
kind: "varDecl",
localId: vLocal.id,
init: {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: DYN,
loc,
},
loc,
},
{ kind: "return", value: { kind: "varRef", localId: vLocal.id, type: DYN, loc }, loc },
];
const catchBody: IrStmt[] = [
{
kind: "return",
value: {
kind: "dynCall",
callee: { kind: "varRef", localId: cbLocal.id, type: DYN, loc },
calleeName: jsFuncNameOf(call.arguments[0]!) ?? "onRejected",
args: [
{
kind: "caughtToDyn",
value: { kind: "varRef", localId: eLocal.id, type: CAUGHT, loc },
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
];
const body: IrStmt[] = [
{ kind: "tryCatch", tryBody, catchBody, catchLocalId: eLocal.id, finallyBody: null, loc },
];
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [
{ localId: pLocal.id, name: pLocal.name, type: promT },
{ localId: cbLocal.id, name: cbLocal.name, type: DYN },
],
returnType: DYN,
locals: ctx.locals,
captures: ctx.captures!,
body,
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver, cb], type: resultT, loc };
} finally {
L.fnStack.pop();
}
}
}
// .catch: the handler must be INLINE — its parameter becomes the
// catch binding.
let handlerNode: ts.Expression = call.arguments[0]!;
while (ts.isParenthesizedExpression(handlerNode)) handlerNode = handlerNode.expression;
if (!ts.isArrowFunction(handlerNode) && !ts.isFunctionExpression(handlerNode)) {
L.unsupported(
"SC1090",
call.arguments[0]!,
"catch handlers that are not inline function literals (the handler's parameter " +
"becomes a typed-catch binding, which only an inline `(e) => ...` can receive)",
);
}
if (handlerNode.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) {
L.unsupported("SC1090", handlerNode, "async catch handlers");
}
if (handlerNode.parameters.length > 1) {
L.unsupported("SC1090", handlerNode, "catch handlers with more than one parameter");
}
const param = handlerNode.parameters[0];
if (param && (!ts.isIdentifier(param.name) || param.dotDotDotToken || param.initializer)) {
L.unsupported("SC1062", param);
}
if (
param?.type &&
param.type.kind !== ts.SyntaxKind.AnyKeyword &&
param.type.kind !== ts.SyntaxKind.UnknownKeyword
) {
L.unsupported(
"SC1090",
param,
"catch handlers with a typed parameter (the reject payload can be any thrown " +
"value — take `(e)` or `(e: unknown)` and narrow with instanceof)",
);
}
const resultT = L.mapTypeOf(L.typeOf(call));
if (resultT?.kind !== "promise") {
L.noLowering(
"catch with this handler's result type",
call,
"the combined result must be representable — a handler with no return value over a " +
"non-void promise makes the result 'T | void': return a fallback of the promise's " +
"own type, or annotate the handler `(): undefined =>` for the T | undefined result",
);
}
const R = resultT.inner;
const fnName = `%fn${L.lambdaCounter++}_catch`;
const funcType: IrType & { kind: "func" } = { kind: "func", params: [promT], ret: resultT };
const fnCtx = newFnCtx(true, null, funcType, R);
fnCtx.isAsync = true;
L.fnStack.push(fnCtx);
try {
const pLocal = L.declareHiddenLocal("p", promT);
const awaitE: IrExpr = {
kind: "awaitExpr",
value: { kind: "varRef", localId: pLocal.id, type: promT, loc },
type: inner,
loc,
};
const tryBody: IrStmt[] =
R.kind === "void"
? [
{ kind: "exprStmt", expr: awaitE, loc },
{ kind: "return", value: null, loc },
]
: [{ kind: "return", value: L.coerceInto(call, awaitE, R), loc }];
// The handler body lowers as the catch clause, its parameter bound
// as the CAUGHT local — exactly `catch (e) { ... }`.
let catchLocalId: string | null = null;
let catchBody: IrStmt[];
L.scopes.push(new Map());
try {
if (param && ts.isIdentifier(param.name)) {
catchLocalId = L.declareLocal(param.name, param.name.text, CAUGHT, false).id;
}
const hb = handlerNode.body;
if (ts.isBlock(hb)) {
catchBody = L.lowerStmts(hb.statements);
} else if (R.kind === "void") {
catchBody = [{ kind: "exprStmt", expr: L.lowerExpr(hb), loc: locOf(hb) }];
} else {
// Bare-expression handler: `(e) => v` (promise results flatten
// through the async-return path, like any `return v`).
catchBody = [{ kind: "return", value: L.lowerReturnValue(hb), loc: locOf(hb) }];
}
} finally {
L.scopes.pop();
}
// A handler falling off its end resolves with undefined — the
// checker already typed R with the undefined arm; the appended wrap
// also satisfies the validator's always-returns analysis.
if (R.kind === "union") {
const def = L.unions.get(R.unionId);
const undefTag = def ? def.arms.findIndex((a) => a.kind === "undefinedT") : -1;
if (undefTag >= 0) {
catchBody.push({
kind: "return",
value: {
kind: "unionWrap",
unionId: R.unionId,
tag: undefTag,
value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
type: R,
loc,
},
loc,
});
}
} else if (R.kind === "void") {
catchBody.push({ kind: "return", value: null, loc });
} else if (R.kind === "dyn") {
// A checked-dynamic result (the dyn-handler .then's promise-of-dyn
// chained into .catch): falling off the handler's end resolves
// with the dyn undefined.
catchBody.push({ kind: "return", value: dynUndefinedExpr(loc), loc });
} else if (R.kind === "jsval") {
// A package-typed result (Promise<Command> — the parseAsync().catch
// entry line): falling off the handler's end resolves with
// undefined, which on the island side is the engine's own
// undefined. Also what makes a never-returning handler (ending in
// process.exit) satisfy the always-returns analysis.
catchBody.push({
kind: "return",
value: { kind: "jsOp", op: "globalGet", name: "undefined", args: [], type: JSVAL, loc },
loc,
});
}
const ctx = L.ctx;
const lifted: IrFunction = {
name: fnName,
params: [{ localId: pLocal.id, name: pLocal.name, type: promT }],
returnType: R,
locals: ctx.locals,
captures: ctx.captures!,
body: [{ kind: "tryCatch", tryBody, catchBody, catchLocalId, finallyBody: null, loc }],
loc,
async: true,
};
L.liftedFns.push(lifted);
const closure: IrExpr = { kind: "closure", fnName, captures: ctx.captureSources, type: funcType, loc };
return { kind: "callValue", callee: closure, args: [receiver], type: resultT, loc };
} finally {
L.fnStack.pop();
}
}
/** NARROWING `a.filter(...)` — the two callback forms whose result the
* checker types as a NARROWER array than the receiver:
*
* xs.filter((x) => x !== undefined) // TS-inferred type predicate
* xs.filter(Boolean) // BooleanConstructor overload
*
* Trust discipline: only tests the RUNTIME actually performs may re-tag.
* An INFERRED predicate (inline arrow/function expression with no return
* annotation — TS 5.5 only infers `x is T` when the body proves it) and
* `Boolean` (retained elements are truthy, hence never the undefined/
* null arm) both qualify; a HAND-WRITTEN `x is T` annotation is an
* unchecked assertion (a lying one would corrupt the extraction) and
* stays fenced. The narrowed element must be a SINGLE arm of the
* receiver's union — retained elements re-tag through unionNarrow in the
* synthesized loop; a multi-arm target would need the union-to-union
* re-tag that doesn't exist (fenced with the annotate-the-callback
* escape). Null hands non-narrowing filters to the generic HOF path. */
export function lowerFilterNarrowCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (access.name.text !== "filter") return null;
if (L.chainBlocked(access, call)) return null;
const receiverIr = L.mapTypeOf(L.typeOf(access.expression));
if (receiverIr?.kind !== "array") return null;
if (!L.isStdlibMember(access)) return null;
if (call.arguments.length !== 1) return null; // the generic path's arity fence
const elem = receiverIr.elem;
const argNode = call.arguments[0]!;
const loc = locOf(call);
const isBooleanArg =
ts.isIdentifier(argNode) &&
argNode.text === "Boolean" &&
L.isStdlibSymbol(L.resolveValueSymbol(argNode) ?? undefined);
// The checker's verdict on the call: filter(Boolean) and predicate
// callbacks type the result element NARROWER than the receiver's.
const callT = L.typeOf(call);
const resultIr = L.mapTypeOf(callT);
let outElem = resultIr?.kind === "array" ? resultIr.elem : null;
if (outElem !== null && !typeEquals(outElem, elem)) {
// An annotation pinning the receiver's own element type opts OUT of
// the narrowing (`const kept: (Hit | undefined)[] = xs.filter(...)`):
// tsc allows the covariant assignment, and the wide result is what
// the desugared loop produces — the pre-predicate behavior, kept.
const ctxIr = L.mapTypeOf(L.checker.getContextualType(call) ?? callT);
if (ctxIr?.kind === "array" && typeEquals(ctxIr.elem, elem)) outElem = elem;
}
const narrowed = outElem !== null && !typeEquals(outElem, elem);
if (!narrowed && !isBooleanArg) return null;
const annotateEscape =
"keep the receiver's element type instead — annotate the callback's return ': boolean' " +
"(the checker then skips the predicate) or annotate the result with the receiver's own " +
"element type — and narrow the elements after";
let tag: number | null = null;
if (narrowed) {
if (outElem === null || elem.kind !== "union") {
L.badType(call, callT); // defensive: a narrowed non-union receiver
}
tag = L.armTag(elem.unionId, outElem);
if (tag < 0) {
L.unsupported(
"SC1090",
call,
`'.filter' narrowing '${L.fmt(elem)}' elements to the multi-arm '${L.fmt(outElem)}' ` +
`(only a SINGLE arm re-tags — ${annotateEscape})`,
);
}
}
if (isBooleanArg) {
// ToBoolean must be answerable per element (dyn/caught arms are not).
if (elem.kind === "union") L.requireTruthyUnion(elem.unionId, argNode);
if (elem.kind === "dyn" || elem.kind === "jsval" || elem.kind === "void" || isUnitType(elem)) {
L.badType(argNode, L.typeOf(argNode));
}
const receiver = L.lowerExpr(access.expression);
const helper = filterNarrowHelper(L, "truthy", elem, outElem ?? elem, tag, loc);
return { kind: "call", callee: helper, args: [receiver], type: arrayOf(outElem ?? elem), loc };
}
// Inferred type predicate: inline function literal, NO return
// annotation (a written one is an unchecked assertion), and the
// checker reports a predicate over parameter 0.
if (!ts.isArrowFunction(argNode) && !ts.isFunctionExpression(argNode)) {
L.unsupported(
"SC1090",
argNode,
`narrowing '.filter' through a callback VALUE ` +
`(only an inline callback whose predicate the checker inferred can re-tag — ${annotateEscape})`,
);
}
if (argNode.type) {
L.unsupported(
"SC1090",
argNode,
`narrowing '.filter' with a hand-written type predicate ` +
`(a written 'x is T' is an unchecked assertion nothing validates at runtime — ${annotateEscape})`,
);
}
// The receiver evaluates FIRST, in the enclosing function, like JS.
const receiver = L.lowerExpr(access.expression);
const fnArg = L.lowerExpr(argNode);
if (
fnArg.type.kind !== "func" ||
fnArg.type.params.length !== 1 ||
!typeEquals(fnArg.type.params[0]!, elem) ||
fnArg.type.ret.kind !== "bool"
) {
L.badType(argNode, L.typeOf(argNode));
}
const helper = filterNarrowHelper(L, "callback", elem, outElem!, tag, loc);
return { kind: "call", callee: helper, args: [receiver, fnArg], type: arrayOf(outElem!), loc };
}
/** Interned synthetic loop for one narrowing/truthy filter combo — the
* filter twin of arrayHofHelper, with the retained element re-tagged
* (unionNarrow) when the output arm is narrower than the element union:
*
* out = []; n = a.length;
* for (i = 0; i < n; i++) { v = a[i]; if (<test>) out.push(narrow(v)); }
* return out;
*
* <test> is f(v) for the predicate form and ToBoolean(v) for Boolean.
* The re-tag is sound exactly because the test just PASSED for v: an
* inferred predicate proved the arm dynamically, and a truthy value is
* never the undefined/null arm. */
function filterNarrowHelper(L: Lowerer, test: "callback" | "truthy",
elem: IrType,
outElem: IrType,
tag: number | null,
loc: SrcLoc,): string {
const key = `filterNarrow:${test}:${typeKey(elem)}:${typeKey(outElem)}`;
const existing = L.arrHofHelpers.get(key);
if (existing) return existing;
const name = `%arr.filterNarrow.${L.arrHofHelpers.size}`;
L.arrHofHelpers.set(key, name);
const arrT = arrayOf(elem);
const outT = arrayOf(outElem);
const fnT = funcOf([elem], BOOL);
const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc });
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const locals: IrLocal[] = [
{ id: "a.0", name: "a", type: arrT, mutable: true },
...(test === "callback" ? [{ id: "f.0", name: "f", type: fnT, mutable: true } as IrLocal] : []),
{ id: "n.0", name: "n", type: F64, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "v.0", name: "v", type: elem, mutable: false },
];
const params: IrParam[] = [
{ localId: "a.0", name: "a", type: arrT },
...(test === "callback" ? [{ localId: "f.0", name: "f", type: fnT }] : []),
];
const v = ref("v.0", elem);
const cond: IrExpr =
test === "callback"
? { kind: "callValue", callee: ref("f.0", fnT), args: [v], type: BOOL, loc }
: { kind: "toBool", operand: v, type: BOOL, loc };
const kept: IrExpr =
tag !== null && elem.kind === "union"
? { kind: "unionNarrow", unionId: elem.unionId, tag, value: v, type: outElem, loc }
: v;
const body: IrStmt[] = [
{ kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: outT, loc }, loc },
{
kind: "varDecl",
localId: "n.0",
init: { kind: "arrIntrinsic", method: "length", receiver: ref("a.0", arrT), args: [], type: F64, loc },
loc,
},
{
kind: "for",
init: { kind: "varDecl", localId: "i.0", init: num(0), loc },
cond: { kind: "bin", op: "<", left: ref("i.0", F64), right: ref("n.0", F64), type: BOOL, loc },
update: {
kind: "assign",
localId: "i.0",
value: { kind: "bin", op: "+", left: ref("i.0", F64), right: num(1), type: F64, loc },
loc,
},
body: [
{
kind: "varDecl",
localId: "v.0",
init: { kind: "arrayGet", arr: ref("a.0", arrT), index: ref("i.0", F64), type: elem, loc },
loc,
},
{
kind: "if",
cond,
then: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: ref("out.0", outT),
args: [kept],
type: F64,
loc,
},
loc,
},
],
else_: null,
loc,
},
],
loc,
},
{ kind: "return", value: ref("out.0", outT), loc },
];
L.liftedFns.push({ name, params, returnType: outT, locals, body, loc });
return name;
}
/** `Object.keys(r)` / `Object.values(r)` / `Object.entries(r)` over FIXED
* record shapes: the field list is compile-time-known, so each lowers to
* an interned helper whose body is a sequence of pushes — no reflection,
* no runtime walk. ORDER is the shape's first-seen DECLARATION order
* (threaded through the shape registry), which matches Node whenever
* objects are constructed in declaration order — the divergence for
* reordered construction is SEMANTICS.md 36. Fields holding the
* undefined arm of their union are SKIPPED at runtime (Node's missing
* key: an unset optional never made it into the object), which also
* means an EXPLICIT `{ a: undefined }` key is dropped where Node lists
* it — same rule as jsonStringify, same SEMANTICS entry. Values wrap
* into the checker's result-element type per field; a multi-arm field
* union that differs from the result union would need a re-tag — fenced.
* Null when this isn't an Object static over a fixed record (index
* signatures keep the SC2020 fence: the overflow needs a runtime walk). */
/** Statics on the global Symbol object: `Symbol.for(key)` (the global
* registry — one interned symbol per key, identical on every call, like
* Node across realms) and `Symbol.keyFor(sym)` (the registry key as the
* checker's `string | undefined` — undefined for unregistered symbols).
* Every OTHER member of SymbolConstructor is a well-known symbol
* (Symbol.iterator, Symbol.asyncIterator, Symbol.toStringTag, ...) —
* language-level protocol uses (for-of, template literals) already
* compile through their constructs without reifying the symbol, so the
* VALUE forms fence with a named message rather than a blanket
* SymbolConstructor type fence. */
function lowerSymbolStaticCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (!L.isStdlibGlobal(access.expression, "Symbol")) return null;
const member = access.name.text;
const loc = locOf(call);
if (member === "for") {
if (call.arguments.length !== 1) {
L.noLowering(`Symbol.for with ${call.arguments.length} arguments`, call);
}
const key = L.lowerExprExpecting(call.arguments[0]!, STRING);
return { kind: "libCall", fn: "sym.for", args: [key], type: SYMBOL_T, loc };
}
if (member === "keyFor") {
if (call.arguments.length !== 1) {
L.noLowering(`Symbol.keyFor with ${call.arguments.length} arguments`, call);
}
const sym = L.lowerExpr(call.arguments[0]!);
if (sym.type.kind !== "symbol") {
L.noLowering(
`Symbol.keyFor of a '${L.fmt(sym.type)}' value`,
call.arguments[0]!,
"the argument must be symbol-typed",
);
}
// The checker types the call `string | undefined`, which interns
// the result union (the map.get pattern); the backend builds the
// arms from the runtime's +1-or-NULL answer.
const type = L.irTypeOf(call);
if (type.kind !== "union") L.badType(call, L.typeOf(call));
const read: IrExpr = { kind: "libCall", fn: "sym.keyFor", args: [sym], type, loc };
return L.maybeNarrow(read, call);
}
L.unsupported(
"SC1090",
call,
`well-known symbols as values (Symbol.${member} — for-of, iteration protocols, and template literals compile through their language constructs; the reified symbol has no static lowering)`,
);
}
/** Method calls on symbol-typed receivers: `.toString()` is the
* "Symbol(desc)" text (Node's Symbol.prototype.toString — note that
* template literals and concatenation THROW in JS and stay fenced;
* toString is the one sanctioned spelling). `.valueOf()` is the
* identity read. */
function lowerSymbolMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.mapTypeOf(L.typeOf(access.expression))?.kind !== "symbol") return null;
if (!L.isStdlibMember(access)) return null;
const name = access.name.text;
const loc = locOf(call);
if (name === "toString" && call.arguments.length === 0) {
const receiver = L.lowerExpr(access.expression);
if (receiver.type.kind !== "symbol") return null;
return { kind: "libCall", fn: "sym.toString", args: [receiver], type: STRING, loc };
}
if (name === "valueOf" && call.arguments.length === 0) {
const receiver = L.lowerExpr(access.expression);
if (receiver.type.kind !== "symbol") return null;
return receiver;
}
return null; // description-as-a-call, ... → the stdlib member fence
}
/** The interned keys-array helper over a FIXED record shape: a call of a
* lifted helper whose body pushes each declared field name in first-seen
* DECLARATION order, skipping fields currently holding the undefined arm
* of their union at runtime (Node's missing key — an unset optional
* never made it into the object; SEMANTICS.md 37's rules). ONE
* construction, interned per shape, shared by Object.keys and for-in —
* for-in iterates exactly the keys Object.keys answers. */
export function recordKeysArrayCall(
L: Lowerer,
receiver: IrExpr,
argIr: IrType & { kind: "record" },
shape: { declaredOrder?: string[]; fields: { name: string; type: IrType }[] },
loc: SrcLoc,
): IrExpr {
const resultT = arrayOf(STRING);
const key = `obj.keys:${argIr.shapeId}:${typeKey(resultT)}`;
let helper = L.arrHofHelpers.get(key);
if (!helper) {
helper = `%obj.keys.${L.arrHofHelpers.size}`;
const ref: IrExpr = { kind: "varRef", localId: "r.0", type: argIr, loc };
const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: resultT, loc };
const body: IrStmt[] = [
{ kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc },
];
const order = shape.declaredOrder ?? shape.fields.map((f) => f.name);
for (const name of order) {
const f = shape.fields.find((x) => x.name === name)!;
const pushStmt: IrStmt = {
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: outRef,
args: [{ kind: "strLit", value: f.name, type: STRING, loc }],
type: F64,
loc,
},
loc,
};
// Undefined-armed fields: the push is guarded by a tag test (the
// key exists exactly when the arm is not undefined).
const utag = f.type.kind === "union" ? L.armTag(f.type.unionId, UNDEFINED_T) : -1;
body.push(
utag >= 0 && f.type.kind === "union"
? {
kind: "if",
cond: {
kind: "unionIsTag",
unionId: f.type.unionId,
tag: utag,
negated: true,
value: { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc },
type: BOOL,
loc,
},
then: [pushStmt],
else_: null,
loc,
}
: pushStmt,
);
}
body.push({ kind: "return", value: outRef, loc });
L.arrHofHelpers.set(key, helper);
L.liftedFns.push({
name: helper,
params: [{ localId: "r.0", name: "r", type: argIr }],
returnType: resultT,
locals: [
{ id: "r.0", name: "r", type: argIr, mutable: true },
{ id: "out.0", name: "out", type: resultT, mutable: false },
],
body,
loc,
});
}
return { kind: "call", callee: helper, args: [receiver], type: resultT, loc };
}
/** Interned `%obj.hasOwn.<n>(r, k)` — Object.hasOwn's membership walk
* over a signature-free record shape: the key compares against each
* declared field name, undefined-armed fields answering by their tag
* (a key is own exactly when Object.keys would list it — the two share
* the guard), everything else true, no match false. */
function recordHasOwnHelper(L: Lowerer, shapeId: string, loc: SrcLoc): string {
const key = `obj.hasOwn:${shapeId}`;
const existing = L.arrHofHelpers.get(key);
if (existing) return existing;
const helper = `%obj.hasOwn.${L.arrHofHelpers.size}`;
L.arrHofHelpers.set(key, helper);
const shape = L.shapes.get(shapeId)!;
const recT: IrType = { kind: "record", shapeId };
const rRef: IrExpr = { kind: "varRef", localId: "r.0", type: recT, loc };
const kRef: IrExpr = { kind: "varRef", localId: "k.0", type: STRING, loc };
const body: IrStmt[] = [];
for (const f of shape.fields) {
const utag = f.type.kind === "union" ? L.armTag(f.type.unionId, UNDEFINED_T) : -1;
const answer: IrExpr =
utag >= 0 && f.type.kind === "union"
? {
kind: "unionIsTag",
unionId: f.type.unionId,
tag: utag,
negated: true,
value: { kind: "recordGet", obj: rRef, shapeId, field: f.name, type: f.type, loc },
type: BOOL,
loc,
}
: { kind: "boolLit", value: true, type: BOOL, loc };
body.push({
kind: "if",
cond: { kind: "strEq", negated: false, left: kRef, right: { kind: "strLit", value: f.name, type: STRING, loc }, type: BOOL, loc },
then: [{ kind: "return", value: answer, loc }],
else_: null,
loc,
});
}
body.push({ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc });
L.liftedFns.push({
name: helper,
params: [
{ localId: "r.0", name: "r", type: recT },
{ localId: "k.0", name: "k", type: STRING },
],
returnType: BOOL,
locals: [
{ id: "r.0", name: "r", type: recT, mutable: true },
{ id: "k.0", name: "k", type: STRING, mutable: false },
],
body,
loc,
});
return helper;
}
/** Interned `%obj.assign.<n>(t, s)` — Object.assign's per-field copy
* over signature-free records (every source field lands on a same-named,
* same-typed target field — the caller's gate): undefined-armed source
* fields copy behind the not-undefined guard, everything else straight,
* and the TARGET returns (JS's aliasing). */
function recordAssignHelper(L: Lowerer, targetShapeId: string, srcShapeId: string, loc: SrcLoc): string {
const key = `obj.assign:${targetShapeId}:${srcShapeId}`;
const existing = L.arrHofHelpers.get(key);
if (existing) return existing;
const helper = `%obj.assign.${L.arrHofHelpers.size}`;
L.arrHofHelpers.set(key, helper);
const sShape = L.shapes.get(srcShapeId)!;
const tT: IrType = { kind: "record", shapeId: targetShapeId };
const sT: IrType = { kind: "record", shapeId: srcShapeId };
const tRef: IrExpr = { kind: "varRef", localId: "t.0", type: tT, loc };
const sRef: IrExpr = { kind: "varRef", localId: "s.0", type: sT, loc };
const body: IrStmt[] = [];
for (const f of sShape.fields) {
const get: IrExpr = { kind: "recordGet", obj: sRef, shapeId: srcShapeId, field: f.name, type: f.type, loc };
const set: IrStmt = { kind: "recordSet", obj: tRef, shapeId: targetShapeId, field: f.name, value: get, loc };
const utag = f.type.kind === "union" ? L.armTag(f.type.unionId, UNDEFINED_T) : -1;
body.push(
utag >= 0 && f.type.kind === "union"
? {
kind: "if",
cond: { kind: "unionIsTag", unionId: f.type.unionId, tag: utag, negated: true, value: get, type: BOOL, loc },
then: [set],
else_: null,
loc,
}
: set,
);
}
body.push({ kind: "return", value: tRef, loc });
L.liftedFns.push({
name: helper,
params: [
{ localId: "t.0", name: "t", type: tT },
{ localId: "s.0", name: "s", type: sT },
],
returnType: tT,
locals: [
{ id: "t.0", name: "t", type: tT, mutable: true },
{ id: "s.0", name: "s", type: sT, mutable: true },
],
body,
loc,
});
return helper;
}
/** The `Iterator` global's statics (ES2025 — Iterator.from, and the
* abstract constructor as a value): no first-class iterator objects
* exist here, so every member fences with the working spelling named
* instead of the generic-method fence's monomorphization wording. */
function lowerIteratorStaticFence(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (!L.isStdlibGlobal(access.expression, "Iterator")) return null;
if (!L.isStdlibMember(access)) return null;
L.noLowering(
`Iterator.${access.name.text}`,
call,
"first-class iterator objects have no lowering — iterator helpers compile as one chain on an " +
"array iterator, consumed in place: arr.values().map(f).take(n).toArray()",
);
}
/** `RegExp.escape(s)` (ES2025) — the one RegExp static with a lowering:
* a total string→string libCall (scr_regexp_escape). The lib pins the
* argument to string, so the only unlowered shape is a non-string-typed
* lowering (dyn/union), which fences. Null for other RegExp members
* (the stdlib member fence names them). */
function lowerRegExpStaticCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (!L.isStdlibGlobal(access.expression, "RegExp")) return null;
if (access.name.text !== "escape") return null;
if (!L.isStdlibMember(access)) return null;
if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) {
L.noLowering(`RegExp.escape with ${call.arguments.length} arguments`, call);
}
const arg = L.lowerExprExpecting(call.arguments[0]!, STRING);
if (arg.type.kind !== "string") L.badType(call.arguments[0]!, L.typeOf(call.arguments[0]!));
return { kind: "libCall", fn: "regexp.escape", args: [arg], type: STRING, loc: locOf(call) };
}
/** The composed en-US Intl.NumberFormat form: `new Intl.NumberFormat(
* "en-US").format(x)` (and the callable spelling without `new` — the
* spec makes them the same formatter). Only the COMPOSED form lowers —
* formatter values have no representation — and only the one locale
* whose data the runtime embeds, with default options: decimal
* notation, 0–3 fraction digits rounded half-up on the shortest
* round-tripping decimal (ICU's rounding input — format(1.0005) is
* "1.001" though toFixed(3) answers "1.000"), "," grouping. The
* unlowered forms fence by NAME (no locale — the host environment's
* default, which a compiled binary cannot carry; other locales — ICU
* data the binary does not embed; options bags; non-number arguments).
* Null when the callee isn't a NumberFormat-construction .format. */
function lowerIntlNumberFormatCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (access.name.text !== "format") return null;
let recv: ts.Expression = access.expression;
while (ts.isParenthesizedExpression(recv)) recv = recv.expression;
let ctorArgs: readonly ts.Expression[];
if (ts.isNewExpression(recv) || (ts.isCallExpression(recv) && !recv.questionDotToken)) {
const ctor = recv.expression;
if (
!ts.isPropertyAccessExpression(ctor) || ctor.questionDotToken ||
ctor.name.text !== "NumberFormat" || !L.isStdlibGlobal(ctor.expression, "Intl")
) {
return null;
}
ctorArgs = recv.arguments ?? [];
} else {
return null;
}
const loc = locOf(call);
if (ctorArgs.length === 0) {
L.noLowering(
"Intl.NumberFormat without a locale",
recv,
"the default locale is the host environment's, which a compiled binary cannot carry — " +
'pass it explicitly: new Intl.NumberFormat("en-US").format(x)',
);
}
if (ctorArgs.length > 1) {
L.noLowering(
"Intl.NumberFormat with an options bag",
ctorArgs[1]!,
"the embedded data covers DEFAULT options only (decimal notation, up to 3 fraction digits, " +
'grouping) — new Intl.NumberFormat("en-US").format(x)',
);
}
const locArg = ctorArgs[0]!;
if (ts.isSpreadElement(locArg) || !ts.isStringLiteralLike(locArg) || locArg.text !== "en-US") {
L.noLowering(
!ts.isSpreadElement(locArg) && ts.isStringLiteralLike(locArg)
? `Intl.NumberFormat at locale "${locArg.text}"`
: "Intl.NumberFormat with a non-literal locale",
locArg,
'"en-US" (Node\'s default-build locale) is the one locale whose data the runtime embeds — ' +
"everything else is ICU data the binary does not carry",
);
}
if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) {
L.noLowering(`Intl.NumberFormat("en-US").format with ${call.arguments.length} arguments`, call);
}
const argNode = call.arguments[0]!;
if (L.mapTypeOf(L.typeOf(argNode))?.kind !== "f64") {
L.noLowering(
`Intl.NumberFormat("en-US").format over a '${L.checker.typeToString(L.typeOf(argNode))}'`,
argNode,
"a number argument is the lowered form (bigint and numeric-string inputs have no representation)",
);
}
const arg = L.lowerExprExpecting(argNode, F64);
if (arg.type.kind !== "f64") L.badType(argNode, L.typeOf(argNode));
return { kind: "libCall", fn: "intl.numFormatEnUs", args: [arg], type: STRING, loc };
}
/** Object.is over statically disjoint kinds: the constant false, with
* both operands still evaluated for their effects (droppable statics
* fold away — JS evaluates arguments, but nothing observes a pure one). */
function objectIsDisjointFalse(left: IrExpr, right: IrExpr, loc: SrcLoc): IrExpr {
const stmts: IrStmt[] = [];
for (const e of [left, right]) {
if (!droppableStatic(e)) stmts.push({ kind: "exprStmt", expr: e, loc });
}
const answer: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc };
if (stmts.length === 0) return answer;
return { kind: "seqExpr", stmts, result: answer, type: BOOL, loc };
}
function lowerObjectStaticCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (!L.isStdlibGlobal(access.expression, "Object")) return null;
const member = access.name.text;
// Object.is — the spec's SameValue over the static kinds. Number
// pairs take the runtime SameValue (NaN equals NaN, +0 differs from
// -0 — the two divergences from ===); every other supported pair
// rides exactly the strict-equality machinery, whose answers
// SameValue shares: strings by bytes, bools by value, unit literals
// by tag, unions per arm (a number arm's payload compare upgrades to
// SameValue via unionEq's flag), and the reference kinds by pointer
// identity. Statically DISJOINT kind pairs answer the constant false
// with the operands still evaluated (tsc admits any pair — Object.is
// is (any, any) — and JS evaluates the arguments either way).
// dyn/jsval operands keep strict equality's stance: validate first.
if (member === "is") {
if (call.arguments.length !== 2 || call.arguments.some((a) => ts.isSpreadElement(a))) {
L.noLowering(
`Object.is with ${call.arguments.length} arguments`,
call,
"exactly two arguments are the lowered form (JS treats a missing one as undefined — pass it explicitly)",
);
}
const loc = locOf(call);
const leftNode = call.arguments[0]!;
const rightNode = call.arguments[1]!;
const left = L.lowerExpr(leftNode);
const right = L.lowerExpr(rightNode);
const lk = left.type.kind;
const rk = right.type.kind;
if (lk === "f64" && rk === "f64") {
return { kind: "libCall", fn: "num.sameValue", args: [left, right], type: BOOL, loc };
}
if (left.type.kind === "string" && right.type.kind === "string") {
return { kind: "strEq", negated: false, left, right, type: BOOL, loc };
}
if (lk === "bool" && rk === "bool") {
return { kind: "bin", op: "===", left, right, type: BOOL, loc };
}
const unitTest = L.lowerUnitComparison(left, right, false, loc);
if (unitTest) return unitTest;
if (lk === "dyn" || rk === "dyn" || lk === "jsval" || rk === "jsval") {
L.noLowering(
"Object.is over a dynamic operand",
call,
"validate/narrow the value first (strict equality's rule) — SameValue only differs from === on numbers (NaN, ±0)",
);
}
if (left.type.kind === "union" || right.type.kind === "union") {
const ut = left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" });
const bothUnion = left.type.kind === "union" && right.type.kind === "union";
const sameUnion = bothUnion && typeEquals(left.type, right.type);
if ((sameUnion || !bothUnion) && L.eqComparableUnion(ut.unionId)) {
const plain = left.type.kind === "union" ? right : left;
const arms = L.unions.get(ut.unionId)?.arms ?? [];
// The plain side wraps into the union exactly like === when the
// union holds its type; a plain PRIMITIVE the union has no arm
// for is the disjoint constant false (coercing it would strand).
if (bothUnion || arms.some((a) => typeEquals(a, plain.type))) {
const sameValue = arms.some((a) => a.kind === "f64");
return {
kind: "unionEq",
unionId: ut.unionId,
negated: false,
sameValue,
left: L.coerceInto(leftNode, left, ut),
right: L.coerceInto(rightNode, right, ut),
type: BOOL,
loc,
};
}
if (
plain.type.kind === "f64" || plain.type.kind === "string" ||
plain.type.kind === "bool" || isUnitType(plain.type)
) {
return objectIsDisjointFalse(left, right, loc);
}
}
L.noLowering(
"Object.is over these union operands",
call,
`union-typed comparisons need one comparable shape (${NARROW_FIRST})`,
);
}
// Reference kinds: pointer identity — exactly strict equality
// (hierarchy-related classes widen the derived side first).
let idLeft = left;
let idRight = right;
if (left.type.kind === "object" && right.type.kind === "object") {
if (L.isSubclassOf(left.type.className, right.type.className)) {
idLeft = L.upcastTo(left, right.type.className);
} else if (L.isSubclassOf(right.type.className, left.type.className)) {
idRight = L.upcastTo(right, left.type.className);
}
}
if (
(idLeft.type.kind === "func" && idRight.type.kind === "func") ||
(idLeft.type.kind === "classval" && idRight.type.kind === "classval")
) {
return { kind: "bin", op: "===", left: idLeft, right: idRight, type: BOOL, loc };
}
if (
(idLeft.type.kind === "array" || idLeft.type.kind === "map" ||
idLeft.type.kind === "set" || idLeft.type.kind === "object" ||
idLeft.type.kind === "record" || idLeft.type.kind === "symbol" ||
idLeft.type.kind === "bytes" || idLeft.type.kind === "promise") &&
typeEquals(idLeft.type, idRight.type)
) {
return { kind: "bin", op: "===", left: idLeft, right: idRight, type: BOOL, loc };
}
// Statically disjoint pairs with a primitive/unit side: SameValue
// never crosses kinds, so the answer is the constant false.
const disjoint = new Set(["f64", "string", "bool", "undefinedT", "nullT"]);
if (lk !== rk && (disjoint.has(lk) || disjoint.has(rk))) {
return objectIsDisjointFalse(left, right, loc);
}
L.noLowering(
`Object.is over '${L.fmt(left.type)}' and '${L.fmt(right.type)}' operands`,
call,
"the operands must share one comparable kind (numbers, strings, booleans, units, one union shape, or one reference type)",
);
}
// Object.create — the null-prototype DICTIONARY (`Object.create(null)`
// then keyed assignment, the memo-table idiom prettier's index/
// group-mode maps spell) and, under --dynamic, the engine's own
// Object.create for engine-held prototypes. Everything else is a
// NAMED fence: the compiled representations have no prototype chain,
// and the own-copy stand-in would answer WRONG observably — Node's
// Object.keys/inspect/JSON of the created object list NO own keys,
// and mutating the prototype afterwards is visible through the
// created object (live delegation), which no copy can honor.
if (member === "create") {
if (call.arguments.some((a) => ts.isSpreadElement(a))) {
L.noLowering("Object.create with spread arguments", call);
}
if (call.arguments.length >= 2) {
L.noLowering(
"Object.create with a properties-descriptor argument",
call,
"create first, then assign: const o = Object.create(null); o.k = v",
);
}
if (call.arguments.length !== 1) {
L.noLowering(`Object.create with ${call.arguments.length} arguments`, call);
}
const loc = locOf(call);
let protoNode: ts.Expression = call.arguments[0]!;
while (ts.isParenthesizedExpression(protoNode)) protoNode = protoNode.expression;
const nullProto = protoNode.kind === ts.SyntaxKind.NullKeyword;
if (L.dynamic) {
// The checker types the result `any` — an ENGINE value under
// --dynamic — and the engine's own Object.create answers with
// REAL prototype semantics: reads delegate LIVE, writes shadow,
// and inspect renders Node's exact shapes ("[Object: null
// prototype]" included). null and engine-held (jsval) prototypes
// route; checked-dynamic (dyn) prototypes keep the named fence —
// their marshal into the engine is a DEEP COPY, so a later
// prototype mutation would be invisible through the created
// object where Node delegates live.
const objectGlobal = (): IrExpr => ({ kind: "jsOp", op: "globalGet", name: "Object", args: [], type: JSVAL, loc });
if (nullProto) {
const nullIn: IrExpr = { kind: "jsOp", op: "nullLit", args: [], type: JSVAL, loc };
return { kind: "jsOp", op: "callMethod", name: "create", args: [objectGlobal(), nullIn], type: JSVAL, loc };
}
const proto = L.lowerExpr(protoNode);
if (proto.type.kind === "jsval") {
return { kind: "jsOp", op: "callMethod", name: "create", args: [objectGlobal(), proto], type: JSVAL, loc };
}
L.noLowering(
`Object.create over '${L.fmt(proto.type)}' prototypes`,
call,
"prototype reads delegate LIVE in Node (mutating the prototype shows through the created object), which the boundary's deep copy cannot honor — only null and engine-held ('any') prototypes lower",
);
}
if (nullProto) {
return { kind: "libCall", fn: "dyn.objCreateNullProto", args: [], type: DYN, loc };
}
const proto = L.lowerExpr(protoNode);
L.noLowering(
`Object.create over '${L.fmt(proto.type)}' prototypes`,
call,
"the compiled representations have no prototype chain, and an own-copy would answer wrong observably (Node lists NO own keys on the created object, and prototype mutations show through it live) — only Object.create(null) lowers",
);
}
// `Object.assign(fn, { props })` whose RESULT type maps to the hybrid
// (function-with-properties) record: the chalk-shape CONSTRUCTOR.
if (member === "assign") {
const hybrid = lowerObjectAssignHybrid(L, call);
if (hybrid) return hybrid;
// `Object.assign({}, lit)` — an EMPTY fresh-literal target and one
// object-literal source: the result is a fresh object carrying
// exactly the source literal's properties, which IS the source
// literal evaluated (both fresh, no alias can tell them apart).
// Everything else keeps the spread hint (stdlibMemberFence).
if (call.arguments.length === 2 && !call.arguments.some((a) => ts.isSpreadElement(a))) {
let target: ts.Expression = call.arguments[0]!;
while (ts.isParenthesizedExpression(target)) target = target.expression;
let source: ts.Expression = call.arguments[1]!;
while (ts.isParenthesizedExpression(source)) source = source.expression;
if (
ts.isObjectLiteralExpression(target) && target.properties.length === 0 &&
ts.isObjectLiteralExpression(source)
) {
return L.lowerExpr(source);
}
}
// `Object.assign(target, ...sources)` into an INDEX-SIGNATURE record
// (the init-config merge pattern): the keyed-write walk over each
// source, returning the target — lower-containers owns the matrix.
const merged = lowerObjectAssignIndexShape(L, call);
if (merged) return merged;
// `Object.assign(target, source)` over signature-free RECORDS whose
// source fields all land on same-named, same-typed target fields
// (the mockable-clock restore: `Object.assign(mocked,
// implementations)` over one shape): the per-field copy helper,
// returning the TARGET — JS's aliasing, the target mutates in
// place. Undefined-armed source fields copy behind the
// not-undefined guard (an omitted optional field holds the
// undefined arm and must not erase the target's value — Node
// copies own keys only; an EXPLICIT `k: undefined` source diverges,
// the explicit-undefined-is-absent stance). Everything else keeps
// the spread hint.
if (call.arguments.length === 2 && !call.arguments.some((a) => ts.isSpreadElement(a))) {
const tProbe = probeLower(L, call.arguments[0]!);
const sProbe = probeLower(L, call.arguments[1]!);
// CHECKED-DYNAMIC target and source (the JS file-scope
// object-literal identity story): the runtime dyn copy — own
// members of the source land on the target, which returns.
if (tProbe?.type.kind === "dyn") {
const loc = locOf(call);
const target = L.lowerExpr(call.arguments[0]!);
const source = L.coerceToExpected(L.lowerExpr(call.arguments[1]!), DYN);
if (target.type.kind === "dyn" && source.type.kind === "dyn") {
return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc };
}
}
if (tProbe?.type.kind === "record" && sProbe?.type.kind === "record") {
const tShape = L.shapes.get(tProbe.type.shapeId);
const sShape = L.shapes.get(sProbe.type.shapeId);
const ok =
tShape && sShape &&
!tShape.tuple && !sShape.tuple &&
!tShape.indexValue && !sShape.indexValue &&
!shapeHasAccessorSlots(tShape) && !shapeHasAccessorSlots(sShape) &&
sShape.fields.every((sf) => {
const tf = tShape.fields.find((x) => x.name === sf.name);
return tf !== undefined && typeEquals(tf.type, sf.type);
});
if (ok) {
const loc = locOf(call);
const target = L.lowerExpr(call.arguments[0]!);
const source = L.lowerExpr(call.arguments[1]!);
if (target.type.kind === "record" && source.type.kind === "record") {
const helper = recordAssignHelper(L, target.type.shapeId, source.type.shapeId, loc);
return { kind: "call", callee: helper, args: [target, source], type: target.type, loc };
}
}
}
}
// `Object.assign(target, ...sources)` over a CHECKED-DYNAMIC target
// — the n-ary/spread form (`Object.assign({}, ...plugins.map(p =>
// p.options), coreOptions)`, support.js's option-table merge). The
// sources pack into one fresh dyn array FIRST — plain sources
// retain in, spread sources flatten through the spread-call walk
// (V8's exact TypeError texts, the source spelling carried for the
// nullish form) — so every source evaluates and flattens before any
// copying (JS's ArgumentListEvaluation: a throwing spread leaves
// the target untouched), then one runtime walk copies each source's
// own enumerable keys left to right and answers the TARGET
// (identity, like JS). Each source must enter the dyn world (dyn
// already, or dynFrom's JSON-safe conversion — a STATIC array
// spread copies in at the boundary, the documented aliasing
// stance); anything else keeps the fence. Targets: dyn values, a
// FRESH object-literal target (`Object.assign({}, ...)` — no alias
// exists, so building it as a dyn object instead of a record is
// unobservable), or a nullish unit (Node's ToObject TypeError
// throws at the call, catchably); aliased record targets keep the
// fence — their identity could not survive the conversion.
if (call.arguments.length >= 1 && !ts.isSpreadElement(call.arguments[0]!)) {
let targetNode: ts.Expression = call.arguments[0]!;
while (ts.isParenthesizedExpression(targetNode)) targetNode = targetNode.expression;
const freshLiteralTarget = ts.isObjectLiteralExpression(targetNode);
const tProbe = freshLiteralTarget ? null : probeLower(L, call.arguments[0]!);
const tKind = tProbe?.type.kind;
if (freshLiteralTarget || tKind === "dyn" || tKind === "nullT" || tKind === "undefinedT") {
const loc = locOf(call);
const target = L.lowerExprExpecting(call.arguments[0]!, DYN);
if (target.type.kind === "dyn") {
const t = L.declareHiddenLocal("%oat", DYN);
const p = L.declareHiddenLocal("%oap", DYN);
const tRef = (): IrExpr => ({ kind: "varRef", localId: t.id, type: DYN, loc });
const pRef = (): IrExpr => ({ kind: "varRef", localId: p.id, type: DYN, loc });
const stmts: IrStmt[] = [
{ kind: "varDecl", localId: t.id, init: target, loc },
{ kind: "varDecl", localId: p.id, init: { kind: "dynArrLit", elems: [], type: DYN, loc }, loc },
];
// V8 spells the optimized apply-path texts (the expression
// named for a nullish source) only when the spread is the
// SINGLE LAST argument; every other spread position drives
// the real iterator protocol, whose failure describes the
// value — the two runtime variants, picked here by position.
const sources = call.arguments.slice(1);
const spreadCount = sources.filter((a) => ts.isSpreadElement(a)).length;
let ok = true;
for (let i = 0; i < sources.length; i++) {
const argNode = sources[i]!;
const spread = ts.isSpreadElement(argNode);
const srcNode = spread ? argNode.expression : argNode;
const src = L.coerceToExpected(L.lowerExpr(srcNode), DYN);
if (src.type.kind !== "dyn") {
ok = false;
break;
}
const argLoc = locOf(argNode);
const optimized = spreadCount === 1 && i === sources.length - 1;
stmts.push({
kind: "exprStmt",
expr: spread
? optimized
? {
kind: "libCall",
fn: "dyn.packPushSpread",
args: [pRef(), src, { kind: "strLit", value: srcNode.getText(), type: STRING, loc: argLoc }],
type: VOID,
loc: argLoc,
}
: { kind: "libCall", fn: "dyn.packPushSpreadIter", args: [pRef(), src], type: VOID, loc: argLoc }
: { kind: "libCall", fn: "dyn.packPush", args: [pRef(), src], type: VOID, loc: argLoc },
loc: argLoc,
});
}
if (ok) {
return {
kind: "seqExpr",
stmts,
result: { kind: "libCall", fn: "dyn.assignAll", args: [tRef(), pRef()], type: DYN, loc },
type: DYN,
loc,
};
}
}
}
}
return null;
}
// Object.defineProperties over a CHECKED-DYNAMIC target (test/common's
// _mustCallInner copying name/length onto the mustCall wrapper): the
// runtime turns each descriptor's `value` into a plain own property on
// the dyn node (OBJ members; FUNC nodes carry an own-property table) —
// flags accepted and ignored, accessors throw loudly (SEMANTICS.md).
// The result is the target, like JS. Typed targets keep the fence:
// static shapes have no property table to extend.
if (member === "defineProperties" && call.arguments.length === 2 &&
!call.arguments.some((a) => ts.isSpreadElement(a))) {
let target = probeLower(L, call.arguments[0]!);
// A FUNCTION-typed target boxes through the dyn boundary: the
// property table lives on the CLOSURE (shared by every box of this
// function value), so defining through a fresh box sticks — the
// wrapper returned later reads the same table.
if (
target && target.type.kind === "func" &&
canBoxFuncIntoDyn(target.type, (id) => L.shapes.get(id), (id) => L.unions.get(id))
) {
target = { kind: "dynFrom", value: target, type: DYN, loc: locOf(call.arguments[0]!) };
}
if (target?.type.kind === "dyn") {
const descs = L.lowerExprExpecting(call.arguments[1]!, DYN);
if (descs.type.kind === "dyn") {
return { kind: "libCall", fn: "dyn.defineProps", args: [target, descs], type: DYN, loc: locOf(call) };
}
}
return null;
}
// Object.freeze: on a FRESH literal (object or array) the result IS
// the argument — no alias exists, so the frozen bit is unobservable
// (writes through the Readonly<T> result are compile errors, and no
// other reference can write). Primitives pass through per ES2015.
// Aliased objects keep a fence: a later write through the original
// reference would need the runtime frozen bit (strict mode throws).
if (member === "freeze") {
if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) {
L.noLowering(`Object.freeze with ${call.arguments.length} arguments`, call);
}
const argNode = call.arguments[0]!;
let inner: ts.Expression = argNode;
while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner)) inner = inner.expression;
const value = L.lowerExpr(argNode);
if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) {
return value; // fresh — freeze is identity here, honestly
}
if (
value.type.kind === "string" || value.type.kind === "f64" ||
value.type.kind === "bool" || value.type.kind === "symbol" ||
isUnitType(value.type)
) {
return value; // ES2015: freeze of a primitive is the primitive
}
L.noLowering(
"Object.freeze of a possibly-aliased value",
call,
"freeze of a FRESH object/array literal (and of primitives) compiles — frozen-ness is unobservable there; an aliased target's later writes would need the runtime frozen bit",
);
}
// `Object.hasOwn(r, k)` over a RECORD receiver: a record's own-key set
// is its declared field list, so membership is a compare chain against
// the field names (interned per shape). Undefined-armed (optional)
// fields answer by their runtime tag — the explicit-undefined-is-absent
// stance: an omitted optional field holds the undefined arm and reads
// as NOT own, exactly Node's absent key (an EXPLICIT `k: undefined`
// diverges — documented next to the child-env/JSON rule). Tuple,
// index-signature (overflow membership lives in the runtime map), and
// accessor-carrying shapes keep the SC2020 fence; non-record receivers
// do too.
if (member === "hasOwn" && call.arguments.length === 2 && !call.arguments.some((a) => ts.isSpreadElement(a))) {
const recvNode = call.arguments[0]!;
const keyNode = call.arguments[1]!;
const probed = probeLower(L, recvNode);
// A CHECKED-DYNAMIC receiver (the JS file-scope object-literal
// identity story): the runtime dyn probe — OBJ member presence, ARR
// index bounds, Node's ToObject TypeError on nullish.
if (probed?.type.kind === "dyn") {
const loc = locOf(call);
const receiver = L.lowerExpr(recvNode);
let key = L.lowerExpr(keyNode);
if (key.type.kind === "f64" || key.type.kind === "bool" || key.type.kind === "dyn") {
key = { kind: "toString", operand: key, type: STRING, loc: locOf(keyNode) };
}
if (key.type.kind !== "string") return null;
return { kind: "libCall", fn: "dyn.hasOwn", args: [receiver, key], type: BOOL, loc };
}
if (probed?.type.kind !== "record") return null;
const shape = L.shapes.get(probed.type.shapeId);
if (!shape || shape.tuple || shape.indexValue || shapeHasAccessorSlots(shape)) return null;
const loc = locOf(call);
const receiver = L.lowerExpr(recvNode);
if (receiver.type.kind !== "record") return null; // probe/lower drift: keep the fence
let key = L.lowerExpr(keyNode);
// Number/boolean/dyn keys stringify — ToPropertyKey, the keyed-write
// path's rule; symbol and composite keys keep the fence.
if (key.type.kind === "f64" || key.type.kind === "bool" || key.type.kind === "dyn") {
key = { kind: "toString", operand: key, type: STRING, loc: locOf(keyNode) };
}
if (key.type.kind !== "string") return null;
const helper = recordHasOwnHelper(L, receiver.type.shapeId, loc);
return { kind: "call", callee: helper, args: [receiver, key], type: BOOL, loc };
}
if (member !== "keys" && member !== "values" && member !== "entries") return null;
if (call.arguments.length !== 1 || ts.isSpreadElement(call.arguments[0]!)) return null;
const argNode = call.arguments[0]!;
// A CHECKED-DYNAMIC argument — the checker may still spell a record
// type (the JS file-scope object-literal identity story stores the
// dyn object), so the LOWERED value's kind is the dispatch: the
// runtime walks the dyn node's own keys (integer-like keys first,
// JS's own-key order) and answers a dyn array.
{
const probed = probeLower(L, argNode);
const isDyn = probed?.type.kind === "dyn";
// Unit-typed arguments (Object.keys(null)) ride the same runtime
// walk: it throws Node's catchable TypeError.
const isUnit = probed !== null && probed !== undefined && isUnitType(probed.type);
if (isDyn || isUnit) {
const fn = member === "keys" ? "dyn.objKeys" : member === "values" ? "dyn.objValues" : "dyn.objEntries";
let v = L.lowerExpr(argNode);
if (v.type.kind !== "dyn") v = { kind: "dynFrom", value: v, type: DYN, loc: locOf(call) };
return { kind: "libCall", fn, args: [v], type: DYN, loc: locOf(call) };
}
}
let argIr = L.mapTypeOf(L.typeOf(argNode));
// JS: an unmappable CHECKER type over a value that lowered to a real
// record (the narrowed export-table literal) — the lowered value's
// shape is the honest dispatch key, exactly the identity-Set stance.
if (argIr === null && isJsSourceFile(argNode.getSourceFile())) {
const probed = probeLower(L, argNode);
if (probed?.type.kind === "record") argIr = probed.type;
}
if (argIr?.kind !== "record") return null; // Maps, classes, arrays → the SC2020 fence
const shape = L.shapes.get(argIr.shapeId);
if (!shape || shape.tuple) return null; // tuple → the fence
// Accessor-carrying shapes: Node's answer includes the accessor NAMES
// (own enumerable properties) and — for values/entries — the getter
// RESULTS, invoked in key order. The static field walk models neither
// (accessor slots live outside declaredOrder), so the surface fences.
if (shapeHasAccessorSlots(shape)) {
L.unsupported(
"SC1090",
call,
`Object.${member} over a shape carrying get/set accessor properties (Node lists the accessor names${member === "keys" ? "" : " and invokes the getters"} — the static key walk cannot; read the properties explicitly)`,
);
}
if (shape.indexValue) {
// Index-signature (overflow-carrying) shapes: the runtime walk —
// declared fields first, then the overflow in JS own-key order
// (lowerObjectIterOverIndexShape in lower-containers).
return lowerObjectIterOverIndexShape(L, call, member, argIr, shape);
}
const loc = locOf(call);
const resultT = L.irTypeOf(call);
if (resultT.kind !== "array") L.badType(call, L.typeOf(call)); // defensive
const receiver = L.lowerExpr(argNode);
if (member === "keys") {
// The keys walk is shared with for-in (which iterates exactly the
// keys Object.keys answers — one construction, one intern key).
return recordKeysArrayCall(L, receiver, argIr, shape, loc);
}
// The result-element type each field's value flows into: string for
// keys, the checker's value union for values, the [string, V] tuple's
// "1" field for entries.
let valueT: IrType | null = null;
let tupleT: (IrType & { kind: "record" }) | null = null;
if (member === "values") valueT = resultT.elem;
if (member === "entries") {
if (resultT.elem.kind !== "record") L.badType(call, L.typeOf(call));
tupleT = resultT.elem;
const tupleShape = L.shapes.get(resultT.elem.shapeId);
if (!tupleShape?.tuple || tupleShape.fields.length !== 2) L.badType(call, L.typeOf(call));
valueT = tupleShape.fields.find((f) => f.name === "1")!.type;
}
const order = shape.declaredOrder ?? shape.fields.map((f) => f.name);
const key = `obj.${member}:${argIr.shapeId}:${typeKey(resultT)}`;
let helper = L.arrHofHelpers.get(key);
if (!helper) {
helper = `%obj.${member}.${L.arrHofHelpers.size}`;
const recT = argIr;
const ref: IrExpr = { kind: "varRef", localId: "r.0", type: recT, loc };
const body: IrStmt[] = [
{ kind: "varDecl", localId: "out.0", init: { kind: "arrayLit", elems: [], type: resultT, loc }, loc },
];
const outRef: IrExpr = { kind: "varRef", localId: "out.0", type: resultT, loc };
for (const name of order) {
const f = shape.fields.find((x) => x.name === name)!;
const raw: IrExpr = { kind: "recordGet", obj: ref, shapeId: argIr.shapeId, field: f.name, type: f.type, loc };
// The pushed element per member; null when the field's value
// cannot flow into the result element type.
const elemOf = (value: IrExpr, vt: IrType): IrExpr | null => {
if (!valueT) return null;
if (typeEquals(vt, valueT)) return value;
if (valueT.kind === "union" && vt.kind !== "union") {
const tag = L.armTag(valueT.unionId, vt);
if (tag >= 0) {
return { kind: "unionWrap", unionId: valueT.unionId, tag, value, type: valueT, loc };
}
}
return null;
};
// Undefined-armed fields: the push is guarded by a tag test, and
// the pushed value is the narrowed non-undefined arm.
let guardUndefTag: number | null = null;
let value: IrExpr = raw;
let vt: IrType = f.type;
if (f.type.kind === "union") {
const undefTag = L.armTag(f.type.unionId, UNDEFINED_T);
if (undefTag >= 0) {
guardUndefTag = undefTag;
const arms = L.unions.get(f.type.unionId)?.arms ?? [];
const others = arms.filter((a) => a.kind !== "undefinedT");
if (typeEquals(f.type, valueT ?? f.type)) {
// The field union IS the result union (single-field shapes):
// push the raw box — but then the undefined skip must NOT
// narrow. Handled below via vt === valueT.
value = raw;
vt = f.type;
} else if (others.length === 1) {
vt = others[0]!;
// A UNIT other arm (`null | undefined` fields — the mixed-
// defaults spread idiom; undefined was filtered above, so
// the unit is null): units carry no payload, so the guarded
// push writes the unit LITERAL — unionNarrow to a unit arm
// (and unionWrap of a narrowed unit) is malformed IR; the
// literal is the one legal unit spelling.
value = isUnitType(vt)
? { kind: "unitLit", unit: "null", type: vt, loc }
: { kind: "unionNarrow", unionId: f.type.unionId, tag: L.armTag(f.type.unionId, vt), value: raw, type: vt, loc };
} else {
L.unsupported(
"SC1090",
call,
`Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a multi-arm union that ` +
"cannot re-tag into the result element type — read the fields directly)",
);
}
} else if (!typeEquals(f.type, valueT ?? f.type)) {
L.unsupported(
"SC1090",
call,
`Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' is a union that cannot ` +
"re-tag into the result element type — read the fields directly)",
);
}
}
const coerced = elemOf(value, vt);
if (!coerced) {
L.unsupported(
"SC1090",
call,
`Object.${member} over '${L.fmt(argIr)}' (field '${f.name}' of type '${L.fmt(f.type)}' ` +
`cannot flow into the '${L.fmt(valueT!)}' result element — read the fields directly)`,
);
}
const pushed: IrExpr =
member === "values"
? coerced
: {
kind: "recordLit",
fields: [
{ name: "0", value: { kind: "strLit", value: f.name, type: STRING, loc } },
{ name: "1", value: coerced },
],
type: tupleT!,
loc,
};
const pushStmt: IrStmt = {
kind: "exprStmt",
expr: { kind: "arrIntrinsic", method: "push", receiver: outRef, args: [pushed], type: F64, loc },
loc,
};
body.push(
guardUndefTag !== null && f.type.kind === "union"
? {
kind: "if",
cond: { kind: "unionIsTag", unionId: f.type.unionId, tag: guardUndefTag, negated: true, value: raw, type: BOOL, loc },
then: [pushStmt],
else_: null,
loc,
}
: pushStmt,
);
}
body.push({ kind: "return", value: outRef, loc });
L.arrHofHelpers.set(key, helper);
L.liftedFns.push({
name: helper,
params: [{ localId: "r.0", name: "r", type: recT }],
returnType: resultT,
locals: [
{ id: "r.0", name: "r", type: recT, mutable: true },
{ id: "out.0", name: "out", type: resultT, mutable: false },
],
body,
loc,
});
}
return { kind: "call", callee: helper, args: [receiver], type: resultT, loc };
}
/** The declaration's real Block body. tsgo's remote child indexing can hand
* back a jsdoc node as `.body` — a JS `function f() {...}` annotated
* `@type {() => undefined}` answers the jsdoc FUNCTION TYPE node (the
* 09-lower-stmts-undefined crash signature) while the actual Block sits
* elsewhere in the children — so recover it by kind, never by slot. Null
* when the declaration truly has no block. */
function blockBodyOf(decl: ts.FunctionLikeDeclaration): ts.Block | null {
const body = decl.body;
if (body === undefined) return null;
if (ts.isBlock(body)) return body;
return decl.forEachChild((c) => (ts.isBlock(c) ? c : undefined)) ?? null;
}
export function lowerFunction(L: Lowerer, decl: ts.FunctionDeclaration): IrFunction | null {
// Overload signatures and ambient declarations are type-world: they
// share the implementation's symbol (when one exists) but have no body
// of their own — collection skipped them and the run/discover loops do
// too; this guard is defensive.
if (!decl.body) return null;
const declSymbol = declSymbolOf(L, decl);
const sig = declSymbol ? L.fnSigsBySymbol.get(declSymbol) : undefined;
if (!sig) return null; // signature collection failed
const bodyReturn = sig.generator !== undefined
? L.genBodyReturnType(sig.returnType)
: L.bodyReturnType(sig.isAsync === true, sig.returnType);
const ctx = newFnCtx(false, null, null, bodyReturn);
ctx.isAsync = sig.isAsync === true;
if (sig.generator !== undefined) ctx.generator = sig.generator;
const diagsBefore = L.diags.length;
L.fnStack.push(ctx);
try {
const { params, prologue } = L.declareParams(decl.parameters, sig.params);
// The synthetic `arguments` slot (a dynRest shape BEYOND the declared
// parameters — collectSignatureInner appended it): one trailing
// dyn-array param, resolved by `arguments` reads.
if (sig.params.length > decl.parameters.length && sig.params[sig.params.length - 1]!.mode === "dynRest") {
const argsLocal = L.declareHiddenLocal("%arguments", DYN);
params.push({ localId: argsLocal.id, name: "%arguments", type: DYN });
ctx.argumentsLocal = argsLocal;
}
const bodyBlock = blockBodyOf(decl);
if (!bodyBlock) {
L.unsupported("SC1090", decl, "function declarations whose block body the frontend cannot locate");
}
const body = [...prologue, ...L.lowerStmts(bodyBlock.statements)];
appendImplicitUndefinedReturn(L, body, bodyReturn, locOf(decl));
const fn: IrFunction = {
name: sig.name,
params,
returnType: bodyReturn,
locals: L.ctx.locals,
body,
loc: locOf(decl),
};
if (sig.isAsync) fn.async = true;
if (sig.generator !== undefined) fn.generator = sig.generator;
return fn;
} catch (e) {
// A poison OUTSIDE the per-statement catches (a parameter DEFAULT
// whose initializer is fenced, a parameter PATTERN over a class
// that never lowered): the diagnostic is already recorded — the
// function skips, like a signature-blocked one, instead of killing
// the whole analysis.
if (!(e instanceof PoisonError)) throw e;
// JS sources defer function-level poisons like statement fences
// (the sentence-walker idiom `({ parent: sentenceNode })` over the
// #private-fenced AstPath): the function compiles as its OWN
// runtimeFence — CALLING it throws the first captured diagnostic
// at the declaration's position — so a reachable-but-broken
// signature stops the RUN at its own site instead of the build.
// ICEs (SC9001) stay compile errors, exactly like lowerStmts.
if (
isJsSourceFile(decl.getSourceFile()) &&
L.diagSink === null &&
L.diags.length > diagsBefore &&
!L.diags.slice(diagsBefore).some((d) => d.code === "SC9001")
) {
const captured = L.diags.splice(diagsBefore);
L.runtimeFences.push(...captured);
// An ABI type naming a class that never REGISTERED (the sentence-
// walker idiom's path type — the #private fence) is fine to emit:
// callers CAN lower calls to this symbol (a same-typed param
// passes straight through — no construction needed), so the fence
// function must exist, and run()'s unregistered-class sweep
// rewrites every such slot to the inert f64 placeholder before
// emission — caller and fence stay ABI-consistent.
const first = captured[0]!;
const loc = locOf(decl);
const pos = ts.getLineAndCharacterOfPosition(
L.program.getSourceFile(first.loc.file) ?? decl.getSourceFile(),
first.loc.start,
);
const params: IrParam[] = sig.params.map((p, i) => ({ localId: `%pf${i}`, name: `%pf${i}`, type: p.type }));
const fn: IrFunction = {
name: sig.name,
params,
returnType: bodyReturn,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body: [
{
kind: "runtimeFence",
code: first.code,
message: `${first.message} [${first.code} at ${first.loc.file}:${pos.line + 1}]`,
loc,
},
],
loc,
};
if (sig.isAsync) fn.async = true;
if (sig.generator !== undefined) fn.generator = sig.generator;
return fn;
}
return null;
} finally {
L.fnStack.pop();
}
}
/** `r.f(args)` where `r` is a record and `f` a func-typed field: an
* ordinary indirect call through the field's closure value. Deliberately
* record-only — calling a func-typed CLASS field stays rejected (the
* generic method-call rejection in lowerCall). */
/** `Object.assign(fn, { bold, ... })` → a HYBRID record literal: the
* reserved %call field takes the function, each source object literal's
* properties fill their declared fields (later sources override, JS's
* last-write-wins — one entry per name, source values still evaluate in
* order through the literal lowering's shared rules). Bounded to the
* chalk shape on purpose: the RESULT type must map to a %call-carrying
* record, sources must be plain object literals (an `as` cast unwraps),
* and every declared field must be filled. REPRESENTATION NOTE
* (SEMANTICS.md): the result is a FRESH record, not the mutated `fn` —
* `assigned === fn` is false here where JS answers true, and `typeof`
* would answer object; portless's colors.ts never observes either.
* Null (→ the stdlib fence) for every other Object.assign form. */
function lowerObjectAssignHybrid(L: Lowerer, call: ts.CallExpression): IrExpr | null {
const mapped = L.mapTypeOf(L.typeOf(call));
if (mapped?.kind !== "record") return null;
const shape = L.shapes.get(mapped.shapeId);
const callField = shape?.fields.find((f) => f.name === "%call");
if (!shape || !callField || callField.type.kind !== "func") return null;
if (call.arguments.length < 2 || call.arguments.some((a) => ts.isSpreadElement(a))) return null;
const loc = locOf(call);
const values = new Map<string, IrExpr>();
values.set("%call", L.lowerExprExpecting(call.arguments[0]!, callField.type));
for (const argNode of call.arguments.slice(1)) {
let src: ts.Expression = argNode;
while (ts.isParenthesizedExpression(src) || ts.isAsExpression(src) || ts.isTypeAssertion(src)) src = src.expression;
if (!ts.isObjectLiteralExpression(src)) {
L.unsupported(
"SC1090",
argNode,
"Object.assign sources other than plain object literals when building a function-with-properties value",
);
}
for (const prop of src.properties) {
const nameOk =
(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) &&
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name));
if (!nameOk) {
L.unsupported(
"SC1090",
prop,
"this property form in an Object.assign source building a function-with-properties value",
);
}
const name = (prop.name as ts.Identifier | ts.StringLiteral).text;
const fieldType = shape.fields.find((f) => f.name === name)?.type;
if (!fieldType) {
L.unsupported(
"SC1090",
prop,
`the property '${name}' missing from the assigned result type '${L.fmt(mapped)}'`,
);
}
const value = ts.isPropertyAssignment(prop)
? L.lowerExprExpecting(prop.initializer, fieldType)
: L.coerceInto(prop, L.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment), fieldType);
values.set(name, value);
}
}
const fields: { name: string; value: IrExpr }[] = [];
for (const f of shape.fields) {
const v = values.get(f.name);
if (!v) {
const absent = L.wrappedUndefined(f.type, loc);
if (!absent) {
L.unsupported(
"SC1090",
call,
`Object.assign leaving the required field '${f.name}' of '${L.fmt(mapped)}' unfilled`,
);
}
fields.push({ name: f.name, value: absent });
continue;
}
fields.push({ name: f.name, value: v });
}
return { kind: "recordLit", fields, type: mapped, loc };
}
export function lowerRecordFieldCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.chainBlocked(call)) return null;
if (L.mapTypeOf(L.typeOf(access.expression))?.kind !== "record") return null;
const target = L.fieldTarget(access);
let callee = target ? L.fieldGetExpr(target, locOf(access), access) : null;
if (!callee) return null;
// A HYBRID (function-with-properties) field is callable through its
// reserved %call slot — `colors.blue("x")` where blue also carries
// `.bold` (the chalk shape).
if (callee.type.kind === "record") callee = L.hybridCallUnwrap(callee);
if (callee.type.kind !== "func") L.badType(access, L.typeOf(access));
const params = callee.type.params;
const args = call.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
return { kind: "callValue", callee, args, type: callee.type.ret, loc: locOf(call) };
}
/** The function-like node behind an object-literal generic-method member:
* the MethodDeclaration itself (`{ m<T>(x: T) {...} }`) or a generic
* arrow/function-expression property's initializer (`{ m: <T>(x: T) =>
* ... }`). Null when the property's declaration isn't that shape. */
export function objLitGenericFnNodeOf(L: Lowerer, propSym: ts.Symbol): { fnNode: ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction; literal: ts.ObjectLiteralExpression } | null {
const decl = L.checker.valueDeclarationOf(propSym);
if (!decl) return null;
if (ts.isMethodDeclaration(decl) && ts.isObjectLiteralExpression(decl.parent)) {
return decl.typeParameters !== undefined && decl.body !== undefined
? { fnNode: decl, literal: decl.parent }
: null;
}
if (ts.isPropertyAssignment(decl) && ts.isObjectLiteralExpression(decl.parent)) {
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) &&
init.typeParameters !== undefined && init.body !== undefined
) {
return { fnNode: init, literal: decl.parent };
}
}
return null;
}
/** The interned GenericFnInfo for one object-literal generic method, with
* the supportability fences applied ONCE per declaration: the defining
* literal must sit at module scope (the compiled instance is a plain
* module function — an enclosing frame would need captures), and
* async/generator forms keep the method fences. The name is source-
* position-derived (`%ol<start>.<name>`, qualified per file) —
* deterministic across the discovery and emit passes. */
export function objLitGenericFnInfoOf(L: Lowerer, blame: ts.Node, name: string,
found: { fnNode: ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction; literal: ts.ObjectLiteralExpression },): GenericFnInfo {
const { fnNode, literal } = found;
const existing = L.objLitGenericFns.get(fnNode);
if (existing) return existing;
if (fnNode.asteriskToken) L.unsupported("SC1071", blame);
if (fnNode.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) {
L.unsupported("SC1090", blame, "async object-literal generic methods");
}
// `this` is the receiver object — records don't model it (the
// lowerObjectLiteral fence, applied at registration because
// arrow/function-expression properties skip that walk and the compiled
// instances are plain module functions).
if (fnNode.body) L.rejectThisInObjectMethod(fnNode.body);
for (let n: ts.Node = literal.parent; n && !ts.isSourceFile(n); n = n.parent) {
if (ts.isFunctionLike(n)) {
L.unsupported(
"SC1090",
blame,
`object-literal generic methods declared inside functions (the compiled instantiations of '${name}' are module functions and cannot capture the enclosing frame — declare the object at module scope)`,
);
}
}
const typeParams: ts.Symbol[] = [];
for (const tp of fnNode.typeParameters!) {
const sym = L.checker.getSymbolAtLocation(tp.name);
if (!sym) L.unsupported("SC1090", blame, "this method form");
typeParams.push(sym);
}
for (const param of fnNode.parameters) {
if (!ts.isIdentifier(param.name)) L.unsupported("SC1031", param);
}
const info: GenericFnInfo = {
decl: fnNode,
baseName: name,
qualifiedName: L.qualify(fnNode.getSourceFile(), `%ol${fnNode.getStart()}.${name}`),
typeParams,
instances: new Map(),
objectLiteral: true,
};
L.objLitGenericFns.set(fnNode, info);
return info;
}
/** True when nothing in `sym`'s DECLARING FILE ever writes it after the
* initializer: assignments (plain and compound, destructuring targets
* included), ++/--, and for-of/for-in expression targets all count.
* Sound file-locally for module-scope bindings because ESM import
* bindings are read-only — no other file can write one. Cached per
* symbol (the scan walks the whole file once). */
export function bindingNeverReassigned(L: Lowerer, sym: ts.Symbol, decl: ts.Node): boolean {
const cached = L.neverReassignedCache.get(sym);
if (cached !== undefined) return cached;
let written = false;
// Text pre-check keeps the file walk cheap: only same-named
// identifiers pay a symbol resolution.
const symText = sym.name;
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && L.resolveValueSymbol(e) === sym;
const scanTarget = (t: ts.Expression): void => {
let e: ts.Expression = t;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (namesSym(e)) {
written = true;
return;
}
// Destructuring assignment targets: any identifier inside the LHS
// pattern could be the binding — over-approximate by scanning.
if (ts.isArrayLiteralExpression(e) || ts.isObjectLiteralExpression(e)) {
const walk = (n: ts.Node): void => {
if (namesSym(n)) written = true;
else n.forEachChild(walk);
};
walk(e);
}
};
const visit = (n: ts.Node): void => {
if (written) return;
if (ts.isBinaryExpression(n)) {
const k = n.operatorToken.kind;
if (k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment) {
scanTarget(n.left);
}
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)
) {
scanTarget(n.operand as ts.Expression);
} else if ((ts.isForOfStatement(n) || ts.isForInStatement(n)) && !ts.isVariableDeclarationList(n.initializer)) {
scanTarget(n.initializer as ts.Expression);
}
n.forEachChild(visit);
};
decl.getSourceFile().forEachChild(visit);
L.neverReassignedCache.set(sym, !written);
return !written;
}
/** Strips the value-preserving wrappers off an expression: parens,
* non-null assertions, `as`/`satisfies`/angle-bracket casts. What
* remains is the expression that actually evaluates. */
export function stripValueWrappers(e: ts.Expression): ts.Expression {
let v: ts.Expression = e;
for (;;) {
if (
ts.isParenthesizedExpression(v) || ts.isNonNullExpression(v) ||
ts.isAsExpression(v) || ts.isSatisfiesExpression(v) || ts.isTypeAssertion(v)
) {
v = v.expression;
continue;
}
return v;
}
}
/** The nullish unit `e` provably evaluates to: a bare null/undefined
* literal (assertion-wrapped — `null as any`, `null!`), or a read of a
* registered NULLISH binding (nullishGenericBindingUnitOf). Null when
* the value could be anything else. */
export function nullishExprUnitOf(L: Lowerer, e: ts.Expression): "null" | "undefined" | null {
const v = stripValueWrappers(e);
if (v.kind === ts.SyntaxKind.NullKeyword) return "null";
if (ts.isIdentifier(v)) {
if (v.text === "undefined" && (L.typeOf(v).flags & ts.TypeFlags.Undefined) !== 0) {
return "undefined";
}
return nullishValueUnitOf(L, L.resolveValueSymbol(v));
}
return null;
}
/** The nullish unit a binding provably holds FOREVER, by VALUE alone: its
* initializer is nullish (`const i: I<A & B> = null as any`) and every
* write in its declaring file is nullish too (`a = b` where b is
* another nullish binding). No type condition — callers add their own
* (nullishGenericBindingUnitOf gates the no-storage family on
* unmappable types; the generic-method call path rescues its fence with
* the value fact alone). Cached per symbol; the pre-seeded null entry
* guards probe cycles (mutually-assigned bindings resolve link by link,
* declaration order). */
export function nullishValueUnitOf(L: Lowerer, sym: ts.Symbol | null): "null" | "undefined" | null {
if (!sym) return null;
const cached = L.nullishBindings.get(sym);
if (cached !== undefined) return cached;
L.nullishBindings.set(sym, null); // cycle guard: self-referential probes answer non-qualifying
const decl = L.checker.valueDeclarationOf(sym);
// Statement-position declarators only: a for-loop head (`for (let x =
// null as any; ...)`) declares a LOCAL with per-iteration semantics —
// lowerVarDeclList's contract requires a lowered statement for it, so
// the no-storage family never claims it.
if (
!decl || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) ||
decl.getSourceFile().isDeclarationFile || decl.initializer === undefined ||
!ts.isVariableStatement(decl.parent.parent)
) {
return null;
}
const unit = nullishExprUnitOf(L, decl.initializer);
if (unit === null) return null;
// Bindings with a CHECKED-DYNAMIC fallback (`const maybe: any =
// undefined`, JS inference residue) keep that story: the dyn world
// already holds null/undefined correctly and serves every read form
// (optional chains included) — this family exists for types with NO
// other home.
if (dynFallbackType(L, decl.name, L.checker.getTypeOfSymbol(sym)) !== null) return null;
if (!allWritesNullish(L, sym, decl)) return null;
// A use inside a class HERITAGE clause (`class X extends Mixin(...)`)
// declines the whole family: heritage resolution is structural (the
// mixin machinery can pin the instantiation from the ARGUMENT class
// expression without ever reading the callee binding), so a claimed
// nullish callee would compile a working class where Node throws
// "Mixin is not a function" evaluating the extends expression. The
// declaration keeps its type fence instead.
if (usedInHeritageClause(L, sym)) return null;
L.nullishBindings.set(sym, unit);
return unit;
}
/** True when any identifier resolving to `sym` sits inside a class
* heritage clause anywhere in the program. */
function usedInHeritageClause(L: Lowerer, sym: ts.Symbol): boolean {
const symText = sym.name;
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
if (
ts.isIdentifier(n) && n.text === symText &&
L.resolveValueSymbol(n) === sym
) {
for (let p: ts.Node | undefined = n.parent; p !== undefined && !ts.isSourceFile(p); p = p.parent) {
if (ts.isHeritageClause(p)) {
found = true;
return;
}
}
return;
}
n.forEachChild(visit);
};
for (const file of L.program.getSourceFiles()) {
if (found) break;
if (file.isDeclarationFile) continue;
file.forEachChild(visit);
}
return found;
}
/** nullishValueUnitOf gated on a declared type that CANNOT hold the
* value — the NO-STORAGE family: an unmappable type has no other story,
* and a RECORD-mapped one (`const i: I<A & B> = null as any` — an
* interface whose members are all generic signatures interns an empty
* shape) has a slot null can never inhabit, so storing would throw the
* representation error where Node stores null silently. Either way the
* declaration emits nothing and reads know the value. Null-tolerant
* mappings (unions with a null/undefined arm, dyn) keep their real
* storage and every ordinary lowering. */
export function nullishGenericBindingUnitOf(L: Lowerer, sym: ts.Symbol | null): "null" | "undefined" | null {
if (!sym) return null;
// The VALUE probe first — it is purely syntactic, so no checker type
// query runs for the overwhelmingly common non-nullish declarations
// (a query can even panic upstream — the 1e999 checker bug).
const unit = nullishValueUnitOf(L, sym);
if (unit === null) return null;
const mapped = L.mapTypeOf(L.checker.getTypeOfSymbol(sym));
if (mapped !== null) {
// Only the EMPTY interned shape qualifies among record mappings —
// the all-generic-signature interface (`I<A & B>`) whose struct has
// no slot at all. A record with DATA fields (`const value: { inner:
// number | string } = null as any`) keeps its real storage and
// every ordinary lowering: its reads flow through positions (comma
// chains, call arguments) the no-storage read paths never claim,
// so claiming the binding would fence working programs.
if (mapped.kind !== "record") return null;
const shape = L.shapes.get(mapped.shapeId);
if (!shape || shape.fields.length > 0 || shape.tuple !== undefined || shape.indexValue !== undefined) {
return null;
}
}
return unit;
}
/** True when every write of `sym` in its declaring file is a plain `x =
* <nullish>` assignment — the discipline that keeps a nullish binding's
* value knowable. Compound assignments, ++/--, for-in/of cursors, and
* destructuring targets all disqualify. */
function allWritesNullish(L: Lowerer, sym: ts.Symbol, decl: ts.Node): boolean {
const symText = sym.name;
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && L.resolveValueSymbol(e) === sym;
let ok = true;
const visit = (n: ts.Node): void => {
if (!ok) return;
if (ts.isBinaryExpression(n)) {
const k = n.operatorToken.kind;
if (k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment) {
let lhs: ts.Expression = n.left;
while (ts.isParenthesizedExpression(lhs)) lhs = lhs.expression;
if (namesSym(lhs)) {
if (k !== ts.SyntaxKind.EqualsToken || nullishExprUnitOf(L, n.right) === null) ok = false;
} else if (ts.isArrayLiteralExpression(lhs) || ts.isObjectLiteralExpression(lhs)) {
const walk = (m: ts.Node): void => {
if (namesSym(m)) ok = false;
else m.forEachChild(walk);
};
walk(lhs);
}
}
} else if (
(ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
(n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)
) {
let op: ts.Expression = n.operand as ts.Expression;
while (ts.isParenthesizedExpression(op)) op = op.expression;
if (namesSym(op)) ok = false;
} else if ((ts.isForOfStatement(n) || ts.isForInStatement(n)) && !ts.isVariableDeclarationList(n.initializer)) {
let t: ts.Node = n.initializer;
while (ts.isParenthesizedExpression(t as ts.Expression)) t = (t as ts.ParenthesizedExpression).expression;
if (namesSym(t)) ok = false;
}
n.forEachChild(visit);
};
decl.getSourceFile().forEachChild(visit);
return ok;
}
/** A VALUE-ONLY expression: materializing it has no observable effect
* beyond the value itself — function/arrow literals, class-free
* literals, nullish units. The dead-binding rule's purity test: Node
* builds the value and drops it, so skipping the build entirely is
* unobservable. Bare identifier reads stay OUT (a read above a `let`
* declaration is a TDZ throw Node WOULD serve). */
function sideEffectFreeValueExpr(L: Lowerer, e: ts.Expression): boolean {
const v = stripValueWrappers(e);
if (ts.isArrowFunction(v) || ts.isFunctionExpression(v)) return true;
if (ts.isLiteralExpression(v) || v.kind === ts.SyntaxKind.NullKeyword ||
v.kind === ts.SyntaxKind.TrueKeyword || v.kind === ts.SyntaxKind.FalseKeyword) {
return true;
}
if (ts.isIdentifier(v) && v.text === "undefined" && (L.typeOf(v).flags & ts.TypeFlags.Undefined) !== 0) {
return true;
}
return false;
}
/** True when `sym` — a binding whose type has NO static mapping — is DEAD:
* never read anywhere in the program, not exported through a specifier,
* declared with no initializer or a side-effect-free one, and written
* (if at all) only by plain assignments of side-effect-free values. Node
* materializes those values and drops them — zero observable effect —
* so the declaration and its writes lower to NOTHING instead of fencing
* on a type the program never consumes (`var xs2: typeof Array;`, the
* write-only `var f2: { <T, U>(x: T, y: U): T }`). TS program files
* only: JS bindings keep their checked-dynamic fallbacks. Positive
* answers register in L.deadBindings (the assignment lowering skips
* writes by the same set). */
export function deadUnmappableBinding(L: Lowerer, sym: ts.Symbol | null, decl: ts.VariableDeclaration): boolean {
if (!sym) return false;
if (L.deadBindings.has(sym)) return true;
if (!ts.isIdentifier(decl.name)) return false;
// Statement-position declarators only: a for-loop head (`for (let x;
// false;) {}`) declares a LOCAL with per-iteration semantics —
// lowerVarDeclList's contract requires a lowered statement for it, so
// the no-storage family never claims it (catch bindings sit outside a
// variable statement too and stay out the same way).
if (!ts.isVariableStatement(decl.parent.parent)) return false;
const sf = decl.getSourceFile();
if (sf.isDeclarationFile || isJsSourceFile(sf)) return false;
if (decl.initializer !== undefined && !sideEffectFreeValueExpr(L, decl.initializer)) return false;
// Exported bindings stay out: a library build's exports are consumed
// from outside the graph, and export specifiers double as reads.
if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) return false;
// The type gate LAST among the cheap checks: querying the checker for
// a type is the expensive step (and can panic upstream — the 1e999
// bug), so only survivors of the syntactic filters pay it. Mappable
// types keep their real storage.
if (L.mapTypeOf(L.checker.getTypeOfSymbol(sym)) !== null) return false;
const symText = sym.name;
const namesSym = (e: ts.Node): boolean =>
ts.isIdentifier(e) && e.text === symText && L.resolveValueSymbol(e) === sym;
let dead = true;
const visit = (n: ts.Node): void => {
if (!dead) return;
if (ts.isIdentifier(n) && n.text === symText) {
// Declaration-name occurrences are not reads.
if (n.parent !== undefined && ts.isVariableDeclaration(n.parent) && n.parent.name === n) {
n.forEachChild(visit);
return;
}
// A plain-assignment LHS is a WRITE — dead only when the RHS
// builds no observable effect (the value is dropped with the
// binding).
const p = n.parent;
if (
p !== undefined && ts.isBinaryExpression(p) &&
p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.left === n
) {
if (!namesSym(n)) return;
if (!sideEffectFreeValueExpr(L, p.right)) dead = false;
return;
}
// Import/export specifiers, and every other occurrence, count as
// reads.
if (namesSym(n)) dead = false;
return;
}
n.forEachChild(visit);
};
for (const file of L.program.getSourceFiles()) {
if (!dead) break;
if (file.isDeclarationFile) continue;
file.forEachChild(visit);
}
if (dead) L.deadBindings.add(sym);
return dead;
}
/** The generic function-like INITIALIZER behind a binding declaration —
* `const f = <T>(x: T) => x` or `const f = function g<T>(x: T) {...}`
* (parens stripped). Null when the declaration isn't that shape; the
* SHAPE only — whether the binding qualifies (module scope, never
* reassigned) is bindingGenericFnInfoOf's business. */
export function bindingGenericFnNodeOf(decl: ts.VariableDeclaration): ts.FunctionExpression | ts.ArrowFunction | null {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null;
// Assertion wrappers strip like parens: `const r = (<T>(x: T) => x) as
// Mapper` evaluates the arrow — the cast only renames its type.
const init = stripValueWrappers(decl.initializer);
if (
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) &&
init.typeParameters !== undefined && init.body !== undefined
) {
return init;
}
return null;
}
/** The CONTEXTUAL twin of bindingGenericFnNodeOf: `const g: Mapper = (x)
* => x` where `type Mapper = <T>(x: T) => T` — the initializer declares
* no type parameters of its own, but the ANNOTATION's one call signature
* does, and the checker types the arrow's parameters by those (`x: T`).
* Such a binding monomorphizes exactly like `const g = <T>(x: T) => x`;
* bindingGenericFnInfoOf reads the type parameters off the annotation's
* signature. Null when the shape doesn't match (a concrete annotation, a
* generic arrow — the syntactic probe's case, an overloaded alias). */
export function bindingContextualGenericFnNodeOf(L: Lowerer, decl: ts.VariableDeclaration): ts.FunctionExpression | ts.ArrowFunction | null {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null;
// The generic signature can arrive as an ANNOTATION or as a type
// ASSERTION on the initializer (`var r = < <T>(x: T) => T >((x) => x)`
// — the checker contextually types the operand's parameters by the
// asserted signature exactly like an annotation would).
const asserted =
ts.isAsExpression(decl.initializer) || ts.isTypeAssertion(decl.initializer);
if (decl.type === undefined && !asserted) return null;
const init = stripValueWrappers(decl.initializer);
if (
!(ts.isArrowFunction(init) || ts.isFunctionExpression(init)) ||
init.typeParameters !== undefined || init.body === undefined
) {
return null;
}
const sigs = L.checker.getCallSignatures(L.typeOf(decl.name));
if (sigs.length !== 1 || sigs[0]!.getTypeParameters().length === 0) return null;
return init;
}
/** The interned GenericFnInfo for one generic arrow/function-expression
* binding initializer, with the supportability fences applied ONCE per
* declaration: the binding must sit at module scope (the compiled
* instances are plain module functions — an enclosing frame would need
* captures) and must provably HOLD the initializer once initialized — a
* const, or a let/var nothing in its declaring file ever writes (ESM
* import bindings are read-only, so the file scan is the whole story;
* observing the UNINITIALIZED state needs a hoisted early call, the
* same temporal hole const TDZ leaves — the object-literal generic-
* method receiver stance). Successful registration enters the info
* in genericFnsBySymbol under the binding's symbol — and under a named
* function expression's own inner name (it binds itself inside the
* body, the class-expression rule) — so every genericFnOf consumer
* (calls, pinned values, instantiation expressions, namespace and CJS
* member paths) resolves it like a top-level generic declaration. */
export function bindingGenericFnInfoOf(L: Lowerer, decl: ts.VariableDeclaration,
fnNode: ts.FunctionExpression | ts.ArrowFunction,): GenericFnInfo {
const existing = L.bindingGenericFns.get(fnNode);
if (existing) return existing;
const name = (decl.name as ts.Identifier).text;
if (fnNode.asteriskToken) L.unsupported("SC1071", fnNode);
for (let n: ts.Node = decl.parent; n !== undefined && !ts.isSourceFile(n); n = n.parent) {
if (ts.isFunctionLike(n)) {
L.unsupported(
"SC1090",
fnNode,
`generic arrow/function-expression bindings declared inside functions (the compiled instantiations of '${name}' are module functions and cannot capture the enclosing frame — declare the binding at module scope)`,
);
}
}
const sym = L.checker.getSymbolAtLocation(decl.name);
if (!sym) L.unsupported("SC1090", decl.name, "this binding form");
const isConst = (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0;
// Merged `var` redeclarations (`var f = <T>...; var f = ...`) are one
// symbol with several initializers — writes the assignment scan never
// sees; they fence exactly like a reassignment.
const redeclared = L.checker
.declarationsOf(sym)
.some((d) => d !== decl && ts.isVariableDeclaration(d) && d.initializer !== undefined);
if (!isConst && (redeclared || !bindingNeverReassigned(L, sym, decl))) {
L.unsupported(
"SC1090",
decl.name,
`generic function values in reassigned bindings (calls of '${name}' resolve statically against this initializer, so the binding must provably hold it — a const, or a let/var nothing in its declaring file writes)`,
);
}
const typeParams: ts.Symbol[] = [];
if (fnNode.typeParameters !== undefined) {
for (const tp of fnNode.typeParameters) {
const tpSym = L.checker.getSymbolAtLocation(tp.name);
if (!tpSym) L.unsupported("SC1090", fnNode, "this function form");
typeParams.push(tpSym);
}
} else {
// The CONTEXTUAL shape (bindingContextualGenericFnNodeOf): the type
// parameters live on the annotation's one call signature, and the
// checker types the initializer's parameters by them — the same
// symbols the instance bodies resolve through.
const sigs = L.checker.getCallSignatures(L.typeOf(decl.name));
const tps = sigs.length === 1 ? sigs[0]!.getTypeParameters() : [];
if (tps.length === 0) L.unsupported("SC1090", fnNode, "this function form");
for (const tp of tps) {
const tpSym: ts.Symbol | undefined = tp.getSymbol();
if (!tpSym) L.unsupported("SC1090", fnNode, "this function form");
typeParams.push(tpSym);
}
}
// Only NAME syntax is checkable here; optional/default/rest shapes are
// computed per instantiation from the resolved signature — exactly
// collectGenericSignature's rule, binding patterns included.
for (const param of fnNode.parameters) {
if (!ts.isIdentifier(param.name) && !ts.isObjectBindingPattern(param.name) && !ts.isArrayBindingPattern(param.name)) {
L.unsupported("SC1031", param);
}
}
const stmt = decl.parent.parent; // declarator → list → statement (nsPathPrefix wants the statement)
const info: GenericFnInfo = {
decl: fnNode,
baseName: name,
qualifiedName: L.qualify(decl.getSourceFile(), nsPathPrefix(stmt, decl) + name),
typeParams,
instances: new Map(),
};
L.bindingGenericFns.set(fnNode, info);
L.genericFnsBySymbol.set(sym, info);
if (ts.isFunctionExpression(fnNode) && fnNode.name !== undefined) {
const inner = L.checker.getSymbolAtLocation(fnNode.name);
if (inner) L.genericFnsBySymbol.set(inner, info);
}
return info;
}
/** `const h = id` — a binding ALIASING a generic function (a top-level
* declaration, a registered generic binding, or another alias — resolved
* left to right in declaration order). The alias registers the SAME info
* under its own symbol, so calls (`h(3)`) and pinned values (`take(h)`)
* resolve exactly like the target's own name, and the binding itself has
* no runtime value (a generic function value cannot materialize). Claims
* only bindings whose OWN type still keeps type parameters — a
* concrete-annotated alias (`const h: (x: number) => number = id`) is a
* pinned VALUE, the existing lowerGenericFnValue story. Null when the
* shape doesn't match or the target isn't a registered generic; fences
* (reassignment, var redeclaration) report by name inside. */
export function bindingGenericFnAliasInfoOf(L: Lowerer, decl: ts.VariableDeclaration): GenericFnInfo | null {
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return null;
let init: ts.Expression = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (!ts.isIdentifier(init)) return null;
const target = genericFnOf(L, init);
if (!target) return null;
// A concrete annotation pins one signature — that value story
// (lowerGenericFnValue at the reference) stays untouched.
const ownSigs = L.checker.getCallSignatures(L.typeOf(decl.name));
if (ownSigs.length === 0 || !ownSigs.every((s) => s.getTypeParameters().length > 0)) return null;
const sym = L.checker.getSymbolAtLocation(decl.name);
if (!sym) return null;
const existing = L.genericFnsBySymbol.get(sym);
if (existing) return existing;
const name = decl.name.text;
const isConst = (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0;
// The same holds-it-forever discipline as generic arrow bindings:
// calls through the alias resolve statically against the target, so
// nothing may ever rebind it (merged `var` redeclarations included).
const redeclared = L.checker
.declarationsOf(sym)
.some((d) => d !== decl && ts.isVariableDeclaration(d) && d.initializer !== undefined);
if (!isConst && (redeclared || !bindingNeverReassigned(L, sym, decl))) {
L.unsupported(
"SC1090",
decl.name,
`generic function values in reassigned bindings (calls of '${name}' resolve statically against this initializer, so the binding must provably hold it — a const, or a let/var nothing in its declaring file writes)`,
);
}
L.genericFnsBySymbol.set(sym, target);
return target;
}
/** Static resolution stands in for the receiver's runtime value, so an
* object-literal generic-method receiver must provably HOLD the defining
* literal: a direct read of a binding whose initializer IS that literal
* and that nothing ever reassigns — a const, or a let with no write in
* its declaring file (ESM import bindings are read-only, so the file
* scan is the whole story). The read is pure — call and value sites skip
* evaluating it entirely. A reassignable binding could hold a
* structurally identical literal with a DIFFERENT body, which static
* resolution would silently miss. */
export function requireObjLitGenericReceiver(L: Lowerer, blame: ts.Node, recvExpr: ts.Expression,
literal: ts.ObjectLiteralExpression, name: string,): void {
let recv: ts.Expression = recvExpr;
while (ts.isParenthesizedExpression(recv)) recv = recv.expression;
const fenceReceiver: () => never = () =>
L.unsupported(
"SC1090",
blame,
`reaching the object-literal generic method '${name}' through this receiver (resolution is static, so the receiver must be a never-reassigned binding initialized with the defining literal)`,
);
if (!ts.isIdentifier(recv)) fenceReceiver();
const recvSym = L.resolveValueSymbol(recv);
const recvDecl = recvSym ? L.checker.valueDeclarationOf(recvSym) : undefined;
if (
!recvDecl || !ts.isVariableDeclaration(recvDecl) ||
!ts.isVariableDeclarationList(recvDecl.parent) ||
recvDecl.initializer === undefined
) {
fenceReceiver();
}
if (
(recvDecl.parent.flags & ts.NodeFlags.Const) === 0 &&
!bindingNeverReassigned(L, recvSym!, recvDecl)
) {
fenceReceiver();
}
let init: ts.Expression = recvDecl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (init !== literal) fenceReceiver();
}
/** `o.m(args)` where `m` is an object-literal GENERIC method (own type
* parameters — the member is excluded from the record shape, see
* isGenericCallableMemberType): monomorphized per call site against the
* DEFINING literal's declaration, exactly like top-level generic
* functions. Resolution is static, so the receiver must provably BE the
* defining literal: a const binding whose initializer is that literal,
* read directly. The receiver read is pure and the compiled instance is
* a plain module function (no `this`, fenced), so the call lowers to a
* direct `call` of the instance with the receiver unevaluated. Claims
* every call whose member is generic-callable — lowering it or fencing
* with a named message. */
/** URL.revokeObjectURL() with NO argument: Node's ERR_MISSING_ARGS
* throws before the registry lookup, so the zero-argument contract is
* exact without any blob machinery. The one-argument form (Node's
* silent no-op for unregistered ids) and createObjectURL keep their
* fences — a compiled program has no blob registry to consult. */
function lowerUrlStaticCall(L: Lowerer, call: ts.CallExpression, callee: ts.Expression): IrExpr | null {
if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken !== undefined) return null;
if (!ts.isIdentifier(callee.expression) || callee.expression.text !== "URL") return null;
if (callee.name.text !== "revokeObjectURL" || call.arguments.length !== 0) return null;
const sym = L.resolveValueSymbol(callee.expression);
if (!sym || !L.isStdlibSymbol(sym)) return null;
return nodeThrowExpr(1, "ERR_MISSING_ARGS", 'The "url" argument must be specified', VOID, locOf(call));
}
const SP_BRAND_METHODS = new Set([
"append", "delete", "get", "getAll", "has", "set", "sort",
"forEach", "keys", "values", "entries", "toString",
]);
export function lowerObjLitGenericMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.chainBlocked(access, call)) return null;
const name = access.name.text;
// URLSearchParams method values through Function.prototype.call/apply
// with a receiver that is provably NOT a URLSearchParams (the suite's
// `params.append.call(undefined)` probes): the WHATWG brand check
// throws ERR_INVALID_THIS before any argument conversion — the whole
// call IS that throw. A receiver that IS searchParams-typed, or one
// whose runtime kind is unknowable (dyn/'any'), keeps the fence.
if (
(name === "call" || name === "apply") &&
ts.isPropertyAccessExpression(access.expression) &&
L.mapTypeOf(L.typeOf(access.expression.expression))?.kind === "searchParams" &&
SP_BRAND_METHODS.has(access.expression.name.text) &&
L.isStdlibMember(access.expression)
) {
const thisArg = call.arguments[0];
const thisT = thisArg ? L.mapTypeOf(L.typeOf(thisArg)) : { kind: "undefinedT" as const };
const provablyNot =
thisT !== null &&
thisT.kind !== "searchParams" &&
thisT.kind !== "dyn" &&
thisT.kind !== "jsval" &&
(!thisArg || ts.isIdentifier(thisArg) || ts.isLiteralExpression(thisArg) ||
thisArg.kind === ts.SyntaxKind.UndefinedKeyword ||
thisArg.kind === ts.SyntaxKind.NullKeyword ||
isUnitType(thisT));
if (provablyNot) {
return nodeThrowExpr(
1,
"ERR_INVALID_THIS",
'Value of "this" must be of type URLSearchParams',
L.mapTypeOf(L.typeOf(call)) ?? VOID,
locOf(call),
);
}
}
const recvT = L.typeOf(access.expression);
const propSym = L.checker.getPropertyOfType(recvT, name);
if (!propSym) return null;
if (!isGenericCallableMemberType(L.checker.getTypeOfSymbol(propSym), L.checker)) return null;
// CLASS members belong to the class path (lowerClassGenericMethodCall
// claimed compilable ones; a class that failed collection keeps its
// own diagnostics and the generic method-call fence downstream).
if (
L.checker.declarationsOf(propSym).some(
(d) => d.parent !== undefined && (ts.isClassDeclaration(d.parent) || ts.isClassExpression(d.parent)),
)
) {
return null;
}
// An INTERFACE-typed receiver over a class instance (`const r: Repo =
// new MemRepo(); r.get(...)` — the declaration is signature-only, but
// the receiver's exact class is statically proven and the binding
// kept the class representation, genericIfaceBindingKeepsClass): the
// call is a class generic-method call on that exact class. The
// receiver must LOWER as the class — a record-held value (a `let`, a
// produced value, a parameter) has already dropped it, and keeps the
// named fence below.
{
const exact = exactInstanceClassOf(L, access.expression);
const gfound = exact ? findGenericMethodOn(L, exact, name) : null;
if (gfound && ts.isIdentifier(access.expression)) {
const recv = L.lowerExpr(access.expression); // identifier reads are pure — no double evaluation
if (recv.type.kind === "object") {
return lowerClassGenericMethodCall(L, call, access, exact!, gfound, recv);
}
}
}
const found = objLitGenericFnNodeOf(L, propSym);
if (!found) {
// Function.prototype.apply/call/bind spelled through a FUNCTION
// receiver: compiled functions are direct calls with no runtime
// `this`/arguments object to re-route — name the working spelling
// instead of a class-receiver hint (or an SC2020 recitation) no
// function value can follow. Before the stdlib decline: these ARE
// stdlib members (CallableFunction), but the pointed message is
// the honest one.
if (
(name === "apply" || name === "call" || name === "bind") &&
L.checker.getCallSignatures(recvT).length > 0
) {
L.unsupported(
"SC1090",
call,
`Function.prototype.${name} on a compiled function value (compiled calls are direct — no runtime 'this' or arguments object exists to re-route; spell the call directly: '${access.expression.getText()}(...)')`,
);
}
// STANDARD-LIBRARY generic members (Promise.then, Object.
// defineProperty, Array-augmentation methods) are the lib fence's
// story (SC2020, naming the member) — decline so the stdlib
// chokepoint downstream reports, instead of an interface-dispatch
// recitation about a receiver no user constructed.
if (L.isStdlibMember(access)) return null;
// Interface-declared generic methods dispatch statically, so the
// receiver's runtime class must be provable — name that discipline
// instead of the object-literal wording when the method lives on an
// interface.
const onInterface = L.checker
.declarationsOf(propSym)
.some((d) => d.parent !== undefined && ts.isInterfaceDeclaration(d.parent));
if (onInterface) {
L.unsupported(
"SC1090",
call,
`calls of the generic method '${name}' through this receiver (the interface declaration is signature-only and generic methods dispatch statically, so the receiver's runtime class must be provable — bind the receiver to a const initialized with its 'new' expression, e.g. 'const r: ${L.checker.typeToString(recvT)} = new C(...)')`,
);
}
L.unsupported(
"SC1090",
call,
`calls of the generic method '${name}' with no defining object literal (the declaration is signature-only — only methods declared with a body in an object literal monomorphize)`,
);
}
requireObjLitGenericReceiver(L, call, access.expression, found.literal, name);
const info = objLitGenericFnInfoOf(L, call, name, found);
const instance = genericCallInstance(L, call, info);
const loc = locOf(call);
const args = L.completeArgs(call.arguments, instance.params, loc, call);
return { kind: "call", callee: instance.name, args, type: instance.returnType, loc };
}
/** `obj.method(args)` — whole-program devirtualization decides the form:
* a method some strict subclass of the receiver's STATIC class overrides
* must dispatch on the dynamic class (`virtualCall`, through the vtable);
* everything else — standalone classes, non-overridden methods, leaf
* receivers — stays a direct `call` of the nearest declaration, exactly
* as before inheritance existed. */
export function lowerObjectMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (L.chainBlocked(access, call)) return null;
const receiverIr = L.mapTypeOf(L.typeOf(access.expression));
if (receiverIr?.kind !== "object") return null;
const info = L.classes.get(receiverIr.className);
if (!info) L.flushDeferredClass(receiverIr.className);
const found = info ? L.findMethodOn(info, access.name.text) : null;
// The stream surface: API-named calls on stream-rooted receivers
// lower through the stream spoke (checked before the emitter surface
// — the two member sets are disjoint, but streams root at the emitter
// so both guards would pass an emitter-named call).
if (info && !found && STREAM_API_MEMBERS.has(access.name.text) && streamSidesOf(L, info) !== null) {
const stream = lowerStreamMethodCall(L, call, access, info);
if (stream) return stream;
}
// The EventEmitter surface: API-named calls on emitter-rooted
// receivers lower through the emitter spoke (subclass members with
// these names are fenced at collection, so `found` never shadows).
if (info && !found && EMITTER_API_MEMBERS.has(access.name.text) && emitterRooted(L, info)) {
return lowerEmitterMethodCall(L, call, access, info);
}
// GENERIC methods (own type parameters) never enter the methods table:
// they monomorphize per call site and dispatch statically
// (lowerClassGenericMethodCall has the exactness rules).
if (info && !found) {
const gfound = findGenericMethodOn(L, info, access.name.text);
if (gfound) return lowerClassGenericMethodCall(L, call, access, info, gfound);
}
// A FUNC- or DYN-typed FIELD in call position: `this.cb()` — the
// ctor-assigned callback field (countdown.js's shape). The call is an
// ordinary call through the field's VALUE — read the field, then
// callValue (func fields) or the dynCall boundary (checked-dynamic
// fields: implicit-any ctor params, validated at the call like every
// dyn callee). Every other field type falls through to the fences.
if (info && !found) {
const fieldType = info.fields.get(access.name.text);
if (fieldType && (fieldType.kind === "func" || fieldType.kind === "dyn")) {
const target = L.fieldTarget(access);
const callee = target ? L.fieldGetExpr(target, locOf(access), access) : null;
if (callee?.type.kind === "func") {
const params = callee.type.params;
const args = call.arguments.map((a, i) => L.lowerExprExpecting(a, params[i]));
for (let i = args.length; i < params.length; i++) {
const absent = omittedArgFor(L, params[i]!, locOf(call));
if (!absent) {
L.unsupported("SC1090", call, "calls omitting a non-optional parameter of the callee's type");
}
args.push(absent);
}
return { kind: "callValue", callee, args, type: callee.type.ret, loc: locOf(call) };
}
if (callee?.type.kind === "dyn") {
if (call.arguments.some((a) => ts.isSpreadElement(a))) {
L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values");
}
const args = call.arguments.map((a) => L.lowerExprExpecting(a, DYN));
return { kind: "dynCall", callee, calleeName: access.getText(), args, type: DYN, loc: locOf(call) };
}
}
return null;
}
if (!info || !found) return null;
const method = access.name.text;
if (found.declarer.builtinError) {
// The one builtin method: Error.prototype.toString, a runtime
// implementation called directly (overriding it is fenced, so no
// dispatch can ever be needed). Receiver BORROWED by the libCall.
const receiver = L.lowerExpr(access.expression);
return {
kind: "libCall",
fn: "error.toString",
args: [L.upcastTo(receiver, found.declarer.def.name)],
type: STRING,
loc: locOf(call),
};
}
// An ABSTRACT nearest declaration with no concrete override below the
// static class: no implementation exists for a direct call to target.
// Unreachable in a program that constructs anything of this type (tsc
// makes instantiable subclasses implement, and their declarations flip
// overrideBelow) — reaching here means the receiver can only be a
// non-value (`null!`); the fence is the honest answer.
if (found.sig.abstract === true && !L.overrideBelow(info, method)) {
L.unsupported(
"SC1090",
call,
`calls of the abstract method '${method}' with no concrete implementation below the receiver's static class`,
);
}
if (L.overrideBelow(info, method)) L.noteVirtualEdge(info, method);
else L.noteEdge(`%${found.declarer.def.name}.${method}`);
const receiver = L.lowerExpr(access.expression);
const args = L.completeArgs(call.arguments, found.sig.params, locOf(call), call);
if (L.overrideBelow(info, method)) {
return reconcileOverloadReturn(L, call, {
kind: "virtualCall",
className: info.def.name,
method,
args: [L.upcastTo(receiver, info.def.name), ...args],
type: found.sig.ret,
loc: locOf(call),
});
}
return reconcileOverloadReturn(L, call, {
kind: "call",
callee: `%${found.declarer.def.name}.${method}`,
args: [L.upcastTo(receiver, found.declarer.def.name), ...args],
type: found.sig.ret,
loc: locOf(call),
});
}