diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts new file mode 100644 index 000000000..ef2aa9778 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -0,0 +1,767 @@ +/* Array literals. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer, neverTaintedJsType } from "../lowerer.js"; +import { + BOOL, + DYN, + F64, + type IrExpr, + type IrRecordShape, + type IrStmt, + type IrType, + STRING, + type SrcLoc, + UNDEFINED_T, + VOID, + arrayOf, + isSupportedArrayElem, + typeEquals, +} from "../../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { underConstAssertion, unitOnlyUnion } from "../../type-mapper.js"; +import { dateJsonType, isDateJsonSource } from "../builtins/dates.js"; +import { npmStaticPackageOfPath } from "../../npm-static.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { maybePromiseUnionOf } from "../maybe-promise.js"; +import { lowerTernary } from "./narrowing.js"; +import { lowerCollectionSpread } from "../containers/collection-methods.js"; +import { strCharsCall } from "../containers/array-construction.js"; +import { generatorDrain } from "../iterator-adapters.js"; +import { isSearchParamsIterable } from "../builtins/url.js"; +import { varRef } from "../../../ir/build.js"; +import { arrayValueStore } from "../array-values.js"; + +/** JavaScript: whether `expr` is or holds an existing checked-dynamic + * object — a binding stored as a dyn value whose checker type is an + * object, or an array or object literal with such an element. Building + * the enclosing literal with a static element type would copy it. */ +export function jsHoldsDynObject(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (ts.isIdentifier(expr)) { + const stored = lowerer.peekLocal(expr)?.type ?? lowerer.globalOf(expr)?.type; + return stored?.kind === "dyn" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Object) !== 0; + } + if (ts.isArrayLiteralExpression(expr)) + return expr.elements.nodes.some( + (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), + ); + if (ts.isObjectLiteralExpression(expr)) + return expr.properties.nodes.some( + (property) => + (ts.isPropertyAssignment(property) && jsHoldsDynObject(lowerer, property.initializer)) || + (ts.isShorthandPropertyAssignment(property) && + ts.isIdentifier(property.name) && + jsHoldsDynObject(lowerer, property.name)), + ); + return false; +} + +/** `[a, b, c]`. The element type comes from the contextual type when tsc + * has one (`const a: number[] = []`, arguments, nested literals) and from + * the literal's own inferred type otherwise. A bare `[]` with no context + * is `never[]` — unmappable, rejected with the component fence (SC2009). `expected` overrides + * the contextual lookup where the caller knows the slot's array type and + * tsc's API doesn't surface it (ternary arms under an array context — + * tsc accepts the arm covariantly, but a tagged element representation + * must be BUILT as the slot's element type). */ +export function lowerArrayLiteral( + lowerer: Lowerer, + expr: ts.ArrayLiteralExpression, + expected?: (IrType & { kind: "array" }) | (IrType & { kind: "record" }), +): IrExpr { + const loc = locOf(expr); + const ctxType = lowerer.checker.getContextualType(expr); + // `as const satisfies readonly T[]` checks against an array but keeps + // the literal's inferred tuple type. Build that shape; an actual array + // destination can use the ordinary tuple-to-array coercion afterward. + const tsType = underConstAssertion(expr) + ? lowerer.typeOf(expr) + : (ctxType ?? lowerer.typeOf(expr)); + let mapped = expected ?? lowerer.mapTypeOf(tsType); + // Inferred JavaScript arrays can contain unrelated constructors with + // structurally equal checker types. Their native class ABIs still differ. + if ( + !expected && + isJsSourceFile(expr.getSourceFile()) && + mapped?.kind === "array" && + mapped.elem.kind === "classval" + ) + mapped = DYN; + // An inferred destructuring pattern supplies a contextual [any, ...] + // tuple. Its positions do not erase the literal's inferred element + // types; explicit destinations already arrive through expected. + if ( + expected === undefined && + ctxType && + lowerer.checker.isTupleType(ctxType) && + lowerer.checker + .getTypeArguments(ctxType as ts.TypeReference) + .some((type) => (type.flags & ts.TypeFlags.Any) !== 0) + ) { + const inferred = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if ( + inferred?.kind === "array" || + (inferred?.kind === "record" && lowerer.shapes.get(inferred.shapeId)?.tuple) + ) + mapped = inferred; + } + // A JS literal whose OWN inferred type is never-tainted + // (neverTaintedJsType — the evolving `const gb = []`, the mixed + // command tuple `['pwd', []]`) carries no element information: route + // it to the checked-dynamic tree fallback below rather than letting never's f64 + // representation build a static number array (a later dyn push would + // throw "expected number at $, got string"; the tuple's union arm + // would re-tag as number[] and fence). A REAL slot still wins: + // `expected`, a contextual array/tuple, or a contextual union's + // single array arm all provide element information first. + // (The contextual lookup can answer the binding's own tainted + // inference back — `const cmd = ['pwd', []]` — so the slot test is a + // taint test on whichever type mapped, not a presence test.) + const neverTaintedOwnJs = + expected === undefined && neverTaintedJsType(lowerer, expr, lowerer.typeOf(expr)); + if (expected === undefined && neverTaintedJsType(lowerer, expr, tsType)) mapped = null; + // A union-typed slot (`const x: string[] | number = [..]`) contextually + // types the literal as the union; build the literal as its OWN type and + // let the slot's coercion wrap it into the union. An unknown-typed slot + // (`JSON.stringify([1, 2])`) likewise: build the literal's own type. An + // UNMAPPABLE context falls back the same way — a destructuring pattern + // without annotations contextually types its initializer `[any]`, while + // the literal's own inferred type is the real tuple. + if (!mapped || mapped.kind === "union" || mapped.kind === "dyn") { + const ctxUnion = mapped?.kind === "union" ? mapped : null; + mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + // The never-tainted own type carries no element information here + // either (the JS story above) — only a contextual union's single + // array arm below may still supply a static home. + if (neverTaintedOwnJs) mapped = null; + // An EMPTY literal in a union slot (`const t: string[] | undefined = + // []`, the `[]` default argument against `string[] | undefined`) has + // no useful own type — tsc infers `never[]`, whose f64-element + // mapping is a representation for the uninhabited, not the slot's + // arm. When the contextual union has exactly ONE array arm there is + // no ambiguity: build the literal as that arm and let the slot's + // coercion wrap it. Two array arms would need tsc's own best-fit + // choice — none arises in practice; the fence stands. + if (ctxUnion && expr.elements.nodes.length === 0) { + const def = lowerer.unions.get(ctxUnion.unionId); + const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; + if (arrayArms.length === 1) mapped = arrayArms[0]!; + } + // A NON-EMPTY literal whose own type has no static home (the JS dyn + // fallback — a null/dyn mapping, or an inference that degraded to a + // unit-only-element array over non-unit elements) under a union + // with exactly ONE array-family arm — an array, or an + // arity-matching tuple (the option-table `default: [{ value: [] }]` + // shape): build AS that arm, exactly the empty-literal rule above; + // the slot's coercion wraps it. + if (ctxUnion && expr.elements.nodes.length > 0) { + // A unit-only-element array (`(null | undefined)[]`) cannot hold + // non-unit elements — neither as the literal's own mapping nor as + // a competing union arm. + const nonUnitElems = expr.elements.nodes.some( + (el) => + !ts.isOmittedExpression(el) && + el.kind !== ts.SyntaxKind.NullKeyword && + !(ts.isIdentifier(el) && el.text === "undefined"), + ); + const ownUnhelpful = + mapped === null || + mapped.kind === "dyn" || + // The checker may echo the context union rather than infer this literal. + mapped.kind === "union" || + (mapped.kind === "array" && nonUnitElems && lowerer.unitOnlyElem(mapped.elem)); + if (ownUnhelpful) { + const def = lowerer.unions.get(ctxUnion.unionId); + const arms = (def?.arms ?? []).filter( + (a) => + (a.kind === "array" && !(nonUnitElems && lowerer.unitOnlyElem(a.elem))) || + (a.kind === "record" && + !!lowerer.shapes.get(a.shapeId)?.tuple && + lowerer.shapes.get(a.shapeId)!.fields.length === expr.elements.nodes.length), + ); + if (arms.length === 1) mapped = arms[0]!; + } + } + } + // A Date's toJSON() element keeps its null in a literal with no declared + // destination: the lib types say string. + if ( + !expected && + mapped?.kind === "array" && + mapped.elem.kind === "string" && + expr.elements.nodes.some((element) => isDateJsonSource(lowerer, element)) + ) + mapped = { kind: "array", elem: dateJsonType(lowerer) }; + // An array literal inside an npm-static implicit-any instance can + // inherit the checker's poisoned any[] context even after the + // active binding gives every element one concrete lowering. Recover a + // static array only when every spelled element lowers to the same + // supported type. An explicit checked-dynamic destination still owns + // the later slot coercion; heterogeneous literals, holes, and spreads + // keep the checked-dynamic fallback below. + if ( + lowerer.implicitParamTypes !== null && + npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null && + (mapped === null || + mapped.kind === "dyn" || + (mapped.kind === "array" && mapped.elem.kind === "dyn")) && + expr.elements.nodes.length > 0 && + expr.elements.nodes.every( + (element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element), + ) + ) { + const elements = expr.elements.nodes.map((element) => tryLowerExpression(lowerer, element)); + const first = elements[0]; + if ( + first !== undefined && + first !== null && + isSupportedArrayElem(first.type) && + elements.every((element) => element !== null && typeEquals(element.type, first.type)) + ) { + mapped = arrayOf(first.type); + } + } + if (!expected && isJsSourceFile(expr.getSourceFile()) && mapped?.kind === "array") { + const elem = mapped.elem; + const arms = elem.kind === "union" ? lowerer.unions.get(elem.unionId)?.arms : [elem]; + if (arms?.length && arms.every((arm) => arm.kind === "classval")) mapped = DYN; + // Inferred JavaScript literals mixing promises and plain values + // (`[Promise.resolve(1), 2]`) keep the checked-dynamic array: their + // consumers (Readable.from, Promise.all over iterators) inspect each + // element at run time. + if (maybePromiseUnionOf(lowerer, elem)) mapped = DYN; + } + if ( + !expected && + isJsSourceFile(expr.getSourceFile()) && + expr.elements.nodes.some( + (element) => + ts.isSpreadElement(element) && + tryLowerExpression(lowerer, element.expression)?.type.kind === "dyn", + ) + ) + mapped = DYN; + // An element that is an existing checked-dynamic object (a file-scope + // object literal binding, or a literal holding one) keeps its identity + // only in a checked-dynamic array: a static element type would copy it. + if ( + !expected && + isJsSourceFile(expr.getSourceFile()) && + mapped?.kind !== "dyn" && + expr.elements.nodes.some( + (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), + ) + ) + mapped = DYN; + // An EMPTY literal under a CONST ASSERTION whose type queries panicked + // (tsgo's readonly-[] TupleType conversion) uses a unit-element array, + // mapType's own `[] as const` rule. + if ( + mapped === null && + expr.elements.nodes.length === 0 && + (tsType.flags & ts.TypeFlags.Any) !== 0 && + underConstAssertion(expr) + ) { + mapped = arrayOf(unitOnlyUnion(lowerer.unions)); + } + // Heterogeneous JavaScript object arrays must retain each literal's + // real keys. A structural union can otherwise drop descriptor fields. + if ( + !expected && + isJsSourceFile(expr.getSourceFile()) && + mapped?.kind === "array" && + mapped.elem.kind === "union" && + expr.elements.nodes.some(ts.isObjectLiteralExpression) + ) + mapped = DYN; + + // A TUPLE-typed slot (`const t: [string, number] = ["a", 1]`): the + // literal constructs the tuple's record shape — one positional field + // per element, source order (which IS index order, so evaluation order + // is JS-exact). tsc has already checked the arity; the recount below + // backstops `as` smuggling. Fixed tuple spreads have known positions. + if (mapped?.kind === "record") { + const shape = lowerer.shapes.get(mapped.shapeId); + if (shape?.tuple) { + const spread = expr.elements.nodes.find(ts.isSpreadElement); + if (spread) { + return lowerTupleSpreadLiteral(lowerer, expr, mapped, shape); + } + if (expr.elements.nodes.length !== shape.fields.length) { + // tsc padded an UNDER-LENGTH literal against an optional-element + // tuple context (`options || []` with `options?: [string?, + // number?]` — the instantiated type spells every position, the + // literal spells fewer): no fixed shape holds it. + lowerer.badType(expr, tsType); + } + const byName = new Map(shape.fields.map((f) => [f.name, f.type])); + const fields = expr.elements.nodes.map((el, i) => { + const fieldType = byName.get(String(i)); + if (!fieldType) lowerer.badType(el, lowerer.typeOf(el)); + return { name: String(i), value: lowerer.lowerExprExpecting(el, fieldType) }; + }); + return { kind: "recordLit", fields, type: mapped, loc }; + } + } + if (!mapped || mapped.kind !== "array") { + // The JS declaration fallback, literal-side: an element type with no + // static home (a string | string[] mixed command tuple, an evolving + // []) builds as a dyn ARRAY — one dyn value whose elements each + // convert through the usual boundary (dynFrom's JSON-safe domain); + // an element that cannot convert fences per element. length/index + // reads and dynamic consumers ride the keyed-dyn paths. TS literals + // take the same build when the slot ITSELF is checked-dynamic — an + // `unknown[]` annotation or a collapsed `(string | object)[]` maps + // to DYN wholesale now (mapType's dyn-element array rule), so the + // literal IS the dyn array. + if ( + isJsSourceFile(expr.getSourceFile()) || + mapped?.kind === "dyn" || + lowerer.checkerAnyArray(expr) + ) { + if (expr.elements.nodes.some(ts.isSpreadElement)) { + // Native collection drains return vectors of retained dyn + // elements. Reuse the ordinary spread builder, then expose the + // resulting array through the established unknown[] representation. + const vector = lowerArrayLiteral(lowerer, expr, arrayOf(DYN) as IrType & { kind: "array" }); + return lowerer.coerceInto(expr, vector, DYN); + } + const elems = expr.elements.nodes.map((el): IrExpr => { + if (ts.isSpreadElement(el)) { + lowerer.unsupported( + "SC1090", + el, + "spread elements in a dynamic (unknown[]) array literal", + ); + } + const v = lowerer.coerceToExpected(lowerer.lowerExpr(el), DYN); + if (v.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + el, + `holding '${lowerer.fmt(v.type)}' values in a dynamic (unknown[]) array literal`, + ); + } + return v; + }); + return { kind: "dynArrLit", elems, type: DYN, loc }; + } + lowerer.badType(expr, tsType); + } + let type = mapped as IrType & { kind: "array" }; + // An inferred array of records takes the layout of an element literal + // whose fields widened for missing-element reads; the other elements + // build that layout directly. An explicit destination keeps its own. + if (!expected && type.elem.kind === "record") { + let variant: IrType | null = null; + for (const candidate of expr.elements.nodes) { + let x: ts.Expression = candidate; + while (ts.isParenthesizedExpression(x)) x = x.expression; + if (!ts.isObjectLiteralExpression(x)) continue; + const probe = tryLowerExpression(lowerer, x); + if (!probe || !lowerer.isRuntimeOptionalRecordVariant(probe.type, type.elem)) continue; + if (variant !== null && !typeEquals(variant, probe.type)) { + variant = null; + break; + } + variant = probe.type; + } + if (variant !== null) type = arrayOf(variant) as IrType & { kind: "array" }; + } + // Keep the array payload type fixed. Optional reads are stored through + // arrayValueStore, which records UNDEFINED in the state byte while the + // payload remains number/string/etc.; widening the array element here + // would force boxed union storage and change aliases. + const spreads: number[] = []; + const holes = new Set(); + const runtimeOptionalElements = new Set(); + let needsStateBuild = false; + for (let i = 0; i < expr.elements.nodes.length; i++) { + const candidate = expr.elements.nodes[i]!; + if (ts.isSpreadElement(candidate) || ts.isOmittedExpression(candidate)) continue; + const probe = tryLowerExpression(lowerer, candidate); + if (probe && lowerer.runtimeOptionalWidening(probe.type, type.elem) !== null) { + runtimeOptionalElements.add(i); + needsStateBuild = true; + } + } + const elems: IrExpr[] = expr.elements.nodes.map((el, i): IrExpr => { + if (ts.isSpreadElement(el)) { + // `[...xs, b]`: xs must be an array of the literal's own element + // type — its elements copy in at construction (a fresh array, + // JS-exact). Iterables that aren't arrays (strings, Sets, Maps) + // and mismatched element types stay fenced. A TERNARY source gets + // the literal's own type as its expected type (the conditional- + // spread idiom — tsc surfaces no contextual type through spreads, + // and the arm literals must BUILD as this element type). + let srcNode: ts.Expression = el.expression; + while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; + lowerer.fenceStaticHeadersIteration(el.expression); + let src = ts.isConditionalExpression(srcNode) + ? lowerTernary(lowerer, srcNode, type) + : lowerer.lowerExpr(el.expression); + src = lowerCollectionSpread(lowerer, src, el.expression) ?? src; + // `[...new SymbolIterator]`: a CLASS ITERABLE drains through its + // own protocol into a fresh element array (classIteratorDrainCall + // — an infinite iterator loops forever, exactly Node), and the + // spread machinery copies like any array source. + if (src.type.kind === "object") { + const drained = lowerer.classIteratorDrainCall(src, locOf(el), type.elem); + if (drained) src = drained; + } + // `[...s]` on a STRING spreads its code-point characters (the + // string iterator's walk — astral chars whole) through the same + // interned helper as Array.from(s); the result rides the array + // machinery below like any string[] source. + if (src.type.kind === "string") { + src = strCharsCall(lowerer, src, locOf(el)); + } + // `[...typedArray]`: represented typed arrays are dense numeric + // iterables, so drain their elements into the fresh number[] that + // the surrounding array literal will copy. In particular this is + // the Uint8Array-to-Array bridge (`[...u8]`), with element values + // read rather than backing bytes for the wider typed-array kinds. + if (src.type.kind === "bytes") { + src = { + kind: "bytesIntrinsic", + method: "toArray", + receiver: src, + args: [], + type: arrayOf(F64), + loc: locOf(el), + }; + } + // `[...it]` over a generator (or an iterator adapted into one): + // drain it once, in order, into a fresh element array. + if (src.type.kind === "generator") { + const drained = generatorDrain(lowerer, src, type.elem, locOf(el)); + if (drained) src = drained; + } + // `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its + // checked-dynamic view iterates the live pair list, and the drained + // [name, value] pairs convert to the literal's tuple elements. + const searchParamsSource = isSearchParamsIterable(lowerer, el.expression); + if (src.type.kind === "searchParams") src = lowerer.coerceInto(el.expression, src, DYN); + // A native checked iterable can supply scalar elements even when + // the checker inferred a typed array from its producer. Drain once + // and validate the elements before the ordinary spread copy. + if ( + src.type.kind === "dyn" && + (type.elem.kind === "dyn" || + type.elem.kind === "f64" || + type.elem.kind === "string" || + type.elem.kind === "bool" || + searchParamsSource) + ) { + src = lowerer.coerceInto(el.expression, checkedIteratorPack(lowerer, src, locOf(el)), type); + } + // A same-family array whose ELEMENT lifts (string[] into a + // (string | symbol)[] literal — per-element wrap/width copy): + // the interned width helper reshapes before the spread copies. + if (src.type.kind === "array" && !typeEquals(src.type, type)) { + const w = lowerer.widthCoerce(src, type); + if (w) src = w; + } + if (!typeEquals(src.type, type)) { + lowerer.unsupported( + "SC1090", + el, + `spreading '${lowerer.fmt(src.type)}' into a '${lowerer.fmt(type)}' literal (only a same-element-type array spreads)`, + ); + } + spreads.push(i); + return src; + } + // A HOLE (`[,]` — an elision) remains absent. Ordinary reads still + // answer undefined, while presence-sensitive methods and concat keep + // the hole. The state-building path below advances length without + // writing a value into this slot. + if (ts.isOmittedExpression(el)) { + needsStateBuild = true; + holes.add(i); + return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(el) }; + } + // An ARRAY-LITERAL element under a UNION element slot whose own type + // has no lift into the slot builds against the union's single array + // arm instead (`['pwd', []]` as (string | string[])[] — tsc pushes no + // contextual type through the unannotated chain, so the empty nested + // literal types never[], whose f64-element representation re-tags + // into nothing). One array arm is unambiguous — the union-slot rule's + // stance; several keep the fence. + if (type.elem.kind === "union" || type.elem.kind === "array") { + let x: ts.Expression = el; + while (ts.isParenthesizedExpression(x)) x = x.expression; + if (ts.isArrayLiteralExpression(x)) { + const ownT = lowerer.mapTypeOf(lowerer.checker.getContextualType(x) ?? lowerer.typeOf(x)); + if (ownT === null || lowerer.widthLiftPlan(ownT, type.elem) === null) { + // An ARRAY-typed slot takes the literal directly; a union slot + // routes through its single array arm and wraps. + if (type.elem.kind === "array") { + return lowerer.lowerArrayLiteral(x, type.elem); + } + const def = lowerer.unions.get(type.elem.unionId); + const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; + if (arrayArms.length === 1) { + const built = lowerer.lowerArrayLiteral(x, arrayArms[0] as IrType & { kind: "array" }); + return lowerer.coerceInto(el, built, type.elem); + } + } + } + } + // Elements flow into the element slot like an assignment: union + // elements wrap plain arm values (`[1, "a"]` as (number | string)[]), + // holes reject inside lowerExpr. + let lowered: IrExpr; + const directOptionalCandidate = + runtimeOptionalElements.has(i) || + ts.isElementAccessExpression(el) || + (ts.isCallExpression(el) && + ts.isPropertyAccessExpression(el.expression) && + (el.expression.name.text === "pop" || el.expression.name.text === "shift")); + if (directOptionalCandidate) { + const raw = lowerer.lowerExpr(el); + if (!typeEquals(raw.type, type.elem)) { + needsStateBuild = true; + return raw; + } + lowered = raw; + } else { + lowered = lowerer.lowerExprExpecting(el, type.elem); + } + if (!typeEquals(lowered.type, type.elem)) lowerer.badType(el, lowerer.typeOf(el)); + return lowered; + }); + if (!needsStateBuild) { + return { kind: "arrayLit", elems, ...(spreads.length > 0 ? { spreads } : {}), type, loc }; + } + const out = lowerer.declareHiddenLocal("%arrayLit", type); + const outRef = varRef(out.id, type, loc); + // Dense capacity for the elements the statements below store. + const capacity = Math.min(elems.length - spreads.length, 1024); + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: out.id, + init: { + kind: "arrayLit", + elems: [], + ...(capacity > 0 ? { capacity } : {}), + type, + loc, + }, + loc, + }, + ]; + const spreadSet = new Set(spreads); + const currentLength = (): IrExpr => ({ + kind: "arrIntrinsic", + method: "length", + receiver: outRef, + args: [], + type: F64, + loc, + }); + const nextLength = (): IrExpr => ({ + kind: "bin", + op: "+", + left: currentLength(), + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }); + for (let i = 0; i < elems.length; i++) { + const el = elems[i]!; + if (spreadSet.has(i)) { + body.push({ + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "pushSpread", + receiver: outRef, + args: [el], + type: F64, + loc, + }, + loc, + }); + continue; + } + if (holes.has(i)) { + body.push({ kind: "arraySetLength", arr: outRef, length: nextLength(), loc }); + continue; + } + let value = el; + if (!typeEquals(value.type, type.elem)) { + const temp = lowerer.declareHiddenLocal("%arrayValue", value.type); + body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + value = varRef(temp.id, temp.type, loc); + } + body.push(arrayValueStore(lowerer, outRef, currentLength(), value, type.elem, loc)); + } + return { + kind: "seqExpr", + stmts: body, + result: outRef, + type, + loc, + }; +} + +/** Build a fixed tuple from positional elements and fixed tuple spreads. + * Capture each value in source order: deferring the reads until recordLit + * would observe a later element's mutations of the spread source. */ +function lowerTupleSpreadLiteral( + lowerer: Lowerer, + expr: ts.ArrayLiteralExpression, + type: IrType & { kind: "record" }, + shape: IrRecordShape, +): IrExpr { + const loc = locOf(expr); + const byName = new Map(shape.fields.map((field) => [field.name, field.type])); + const stmts: IrStmt[] = []; + const fields: { name: string; value: IrExpr }[] = []; + const append = (node: ts.Expression, value: IrExpr): void => { + const name = String(fields.length); + const expected = byName.get(name); + if (!expected) lowerer.badType(expr, lowerer.typeOf(expr)); + const coerced = lowerer.coerceInto(node, value, expected); + const temp = lowerer.declareHiddenLocal("%tupleElement", expected); + const at = locOf(node); + stmts.push({ kind: "varDecl", localId: temp.id, init: coerced, loc: at }); + fields.push({ name, value: varRef(temp.id, expected, at) }); + }; + for (const element of expr.elements.nodes) { + if (!ts.isSpreadElement(element)) { + const expected = byName.get(String(fields.length)); + if (!expected) lowerer.badType(element, lowerer.typeOf(element)); + append(element, lowerer.lowerExprExpecting(element, expected)); + continue; + } + const source = lowerer.lowerExpr(element.expression); + const sourceShape = + source.type.kind === "record" ? lowerer.shapes.get(source.type.shapeId) : undefined; + if (!sourceShape?.tuple) { + // Empty tuples use a zero-length array representation. Preserve a + // producing call's effects even though there are no positions to copy. + const sourceTs = lowerer.typeOf(element.expression); + if ( + source.type.kind === "array" && + lowerer.checker.isTupleType(sourceTs) && + lowerer.checker.getTypeArguments(sourceTs as ts.TypeReference).length === 0 + ) { + stmts.push({ kind: "exprStmt", expr: source, loc: locOf(element) }); + continue; + } + lowerer.unsupported( + "SC1090", + element, + "spreading a variable-length value into a fixed tuple literal", + ); + } + const temp = lowerer.declareHiddenLocal("%tupleSpread", source.type); + const at = locOf(element); + stmts.push({ kind: "varDecl", localId: temp.id, init: source, loc: at }); + const receiver = varRef(temp.id, source.type, at); + const positions = [...sourceShape.fields].sort((a, b) => Number(a.name) - Number(b.name)); + for (const field of positions) { + append(element.expression, { + kind: "recordGet", + obj: receiver, + shapeId: sourceShape.id, + field: field.name, + type: field.type, + loc: at, + }); + } + } + if (fields.length !== shape.fields.length) lowerer.badType(expr, lowerer.typeOf(expr)); + return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc }; +} + +/** Drain using checked dispatch so native class and generator iterators keep their methods. */ +function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, loc: SrcLoc): IrExpr { + const name = "%iterator.pack.checked"; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: DYN, loc }); + const property = (key: string): IrExpr => ({ + kind: "dynKeyGet", + value: ref("step"), + key: { kind: "strLit", value: key, type: STRING, loc }, + type: DYN, + loc, + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "source", name: "source", type: DYN }], + returnType: DYN, + locals: ["source", "iterator", "step", "result"].map((id) => ({ + id, + name: id, + type: DYN, + mutable: id !== "source", + })), + body: [ + { + kind: "varDecl", + localId: "iterator", + init: { + kind: "libCall", + fn: "dyn.iterator", + args: [ref("source"), { kind: "strLit", value: "value", type: STRING, loc }], + type: DYN, + loc, + }, + loc, + }, + { + kind: "varDecl", + localId: "result", + init: { kind: "dynArrLit", elems: [], type: DYN, loc }, + loc, + }, + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + { + kind: "varDecl", + localId: "step", + init: { + kind: "dynInvoke", + recv: ref("iterator"), + method: "next", + calleeName: "iterator.next", + args: [], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: { kind: "dynTest", test: "truthy", value: property("done"), type: BOOL, loc }, + then: [{ kind: "return", value: ref("result"), loc }], + else_: null, + loc, + }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.packPush", + args: [ref("result"), property("value")], + type: VOID, + loc, + }, + loc, + }, + ], + loc, + }, + { kind: "return", value: ref("result"), loc }, + ], + loc, + }); + } + return { kind: "call", callee: name, args: [source], type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts new file mode 100644 index 000000000..2a8de78b9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -0,0 +1,2791 @@ +/* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer, jsFuncNameOf } from "../lowerer.js"; +import { + BIGINT_T, + BOOL, + DYN, + DYN_HANDLE_KINDS, + F64, + type IrExpr, + type IrStmt, + type IrType, + REF_TRUTHY_KINDS, + STRING, + type SrcLoc, + UNDEFINED_T, + VOID, + canBoxFuncIntoDyn, + isUnitType, + typeEquals, + typeKey, +} from "../../../ir/ir.js"; +import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; +import { + bigintCompoundValue, + compoundUnsupported, + concatOperand, + constructorGlobalTypeof, + deferredSlotOr, + dynCompoundValue, + lowerAbsenceProbe, + lowerDynMemberAssignment, + lowerOptionalNumber, + lowerPresenceOperand, + nullishBranches, + operandPairs, + operatorUnsupported, + runtimeOptionalStorageOperand, + symbolFieldInfo, + typeofAnswer, +} from "../lower-exprs.js"; +import { COMPOUND_ASSIGN_OPS, type CompoundOp, NARROW_FIRST } from "../surfaces.js"; +import { lowerElementCompoundParts } from "./element-access.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { storedClassValueType } from "../lower-classes.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-module.js"; +import { errorToStringCall } from "../error-methods.js"; +import { classToStringConversion, classUnionToString } from "../class-to-primitive.js"; +import { isPerfEntryType, perfEntryTag } from "../builtins/performance.js"; +import { guardNullProto, nullProtoRecordShape } from "../null-prototype.js"; +import { numLit, varRef } from "../../../ir/build.js"; +import { lowerUrlAssignment } from "../builtins/url.js"; +import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; +import { lowerShortCircuitAssignment } from "./nullish-assignment.js"; +import { lowerEventObjectPropertyWrite } from "../lower-event-target.js"; +import { lowerFieldCompoundValue } from "./updates.js"; +import { expandoWritableTarget } from "../lower-expando.js"; +import { nsWritableTarget } from "../lower-namespaces.js"; +import { arrayValueStore, tryLowerBytesReadNumberEquality } from "../array-values.js"; +import { cjsExportMemberWriteGlobalOf, cjsInFileExportGlobalOf } from "../cjs-exports-object.js"; +import { lowerUnionFieldWrite } from "./union-fields.js"; +import { lowerAbstractEquality } from "../abstract-equality.js"; +import { lowerInExpression } from "./relational.js"; +import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js"; +import { builtinClassRefOf, sameBuiltinClass } from "../builtins/class-values.js"; +import { tryLowerIndexedComparison } from "../indexed-comparison.js"; +import { lowerUnionEquality, tagEqualityMayMissAlias } from "../strict-equality.js"; +import { withUnitArm } from "../../type-mapper.js"; +import { npmStaticPackageOfPath } from "../../npm-static.js"; +import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js"; + +/** `v === undefined` / `v !== null` — the narrowing tests for unit-armed + * unions. A union operand compared with a unit literal lowers to a + * runtime TAG test (unionIsTag); afterwards tsc's control-flow narrowing + * types the branches and reads bridge through maybeNarrow as usual. When + * the checker already narrowed the non-literal side PAST the union + * (`w = 5; if (w !== null)`), the comparison is statically decided and + * folds to a bool literal — that drops only a side-effect-free read + * (flow narrowing applies to references, never to calls), and soundness + * rests on the same narrowing proof unionNarrow relies on. Null when + * neither side is a unit literal (not a unit comparison). */ +export function lowerUnitComparison( + lowerer: Lowerer, + left: IrExpr, + right: IrExpr, + negated: boolean, + loc: SrcLoc, +): IrExpr | null { + const unit = left.kind === "unitLit" ? left : right.kind === "unitLit" ? right : null; + if (!unit) return null; + const other = unit === left ? right : left; + if (other.kind === "unitLit") { + // Two unit literals (`undefined === undefined`): statically decided. + return { kind: "boolLit", value: (other.unit === unit.unit) !== negated, type: BOOL, loc }; + } + const slot = lowerer.deferredFieldSlot(other); + if (slot) return lowerUnitComparison(lowerer, slot, unit, negated, loc); + // A binding the checker narrowed past the unit can still hold it at + // runtime when its value came from an unassigned deferred-init field; + // the stored tag answers either way. + if ( + other.kind === "unionNarrow" && + other.value.kind === "varRef" && + other.value.type.kind === "union" && + lowerer.armTag(other.value.type.unionId, unit.type) >= 0 + ) + return lowerUnitComparison(lowerer, other.value, unit, negated, loc); + if (other.type.kind === "union") { + const tag = lowerer.armTag(other.type.unionId, unit.type); + if (tag < 0) { + // A union WITHOUT that unit arm: legal TS (`miss === null` on a + // `number | undefined` — null/undefined guard comparisons are + // permitted against nullable-adjacent types), and === never + // coerces, so the answer is the constant `negated`. The literal + // must NOT flow into the union representation (the unionEq + // fallback would coerce it through the stranded-arm trap and + // throw where Node answers false). JS still evaluates the + // operand, so a non-droppable one (`xs.find(f) === null` — the + // callback's effects are observable) rides one throwaway tag + // test; droppable reads fold to the bare literal. + const answer: IrExpr = { kind: "boolLit", value: negated, type: BOOL, loc }; + if (isSafeToDiscard(other)) return answer; + const evalOnce: IrExpr = { + kind: "unionIsTag", + unionId: other.type.unionId, + tag: 0, + negated: false, + value: other, + type: BOOL, + loc, + }; + return { + kind: "logical", + op: negated ? "||" : "&&", + left: evalOnce, + right: answer, + type: BOOL, + loc, + }; + } + return { + kind: "unionIsTag", + unionId: other.type.unionId, + tag, + negated, + value: other, + type: BOOL, + loc, + }; + } + // A process.env read tsc narrowed past its union (an earlier write to + // the same key): the environment is VOLATILE — `delete process.env.K` + // undoes the write the narrowing rode on — so folding would bake a + // stale answer. Compare the fresh read's union tag instead. + const envRead = volatileEnvRead(other); + if (envRead && envRead.type.kind === "union") { + const tag = lowerer.armTag(envRead.type.unionId, unit.type); + if (tag >= 0) { + return { + kind: "unionIsTag", + unionId: envRead.type.unionId, + tag, + negated, + value: envRead, + type: BOOL, + loc, + }; + } + } + // A VOID-typed operand (`foo() === undefined` where foo's declared + // return is undefined/void — the mapping folds both to void): JS + // yields undefined from such a call, so the compare is TRUE-when-equal + // — the opposite of the never-holds-units fold below — and the IR has + // no value (nor a sequence form to keep the call's effects). Fall + // through to the caller's comparison fence instead of folding a lie. + if (other.type.kind === "void") return null; + // Non-union operand: it can never hold undefined/null at runtime (the + // checker narrowed it to a concrete arm), so `=== unit` is false and + // `!== unit` is true. + return { kind: "boolLit", value: negated, type: BOOL, loc }; +} + +/** The union-typed process.envGet read inside a checker-narrowed operand, + * or null — the volatility escape above (and lowerLooseNullCompare's). */ +function volatileEnvRead(e: IrExpr): IrExpr | null { + if (e.kind === "libCall" && e.fn === "process.envGet") return e; + if (e.kind === "unionNarrow" && e.value.kind === "libCall" && e.value.fn === "process.envGet") { + return e.value; + } + return null; +} + +function lowerLooseNullCompare( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken; + const unwrap = (e: ts.Expression): ts.Expression => + ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e; + const left = unwrap(expr.left); + const right = unwrap(expr.right); + const leftIsNull = left.kind === ts.SyntaxKind.NullKeyword; + if (!leftIsNull && right.kind !== ts.SyntaxKind.NullKeyword) return null; + const otherNode = leftIsNull ? right : left; + const other = lowerPresenceOperand(lowerer, otherNode); + if (isUnitType(other.type)) { + // `null == null`, `undefined == null`: units are mutually loose-equal. + return { kind: "boolLit", value: !negated, type: BOOL, loc }; + } + if (other.type.kind === "dyn") { + // `v != null` on unknown: one dyn kind test covers both units. + return { + kind: "dynTest", + test: "nullish", + ...(negated ? { negated: true as const } : {}), + value: other, + type: BOOL, + loc, + }; + } + if (other.type.kind === "union") { + const ut = other.type; + const def = lowerer.unions.get(ut.unionId); + if (!def) lowerer.badType(otherNode, lowerer.typeOf(otherNode)); + const tags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); + if (tags.length === 0) { + // No unit arms: never null-ish (defensive — the fold below). + return { kind: "boolLit", value: negated, type: BOOL, loc }; + } + const isTag = (tag: number): IrExpr => ({ + kind: "unionIsTag", + unionId: ut.unionId, + tag, + negated, + value: other, + type: BOOL, + loc, + }); + if (tags.length === 1) return isTag(tags[0]!); + // Both null AND undefined arms: tag-in-set as a short-circuit pair + // (De Morgan for `!=`). The operand IrExpr rides both tests, so the + // backend emits it per test — restricted to re-emittable pure reads; + // anything effectful gets its own actionable fence (the generic + // SC1040 hint would wrongly suggest `== null` as the fix). + if (!isSafeToRepeat(other)) { + lowerer.unsupported( + "SC1090", + expr, + `'== null' / '!= null' on a '${lowerer.fmt(ut)}' value that isn't a plain read ` + + `(both unit arms need the operand twice — bind it to a const first)`, + ); + } + return { + kind: "logical", + op: negated ? "&&" : "||", + left: isTag(tags[0]!), + right: isTag(tags[1]!), + type: BOOL, + loc, + }; + } + if (other.type.kind === "void") { + return null; // no static tag to test — keep the fence + } + // The env-volatility escape (see lowerUnitComparison): a narrowed + // process.env read compares its FRESH union tag instead of folding. + const envRead = volatileEnvRead(other); + if (envRead && envRead.type.kind === "union") { + const def = lowerer.unions.get(envRead.type.unionId); + const tag = def ? def.arms.findIndex((a) => isUnitType(a)) : -1; + if (tag >= 0) { + return { + kind: "unionIsTag", + unionId: envRead.type.unionId, + tag, + negated, + value: envRead, + type: BOOL, + loc, + }; + } + } + // A non-nullable operand (tsc allows the comparison as a guard): + // `== null` is statically false, `!= null` statically true. + return { kind: "boolLit", value: negated, type: BOOL, loc }; +} + +/** A plain value read that may be runtime-optional: an unchecked element + * read, or a binding, member or call that carries one. Lowering these + * without a destination type is the same as lowering them into one. */ +function isPlainValueRead(node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return ( + ts.isElementAccessExpression(node) || + ts.isIdentifier(node) || + ts.isPropertyAccessExpression(node) || + ts.isCallExpression(node) + ); +} + +/** A member access or call receiver needs the present value itself (JS + * throws a TypeError reading a member of undefined). */ +function isMemberReceiver(node: ts.Expression): boolean { + let child: ts.Node = node; + let parent = node.parent; + while (ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + return ( + ((ts.isPropertyAccessExpression(parent) || + ts.isElementAccessExpression(parent) || + ts.isCallExpression(parent)) && + parent.expression === child) || + (ts.isTaggedTemplateExpression(parent) && parent.tag === child) + ); +} + +export function lowerNullishPair( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, + left: IrExpr, +): IrExpr { + if (left.type.kind === "dyn") { + // `a ?? b` on a CHECKED-DYNAMIC left: the deciding test is the + // runtime kind (scr_dyn_is_nullish — UNDEF/NULL take the default). + // Both sides live in the checked-dynamic tree — the right converts through + // the usual boundary and evaluates lazily in its branch; a default + // with no dyn representation keeps the fence. + const right = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); + if (right.type.kind !== "dyn") { + lowerer.unsupported( + "SC1100", + expr, + "nullish coalescing on 'unknown' values against defaults with no dynamic representation", + ); + } + return { kind: "nullish", left, right, type: DYN, loc }; + } + + if (left.type.kind !== "union") return left; + const def = lowerer.unions.get(left.type.unionId); + if (!def) lowerer.badType(expr.left, lowerer.typeOf(expr.left)); + if (!def.arms.some(isUnitType)) return left; + const rest = def.arms.filter((a) => !isUnitType(a)); + // `optionalMap ?? new Map()` carries its element contract on the left; + // the empty fallback may use it without constructing a Map + // arm that has no first-class union representation. + const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; + if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; + let type = lowerer.irTypeOf(expr); + // A runtime-optional read can reach the default even when declarations + // call the left side present. Preserve a differently typed primitive + // fallback rather than checking it against that incomplete result type. + const rightType = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); + const primitive = (t: IrType): boolean => + t.kind === "f64" || t.kind === "string" || t.kind === "bool"; + if ( + rightType && + primitive(type) && + primitive(rightType) && + !typeEquals(type, rightType) && + rest.every(primitive) + ) { + const arms = [...rest]; + if (!arms.some((arm) => typeEquals(arm, rightType))) arms.push(rightType); + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + type = { kind: "union", unionId: lowerer.unions.intern(arms) }; + lowerer.runtimeOptionalArithmeticTypes.set(expr, type); + } + if (type.kind === "dyn" && lowerer.dynConvertible(left.type)) { + return { + kind: "nullish", + left: lowerer.coerceToExpected(left, DYN), + right: lowerer.lowerExprExpecting(expr.right, DYN), + type: DYN, + loc, + }; + } + // A runtime-optional default (`name ?? names[0]`): the checker types an + // unchecked element read as the bare element type, but an out-of-range + // read is undefined at runtime. When the default shares the left's + // optional union, that union is the result, so the undefined state + // reaches the consumer (an optional return or parameter takes it as its + // undefined arm; a plain slot still extracts the element with its + // checked narrow). + if ( + rest.length === 1 && + typeEquals(type, rest[0]!) && + isPlainValueRead(expr.right) && + !isMemberReceiver(expr) + ) { + const raw = lowerer.lowerExpr(expr.right); + if (typeEquals(raw.type, left.type)) + return { kind: "nullish", left, right: raw, type: left.type, loc }; + // `names[0] ?? undefined`: an explicit unit default stays the left's arm. + if (isUnitType(raw.type) && lowerer.armTag(left.type.unionId, raw.type) >= 0) { + const right = lowerer.coerceInto(expr.right, raw, left.type); + return { kind: "nullish", left, right, type: left.type, loc }; + } + return { kind: "nullish", left, right: lowerer.coerceInto(expr.right, raw, type), type, loc }; + } + if (typeEquals(type, left.type) || (rest.length === 1 && typeEquals(type, rest[0]!))) { + const right = lowerer.lowerExprExpecting(expr.right, type); + return { kind: "nullish", left, right, type, loc }; + } + if (type.kind !== "union" && rest.length > 1 && rest.some((arm) => typeEquals(arm, type))) { + const helper = lowerer.narrowedArmHelper(left.type.unionId, type, loc); + if (helper !== null) { + return nullishBranches( + lowerer, + left, + left.type, + def.arms, + type, + loc, + () => lowerer.lowerExprExpecting(expr.right, type), + (stable) => ({ kind: "call", callee: helper, args: [stable], type, loc }), + ); + } + } + // A subclass value defaulted to a base-class value (`circle ?? shape`): + // the present arm upcasts after the original nullish test, and the + // default lowers lazily into the base-class result. + const present = rest.length === 1 ? rest[0]! : null; + if ( + present?.kind === "object" && + type.kind === "object" && + present.className !== type.className && + lowerer.isSubclassOf(present.className, type.className) + ) { + const helper = lowerer.narrowedArmHelper(left.type.unionId, present, loc); + if (helper !== null) { + return nullishBranches( + lowerer, + left, + left.type, + def.arms, + type, + loc, + () => lowerer.lowerExprExpecting(expr.right, type), + (stable) => ({ + kind: "upcast", + value: { kind: "call", callee: helper, args: [stable], type: present, loc }, + type, + loc, + }), + ); + } + } + // A default may change the union or widen its records. Test the + // original tag before coercing: converting the whole left first can + // lose nullish arms, and evaluating the default eagerly loses laziness. + if (type.kind === "union") { + const retag = + lowerer.unionRetagHelper(left.type.unionId, type.unionId, loc) ?? + lowerer.narrowedRetagHelper(expr.left, left.type.unionId, type.unionId, loc); + if (retag !== null) { + // Narrowing may exclude a record/class arm while storage retains + // it. The checked retag traps that impossible arm, but only AFTER + // the original nullish test: null/undefined must reach the lazy + // default even when neither is representable in the result. + return nullishBranches( + lowerer, + left, + left.type, + def.arms, + type, + loc, + () => lowerer.lowerExprExpecting(expr.right, type), + (stable) => ({ kind: "call", callee: retag, args: [stable], type, loc }), + ); + } + } + lowerer.unsupported( + "SC1090", + expr, + rest.length !== 1 + ? `'??' on '${lowerer.fmt(left.type)}' (the non-nullish result is a sub-union; check a discriminant field first)` + : `'??' where the default changes the result type (left is '${lowerer.fmt(left.type)}' but the whole expression is '${lowerer.fmt(type)}' — give both sides one type)`, + ); +} + +/** `a[i] op= rhs` over native arrays and byte views. A compound assignment + * evaluates the receiver and index, reads the old element, evaluates rhs, + * then writes. Capture each stage so a call in the index or rhs cannot + * change the target or the value used by the operator. The expression yields + * the computed value before a typed array or Buffer coerces it for storage. */ +export function lowerElementCompound( + lowerer: Lowerer, + expr: ts.BinaryExpression, + op: CompoundOp, +): IrExpr { + const lowered = lowerElementCompoundParts(lowerer, expr, op, false); + if (!lowered.expr) + throw new InternalCompilerError("a compound element value lowered to statements"); + return lowered.expr; +} + +/** JavaScript callable members live on the closure, so aliases and chained + * writes share the same storage as Object.defineProperties. */ +export function lowerNativeFunctionAssignment( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const target = expr.left; + if ( + !isJsSourceFile(expr.getSourceFile()) || + (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) || + target.questionDotToken + ) + return null; + // Checked constructors share the callable's live property descriptors. + const classType = storedClassValueType(lowerer, target.expression); + const staticClass = + classType?.kind === "classval" ? lowerer.classes.get(classType.className) : undefined; + if (staticClass && hasRuntimeStatics(staticClass)) { + return lowerDynMemberAssignment( + lowerer, + expr, + lowerer.lowerExprExpecting(target.expression, DYN), + ); + } + // Other class statics and prototype tables have their own paths. + // Probing them as ordinary functions could materialize unused methods. + if ( + lowerer.exactClassOfReceiver(target.expression) || + (ts.isPropertyAccessExpression(target.expression) && + target.expression.name.text === "prototype" && + lowerer.exactClassOfReceiver(target.expression.expression)) + ) + return null; + const receiver = tryLowerExpression(lowerer, target.expression); + if ( + receiver?.type.kind !== "func" || + !canBoxFuncIntoDyn( + receiver.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return null; + fenceNodeModuleMutation(lowerer, target, "assignment"); + const fnName = jsFuncNameOf(target.expression); + const boxed: IrExpr = { + kind: "dynFrom", + value: receiver, + type: DYN, + loc: locOf(target.expression), + ...(fnName !== null ? { fnName } : {}), + }; + return lowerDynMemberAssignment(lowerer, expr, boxed); +} + +/** `typeof globalThis.AbortSignal.any === "function"`: the comparison + * folds with constructorGlobalTypeof's answer. */ +function lowerConstructorGlobalTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; + const answer = constructorGlobalTypeof(lowerer, a.expression); + if (answer !== null) + return { kind: "boolLit", value: (answer === b.text) !== negated, type: BOOL, loc }; + } + return null; +} + +export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { + if (e.type.kind === "string") return e; + if (e.type.kind === "url") + return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc }; + // String(date) / `${date}` / "" + date: Date's ToPrimitive prefers + // toString(). + if (e.type.kind === "date") + return { kind: "libCall", fn: "date.toString", args: [e], type: STRING, loc: e.loc }; + if (e.type.kind === "regex") { + return { + kind: "regexIntrinsic", + method: "toString", + receiver: e, + args: [], + type: STRING, + loc: e.loc, + }; + } + if (e.type.kind === "bigint") { + return { + kind: "libCall", + fn: "bigint.toString", + args: [e, { kind: "numLit", value: 10, type: F64, loc: e.loc }], + type: STRING, + loc: e.loc, + }; + } + if (e.type.kind === "dyn") { + // String(u) / `${u}`: a runtime dispatch over the dyn kind — Node's + // String() exactly (undefined/null texts, JS number formatting, + // strings verbatim, arrays via join, objects as "[object Object]"). + return { kind: "toString", operand: e, type: STRING, loc: e.loc }; + } + if (e.type.kind === "bytes") { + // Preserve the runtime brand: typed arrays join numeric elements, + // while a Buffer stored in a Uint8Array slot decodes its bytes. + const boxed: IrExpr = { kind: "dynFrom", value: e, type: DYN, loc: e.loc }; + return { kind: "toString", operand: boxed, type: STRING, loc: e.loc }; + } + + if (e.type.kind === "object") { + // String(err) / `${err}` uses the same dispatch as err.toString(). + for (let c = lowerer.classes.get(e.type.className) ?? null; c; c = c.base) { + if (c.builtinError) { + return errorToStringCall(lowerer, e); + } + } + // A program class instance converts through its own methods. + const converted = classToStringConversion(lowerer, e, "string", node); + if (converted) return converted; + } + if (e.type.kind === "union") { + // `${u}` — the arm's ToString via a per-union interned helper + // (sc_us_*), Node-exact per arm kind: unit arms are the known texts + // "undefined"/"null", string arms pass through, f64/bool arms use the + // JS-exact formatters. Ref arms (records, arrays, ...) stay fenced: + // JS would print "[object Object]" and friends — narrow first. + const def = lowerer.unions.get(e.type.unionId); + const stringable = def?.arms.every( + (a) => + a.kind === "undefinedT" || + a.kind === "nullT" || + a.kind === "string" || + a.kind === "f64" || + a.kind === "bigint" || + a.kind === "bool", + ); + if (stringable) { + return { kind: "toString", operand: e, type: STRING, loc: e.loc }; + } + // Runtime-optional locals can retain their stored union after the + // checker has narrowed this use to a primitive arm. Validate that + // arm before converting it; an unguarded object arm stays fenced. + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + if ( + narrowed && + (narrowed.kind === "string" || + narrowed.kind === "f64" || + narrowed.kind === "bool" || + narrowed.kind === "bigint") + ) { + const helper = lowerer.narrowedArmHelper(e.type.unionId, narrowed, e.loc); + if (helper) { + return ensureString(lowerer, lowerer.checkedNarrowCall(helper, e, narrowed, e.loc), node); + } + } + if ( + narrowed?.kind === "union" && + !typeEquals(narrowed, e.type) && + lowerer.unions + .get(narrowed.unionId) + ?.arms.every( + (arm) => + isUnitType(arm) || + arm.kind === "string" || + arm.kind === "f64" || + arm.kind === "bool" || + arm.kind === "bigint", + ) + ) { + const helper = lowerer.narrowedRetagHelper(node, e.type.unionId, narrowed.unionId, e.loc); + if (helper) + return ensureString( + lowerer, + { kind: "call", callee: helper, args: [e], type: narrowed, loc: e.loc }, + node, + ); + } + const viaClasses = classUnionToString(lowerer, e, "string", node); + if (viaClasses) return viaClasses; + lowerer.unsupported( + "SC1090", + node, + `string conversions of unions with object arms (${NARROW_FIRST})`, + ); + } + if (e.type.kind === "symbol") { + // JS splits here: `${sym}` and concatenation THROW a TypeError, + // String(sym) answers "Symbol(desc)". One shared lowering cannot + // honor both — fence with the sanctioned spelling instead of + // guessing the context. + lowerer.unsupported( + "SC1090", + node, + "string conversions of symbol values (template literals throw in JS — call .toString() explicitly)", + ); + } + if (isUnitType(e.type)) { + if (e.kind === "unitLit") { + return { kind: "strLit", value: e.unit, type: STRING, loc: e.loc }; + } + // Keep sequencing and branch effects even though the final unit's + // spelling is constant. Never turn an unknown producer into a literal. + if (e.kind === "seqExpr") { + return { ...e, result: lowerer.ensureString(e.result, node), type: STRING }; + } + if (e.kind === "ternary") { + return { + ...e, + then: lowerer.ensureString(e.then, node), + else_: lowerer.ensureString(e.else_, node), + type: STRING, + }; + } + lowerer.unsupported( + "SC1090", + node, + `string conversions of '${e.type.kind === "undefinedT" ? "undefined" : "null"}' values`, + ); + } + if (e.type.kind === "array") { + // `${[1,2,3]}` / String(arr): Array.prototype.toString IS join(",") + // — the SAME intrinsic the .join() lowering emits, fenced to the + // same element kinds (f64/string/bool, unions of those with unit + // arms printing empty). Nested arrays would need JS's recursive + // dispatch — the join fence already tells that story. + const elem = e.type.elem; + const joinableUnion = + elem.kind === "union" && + (lowerer.unions + .get(elem.unionId) + ?.arms.every( + (a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a), + ) ?? + false); + if (elem.kind === "f64" || elem.kind === "string" || elem.kind === "bool" || joinableUnion) { + const sep: IrExpr = { kind: "strLit", value: ",", type: STRING, loc: e.loc }; + return { + kind: "arrIntrinsic", + method: "join", + receiver: e, + args: [sep], + type: STRING, + loc: e.loc, + }; + } + } + if (e.type.kind === "record") { + // `${obj}` / String(obj): a plain data record has no toString of its + // own (a func-typed `toString` FIELD would shadow the prototype's — + // JS would call it, so that shape keeps the fence), and a TUPLE + // prints its elements like an array (not lowered — fenced) — every + // other record is Object.prototype.toString's constant. + const shape = lowerer.shapes.get(e.type.shapeId); + if (isPerfEntryType(lowerer, e.type)) return perfEntryTag(lowerer, e, e.type, e.loc); + if (shape && !shape.tuple && !shape.fields.some((f) => f.name === "toString")) { + // A null-prototype record (Object.groupBy) has no toString or + // valueOf: ToPrimitive throws Node's TypeError. + const nullProtoT = nullProtoRecordShape(lowerer, e.type); + if (nullProtoT) return nullProtoToString(lowerer, e, nullProtoT); + return { kind: "toString", operand: e, type: STRING, loc: e.loc }; + } + } + if (e.type.kind !== "f64" && e.type.kind !== "bool") lowerer.badType(node, lowerer.typeOf(node)); + return { kind: "toString", operand: e, type: STRING, loc: e.loc }; +} + +/** String(r) over a record that may have a null prototype: an interned + * helper that throws Node's TypeError for a null-prototype record and + * answers "[object Object]" otherwise. */ +function nullProtoToString(lowerer: Lowerer, e: IrExpr, t: IrType & { kind: "record" }): IrExpr { + const loc = e.loc; + const key = `nullProtoToString:${t.shapeId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%rec.tostr.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const r = varRef("r.0", t, loc); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "r.0", name: "r", type: t }], + returnType: STRING, + locals: [{ id: "r.0", name: "r", type: t, mutable: false }], + body: [ + { + kind: "return", + value: guardNullProto( + r, + t.shapeId, + "Cannot convert object to primitive value", + { kind: "toString", operand: r, type: STRING, loc }, + loc, + ), + loc, + }, + ], + loc, + }); + } + return { kind: "call", callee: helper, args: [e], type: STRING, loc }; +} + +/** Whether a lowered statement can live inside a seqExpr. These statements + * do not leave the expression through a jump; the native emitters can place + * their writes and local state branches before evaluating the final result. + * Blocks check their bodies recursively. */ +function seqExprSafeStmt(s: IrStmt): boolean { + switch (s.kind) { + case "varDecl": + case "assign": + case "exprStmt": + case "fieldSet": + case "recordSet": + case "recordKeySet": + case "arraySet": + case "arraySetLength": + case "bytesSet": + case "arraySetUndefined": + case "arrayDelete": + return true; + case "if": + return s.then.every(seqExprSafeStmt) && (s.else_?.every(seqExprSafeStmt) ?? true); + case "block": + return s.body.every(seqExprSafeStmt); + default: + return false; + } +} + +export function lowerCompoundValueToTarget( + lowerer: Lowerer, + expr: ts.BinaryExpression, + compound: CompoundOp, + target: { id: string; type: IrType }, +): IrExpr { + const loc = locOf(expr); + const read: IrExpr = { + kind: "varRef", + localId: target.id, + type: target.type, + loc: locOf(expr.left), + }; + const rhs = lowerer.lowerExpr(expr.right); + const numericRead = lowerOptionalNumber(lowerer, read, loc); + const numericRhs = lowerOptionalNumber(lowerer, rhs, loc); + + if ( + compound === "+" && + (target.type.kind === "string" || + (target.type.kind === "union" && + lowerer.stripUndefinedArm(target.type).kind === "string" && + rhs.type.kind === "string")) + ) { + return lowerer.coerceInto( + expr, + { + kind: "strConcat", + left: lowerer.ensureString(read, expr.left), + right: concatOperand(lowerer, rhs, expr.right), + type: STRING, + loc, + }, + target.type, + ); + } + if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { + return lowerer.coerceInto( + expr, + { kind: "bin", op: compound, left: numericRead, right: numericRhs, type: F64, loc }, + target.type, + ); + } + // An untyped operand: exactly the expanded `x = x op rhs` — the runtime + // operator on both values, then the checked conversion into the + // target's declared type (a string result for a number target throws the + // catchable TypeError, as the expanded assignment does). + if ( + (target.type.kind === "dyn" || rhs.type.kind === "dyn") && + (isJsSourceFile(expr.getSourceFile()) || + (lowerer.dynConvertible(read.type) && lowerer.dynConvertible(rhs.type))) + ) { + const left = lowerer.coerceInto(expr.left, read, DYN); + const right = lowerer.coerceInto(expr.right, rhs, DYN); + return lowerer.coerceInto(expr, dynCompoundValue(compound, left, right, loc), target.type); + } + const bigint = bigintCompoundValue(compound, read, rhs, loc); + if (bigint) return bigint; + compoundUnsupported(lowerer, expr, compound, read.type, rhs.type); +} + +/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may + * stand for undefined and never holds a genuine NaN: Node's undefined equals + * undefined, so two NaNs compare equal here. Operands evaluate once, in + * order. */ +function lowerNanCodedEquality(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { + const loc = locOf(expr); + const left = lowerer.lowerExpr(expr.left); + const right = lowerer.lowerExpr(expr.right); + if (left.type.kind !== "f64" || right.type.kind !== "f64") { + throw new InternalCompilerError("NaN-coded equality operand is not a plain number"); + } + const stmts: IrStmt[] = []; + const stable = (value: IrExpr, name: string): IrExpr => { + if (value.kind === "varRef" || value.kind === "numLit") return value; + const tmp = lowerer.declareHiddenLocal(name, F64); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, F64, loc); + }; + const a = stable(left, "%eqLeft"); + const b = stable(right, "%eqRight"); + const isNaN = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "num.isNaN", + args: [value], + type: BOOL, + loc, + }); + const bothUndefined: IrExpr = { + kind: "logical", + op: "&&", + left: isNaN(a), + right: isNaN(b), + type: BOOL, + loc, + }; + const op = expr.operatorToken.kind; + const negated = + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + const result: IrExpr = negated + ? { + kind: "logical", + op: "&&", + left: { kind: "bin", op: "!==", left: a, right: b, type: BOOL, loc }, + right: { kind: "unary", op: "!", operand: bothUndefined, type: BOOL, loc }, + type: BOOL, + loc, + } + : { + kind: "logical", + op: "||", + left: { kind: "bin", op: "===", left: a, right: b, type: BOOL, loc }, + right: bothUndefined, + type: BOOL, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; +} + +export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { + const loc = locOf(expr); + const op = expr.operatorToken.kind; + + const cacheHas = lowerRequireCacheHas(lowerer, expr); + if (cacheHas) return cacheHas; + + if (lowerer.nanCodedReads?.needsUndefinedEquality(expr)) { + const undefinedEquality = lowerNanCodedEquality(lowerer, expr); + if (undefinedEquality) return undefinedEquality; + } + + if ( + op === ts.SyntaxKind.EqualsToken || + (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) + ) { + const urlWrite = lowerUrlAssignment(lowerer, expr); + if (urlWrite) return urlWrite; + if (op === ts.SyntaxKind.EqualsToken) { + const prototypeWrite = lowerClassPrototypeComputedAssignment(lowerer, expr); + if (prototypeWrite) return prototypeWrite; + const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); + if (callableWrite) return callableWrite; + } + if ( + op === ts.SyntaxKind.QuestionQuestionEqualsToken || + op === ts.SyntaxKind.AmpersandAmpersandEqualsToken || + op === ts.SyntaxKind.BarBarEqualsToken + ) { + return lowerShortCircuitAssignment(lowerer, expr); + } + if (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) { + fenceNodeModuleMutation(lowerer, expr.left, "assignment"); + } + // The writable Event accessors on Event subclass instances, in value + // position (`const r = (event.returnValue = false)`). + if (op === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(expr.left)) { + const eventWrite = lowerEventObjectPropertyWrite(lowerer, expr.left, expr.right); + if (eventWrite) return eventWrite; + } + // `x = e` in EXPRESSION position (`while ((idx = s.indexOf("\n")) !== -1)`, + // `f(x = v)`): evaluate e once, write the binding, yield the assigned + // value — JS evaluation order. Variable targets only (locals and module + // globals, captured/boxed included); the RHS coerces into the binding's + // type exactly like statement position, and the expression's value is + // the coerced binding-typed value (representation change only — never + // observably different from JS's raw-RHS yield). Indexed compounds + // use a sequenced read/modify/write; other member targets and + // destructuring targets stay fenced. + if (op === ts.SyntaxKind.EqualsToken && ts.isIdentifier(expr.left)) { + const target = lowerer.resolveWritable(expr.left); + if (!target) { + lowerer.rejectUnresolved( + expr.left, + `assignment to '${expr.left.text}' (not a writable local or module global)`, + ); + } + const value = lowerer.lowerExprExpecting(expr.right, target.type); + const runtimeOptionalRoot = lowerer.runtimeOptionalRootOf(target); + if (lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot)) + lowerer.runtimeOptionalLocals.add(runtimeOptionalRoot); + return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; + } + const indexedCompound = COMPOUND_ASSIGN_OPS[op]; + if (indexedCompound !== undefined && ts.isElementAccessExpression(expr.left)) { + if (symbolFieldInfo(lowerer, expr.left)) + return lowerFieldCompoundValue(lowerer, expr.left, indexedCompound, expr.right, loc); + return lowerElementCompound(lowerer, expr, indexedCompound); + } + if (indexedCompound !== undefined && ts.isIdentifier(expr.left)) { + const target = lowerer.resolveWritable(expr.left); + if (!target) { + lowerer.rejectUnresolved(expr.left, "assignment to an unresolved variable"); + } + const value = lowerCompoundValueToTarget(lowerer, expr, indexedCompound, target); + return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; + } + if ( + indexedCompound !== undefined && + (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) && + !(ts.isPropertyAccessExpression(expr.left) && expr.left.questionDotToken) + ) { + const target = + expandoWritableTarget(lowerer, expr.left) ?? + (ts.isPropertyAccessExpression(expr.left) ? nsWritableTarget(lowerer, expr.left) : null); + if (target) { + const value = lowerCompoundValueToTarget(lowerer, expr, indexedCompound, target); + return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; + } + return lowerFieldCompoundValue(lowerer, expr.left, indexedCompound, expr.right, loc); + } + // `events.defaultMaxListeners = v` — the module-property write + // Node validates (validateNumber(n, 'defaultMaxListeners', 0)): + // the value crosses into the checked-dynamic tree and the runtime ladder throws + // ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE with Node's exact slot + // name; valid numbers apply. The expression's value is the RHS + // (JS's assignment yield). + if ( + op === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(expr.left) && + !expr.left.questionDotToken && + expr.left.name.text === "defaultMaxListeners" + ) { + const bi = lowerer.builtinMemberOf(expr.left); + if (bi && bi.module === "events" && bi.member === "defaultMaxListeners") { + const rhs = lowerer.lowerExpr(expr.right); + if (rhs.type.kind === "dyn" || rhs.kind === "unitLit" || lowerer.dynConvertible(rhs.type)) { + const valTmp = lowerer.declareHiddenLocal("%setVal", rhs.type); + const valRef = (): IrExpr => ({ + kind: "varRef", + localId: valTmp.id, + type: rhs.type, + loc, + }); + const dynVal: IrExpr = + rhs.type.kind === "dyn" + ? valRef() + : { kind: "dynFrom", value: valRef(), type: DYN, loc }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: valTmp.id, init: rhs, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "emitter.setDefaultMaxChk", + args: [ + dynVal, + { kind: "strLit", value: "defaultMaxListeners", type: STRING, loc }, + ], + type: VOID, + loc, + }, + loc, + }, + ], + result: valRef(), + type: rhs.type, + loc, + }; + } + } + } + // Member targets whose storage IS a module global — namespace + // members (`N.x`) and expando function members (`Foo.baz`): the + // same assignExpr, since the "member" is a variable. + if ( + op === ts.SyntaxKind.EqualsToken && + (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) && + !(ts.isPropertyAccessExpression(expr.left) && expr.left.questionDotToken) + ) { + const target = + expandoWritableTarget(lowerer, expr.left) ?? + (ts.isPropertyAccessExpression(expr.left) ? nsWritableTarget(lowerer, expr.left) : null); + if (target) { + const value = lowerer.lowerExprExpecting(expr.right, target.type); + return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; + } + // Bundled numeric enums use e[e["name"] = value] = "name". + // Capture the receiver and key before the RHS. PutValue coerces the + // key afterward, then yields the RHS without rereading the property. + if (ts.isElementAccessExpression(expr.left) && !expr.left.questionDotToken) { + const recv = tryLowerExpression(lowerer, expr.left.expression); + if ( + recv && + (recv.type.kind === "dyn" || + (recv.type.kind === "array" && + isJsSourceFile(expr.getSourceFile()) && + lowerer.dynConvertible(recv.type))) + ) { + return lowerDynMemberAssignment( + lowerer, + expr, + lowerer.coerceInto(expr.left.expression, recv, DYN), + ); + } + if (recv?.type.kind === "array" || recv?.type.kind === "bytes") { + const receiver = lowerer.declareHiddenLocal("%setArray", recv.type); + const index = lowerer.lowerExpr(expr.left.argumentExpression); + if (index.type.kind !== "f64") + lowerer.unsupported( + "SC1090", + expr.left.argumentExpression, + "indexing with non-number keys", + ); + const key = lowerer.declareHiddenLocal("%setIndex", F64); + const raw = lowerer.lowerExpr(expr.right); + const value = lowerer.declareHiddenLocal("%setValue", raw.type); + const result = varRef(value.id, raw.type, loc); + const arr = varRef(receiver.id, recv.type, loc); + const idx = varRef(key.id, F64, loc); + const write: IrStmt = + recv.type.kind === "array" + ? arrayValueStore(lowerer, arr, idx, result, recv.type.elem, loc) + : { + kind: "bytesSet", + arr, + index: idx, + value: lowerer.coerceInto(expr.right, result, F64), + loc, + }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiver.id, init: recv, loc }, + { kind: "varDecl", localId: key.id, init: index, loc }, + { kind: "varDecl", localId: value.id, init: raw, loc }, + write, + ], + result, + type: result.type, + loc, + }; + } + } + + // `h.k = v` on a CHECKED-DYNAMIC receiver in VALUE position + // (`var _ = module.exports = foo`): receiver first, then RHS + // (JS's reference-before-value order), the keyed write, and the + // RHS value is the expression's value — its OWN type, so the + // consumer sees exactly what the checker typed. + if (ts.isPropertyAccessExpression(expr.left) && !expr.left.questionDotToken) { + // `const src = exports.src = []`: an export member assigned in + // value position writes its export global and yields the value. + { + let inFunction = false; + for (let p: ts.Node | undefined = expr.parent; p !== undefined; p = p.parent) { + if (ts.isFunctionLike(p) || ts.isClassDeclaration(p) || ts.isClassExpression(p)) { + inFunction = true; + break; + } + } + const g = inFunction + ? cjsExportMemberWriteGlobalOf(lowerer, expr.left) + : cjsInFileExportGlobalOf(lowerer, expr.left); + if (g) { + const value = lowerer.lowerExprExpecting(expr.right, g.type); + const tmp = lowerer.declareHiddenLocal("%exportValue", value.type); + const result = varRef(tmp.id, value.type, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: tmp.id, init: value, loc }, + { kind: "assign", localId: g.id, value: result, loc }, + ], + result, + type: value.type, + loc, + }; + } + } + const recv = tryLowerExpression(lowerer, expr.left.expression); + if (recv && recv.type.kind === "dyn") return lowerDynMemberAssignment(lowerer, expr, recv); + const unionWrite = lowerUnionFieldWrite(lowerer, expr.left, expr.right); + if (unionWrite) return unionWrite; + const field = lowerer.fieldTarget(expr.left); + if (field) { + const recvTmp = lowerer.declareHiddenLocal("%setReceiver", field.obj.type); + const receiver = field.obj; + field.obj = varRef(recvTmp.id, receiver.type, loc); + const value = lowerer.lowerExprExpecting(expr.right, field.fieldType); + const valTmp = lowerer.declareHiddenLocal("%setValue", value.type); + const result = varRef(valTmp.id, value.type, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: recvTmp.id, init: receiver, loc }, + { kind: "varDecl", localId: valTmp.id, init: value, loc }, + lowerer.fieldSetStmt(field, result, loc, expr.left), + ], + result, + type: result.type, + loc, + }; + } + } + } + // Destructuring assignment in VALUE position (`(() => [i] = [i+1])()`, + // `({} = {x} = a)`, `var d = ([] = src)`): the statement machinery's + // parts under a seqExpr — the expression's value is the RHS value + // (JS's GetValue of the right reference), which is exactly the + // parts' hidden temp. + if ( + op === ts.SyntaxKind.EqualsToken && + (ts.isObjectLiteralExpression(expr.left) || ts.isArrayLiteralExpression(expr.left)) + ) { + const parts = lowerer.lowerDestructuringAssignParts(expr.left, expr.right, loc); + return { + kind: "seqExpr", + stmts: parts.stmts, + result: parts.value, + type: parts.value.type, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + op === ts.SyntaxKind.EqualsToken + ? "assignment to non-variables as an expression (only `x = e` over a variable yields a value; write property/destructuring assignments as statements)" + : "compound assignment as an expression (write `x op= e` as a statement, or spell out `x = x op e`)", + ); + } + if (op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken) { + const nullTest = lowerLooseNullCompare(lowerer, expr, loc); + if (nullTest) return nullTest; + const loose = lowerAbstractEquality( + lowerer, + deferredSlotOr(lowerer, lowerer.lowerExpr(expr.left)), + deferredSlotOr(lowerer, lowerer.lowerExpr(expr.right)), + op === ts.SyntaxKind.ExclamationEqualsToken, + loc, + ); + if (loose) return loose; + lowerer.unsupported( + "SC1040", + expr, + undefined, + "object-to-primitive loose equality can call user-defined valueOf/toString methods — compare explicit primitive conversions instead", + ); + } + if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) { + return lowerLogicalChain(lowerer, expr); + } + if (op === ts.SyntaxKind.QuestionQuestionToken) { + return lowerer.lowerNullishCoalesce(expr, loc); + } + if (op === ts.SyntaxKind.CommaToken) { + // Comma expression in VALUE position (`r = ({} = a, [] = a)` — the + // conformance corpus's paired-destructuring idiom): the left operand + // runs for EFFECT exactly as its statement lowering (JS discards its + // value), the right operand is the expression's value — a seqExpr. + // The validator restricts seqExpr statements to straight-line writes + // (the lowering point is mid-expression), so a left operand whose + // statement lowering needs real control flow keeps a pointed fence + // instead of tripping the validator downstream. + const effect = lowerer.lowerExprStatement(expr.left); + if (!seqExprSafeStmt(effect)) { + lowerer.unsupported( + "SC1090", + expr.left, + "comma expressions whose left operand needs control flow (run it as its own statement first)", + ); + } + const result = lowerer.lowerExpr(expr.right); + return { kind: "seqExpr", stmts: [effect], result, type: result.type, loc }; + } + if (op === ts.SyntaxKind.InstanceOfKeyword) return lowerer.lowerInstanceOf(expr, loc); + if (op === ts.SyntaxKind.InKeyword) { + return lowerInExpression(lowerer, expr, loc); + } + // `typeof e === "string"` on a catch binding or an `unknown` value: a + // runtime kind test — intercepted BEFORE the operands lower (a raw + // read of the binding is the narrowness fence, and bare `typeof e` + // has no lowering). + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + const test = + lowerConstructorGlobalTypeofTest(lowerer, expr, loc) ?? + lowerWebAssemblyTypeofTest(lowerer, expr, loc) ?? + lowerErrorCodeTypeofTest(lowerer, expr, loc) ?? + lowerer.lowerCaughtTypeofTest(expr, loc) ?? + lowerDynTypeofTest(lowerer, expr, loc) ?? + lowerUnionTypeofTest(lowerer, expr, loc); + if (test) return test; + } + // `C === EventEmitter` between two builtin class references: the + // classes are compile-time values, so the answer folds. + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + const leftClass = builtinClassRefOf(lowerer, expr.left); + const rightClass = leftClass === null ? null : builtinClassRefOf(lowerer, expr.right); + if (leftClass !== null && rightClass !== null) { + const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + const same = sameBuiltinClass(leftClass, rightClass); + return { kind: "boolLit", value: same !== negated, type: BOOL, loc }; + } + } + + const strictEquality = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + // An identifier whose storage is runtime-optional is compared by its + // stored value: the checker's narrowing to the present arm is not a + // runtime proof. Identifier reads are pure, so taking the stored value + // after both operands lowered keeps the evaluation order. A union on + // the other side keeps the narrowed read (union-to-union comparison + // has no lowering). + let left = strictEquality + ? (lowerAbsenceProbe(lowerer, expr.left) ?? lowerer.lowerExpr(expr.left)) + : lowerer.lowerExpr(expr.left); + let right = strictEquality + ? (lowerAbsenceProbe(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right)) + : lowerer.lowerExpr(expr.right); + if (strictEquality) { + const storedLeft = runtimeOptionalStorageOperand(lowerer, expr.left); + const storedRight = runtimeOptionalStorageOperand(lowerer, expr.right); + if (storedLeft !== null && right.type.kind !== "union") left = storedLeft; + if (storedRight !== null && left.type.kind !== "union") right = storedRight; + // A typed-array read compared with a plain number needs no + // `number | undefined` box. + const fusedLeft = tryLowerBytesReadNumberEquality(lowerer, left, right, expr.right); + if (fusedLeft) left = fusedLeft; + else { + const fusedRight = tryLowerBytesReadNumberEquality(lowerer, right, left, expr.left); + if (fusedRight) right = fusedRight; + } + } + if (strictEquality && (left.type.kind === "caught" || right.type.kind === "caught")) { + if (left.type.kind === "caught") + left = { kind: "caughtToDyn", value: left, type: DYN, loc: left.loc }; + if (right.type.kind === "caught") + right = { kind: "caughtToDyn", value: right, type: DYN, loc: right.loc }; + } + if (left.type.kind === "dyn" || right.type.kind === "dyn") { + // The narrowing unit comparisons ARE answerable on unknown: `v === + // undefined` / `v !== null` test the dyn node's kind directly, and + // tsc's control flow narrows the branches (reads then bridge through + // maybeNarrow's validated extraction). + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; + const unit = left.kind === "unitLit" ? left : right.kind === "unitLit" ? right : null; + const other = left.kind === "unitLit" ? right : left; + if (unit && other.type.kind === "dyn") { + return { + kind: "dynTest", + test: unit.unit, + ...(negated ? { negated: true as const } : {}), + value: other, + type: BOOL, + loc, + }; + } + // dyn vs SCALAR strict equality (`value !== ""`, `u === 5` — the + // normalizePricing filter): a guarded kind test + payload compare, + // JS-exact (strict equality never coerces; a non-matching dyn kind + // answers false). dyn vs dyn keeps the fence below. + const dynSide = left.type.kind === "dyn" ? left : right; + const scalarSide = dynSide === left ? right : left; + const reference = scalarSide.type; + if ( + (reference.kind === "object" || + reference.kind === "record" || + reference.kind === "array" || + reference.kind === "bytes" || + reference.kind === "func" || + reference.kind === "classval" || + reference.kind === "regex" || + reference.kind === "url" || + reference.kind === "bigint" || + reference.kind === "symbol" || + reference.kind === "map" || + reference.kind === "set" || + reference.kind === "union" || + DYN_HANDLE_KINDS.has(reference.kind)) && + lowerer.dynConvertible(reference) + ) { + // Records, arrays and byte views compare by the capsule identity + // their dyn reads produce, including when they arrive through a + // union (an optional array field read from a typed record). + const liveArm = (type: IrType): boolean => { + if (type.kind === "array" || type.kind === "bytes") return true; + const shape = type.kind === "record" ? lowerer.shapes.get(type.shapeId) : undefined; + return !!shape && (shape.fields.length > 0 || shape.indexValue !== undefined); + }; + const boxed: IrExpr = { + kind: "dynFrom", + value: scalarSide, + type: DYN, + loc: scalarSide.loc, + ...(reference.kind === "record" || + liveArm(reference) || + (reference.kind === "union" && + (lowerer.unions.get(reference.unionId)?.arms.some(liveArm) ?? false)) + ? { liveRef: true as const } + : {}), + }; + return { + kind: "dynScalarEq", + left: scalarSide === left ? boxed : left, + right: scalarSide === right ? boxed : right, + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } + if ( + dynSide.type.kind === "dyn" && + (scalarSide.type.kind === "f64" || + scalarSide.type.kind === "string" || + scalarSide.type.kind === "bool" || + // dyn vs dyn (`context.actual !== context.exact` — + // test/common's exit accounting): the runtime's whole-dyn + // strict equality — scalars by value, units by kind, + // reference kinds by identity (scr_dyn_strict_eq). + scalarSide.type.kind === "dyn") + ) { + return { + kind: "dynScalarEq", + left, + right, + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } + } + const bitwise: Partial> = { + [ts.SyntaxKind.AmpersandToken]: "&", + [ts.SyntaxKind.BarToken]: "|", + [ts.SyntaxKind.CaretToken]: "^", + [ts.SyntaxKind.LessThanLessThanToken]: "<<", + [ts.SyntaxKind.GreaterThanGreaterThanToken]: ">>", + [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: ">>>", + }; + if (bitwise[op]) { + const l = lowerer.coerceToExpected(left, DYN); + const r = lowerer.coerceToExpected(right, DYN); + if (l.type.kind === "dyn" && r.type.kind === "dyn") { + const value: IrExpr = { + kind: "libCall", + fn: "dyn.bitwise", + args: [l, r, { kind: "strLit", value: bitwise[op]!, type: STRING, loc }], + type: DYN, + loc, + }; + return left.type.kind === "f64" || right.type.kind === "f64" + ? { kind: "dynCheck", value, type: F64, loc } + : value; + } + } + // Numeric operators dispatch on ToNumeric's actual Number/BigInt + // result, including values flowing through native callable properties. + if ( + isJsSourceFile(expr.getSourceFile()) || + op === ts.SyntaxKind.PlusToken || + [ + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.GreaterThanEqualsToken, + ].includes(op) || + [ + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ts.SyntaxKind.AsteriskAsteriskToken, + ].includes(op) + ) { + const other = left.type.kind === "dyn" ? right : left; + const NUM_BIN: Partial> = { + [ts.SyntaxKind.MinusToken]: "-", + [ts.SyntaxKind.AsteriskToken]: "*", + [ts.SyntaxKind.SlashToken]: "/", + [ts.SyntaxKind.PercentToken]: "%", + [ts.SyntaxKind.AsteriskAsteriskToken]: "**", + }; + const NUM_CMP: Partial" | ">=">> = { + [ts.SyntaxKind.LessThanToken]: "<", + [ts.SyntaxKind.LessThanEqualsToken]: "<=", + [ts.SyntaxKind.GreaterThanToken]: ">", + [ts.SyntaxKind.GreaterThanEqualsToken]: ">=", + }; + const arith = NUM_BIN[op]; + const cmp = NUM_CMP[op]; + if (arith) { + const l = lowerer.coerceToExpected(left, DYN); + const r = lowerer.coerceToExpected(right, DYN); + if (l.type.kind === "dyn" && r.type.kind === "dyn") { + const value: IrExpr = { + kind: "libCall", + fn: "dyn.arithmetic", + args: [l, r, { kind: "strLit", value: arith, type: STRING, loc }], + type: DYN, + loc, + }; + const numeric = + left.type.kind === "bigint" || right.type.kind === "bigint" + ? BIGINT_T + : left.type.kind === "f64" || right.type.kind === "f64" + ? F64 + : null; + return numeric ? { kind: "dynCheck", value, type: numeric, loc } : value; + } + } + if (cmp && lowerer.dynConvertible(left.type) && lowerer.dynConvertible(right.type)) { + return { + kind: "libCall", + fn: "dyn.compare", + args: [ + lowerer.coerceToExpected(left, DYN), + lowerer.coerceToExpected(right, DYN), + { kind: "strLit", value: cmp, type: STRING, loc }, + ], + type: BOOL, + loc, + }; + } + // Untyped addition chooses concatenation or numeric addition only + // after both operands have undergone ToPrimitive at runtime. + if (op === ts.SyntaxKind.PlusToken) { + const l = lowerer.coerceToExpected(left, DYN); + const r = lowerer.coerceToExpected(right, DYN); + if (l.type.kind === "dyn" && r.type.kind === "dyn") { + const value: IrExpr = { kind: "libCall", fn: "dyn.add", args: [l, r], type: DYN, loc }; + // A statically known string operand guarantees a string result. + return other.type.kind === "string" + ? { kind: "dynCheck", value, type: STRING, loc } + : value; + } + } + } + // Unsupported `any`-origin operations get their specific diagnostic; + // unknown retains the narrowing diagnostic below. + if ( + (left.type.kind === "dyn" && lowerer.anyOrigin(expr.left)) || + (right.type.kind === "dyn" && lowerer.anyOrigin(expr.right)) + ) { + // `JSON.parse` and other checked-dynamic producers deliberately + // remain dyn when assigned to an `any` local: eagerly converting + // the binding would deep-copy its data and sever aliases. At an + // unsupported operator, require an explicit represented type. + lowerer.anyOpFence(`the '${ts.tokenToString(op) ?? ts.syntaxKindName(op)}' operator`, expr); + } + // tsc allows ===/!== on unknown (arithmetic/comparisons it rejects + // itself); a dynamic equality would need a dyn walk — validate first. + lowerer.unsupported("SC1100", expr, "operators on 'unknown' values"); + } + + if (left.type.kind === "bigint" && right.type.kind === "bigint") { + const binary: Partial< + Record< + ts.SyntaxKind, + | "bigint.add" + | "bigint.sub" + | "bigint.mul" + | "bigint.div" + | "bigint.mod" + | "bigint.pow" + | "bigint.and" + | "bigint.or" + | "bigint.xor" + | "bigint.shl" + | "bigint.shr" + > + > = { + [ts.SyntaxKind.PlusToken]: "bigint.add", + [ts.SyntaxKind.MinusToken]: "bigint.sub", + [ts.SyntaxKind.AsteriskToken]: "bigint.mul", + [ts.SyntaxKind.SlashToken]: "bigint.div", + [ts.SyntaxKind.PercentToken]: "bigint.mod", + [ts.SyntaxKind.AsteriskAsteriskToken]: "bigint.pow", + [ts.SyntaxKind.AmpersandToken]: "bigint.and", + [ts.SyntaxKind.BarToken]: "bigint.or", + [ts.SyntaxKind.CaretToken]: "bigint.xor", + [ts.SyntaxKind.LessThanLessThanToken]: "bigint.shl", + [ts.SyntaxKind.GreaterThanGreaterThanToken]: "bigint.shr", + }; + const fn = binary[op]; + if (fn) return { kind: "libCall", fn, args: [left, right], type: BIGINT_T, loc }; + if ( + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + const equal: IrExpr = { + kind: "libCall", + fn: "bigint.eq", + args: [left, right], + type: BOOL, + loc, + }; + return op === ts.SyntaxKind.ExclamationEqualsEqualsToken + ? { kind: "unary", op: "!", operand: equal, type: BOOL, loc } + : equal; + } + if ( + op === ts.SyntaxKind.LessThanToken || + op === ts.SyntaxKind.LessThanEqualsToken || + op === ts.SyntaxKind.GreaterThanToken || + op === ts.SyntaxKind.GreaterThanEqualsToken + ) { + const cmp: IrExpr = { + kind: "libCall", + fn: "bigint.cmp", + args: [left, right], + type: F64, + loc, + }; + const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; + const cmpOp = + op === ts.SyntaxKind.LessThanToken + ? "<" + : op === ts.SyntaxKind.LessThanEqualsToken + ? "<=" + : op === ts.SyntaxKind.GreaterThanToken + ? ">" + : ">="; + return { kind: "bin", op: cmpOp, left: cmp, right: zero, type: BOOL, loc }; + } + } + if ( + (left.type.kind === "bigint" && right.type.kind === "f64") || + (left.type.kind === "f64" && right.type.kind === "bigint") + ) { + if ( + op === ts.SyntaxKind.LessThanToken || + op === ts.SyntaxKind.LessThanEqualsToken || + op === ts.SyntaxKind.GreaterThanToken || + op === ts.SyntaxKind.GreaterThanEqualsToken + ) { + const big = left.type.kind === "bigint" ? left : right; + const num = left.type.kind === "f64" ? left : right; + const cmp: IrExpr = { + kind: "libCall", + fn: "bigint.cmpNumber", + args: [big, num], + type: F64, + loc, + }; + const cmpLocal = lowerer.declareHiddenLocal("%bigcmp", F64); + const cmpRef: IrExpr = { kind: "varRef", localId: cmpLocal.id, type: F64, loc }; + const unordered: IrExpr = { + kind: "bin", + op: "===", + left: cmpRef, + right: { kind: "numLit", value: 2, type: F64, loc }, + type: BOOL, + loc, + }; + const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; + const leftBig = left.type.kind === "bigint"; + const cmpOp = leftBig + ? op === ts.SyntaxKind.LessThanToken + ? "<" + : op === ts.SyntaxKind.LessThanEqualsToken + ? "<=" + : op === ts.SyntaxKind.GreaterThanToken + ? ">" + : ">=" + : op === ts.SyntaxKind.LessThanToken + ? ">" + : op === ts.SyntaxKind.LessThanEqualsToken + ? ">=" + : op === ts.SyntaxKind.GreaterThanToken + ? "<" + : "<="; + const result: IrExpr = { + kind: "logical", + op: "&&", + left: { kind: "unary", op: "!", operand: unordered, type: BOOL, loc }, + right: { kind: "bin", op: cmpOp, left: cmpRef, right: zero, type: BOOL, loc }, + type: BOOL, + loc, + }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: cmpLocal.id, init: cmp, loc }], + result, + type: BOOL, + loc, + }; + } + } + // An unchecked array read keeps its undefined arm in the IR even when + // the checker typed the binding as `string`. An optional string is not + // itself a string operand for `+`: `undefined + 1` and + // `undefined + undefined` are NaN, while a present string concatenates. + // Keep this predicate exact so a nullable string does not take the + // string path either. + const optionalStringType = (type: IrType): boolean => { + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms; + return ( + !!arms && + arms.length === 2 && + arms.some((arm) => arm.kind === "string") && + arms.some((arm) => arm.kind === "undefinedT") + ); + }; + const arithmeticNumber = (operand: IrExpr, node: ts.Expression): IrExpr => + lowerOptionalNumber(lowerer, operand, loc, node); + const arithmeticLeft = strictEquality ? left : arithmeticNumber(left, expr.left); + const arithmeticRight = strictEquality ? right : arithmeticNumber(right, expr.right); + const bothNum = arithmeticLeft.type.kind === "f64" && arithmeticRight.type.kind === "f64"; + const bothStr = left.type.kind === "string" && right.type.kind === "string"; + + const lowerOptionalStringPlus = (): IrExpr | null => { + const leftOptional = optionalStringType(left.type); + const rightOptional = optionalStringType(right.type); + if (!leftOptional && !rightOptional) return null; + const allowed = (type: IrType): boolean => + type.kind === "f64" || type.kind === "string" || optionalStringType(type); + if (!allowed(left.type) || !allowed(right.type)) return null; + + // The tag tests and the chosen concatenation branch must see the same + // operand values. This matters for `take()[0] + 1` and for two reads + // with observable getters/calls. + const prefix: IrStmt[] = []; + const stable = (operand: IrExpr): IrExpr => { + if (isSafeToRepeat(operand) || isSafeToDiscard(operand)) return operand; + const tmp = lowerer.declareHiddenLocal("%plus", operand.type); + prefix.push({ kind: "varDecl", localId: tmp.id, init: operand, loc }); + return varRef(tmp.id, operand.type, loc); + }; + const stableLeft = stable(left); + const stableRight = stable(right); + const leftString = lowerer.ensureString(stableLeft, expr.left); + const rightString = lowerer.ensureString(stableRight, expr.right); + const concat: IrExpr = { + kind: "strConcat", + left: leftString, + right: rightString, + type: STRING, + loc, + }; + const nan: IrExpr = { + kind: "bin", + op: "/", + left: numLit(0, loc), + right: numLit(0, loc), + type: F64, + loc, + }; + const resultT: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, STRING]) }; + lowerer.runtimeOptionalArithmeticTypes.set(expr, resultT); + const numberTag = lowerer.armTag(resultT.unionId, F64); + const stringTag = lowerer.armTag(resultT.unionId, STRING); + if (numberTag < 0 || stringTag < 0) + throw new InternalCompilerError("lowerer bug: optional string plus result union"); + const number = (): IrExpr => ({ + kind: "unionWrap", + unionId: resultT.unionId, + tag: numberTag, + value: nan, + type: resultT, + loc, + }); + const string = (): IrExpr => ({ + kind: "unionWrap", + unionId: resultT.unionId, + tag: stringTag, + value: concat, + type: resultT, + loc, + }); + const undefinedTest = (operand: IrExpr): IrExpr => ({ + kind: "unionIsTag", + unionId: operand.type.kind === "union" ? operand.type.unionId : "", + tag: operand.type.kind === "union" ? lowerer.armTag(operand.type.unionId, UNDEFINED_T) : -1, + negated: false, + value: operand, + type: BOOL, + loc, + }); + let result: IrExpr; + if (leftOptional && rightOptional) { + const rightMissing = undefinedTest(stableRight); + const bothMissing: IrExpr = { + kind: "ternary", + cond: rightMissing, + then: number(), + else_: string(), + type: resultT, + loc, + }; + result = { + kind: "ternary", + cond: undefinedTest(stableLeft), + then: bothMissing, + else_: string(), + type: resultT, + loc, + }; + } else if (leftOptional) { + result = { + kind: "ternary", + cond: undefinedTest(stableLeft), + then: number(), + else_: string(), + type: resultT, + loc, + }; + } else { + result = { + kind: "ternary", + cond: undefinedTest(stableRight), + then: number(), + else_: string(), + type: resultT, + loc, + }; + } + return prefix.length === 0 + ? result + : { kind: "seqExpr", stmts: prefix, result, type: resultT, loc }; + }; + + switch (op) { + case ts.SyntaxKind.PlusToken: + if (bothNum) + return { + kind: "bin", + op: "+", + left: arithmeticLeft, + right: arithmeticRight, + type: F64, + loc, + }; + if (left.type.kind === "string" || right.type.kind === "string") { + return { + kind: "strConcat", + left: concatOperand(lowerer, left, expr.left), + right: concatOperand(lowerer, right, expr.right), + type: STRING, + loc, + }; + } + const optionalStringPlus = lowerOptionalStringPlus(); + if (optionalStringPlus) return optionalStringPlus; + { + const primitive = (type: IrType): boolean => + type.kind === "f64" || + type.kind === "string" || + type.kind === "bool" || + isUnitType(type) || + (type.kind === "union" && + (lowerer.unions.get(type.unionId)?.arms.every(primitive) ?? false)); + if ( + (left.type.kind === "union" || right.type.kind === "union") && + primitive(left.type) && + primitive(right.type) + ) { + lowerer.runtimeOptionalArithmeticTypes.set(expr, DYN); + return { + kind: "libCall", + fn: "dyn.add", + args: [lowerer.coerceToExpected(left, DYN), lowerer.coerceToExpected(right, DYN)], + type: DYN, + loc, + }; + } + } + operatorUnsupported(lowerer, expr, "+", [left.type, right.type]); + break; + case ts.SyntaxKind.MinusToken: + case ts.SyntaxKind.AsteriskToken: + case ts.SyntaxKind.SlashToken: + case ts.SyntaxKind.PercentToken: + case ts.SyntaxKind.AsteriskAsteriskToken: { + if (!bothNum) + operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]); + const binOp = + op === ts.SyntaxKind.MinusToken + ? "-" + : op === ts.SyntaxKind.AsteriskToken + ? "*" + : op === ts.SyntaxKind.SlashToken + ? "/" + : op === ts.SyntaxKind.PercentToken + ? "%" + : "**"; + return { + kind: "bin", + op: binOp, + left: arithmeticLeft, + right: arithmeticRight, + type: F64, + loc, + }; + } + // The bitwise six, JS-exact: operands through ToInt32/ToUint32 + // (NaN/±Infinity → 0, truncate, wrap mod 2^32), the operation in + // 32-bit space (shift counts masked to 5 bits), the result back to + // f64 — `>>>` as Uint32, the rest as Int32 (runtime scr_bit_*). + case ts.SyntaxKind.AmpersandToken: + case ts.SyntaxKind.BarToken: + case ts.SyntaxKind.CaretToken: + case ts.SyntaxKind.LessThanLessThanToken: + case ts.SyntaxKind.GreaterThanGreaterThanToken: + case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: { + if (!bothNum) + operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]); + const bitOp = + op === ts.SyntaxKind.AmpersandToken + ? "&" + : op === ts.SyntaxKind.BarToken + ? "|" + : op === ts.SyntaxKind.CaretToken + ? "^" + : op === ts.SyntaxKind.LessThanLessThanToken + ? "<<" + : op === ts.SyntaxKind.GreaterThanGreaterThanToken + ? ">>" + : ">>>"; + return { + kind: "bin", + op: bitOp, + left: arithmeticLeft, + right: arithmeticRight, + type: F64, + loc, + }; + } + case ts.SyntaxKind.EqualsEqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return lowerStrictEquality( + lowerer, + expr, + expr.left, + expr.right, + left, + right, + op === ts.SyntaxKind.ExclamationEqualsEqualsToken, + loc, + ); + case ts.SyntaxKind.LessThanToken: + case ts.SyntaxKind.LessThanEqualsToken: + case ts.SyntaxKind.GreaterThanToken: + case ts.SyntaxKind.GreaterThanEqualsToken: { + const cmpOp = + op === ts.SyntaxKind.LessThanToken + ? "<" + : op === ts.SyntaxKind.LessThanEqualsToken + ? "<=" + : op === ts.SyntaxKind.GreaterThanToken + ? ">" + : ">="; + if (bothNum) + return { + kind: "bin", + op: cmpOp, + left: arithmeticLeft, + right: arithmeticRight, + type: BOOL, + loc, + }; + if (bothStr) return { kind: "strCmp", op: cmpOp, left, right, type: BOOL, loc }; + // A Date converts to a new checked-dynamic Date on each crossing, so + // comparisons keep their Date fence instead of comparing copies. + if ( + left.type.kind !== "date" && + right.type.kind !== "date" && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { + return { + kind: "libCall", + fn: "dyn.compare", + args: [ + lowerer.coerceToExpected(left, DYN), + lowerer.coerceToExpected(right, DYN), + { kind: "strLit", value: cmpOp, type: STRING, loc }, + ], + type: BOOL, + loc, + }; + } + lowerer.unsupported("SC1043", expr); + break; + } + default: + break; + } + lowerer.unsupported( + "SC1090", + expr, + `operator '${ts.tokenToString(op) ?? ts.syntaxKindName(op)}'`, + ); +} + +/** `left === right` (`!==` when negated) over lowered operands: JS strict + * equality for every pair of static kinds the program can compare. + * `node` anchors diagnostics, and `leftNode`/`rightNode` the coercions. */ +export function lowerStrictEquality( + lowerer: Lowerer, + node: ts.Node, + leftNode: ts.Node, + rightNode: ts.Node, + left: IrExpr, + right: IrExpr, + negated: boolean, + loc: SrcLoc, +): IrExpr { + const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; + const bothStr = left.type.kind === "string" && right.type.kind === "string"; + const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); + if (indexed) return indexed; + if (plainBothNum) + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + if (bothStr) { + if (left.kind === "strLit" && right.kind === "strLit") + return { + kind: "boolLit", + value: (left.value === right.value) !== negated, + type: BOOL, + loc, + }; + return { kind: "strEq", negated, left, right, type: BOOL, loc }; + } + // bool === bool: a plain value compare (the config-drift checks' + // `desired.lanMode !== actual.lanMode` shape). + if (left.type.kind === "bool" && right.type.kind === "bool") { + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + } + const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); + if (unitTest) return unitTest; + if (left.type.kind === "union" || right.type.kind === "union") { + const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); + if (compared) return compared; + const aliasRefusal = (type: IrType): void => { + if (tagEqualityMayMissAlias(lowerer, type)) + lowerer.unsupported( + "SC1090", + node, + `comparisons of related class values beside structural union members (${NARROW_FIRST})`, + ); + }; + + // JS strict equality of the ARM values, per union tag (a + // per-union helper): equal tags compare payloads (f64 ==, string + // bytes, bool ==, ref-arm POINTER identity), different tags are + // never equal. Two shapes lower: same union on both sides, and + // union vs plain arm value (`u === "text"` where the checker + // didn't narrow) — the plain side wraps into the union, which + // preserves payload identity. Different unions (tsc admits + // partially-overlapping ones) still need narrowing first. + 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); + const plainFunc = left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; + const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; + const funcArm = unionArms.find((arm) => arm.kind === "func"); + // `listeners()` reports original prefix callbacks, but the + // checker views an indexed result as function | undefined. For + // identity comparison, retain the original closure pointer rather + // than adapting it to the tuple signature. + if ( + !bothUnion && + plainFunc !== null && + funcArm?.kind === "func" && + unionArms.filter((arm) => arm.kind === "func").length === 1 && + unionArms.some(isUnitType) + ) { + return { + kind: "unionFuncEq", + unionId: ut.unionId, + tag: lowerer.armTag(ut.unionId, funcArm), + union: left.type.kind === "union" ? left : right, + func: plainFunc, + negated, + type: BOOL, + loc, + }; + } + if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { + aliasRefusal(ut); + return { + kind: "unionEq", + unionId: ut.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, ut), + right: lowerer.coerceInto(rightNode, right, ut), + type: BOOL, + loc, + }; + } + if (left.type.kind === "union" && right.type.kind === "union") { + const leftDef = lowerer.unions.get(left.type.unionId); + const rightDef = lowerer.unions.get(right.type.unionId); + if (leftDef && rightDef) { + const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); + const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); + const sameValueArms = + leftValues.length === rightValues.length && + leftValues.every((arm) => rightValues.some((candidate) => typeEquals(candidate, arm))); + if (sameValueArms) { + const arms = [...leftDef.arms]; + for (const arm of rightDef.arms) { + if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); + } + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const common: IrType & { kind: "union" } = { + kind: "union", + unionId: lowerer.unions.intern(arms), + }; + aliasRefusal(common); + return { + kind: "unionEq", + unionId: common.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, common), + right: lowerer.coerceInto(rightNode, right, common), + type: BOOL, + loc, + }; + } + } + } + lowerer.unsupported("SC1090", node, `comparisons of union-typed values (${NARROW_FIRST})`); + } + // Arrays, maps, functions, class instances and records compare by + // reference identity (pointer compare) — JS-exact object equality. + // Hierarchy-related classes compare after widening the derived side + // (same pointer either way — prefix layout). + let idLeft = left; + let idRight = right; + if (left.type.kind === "object" && right.type.kind === "object") { + if (lowerer.isSubclassOf(left.type.className, right.type.className)) { + idLeft = lowerer.upcastTo(left, right.type.className); + } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { + idRight = lowerer.upcastTo(right, left.type.className); + } + } + // Two function values compare by identity whatever their STATIC + // signatures (tsc admits the comparison when one side is + // assignable to the other — a tuple-typed listeners() element + // against a prefix-declared handler): the pointer answers it. + if (idLeft.type.kind === "func" && idRight.type.kind === "func") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Two CLASS values compare by identity whatever their static + // classes — one immortal object per class, one pointer compare + // (the function-identity rule verbatim; `X === D` through a + // base-typed slot answers exactly JS). + if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if ( + (idLeft.type.kind === "array" || + idLeft.type.kind === "map" || + idLeft.type.kind === "set" || + idLeft.type.kind === "regex" || + idLeft.type.kind === "url" || + idLeft.type.kind === "object" || + idLeft.type.kind === "record" || + // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, + // a symbol equals exactly itself, and Symbol.for's interned + // values compare equal across call sites — all one pointer + // compare, JS's spec without approximation. + idLeft.type.kind === "symbol" || + // Typed arrays / Buffers ARE objects to ===: pointer identity + // (buf === buf.swap16() — the in-place mutators return this). + idLeft.type.kind === "bytes" || + idLeft.type.kind === "promise") && + typeEquals(idLeft.type, idRight.type) + ) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Runtime HANDLES are objects to === too: one handle per socket/ + // request/response, so pointer identity IS JS's object equality + // (`c.pause() === c` — Node's chaining assertions). */ + if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // A Date converts to a new checked-dynamic Date on each crossing; its + // identity has no representation, so equality keeps the fence. + if ( + idLeft.type.kind !== "date" && + idRight.type.kind !== "date" && + lowerer.dynConvertible(idLeft.type) && + lowerer.dynConvertible(idRight.type) + ) { + return { + kind: "dynScalarEq", + left: lowerer.coerceToExpected(idLeft, DYN), + right: lowerer.coerceToExpected(idRight, DYN), + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } + return lowerer.unsupported("SC1043", node); +} + +function lowerLogicalChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { + // Logical chains have a left-nested AST. Keep the native stack bounded + // while retaining each pair's checker type and lazy operand evaluation. + const parents: ts.BinaryExpression[] = []; + let current = expr; + let result: IrExpr; + while (true) { + if (current.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + const fallback = lowerAbsenceAwareOrChain(lowerer, current); + if (fallback !== null) { + result = fallback; + break; + } + } + const left = current.left; + if ( + ts.isBinaryExpression(left) && + (left.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + left.operatorToken.kind === ts.SyntaxKind.BarBarToken) && + !lowerer.chainRecvByNode.has(left) + ) { + parents.push(current); + current = left; + continue; + } + result = lowerLogicalPair( + lowerer, + current, + current.operatorToken.kind === ts.SyntaxKind.BarBarToken + ? lowerPresenceOperand(lowerer, left) + : (lowerAbsenceProbe(lowerer, left) ?? lowerer.lowerExpr(left)), + ); + break; + } + for (let i = parents.length - 1; i >= 0; i--) { + result = lowerLogicalPair(lowerer, parents[i]!, result); + } + return result; +} + +function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrExpr): IrExpr { + const op = expr.operatorToken.kind; + const loc = locOf(expr); + // Preserve short-circuiting during lowering too: an unreachable member + // on a specialized value must not poison the surrounding JS statement. + if (left.kind === "boolLit") { + const takeRight = op === ts.SyntaxKind.AmpersandAmpersandToken ? left.value : !left.value; + return takeRight ? lowerer.lowerExpr(expr.right) : left; + } + if (left.kind === "unitLit" && op === ts.SyntaxKind.AmpersandAmpersandToken) return left; + if (op === ts.SyntaxKind.BarBarToken && REF_TRUTHY_KINDS.has(left.type.kind)) return left; + const right = lowerer.lowerExpr(expr.right); + // A LITERAL-unit left operand (a compile-time undefined/null — the + // capability-probe members: `process.features.inspector || + // !flag.startsWith('--inspect')` reads undefined in a compiled + // binary): statically falsy, so the operator folds JS-exactly — + // `unit || X` IS X, `unit && X` IS the unit (X's side effects never + // run in JS either; the lowered right is simply dropped). Literal + // units only — computed unit-typed values keep the fences below. + if (left.kind === "unitLit") { + return op === ts.SyntaxKind.BarBarToken ? right : left; + } + // JS objects and symbols are always truthy. In `ref || fallback` + // the fallback is therefore unreachable and the result is the + // already-evaluated left value — the browser-fallback idiom used by + // packages for `process.argv || []` and `process.env || {}`. The + // operand itself remains in the IR, preserving reads/calls that + // produce the reference; only the unreachable right lowering drops. + if (op === ts.SyntaxKind.BarBarToken && REF_TRUTHY_KINDS.has(left.type.kind)) { + return left; + } + if (left.type.kind === "dyn" || right.type.kind === "dyn") { + // A checked-dynamic operand (`fn.name || ''` — + // test/common's _mustCallInner): both sides live in the checked-dynamic tree and + // the deciding test is ToBoolean over the dyn kind + // (scr_dyn_truthy) — JS value semantics exactly, result dyn. The + // non-dyn side converts through the usual boundary; a value with + // no dyn representation keeps the fence. + const l = lowerer.coerceToExpected(left, DYN); + const r = lowerer.coerceToExpected(right, DYN); + if (l.type.kind !== "dyn" || r.type.kind !== "dyn") { + lowerer.unsupported("SC1100", expr, "logical operators on 'unknown' values"); + } + return { + kind: "logical", + op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", + left: l, + right: r, + type: DYN, + loc, + }; + } + + if ( + isJsSourceFile(expr.getSourceFile()) && + op === ts.SyntaxKind.AmpersandAmpersandToken && + left.type.kind === "union" && + lowerer.unions.get(left.type.unionId)?.arms.some((arm) => arm.kind === "bool") && + !typeEquals(left.type, right.type) && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { + return { + kind: "logical", + op: "&&", + left: lowerer.coerceToExpected(left, DYN), + right: lowerer.coerceToExpected(right, DYN), + type: DYN, + loc, + }; + } + if (left.type.kind === "union" || right.type.kind === "union") { + // Test the deciding operand in its own representation. The result + // contains only the values that can survive short-circuit evaluation. + let target = lowerer.mapTypeOf(lowerer.typeOf(expr)); + // Index reads can be absent without noUncheckedIndexedAccess. Their + // explicit nullish fallback still runs, even if the checker discarded it. + if (target !== null) { + const returned = [ + right.type, + ...(op === ts.SyntaxKind.AmpersandAmpersandToken ? [left.type] : []), + ]; + for (const type of returned) { + const arms = + type.kind === "union" ? (lowerer.unions.get(type.unionId)?.arms ?? []) : [type]; + for (const arm of arms) { + if (arm.kind === "undefinedT" && !isUnitType(target)) + target = lowerer.runtimeOptionalType(target); + if (arm.kind === "nullT") + target = withUnitArm(target, arm.kind, lowerer.unions) ?? target; + } + } + } + // A broad-JSDoc npm-static body can leave the CHECKER result `any` + // even after declaration-backed specialization has recovered both + // operands (`helpOption && args.find(...)` in commander). When the + // left consists only of always-truthy references and falsy units, + // the static result is exactly the right operand plus those units. + if ( + op === ts.SyntaxKind.AmpersandAmpersandToken && + target === null && + lowerer.implicitParamTypes !== null && + npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null && + (lowerer.typeOf(expr).flags & ts.TypeFlags.Any) !== 0 && + left.type.kind === "union" + ) { + const leftArms = lowerer.unions.get(left.type.unionId)?.arms; + const rightArms = + right.type.kind === "union" ? lowerer.unions.get(right.type.unionId)?.arms : [right.type]; + const rightIsStatic = + rightArms?.every( + (arm) => arm.kind !== "dyn" && arm.kind !== "caught" && arm.kind !== "void", + ) ?? false; + if ( + leftArms && + rightArms && + rightIsStatic && + leftArms.some(isUnitType) && + leftArms.every((arm) => isUnitType(arm) || REF_TRUTHY_KINDS.has(arm.kind)) + ) { + const byKey = new Map(); + for (const arm of [...rightArms, ...leftArms.filter(isUnitType)]) { + byKey.set(typeKey(arm), arm); + } + const arms = [...byKey.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + if (arms.length > 1) { + const source = + right.type.kind === "union" ? lowerer.unions.get(right.type.unionId) : undefined; + target = { + kind: "union", + unionId: source ? lowerer.unions.transform(source, arms) : lowerer.unions.intern(arms), + }; + } + } + } + // `&&` over an always-truthy plain left operand (an array, object or + // function value) whose result union has no arm for it: every JS + // object is truthy, so `list && list[0]` evaluates the left for its + // effects and answers the right. Coercing the left into that union + // would trap at run time where Node answers the right operand. + if ( + op === ts.SyntaxKind.AmpersandAmpersandToken && + target?.kind === "union" && + REF_TRUTHY_KINDS.has(left.type.kind) && + lowerer.armTag(target.unionId, left.type) < 0 + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: lowerer.coerceInto(expr.right, right, target), + type: target, + loc, + }; + } + if (target?.kind === "union") { + // `&&` whose left does NOT fit the result union: the checker + // built that result by DROPPING left arms which are always + // truthy (`Option | undefined && string | undefined` answers + // `string | undefined`, not `Option | string | undefined`). + // Evaluate the left once, test it in its own union, and retag it + // only on the falsy path. Any stranded reference arms are + // unreachable there because every JS object is truthy. + if ( + op === ts.SyntaxKind.AmpersandAmpersandToken && + left.type.kind === "union" && + !typeEquals(left.type, target) + ) { + const def = lowerer.unions.get(left.type.unionId); + const trappable = new Set(); + def?.arms.forEach((arm, tag) => { + if (lowerer.armTag(target.unionId, arm) < 0 && REF_TRUTHY_KINDS.has(arm.kind)) { + trappable.add(tag); + } + }); + const retag = + trappable.size === 0 + ? null + : lowerer.unionRetagHelper(left.type.unionId, target.unionId, loc, trappable); + if (retag) { + lowerer.requireTruthyUnion(left.type.unionId, expr); + const stmts: IrStmt[] = []; + let stable: IrExpr = left; + if (!isSafeToRepeat(left)) { + const local = lowerer.declareHiddenLocal("%and", left.type); + stmts.push({ kind: "varDecl", localId: local.id, init: left, loc }); + stable = varRef(local.id, left.type, loc); + } + const result: IrExpr = { + kind: "ternary", + cond: lowerer.ensureBool(stable, expr.left), + then: lowerer.coerceInto(expr.right, right, target), + else_: { kind: "call", callee: retag, args: [stable], type: target, loc }, + type: target, + loc, + }; + return stmts.length === 0 + ? result + : { kind: "seqExpr", stmts, result, type: target, loc }; + } + } + // `||` whose left does NOT fit the result union: the checker built + // that result by DROPPING the left's falsy arms (`process.env.X || + // null` is `string | null`, `|| 3000` is `string | number` — the + // `undefined` is gone from both), so coercing the left eagerly, as + // the shared shape below does, retags an arm the test is about to + // rule out and throws where Node yields the default. Single-eval + // instead: test the left in its OWN union and retag only on the + // truthy side, where the dropped arms are unreachable. + if ( + op === ts.SyntaxKind.BarBarToken && + left.type.kind === "union" && + !typeEquals(left.type, target) + ) { + const retag = lowerer.unionRetagHelper(left.type.unionId, target.unionId, loc); + if (retag) { + lowerer.requireTruthyUnion(left.type.unionId, expr); + return { + kind: "orDefault", + left, + right: lowerer.coerceInto(expr.right, right, target), + retag, + type: target, + loc, + }; + } + } + lowerer.requireTruthyUnion(target.unionId, expr); + return { + kind: "logical", + op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", + left: lowerer.coerceInto(expr.left, left, target), + right: lowerer.coerceInto(expr.right, right, target), + type: target, + loc, + }; + } + // `u || d` NARROWED by the default: the checker types the result + // as u's single non-unit arm (`marker() || "default"`) — evaluate + // u once, truthy extracts the arm, falsy takes d lazily (the + // orDefault node, nullish's truthiness sibling). An UNMAPPABLE + // checker result takes the same rule (`options.runner || + // defaultRunner` — tsc's union of two structurally-compatible + // function types has no representation, while the left's one + // non-unit arm is the only representable answer): the default + // must coerce into the arm or fence on its own. + if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") { + const def = lowerer.unions.get(left.type.unionId); + const rest = def ? def.arms.filter((a) => !isUnitType(a)) : []; + const funcArmDefault = + rest[0]?.kind === "func" && (target === null || target.kind === "func"); + if ( + rest.length === 1 && + ((target !== null && typeEquals(target, rest[0]!)) || funcArmDefault) + ) { + lowerer.requireTruthyUnion(left.type.unionId, expr); + const dflt = lowerer.lowerExprExpecting(expr.right, rest[0]!); + return { kind: "orDefault", left, right: dflt, type: rest[0]!, loc }; + } + } + // JavaScript defaults can join differently inferred object layouts. + // When both operands have native checked representations, preserve + // the deciding value and short-circuit evaluation in that domain. + if ( + isJsSourceFile(expr.getSourceFile()) && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { + return { + kind: "logical", + op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", + left: lowerer.coerceInto(expr.left, left, DYN), + right: lowerer.coerceInto(expr.right, right, DYN), + type: DYN, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + `logical operators on union-typed values outside conditions (${NARROW_FIRST})`, + ); + } + const kind = left.type.kind; + if (kind !== right.type.kind || (kind !== "f64" && kind !== "string" && kind !== "bool")) { + // Mixed PLAIN operands (`value || null`, `flag || undefined`, + // `s || 0`): JS value semantics still compile when the checker + // types the RESULT as one union both operands coerce into — the + // same lift the union-operand path above takes, arriving here + // with two plain arm values instead. + const target = lowerer.mapTypeOf(lowerer.typeOf(expr)); + // An always-truthy left operand (a record, object, array or function + // value: every JS object is truthy): `o && x` evaluates `o` for its + // effects and answers `x`; `o || x` answers `o` and never evaluates + // `x`. supports-color's `stream && stream.isTTY` is the shape. Ahead + // of the union lift below exactly where that lift would store the + // object in a result union with no arm for it; a union holding the + // operand keeps the lift. + if ( + REF_TRUTHY_KINDS.has(kind) && + (target?.kind !== "union" || + (op === ts.SyntaxKind.AmpersandAmpersandToken && + lowerer.armTag(target.unionId, left.type) < 0)) + ) { + const and = op === ts.SyntaxKind.AmpersandAmpersandToken; + const answer = and ? right : left; + const answerT = target ?? answer.type; + if (answerT.kind !== "void") { + const value = lowerer.coerceInto(and ? expr.right : expr.left, answer, answerT); + if (!and) return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: value, + type: answerT, + loc, + }; + } + } + if (target?.kind === "union") { + lowerer.requireTruthyUnion(target.unionId, expr); + return { + kind: "logical", + op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", + left: lowerer.coerceInto(expr.left, left, target), + right: lowerer.coerceInto(expr.right, right, target), + type: target, + loc, + }; + } + lowerer.unsupported("SC1042", expr); + } + return { + kind: "logical", + op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", + left, + right, + type: left.type, + loc, + }; +} + +function lowerAbsenceAwareOrChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { + const nodes: ts.Expression[] = []; + const pending: ts.Expression[] = [expr]; + while (pending.length !== 0) { + const node = pending.pop()!; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) { + pending.push(node.right, node.left); + } else { + nodes.push(node); + } + } + if (nodes.length < 3) return null; + const lowered = nodes.map((node) => lowerAbsenceProbe(lowerer, node) ?? lowerer.lowerExpr(node)); + if ( + !lowered.some( + (value) => + value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0, + ) + ) { + return null; + } + let result = lowered[lowered.length - 1]!; + for (let i = lowered.length - 2; i >= 0; i--) { + const left = lowered[i]!; + const loc = locOf(nodes[i]!); + if (left.type.kind === "union") { + const def = lowerer.unions.get(left.type.unionId); + const nonUnit = def?.arms.filter((arm) => !isUnitType(arm)) ?? []; + if (nonUnit.length !== 1 || !typeEquals(nonUnit[0]!, result.type)) return null; + lowerer.requireTruthyUnion(left.type.unionId, expr); + result = { kind: "orDefault", left, right: result, type: result.type, loc }; + continue; + } + if (!typeEquals(left.type, result.type)) return null; + result = { kind: "logical", op: "||", left, right: result, type: result.type, loc }; + } + return result; +} + +/** `typeof WebAssembly === "object"` and `typeof WebAssembly. === + * "function"`: the operand needs no value, so the test folds as bare typeof + * does (builtins/webassembly.ts) before any dyn-typed operand lowers. */ +function lowerWebAssemblyTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; + const folded = lowerWebAssemblyTypeof(lowerer, a.expression); + if (folded?.kind !== "strLit") continue; + return { kind: "boolLit", value: (folded.value === b.text) !== negated, type: BOOL, loc }; + } + return null; +} + +/** `typeof v === "lit"` / `typeof v !== "lit"` where v is UNION-typed: the + * arms whose static typeof answer equals the literal form a tag set, and + * the test is a runtime tag test — one `unionIsTag` for a single matching + * arm (the operand embeds once, so even effectful operands compose), a + * short-circuit chain for several (pure reads only — the operand rides + * every test, exactly the `== null` composition rule). When every arm + * answers the same way the comparison is statically decided and folds to + * a bool literal — dropping only a side-effect-free read, the same + * trust-the-checker bet as lowerUnitComparison. tsc's control-flow + * narrowing then types the branches, and reads bridge through + * maybeNarrow's unionNarrow as usual. Null when neither side is a typeof + * over a union-typed operand or the literal side isn't a literal (the + * bare-typeof value form then composes with strEq for pure operands). */ +function lowerUnionTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a)) continue; + const stored = runtimeOptionalStorageOperand(lowerer, a.expression); + if (stored === null && lowerer.mapTypeOf(lowerer.typeOf(a.expression))?.kind !== "union") + continue; + if (!ts.isStringLiteral(b)) continue; // bare-typeof + strEq handles pure operands + const value = stored ?? lowerer.lowerExpr(a.expression); + if (value.type.kind !== "union") continue; // defensive: an already-narrowed use + const unionId = value.type.unionId; + const def = lowerer.unions.get(unionId); + const answers = def?.arms.map(typeofAnswer); + if (!def || !answers || !answers.every((s): s is string => s !== null)) continue; + const tags = answers.flatMap((s, i) => (s === b.text ? [i] : [])); + if (tags.length === 0 || tags.length === def.arms.length) { + if (!isSafeToRepeat(value)) { + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'typeof' tests over effectful operands (bind the value to a const first)", + ); + } + return { kind: "boolLit", value: (tags.length !== 0) !== negated, type: BOOL, loc }; + } + const isTag = (tag: number): IrExpr => ({ + kind: "unionIsTag", + unionId, + tag, + negated, + value, + type: BOOL, + loc, + }); + if (tags.length === 1) return isTag(tags[0]!); + if (!isSafeToRepeat(value)) { + lowerer.unsupported( + "SC1090", + expr, + "'typeof' tests matching several union arms over operands that aren't plain reads (bind the value to a const first)", + ); + } + // Tag-in-set as a short-circuit chain (De Morgan for `!==`). + let acc = isTag(tags[0]!); + for (const t of tags.slice(1)) { + acc = { + kind: "logical", + op: negated ? "&&" : "||", + left: acc, + right: isTag(t), + type: BOOL, + loc, + }; + } + return acc; + } + return null; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts new file mode 100644 index 000000000..144742257 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -0,0 +1,2189 @@ +/* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js"; +import { + BOOL, + DYN, + F64, + type IrExpr, + type IrRecordShape, + type IrStmt, + type IrType, + NULL_T, + STRING, + type SrcLoc, + UNDEFINED_T, + VOID, + canBoxFuncIntoDyn, + canDynCheckTo, + isDynTypedRefType, + isUnitType, + shapeHasAccessorSlots, + typeEquals, +} from "../../../ir/ir.js"; +import { importMetaField, isImportMeta } from "../import-meta.js"; +import { hasOptionalChainGuard, isOptionalChainTail } from "./optional-chains.js"; +import { lowerEnumAccess } from "../lower-enums.js"; +import { fenceNodeModuleMutation, lowerRequireCacheElement } from "../lower-node-module.js"; +import { lowerModuleNamespaceElement } from "../module-namespace-elements.js"; +import { type CompoundOp, stdlibGlobalNameOf } from "../surfaces.js"; +import { globalSymbolKey } from "./global-symbols.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { lowerGlobalValue } from "../lower-global-value.js"; +import { expandoMemberRead } from "../lower-expando.js"; +import { + assertedElementKeepsUndefined, + compoundUnsupported, + concatOperand, + dynCompoundValue, + hasClassPayload, + lowerDynMemberAssignment, + lowerOptionalArrayIndexRead, + lowerOptionalNumber, + recordKeyResultOk, + representedClassFieldTarget, + runtimeOptionalElementKey, + symbolFieldTarget, +} from "../lower-exprs.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { classFieldTarget, lowerUnionKeyedRead } from "./property-access.js"; +import { + arrayNumberRead, + arrayValueRead, + arrayValueStore, + arrayValueType, + lowerSafeBytesRead, + lowerSafeIndexRead, +} from "../array-values.js"; +import { lowerOptionalStringIndex, lowerUnionStringIndex } from "../string-index.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js"; +import { npmStaticPackageOfPath } from "../../npm-static.js"; +import { isSafeToRepeat } from "./evaluation-safety.js"; +import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; +import { isClassCallback } from "../class-callbacks.js"; + +/** `a[i]` reads. Only f64 indices into array receivers are modeled; JS + * string-key element access (`a["length"]`) and string indexing (`s[0]` + * typechecks against the lib's index signature; use .charAt) stay out. */ +export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpression): IrExpr { + if (isImportMeta(expr.expression)) { + const name = lowerer.foldedStringKeyOf(expr.argumentExpression); + if (name === null) + lowerer.unsupported( + "SC1090", + expr, + "import.meta with a runtime-computed key (use a statically-known metadata field)", + ); + return importMetaField(lowerer, expr, name); + } + + // `a?.[i]`: the guard lowers as an optional-chain step around the + // plain element read below. + if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) { + return lowerer.lowerOptionalChain(expr); + } + // `a?.b[i]` — the tail of a chain whose token sits deeper: the whole + // tail short-circuits with the guard. + if (!expr.questionDotToken && isOptionalChainTail(lowerer, expr)) { + return lowerer.lowerOptionalChain(expr); + } + // Enum accesses in element clothing — `E["A"]` forward reads and + // `E[0]` reverse-mapping reads — fold to constants (lower-enums.ts), + // claimed before any receiver-kind dispatch can see the enum object. + { + const en = lowerEnumAccess(lowerer, expr); + if (en) return en; + } + const cachedModule = lowerRequireCacheElement(lowerer, expr); + if (cachedModule) return cachedModule; + const namespaceElement = lowerModuleNamespaceElement(lowerer, expr); + if (namespaceElement) return namespaceElement; + // Native Web handles use the same supported surface for bracket and dot + // spellings. Fence a statically-known unsupported key before the generic + // checked-dynamic element-read path can turn it into a runtime missing + // member. + lowerer.fenceStaticAbortControllerMemberRead(expr); + lowerer.fenceStaticResponseMember(expr, "read"); + lowerer.fenceStaticHeadersMember(expr, "read"); + lowerer.fenceStaticReadableStreamMember(expr, "read"); + // Computed global reads share the stored global object's custom + // properties. Symbol keys retain their identity-indexed store. + if (!expr.questionDotToken && stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis") { + const symbol = globalSymbolKey(lowerer, expr.expression, expr.argumentExpression); + if (symbol) + return { + kind: "libCall", + fn: "dyn.globalSymbolGet", + args: [symbol], + type: DYN, + loc: locOf(expr), + }; + const key = lowerer.lowerExpr(expr.argumentExpression); + const name: IrExpr = + key.type.kind === "string" + ? key + : { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [lowerer.coerceInto(expr.argumentExpression, key, DYN)], + type: STRING, + loc: locOf(expr), + }; + return { + kind: "dynKeyGet", + value: lowerGlobalValue(lowerer, expr.expression), + key: name, + type: DYN, + loc: locOf(expr), + }; + } + // `req.headers["x-name"]` — the computed twin of `req.headers.host` + // (the server spoke owns both; the envGet precedent). + { + const header = lowerer.lowerHttpHeadersElement(expr); + if (header) return lowerer.maybeNarrow(header, expr); + } + // `process.env[expr]` — the computed twin of `process.env.NAME`; both + // lower to the ONE process.envGet intrinsic. The read narrows like any + // union-typed expression when the checker narrowed this occurrence. + if (lowerer.isProcessEnv(expr.expression)) { + const key = lowerEnvironmentKey(lowerer, expr.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + expr.argumentExpression, + "indexing process.env with non-string keys", + ); + } + const get: IrExpr = { + kind: "libCall", + fn: "process.envGet", + args: [key], + type: lowerer.envValueType(), + loc: locOf(expr), + }; + return lowerer.maybeNarrow(get, expr); + } + // Expando function members in element clothing (`foo[strMem]`, + // `foo[_private]` where foo is a module-level function/callable const + // and the key folds or is a unique-symbol const): the member's module + // global — the dotted read's twin (lower-expando.ts). + { + const ex = expandoMemberRead(lowerer, expr); + if (ex) return lowerer.maybeNarrow(ex, expr); + } + // Symbol-keyed property READS (`this[kLimit]`): when the key is a + // statically-resolvable symbol declared as a field of the receiver's + // class (classSymbolKeyOf — the countdown.js/OpenTUI idioms), the + // read IS an ordinary field read of the hidden slot. Every other + // symbol key stays fenced: record shapes, runtime-identity keys + // (symbol parameters, reassigned bindings), and keys no class declares + // — the layouts are compile-time field lists. + if (lowerer.mapTypeOf(lowerer.typeOf(expr.argumentExpression))?.kind === "symbol") { + const target = symbolFieldTarget(lowerer, expr); + if (target) return lowerer.maybeNarrow(lowerer.fieldGetExpr(target, locOf(expr), expr), expr); + let receiver = lowerer.lowerExpr(expr.expression); + const classInfo = + receiver.type.kind === "classval" ? lowerer.classes.get(receiver.type.className) : undefined; + if ( + receiver.type.kind === "func" || + receiver.type.kind === "generator" || + ((receiver.type.kind === "array" || + receiver.type.kind === "string" || + receiver.type.kind === "bytes") && + lowerer.dynConvertible(receiver.type)) || + (receiver.type.kind === "object" && lowerer.dynConvertible(receiver.type)) || + isDynTypedRefType(receiver.type) || + (classInfo && (classInfo.callableBase || hasRuntimeStatics(classInfo))) || + ((receiver.type.kind === "map" || receiver.type.kind === "set") && + lowerer.dynConvertible(receiver.type)) + ) { + const fnName = jsFuncNameOf(expr.expression); + receiver = { + kind: "dynFrom", + value: receiver, + type: DYN, + loc: locOf(expr), + ...(fnName !== null ? { fnName } : {}), + }; + } + if (receiver.type.kind === "dyn") { + const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); + return lowerer.maybeNarrow( + { kind: "dynKeyGet", value: receiver, key, type: DYN, loc: locOf(expr) }, + expr, + ); + } + lowerer.unsupported( + "SC1090", + expr, + "symbol-keyed property access outside class fields keyed by a stable module-level literal Symbol()/Symbol.for() (other static shapes have no symbol-keyed storage)", + ); + } + // A never-tainted JS receiver type (neverTaintedJsType — `cmd[1]` on + // `const cmd = ['pwd', []]`, whose binding lowered checked-dynamic) + // dispatches as unmapped so the dyn-receiver branch below reads + // through the checked-dynamic tree instead of dynChecking into never's f64 residue. + let receiverIr = neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)) + ? null + : lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if (isJsSourceFile(expr.getSourceFile())) { + const receiver = tryLowerExpression(lowerer, expr.expression); + if (receiver?.type.kind === "dyn") { + const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); + const optional = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: receiver, + key, + ...(optional ? { optional: true as const } : {}), + type: DYN, + loc: locOf(expr), + }, + expr, + ); + } + if (receiver?.type.kind === "func" && lowerer.dynConvertible(receiver.type)) { + const loc = locOf(expr); + const fnName = jsFuncNameOf(expr.expression); + const boxed: IrExpr = { + kind: "dynFrom", + value: receiver, + type: DYN, + loc, + ...(fnName !== null ? { fnName } : {}), + }; + const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); + return lowerer.maybeNarrow({ kind: "dynKeyGet", key, value: boxed, type: DYN, loc }, expr); + } + if (receiver?.type.kind === "array" || receiver?.type.kind === "bytes") + receiverIr = receiver.type; + if (receiver?.type.kind === "union") { + const present = lowerer.stripUndefinedArm(receiver.type); + if (present.kind === "array" || present.kind === "bytes") receiverIr = present; + } + } + // A readonly tuple union under Array.isArray is checker-typed as an + // intersection with any[], whose structural mapping is not the tuple + // shape. maybeNarrow on the receiver uses the runtime tag proof to + // extract the one array-valued arm; dispatch element access from that + // lowered representation, like the existing any[] array-method path. + const checkerArray = lowerer.checkerArrayValue(expr.expression); + if (checkerArray) receiverIr = checkerArray.type; + if (receiverIr?.kind === "object") { + const key = + recordKeyLiteralText(expr.argumentExpression) ?? + recordKeyTypeLiteralText(lowerer, expr.argumentExpression); + if (key !== null) { + const target = classFieldTarget(lowerer, expr.expression, receiverIr, key); + if (target) return lowerer.maybeNarrow(lowerer.fieldGetExpr(target, locOf(expr), expr), expr); + } + if (receiverIr.className === "%Error" || isDynTypedRefType(receiverIr)) { + const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN); + const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); + return lowerer.maybeNarrow( + { kind: "dynKeyGet", value: receiver, key, type: DYN, loc: locOf(expr) }, + expr, + ); + } + } + + // Typed-array element read `b[i]` — like arrayGet with a scalar + // result; any invalid index traps (the array discipline; JS would + // read undefined — divergence 4's policy). + if (receiverIr?.kind === "bytes") { + let recv = lowerer.lowerExpr(expr.expression); + if (recv.type.kind === "dyn") recv = lowerer.coerceInto(expr.expression, recv, receiverIr); + if (recv.type.kind === "union" && lowerer.armTag(recv.type.unionId, UNDEFINED_T) >= 0) { + const present = lowerer.stripUndefinedArm(recv.type); + const helper = + present.kind === "bytes" + ? lowerer.narrowedArmHelper(recv.type.unionId, present, locOf(expr.expression)) + : null; + recv = helper + ? lowerer.checkedNarrowCall(helper, recv, present, locOf(expr.expression)) + : lowerer.maybeNarrow(recv, expr.expression); + } + + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); + if (isJsSourceFile(expr.getSourceFile()) && recv.type.kind === "bytes") { + return { + kind: "dynKeyGet", + value: lowerer.coerceInto(expr.expression, recv, DYN), + key: + index.type.kind === "f64" + ? { kind: "toString", operand: index, type: STRING, loc: locOf(expr) } + : index, + type: DYN, + loc: locOf(expr), + }; + } + if (index.type.kind === "dyn" && recv.type.kind === "bytes") { + return { + kind: "dynKeyGet", + value: lowerer.coerceInto(expr.expression, recv, DYN), + key: index, + type: DYN, + loc: locOf(expr), + }; + } + if (index.type.kind !== "f64") { + lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); + } + // `t[i]!` asserts presence: the numeric element read (NaN when + // invalid). Every other read answers undefined for an invalid index, + // like Node. + // A read whose every consumer treats undefined like NaN (see + // nan-coded-reads.ts) is the same plain numeric read. + if ( + (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + lowerer.nanCodedReads?.isNumericRead(expr) === true + ) { + return { + kind: "bytesIntrinsic", + method: "get", + receiver: recv, + args: [index], + type: F64, + loc: locOf(expr), + invalidNaN: true, + }; + } + return lowerSafeBytesRead(lowerer, recv, index, locOf(expr)); + } + // Tuple element read `t[0]`: a positional-field read of the tuple's + // record shape — LITERAL indices only (the checker's per-index types + // are what make the read honest; a dynamic index over a heterogeneous + // shape has no single element type). Narrows like any field read. + if (receiverIr?.kind === "record") { + const shape = lowerer.shapes.get(receiverIr.shapeId); + if (shape?.tuple) { + let obj = lowerer.lowerExpr(expr.expression); + + if (obj.type.kind === "dyn") { + // Object.entries over a checked value stores each pair in the + // dynamic tree even when the checker names a tuple type. + const key = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + expr.argumentExpression, + ); + const optional = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: obj, + key, + ...(optional ? { optional: true as const } : {}), + type: DYN, + loc: locOf(expr), + }, + expr, + ); + } + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { + // The checker sees the outer `arrays[i]` as a tuple when + // noUncheckedIndexedAccess is disabled, but the lowered read is + // still tuple | undefined. A nested tuple field must validate the + // outer tag before recordGet; an unchecked unionNarrow would let a + // missing outer element reach the record backend as a bad pointer. + const present = lowerer.stripUndefinedArm(obj.type); + const helper = + present.kind === "record" + ? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(expr.expression)) + : null; + obj = helper + ? lowerer.checkedNarrowCall(helper, obj, present, locOf(expr.expression)) + : lowerer.maybeNarrow(obj, expr.expression); + } + const idx = tupleLiteralIndex(expr.argumentExpression); + if (idx === null) { + lowerer.unsupported( + "SC1090", + expr.argumentExpression, + "tuple indexing with a non-literal index (tuples are fixed-shape — use t[0], t[1], ...)", + ); + } + const shapeId = obj.type.kind === "record" ? obj.type.shapeId : receiverIr.shapeId; + const actualShape = lowerer.shapes.get(shapeId) ?? shape; + const fieldType = actualShape.fields.find((f) => f.name === String(idx))?.type; + if (fieldType === undefined) lowerer.badType(expr, lowerer.typeOf(expr)); // OOB smuggled past tsc + const get: IrExpr = { + kind: "recordGet", + obj, + shapeId, + field: String(idx), + type: fieldType, + loc: locOf(expr), + }; + return lowerer.isRuntimeOptionalField(shapeId, String(idx)) + ? get + : lowerer.maybeNarrow(get, expr); + } + if (shape && !shape.tuple) { + return lowerer.lowerRecordKeyRead(expr, receiverIr.shapeId, shape); + } + } + // `pkg["k"]` / `scripts[name]` / `scopeMatch[1]` on a dyn receiver (a + // JSON.parse result): the dyn keyed read, exactly the dot form. + // NUMBER-typed indices convert through ToString first — JS property + // keys are strings, and the canonical number text answers array + // indices in the helper (fractions, negatives, and NaN read as + // absent keys, exactly JS). An UNMAPPABLE checker type takes the + // same path when the receiver LOWERS dyn + // (`pkg.workspaces.packages["0"]` — the lowering world types the + // unknown-rooted chain `any`); a non-dyn lowering falls through to + // the fences below (re-lowering is pure IR construction). + if (receiverIr?.kind === "dyn" || receiverIr?.kind === "union" || receiverIr === null) { + const obj = lowerer.lowerExpr(expr.expression); + // A generic mapped type can stay unresolved at this body use even + // though its instantiated parameter has a concrete record ABI. + // Dispatch from that stored shape, as the array fallback below does. + if (receiverIr === null && obj.type.kind === "record") { + const shape = lowerer.shapes.get(obj.type.shapeId); + const index = tupleLiteralIndex(expr.argumentExpression); + const field = + shape?.tuple && index !== null + ? shape.fields.find((field) => field.name === String(index)) + : null; + if (field) + return lowerer.maybeNarrow( + { + kind: "recordGet", + obj, + shapeId: obj.type.shapeId, + field: field.name, + type: field.type, + loc: locOf(expr), + }, + expr, + ); + if (shape && !shape.tuple) { + return lowerer.lowerRecordKeyRead(expr, obj.type.shapeId, shape); + } + } + if ( + obj.type.kind === "dyn" || + (obj.type.kind === "object" && obj.type.className === "%Error") || + isDynTypedRefType(obj.type) + ) { + const rawKey = lowerer.lowerExpr(expr.argumentExpression); + // Number, bool, and DYN keys stringify (ToPropertyKey) — the + // dyn-keyed read `catchWarning[warning.name]` where the property + // chain itself lowered dyn. + const key = + rawKey.type.kind === "dyn" || + rawKey.type.kind === "symbol" || + (rawKey.type.kind === "union" && lowerer.dynConvertible(rawKey.type)) + ? lowerer.coerceToExpected(rawKey, DYN) + : lowerRecordPropertyKey(lowerer, rawKey, expr.argumentExpression); + if (key.type.kind === "string" || key.type.kind === "dyn") { + const opt = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + key, + ...(opt ? { optional: true as const } : {}), + value: lowerer.coerceToExpected(obj, DYN), + type: DYN, + loc: locOf(expr), + }, + expr, + ); + } + } + // A checker-`any` receiver that LOWERS to a static ARRAY + // (`pm.split("@")[0]` — the dyn-receiver string machinery answers + // string[]): the element read rides the ordinary array path on the + // lowered value (invalid indices trap, divergence 4's policy). + if (obj.type.kind === "array") { + const index = lowerer.lowerExpr(expr.argumentExpression); + if (index.type.kind === "f64") { + const safe = lowerSafeIndexRead(lowerer, obj, index, locOf(expr)); + if (safe) return safe; + return lowerer.maybeNarrow( + { kind: "arrayGet", arr: obj, index, type: obj.type.elem, loc: locOf(expr) }, + expr, + ); + } + } + } + // `env[key]` on a UNION of record shapes (`ProcessEnv | Record` — the env-bag parameter pattern): the per-arm keyed read, + // joined exactly like the dot form (lowerUnionProperty's keyed path). + if (receiverIr?.kind === "union") { + const value = lowerer.lowerExpr(expr.expression); + if (value.type.kind === "union") { + const key = lowerer.lowerExpr(expr.argumentExpression); + if (key.type.kind === "f64") { + const def = lowerer.unions.get(value.type.unionId); + const presentArms = def?.arms.filter((arm) => !isUnitType(arm)) ?? []; + if (presentArms.length === 1 && presentArms[0]?.kind === "string") { + const property = runtimeOptionalElementKey(expr.argumentExpression); + const receiver = + property === null + ? null + : lowerer.runtimeOptionalPropertyReceiver(expr.expression, value, STRING, property); + if (receiver !== null) { + return { + kind: "strIntrinsic", + method: "charAt", + receiver, + args: [key], + type: STRING, + loc: locOf(expr), + }; + } + } + } + if (key.type.kind === "f64") { + const safe = lowerOptionalArrayIndexRead(lowerer, expr, value, key); + if (safe) return safe; + } + if (key.type.kind === "string" || key.type.kind === "f64") { + const lit = ts.isStringLiteral(expr.argumentExpression) + ? expr.argumentExpression.text + : null; + const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, lit); + if (keyed) return lowerer.maybeNarrow(keyed, expr); + } + } + } + if (receiverIr?.kind !== "array") { + { + const receiver = tryLowerExpression(lowerer, expr.expression); + if ( + receiver?.type.kind === "dyn" || + (receiver && + isJsSourceFile(expr.getSourceFile()) && + (receiver.type.kind === "union" || receiver.type.kind === "bytes") && + lowerer.dynConvertible(receiver.type)) + ) { + const key = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + expr.argumentExpression, + ); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: lowerer.coerceInto(expr.expression, receiver, DYN), + key, + type: DYN, + loc: locOf(expr), + }, + expr, + ); + } + if ( + receiver?.type.kind === "func" && + canBoxFuncIntoDyn( + receiver.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + const key = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + expr.argumentExpression, + ); + if (key.type.kind === "string") { + const boxed: IrExpr = { + kind: "dynFrom", + value: receiver, + type: DYN, + loc: locOf(expr.expression), + }; + return lowerer.maybeNarrow( + { kind: "dynKeyGet", key, value: boxed, type: DYN, loc: locOf(expr) }, + expr, + ); + } + } + } + if (receiverIr?.kind === "string") { + // An indexed string property is absent for non-integer and missing + // indexes. charAt has different coercion and out-of-range semantics. + const recv = lowerer.lowerExpr(expr.expression); + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); + if (index.type.kind === "f64" && recv.type.kind === "string") { + // A proven in-bounds integer index always names a code unit, where + // charAt and the property read agree. + if (lowerer.stringIndexBounds.inBounds(expr)) + return { + kind: "strIntrinsic", + method: "charAt", + receiver: recv, + args: [index], + type: STRING, + loc: locOf(expr), + }; + return lowerOptionalStringIndex( + lowerer, + recv, + index, + lowerer.withUndefinedArm(STRING), + locOf(expr), + ); + } + // The checker narrowed the receiver to string, but its stored value + // is still the union (an array-derived binding, a typeof guard). + if (index.type.kind === "f64") { + const read = lowerUnionStringIndex( + lowerer, + recv, + index, + expr.argumentExpression, + lowerer.withUndefinedArm(STRING), + locOf(expr), + ); + if (read) return read; + } + lowerer.unsupported( + "SC1090", + expr, + "string indexing with this index/result shape (expected a number index and a string or string | undefined result)", + ); + } + lowerer.unsupported("SC1090", expr, "element access on non-array values"); + } + let arr = lowerer.lowerExpr(expr.expression); + const runtimeOptionalSource = lowerer.runtimeOptionalSourceValue(expr.expression, arr); + if ( + runtimeOptionalSource?.kind === "varRef" && + runtimeOptionalSource.type.kind === "union" && + arr.type.kind === "array" + ) { + // lowerer.lowerElementAccess has already recognized a captured + // array | undefined receiver and supplied its checked extraction as + // the expression override. Rebuild that check here so a missing + // receiver throws the member-access error Node exposes, rather than + // the generic target-union narrowing error. The stored receiver is a + // varRef, so both the tag test and extraction read the same value + // without repeating an effect. + const stored = runtimeOptionalSource; + if (stored.type.kind !== "union") + throw new InternalCompilerError("runtime-optional array source lost its union storage"); + const unionId = stored.type.unionId; + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + const valueTag = lowerer.armTag(unionId, arr.type); + const property = runtimeOptionalElementKey(expr.argumentExpression); + if (undefTag >= 0 && valueTag >= 0 && property !== null) { + arr = { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + negated: false, + value: stored, + type: BOOL, + loc: locOf(expr.expression), + }, + then: nodeThrowExpr( + 1, + "", + `Cannot read properties of undefined (reading '${property}')`, + arr.type, + locOf(expr.expression), + ), + else_: { + kind: "unionNarrow", + unionId, + tag: valueTag, + value: stored, + type: arr.type, + loc: locOf(expr.expression), + }, + type: arr.type, + loc: locOf(expr.expression), + }; + } + } + if ( + arr.type.kind === "union" && + lowerer.armTag(arr.type.unionId, UNDEFINED_T) >= 0 && + receiverIr.kind === "array" + ) { + // As with tuple receivers above, an unchecked outer read can be + // undefined even when the checker reports an array. Extract the + // present array arm with a runtime tag check before arrayGet or the + // safe indexed-read helper runs. + const present = lowerer.stripUndefinedArm(arr.type); + const helper = + present.kind === "array" + ? lowerer.narrowedArmHelper(arr.type.unionId, present, locOf(expr.expression)) + : null; + arr = helper + ? lowerer.checkedNarrowCall(helper, arr, present, locOf(expr.expression)) + : lowerer.maybeNarrow(arr, expr.expression); + } + + // The checker sees an array but the VALUE lowered checked-dynamic (a + // jsdoc-typed default export from a .js module — signature 14): the + // read rides the dynCheck boundary when the array shape is one the checked-dynamic tree + // can validate, and fences by name when it is not — an arrayGet over a + // dyn receiver is never emitted (the validator ICE). + if (arr.type.kind === "dyn") { + if ( + canDynCheckTo( + receiverIr, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + arr = { kind: "dynCheck", value: arr, type: receiverIr, loc: locOf(expr) }; + } else { + lowerer.unsupported( + "SC1090", + expr, + `element access on checked-dynamic values with '${lowerer.fmt(receiverIr)}' elements`, + ); + } + } + // Array reads can themselves supply an index. A missing numeric value + // is undefined, whose property lookup misses just like NaN; keep that + // value instead of rejecting a checker-number index with optional IR. + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(expr.argumentExpression), + locOf(expr.argumentExpression), + expr.argumentExpression, + ); + if ( + arr.type.kind === "array" && + (index.type.kind === "dyn" || + (isJsSourceFile(expr.getSourceFile()) && + index.type.kind !== "f64" && + lowerer.dynConvertible(index.type))) + ) { + return { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(arr, DYN), + key: index, + type: DYN, + loc: locOf(expr), + }; + } + if (index.type.kind !== "f64") { + lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); + } + // The LOWERED receiver's element type wins over the checker's when + // both are arrays: a spoke result can be more precise than the + // declared surface (emitter.listeners() answers the event's tuple + // signature where the .d.ts says Function[], which would flatten the + // element to a zero-parameter closure). + const elemT = arr.type.kind === "array" ? arr.type.elem : receiverIr.elem; + // Every ordinary array read is a JavaScript property read: an invalid + // index answers undefined, even when the checker was built without + // noUncheckedIndexedAccess. Keep the explicit optional union in the IR + // so it can flow through templates, locals, calls, returns, operators, + // and narrowing. The helper performs one length/proof check and then + // uses the dense getter for the proven-present path, so this does not + // change the storage representation or the low-level getter itself. + if (arr.type.kind === "array") { + // A non-null assertion is the explicit proven-present form. Preserve + // the dense getter's established bounds trap where the asserted value + // must be the element type (library ABI checks and the catchable + // RangeError contract for an out-of-bounds assert). Where undefined is + // representable, the assertion keeps JavaScript's unchecked read. + const provenPresent = + ts.isNonNullExpression(expr.parent) && + expr.parent.expression === expr && + !assertedElementKeepsUndefined(lowerer, expr.parent); + if (provenPresent) { + return lowerer.maybeNarrow( + { kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, + expr, + ); + } + const safe = lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); + if (safe) return safe; + } + // --npm-static package files retain the original slice-specific route + // for compatibility with package-source inference. + if ( + arr.type.kind === "array" && + arr.kind === "arrIntrinsic" && + arr.method === "slice" && + npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null + ) { + const safe = lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); + if (safe) return safe; + } + // A union-element read narrows like an identifier when the checker has + // narrowed THIS occurrence (`if (a[0] !== undefined) use(a[0])` — tsc + // narrows literal-index element accesses). + return lowerer.maybeNarrow({ kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, expr); +} + +/** A record receiver that an outer keyed read left optional + * (`nested[a][b]`, where `nested[a]` can miss). The receiver evaluates + * once into `stmts`; `present` is its record arm, valid only once the + * access has tested `stable` against the undefined (or, for reads, null) + * arm. */ +interface OptionalRecordReceiver { + stmts: IrStmt[]; + stable: IrExpr; + present: IrExpr; + unionId: string; + undefTag: number; + nullish: "undefined" | "null"; +} + +function optionalRecordReceiver( + lowerer: Lowerer, + value: IrExpr, + loc: SrcLoc, + allowNull = false, +): OptionalRecordReceiver | null { + if (value.type.kind !== "union") return null; + const unionId = value.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if (arms?.length !== 2) return null; + let undefTag = lowerer.armTag(unionId, UNDEFINED_T); + let nullish: OptionalRecordReceiver["nullish"] = "undefined"; + // A null-initialized slot assigned a record later (iconv-lite's + // `encodings = null` table, filled on first use): the checker narrows + // the read to the record, the storage keeps the null arm. + if (undefTag < 0 && allowNull) { + undefTag = lowerer.armTag(unionId, NULL_T); + nullish = "null"; + } + if (undefTag < 0) return null; + const record = arms[1 - undefTag]!; + if (record.kind !== "record") return null; + const stmts: IrStmt[] = []; + const stable = stabilized(lowerer, value, "%recordReceiver", stmts); + const present: IrExpr = { + kind: "unionNarrow", + unionId, + tag: lowerer.armTag(unionId, record), + value: stable, + type: record, + loc, + }; + return { stmts, stable, present, unionId, undefTag, nullish }; +} + +/** `value`, evaluated once at this point of `out`. */ +function stabilized(lowerer: Lowerer, value: IrExpr, name: string, out: IrStmt[]): IrExpr { + if (isSafeToRepeat(value)) return value; + const tmp = lowerer.declareHiddenLocal(name, value.type); + out.push({ kind: "varDecl", localId: tmp.id, init: value, loc: value.loc }); + return varRef(tmp.id, value.type, value.loc); +} + +function undefinedReceiver(receiver: OptionalRecordReceiver, loc: SrcLoc): IrExpr { + return { + kind: "unionIsTag", + unionId: receiver.unionId, + tag: receiver.undefTag, + negated: false, + value: receiver.stable, + type: BOOL, + loc, + }; +} + +/** Node's TypeError for a keyed read or write on an undefined (or null) + * receiver. */ +function undefinedReceiverThrow( + key: IrExpr, + action: "read" | "set", + type: IrType, + loc: SrcLoc, + nullish: OptionalRecordReceiver["nullish"] = "undefined", +): IrExpr { + const head = `Cannot ${action} properties of ${nullish} (${action === "read" ? "reading" : "setting"} '`; + const message: IrExpr = { + kind: "strConcat", + left: { + kind: "strConcat", + left: { kind: "strLit", value: head, type: STRING, loc }, + right: key, + type: STRING, + loc, + }, + right: { kind: "strLit", value: "')", type: STRING, loc }, + type: STRING, + loc, + }; + return { + kind: "libCall", + fn: "error.nodeThrow", + args: [numLit(1, loc), { kind: "strLit", value: "", type: STRING, loc }, message], + type, + loc, + }; +} + +/** The receiver and key of a keyed access whose receiver can be undefined, + * evaluated in order into `stmts`, with the receiver's record arm and the + * key's property-name string. Null when the receiver is not optional. */ +function guardedRecordAccess( + lowerer: Lowerer, + access: ts.ElementAccessExpression, + rawObj: IrExpr, + allowNull = false, +): { receiver: OptionalRecordReceiver; key: IrExpr; name: IrExpr } | null { + const receiver = optionalRecordReceiver(lowerer, rawObj, locOf(access.expression), allowNull); + if (!receiver) return null; + const keyNode = access.argumentExpression; + const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); + const key = + litKey !== null + ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) + : stabilized(lowerer, lowerer.lowerExpr(keyNode), "%recordKey", receiver.stmts); + // A symbol key names no string property; its message spelling is not + // modeled. + const name = + key.type.kind === "symbol" + ? ({ kind: "strLit", value: "Symbol()", type: STRING, loc: locOf(keyNode) } satisfies IrExpr) + : lowerRecordPropertyKey(lowerer, key, keyNode); + return { receiver, key, name }; +} + +/** `nested[a][b] = v` where `nested[a]` can miss: the receiver, the key and + * the value evaluate in order, then an undefined receiver throws Node's + * TypeError and a present one takes the write. Only member and identifier + * receivers are probed, since a probe that finds no optional receiver + * lowers the receiver again. */ +function lowerGuardedRecordWrite( + lowerer: Lowerer, + expr: ts.BinaryExpression, + target: ts.ElementAccessExpression, +): IrStmt | null { + let receiverNode: ts.Expression = target.expression; + while (ts.isParenthesizedExpression(receiverNode)) receiverNode = receiverNode.expression; + if ( + lowerer.chainRecvByNode.has(target.expression) || + !( + ts.isElementAccessExpression(receiverNode) || + ts.isPropertyAccessExpression(receiverNode) || + ts.isIdentifier(receiverNode) + ) + ) + return null; + const guarded = guardedRecordAccess(lowerer, target, lowerer.lowerExpr(target.expression)); + if (!guarded) return null; + const { receiver, key, name } = guarded; + const loc = locOf(expr); + const value = stabilized(lowerer, lowerer.lowerExpr(expr.right), "%recordValue", receiver.stmts); + const write = lowerer.withExpressionOverride(target.expression, receiver.present, () => + lowerer.withExpressionOverride(target.argumentExpression, key, () => + lowerer.withExpressionOverride(expr.right, value, () => lowerElementWrite(lowerer, expr)), + ), + ); + return { + kind: "block", + body: [ + ...receiver.stmts, + { + kind: "if", + cond: undefinedReceiver(receiver, loc), + then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }], + else_: [write], + loc, + }, + ], + loc, + }; +} + +/** `b[i] = v` where the checker's flow type of `b` is a typed array but + * its storage is wider. A hoisted JavaScript `var` (or a field written + * after construction) holds `bytes | undefined`: the receiver, index and + * value evaluate in order, then an undefined receiver throws Node's + * TypeError and a present one takes the typed store. Any other storage (a + * `var` later reassigned a string, a checked-dynamic value) takes the + * checked-dynamic store, which applies whatever value the receiver holds. */ +function lowerWiderBytesWrite( + lowerer: Lowerer, + expr: ts.BinaryExpression, + target: ts.ElementAccessExpression, + recv: IrExpr, +): IrStmt { + const loc = locOf(expr); + if (recv.type.kind === "union") { + const unionId = recv.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + const bytes = arms?.length === 2 && undefTag >= 0 ? arms[1 - undefTag]! : null; + const keyType = lowerer.mapTypeOf(lowerer.typeOf(target.argumentExpression)); + const index = + bytes?.kind === "bytes" && keyType?.kind === "f64" + ? lowerer.lowerExpr(target.argumentExpression) + : null; + // An index stored wider than a number (a hoisted `var` counter) keeps + // the checked-dynamic store below, which converts the key exactly. + if (bytes?.kind === "bytes" && index?.type.kind === "f64") { + const stmts: IrStmt[] = []; + const stable = stabilized(lowerer, recv, "%bytesReceiver", stmts); + const key = stabilized(lowerer, index, "%bytesIndex", stmts); + const value = stabilized( + lowerer, + lowerer.lowerExprExpecting(expr.right, F64), + "%bytesValue", + stmts, + ); + const name = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + const present: IrExpr = { + kind: "unionNarrow", + unionId, + tag: 1 - undefTag, + value: stable, + type: bytes, + loc, + }; + return { + kind: "block", + body: [ + ...stmts, + { + kind: "if", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + negated: false, + value: stable, + type: BOOL, + loc, + }, + then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }], + else_: [{ kind: "bytesSet", arr: present, index: key, value, loc }], + loc, + }, + ], + loc, + }; + } + } + if (!lowerer.dynConvertible(recv.type)) { + lowerer.unsupported( + "SC1090", + target.expression, + `typed-array element writes through '${lowerer.fmt(recv.type)}' storage`, + ); + } + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, lowerer.coerceInto(target.expression, recv, DYN)), + loc, + }; +} + +/** `r[k]` over a (non-tuple) record shape. A LITERAL key naming a declared + * field is the bracket spelling of field access (the ONLY spelling tsc + * permits for signature-declared fields under + * noPropertyAccessFromIndexSignature) — an ordinary recordGet. Everything + * else is a runtime-keyed read (recordKeyGet): declared fields answer + * first via an emitted string-switch, index-signature shapes fall through + * to the overflow map. The result type is the index signature's value type + * armed with undefined (dyn for `unknown`): a missing key reads undefined, + * as in JavaScript, whatever noUncheckedIndexedAccess says. Every declared + * field must be able to SURFACE as that type (equal, an arm of it, or — + * for dyn results — JSON-safe for the dyn conversion); shapes mixing in + * fields outside that stay fenced. Declared-only shapes support reads + * whose key type proves membership (tsc's keyof check): all fields must + * share the result type, and a smuggled miss traps. */ +export function lowerRecordKeyRead( + lowerer: Lowerer, + expr: ts.ElementAccessExpression, + shapeId: string, + shape: IrRecordShape, + includeUndefined = false, +): IrExpr { + const loc = locOf(expr); + const keyNode = expr.argumentExpression; + // Literal declared keys are plain field reads (narrowing included). + // NUMERIC literals are their canonical string spelling — JS object keys + // ARE strings (`r[1]` reads `r["1"]`), so `b[1]` hits a declared field + // named "1" (a mapped type over a numeric enum) exactly like `b["1"]`. + // A key IDENTIFIER whose type proves one literal (a literal-typed + // const, a keyof-constrained type parameter bound to a literal inside + // a generic instance) is the same static read — identifier evaluation + // is pure, so skipping it matches JS exactly. + const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); + // An outer keyed read can leave the receiver undefined: evaluate the + // receiver and the key, then throw Node's TypeError or read the present + // record. + // The receiver lowers FIRST (both branches below read it, and JS + // evaluates the receiver before the key). + const obj = lowerer.lowerExpr(expr.expression); + { + // A null arm is guarded only for string and number keys: other keys + // already take the checked-dynamic read, which handles null itself. + // Only a receiver with a null arm asks about the key. + let allowNull = false; + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, NULL_T) >= 0) { + const keyType = litKey !== null ? STRING : lowerer.mapTypeOf(lowerer.typeOf(keyNode)); + allowNull = keyType?.kind === "string" || keyType?.kind === "f64"; + } + const guarded = guardedRecordAccess(lowerer, expr, obj, allowNull); + if (guarded) { + const { receiver, key, name } = guarded; + const read = lowerer.withExpressionOverride(expr.expression, receiver.present, () => + litKey !== null + ? lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined) + : lowerer.withExpressionOverride(keyNode, key, () => + lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined), + ), + ); + return { + kind: "seqExpr", + stmts: receiver.stmts, + result: { + kind: "ternary", + cond: undefinedReceiver(receiver, loc), + then: undefinedReceiverThrow(name, "read", read.type, loc, receiver.nullish), + else_: read, + type: read.type, + loc, + }, + type: read.type, + loc, + }; + } + } + // A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a JS + // file-scope object-literal global): the checked-dynamic keyed read — + // dynKeyGet against the runtime keys (a missing key answers the checked-dynamic tree + // undefined, exactly JS); consumers validate (dynCheck) where a + // static type is required, the member-read discipline. + if (obj.type.kind === "dyn") { + let dk = + litKey !== null + ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) + : lowerer.lowerExpr(keyNode); + // Number AND checked-dynamic keys ride the JS-exact formatter — + // property keys ARE strings (o[k] is o[String(k)] in JS), and a dyn + // key (agent.sockets[agent.getName(...)]) stringifies the same way. + if (dk.type.kind === "union" && lowerer.dynConvertible(dk.type)) + dk = lowerer.coerceToExpected(dk, DYN); + if (dk.type.kind !== "symbol" && dk.type.kind !== "dyn") + dk = lowerRecordPropertyKey(lowerer, dk, keyNode); + if (dk.type.kind !== "string" && dk.type.kind !== "symbol" && dk.type.kind !== "dyn") { + lowerer.unsupported("SC1090", keyNode, "indexing records with non-string or non-number keys"); + } + const read: IrExpr = { kind: "dynKeyGet", key: dk, value: obj, type: DYN, loc }; + // Absence probes must keep the checked-dynamic undefined produced by + // a missing key. The ordinary read narrows to the checker's declared + // index value (and therefore validates it); ===/!==, ||, and ?? need + // to observe the missing value instead of throwing during that check. + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); + } + if (litKey !== null) { + const nominal = representedClassFieldTarget(lowerer, expr.expression, litKey, obj); + if (nominal) { + const read = lowerer.fieldGetExpr(nominal, loc, expr); + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); + } + } + if (hasClassPayload(lowerer, obj.type)) { + lowerer.unsupported( + "SC1090", + expr, + "computed property reads through structural views of class instances (use a declared literal key)", + ); + } + // Predicates can strengthen optional fields without changing the + // receiver's stored layout. Use that layout for bracket reads just as + // fieldTarget does for the corresponding dot reads. + if (obj.type.kind === "record") { + const actualShape = lowerer.shapes.get(obj.type.shapeId); + if (!actualShape) + throw new InternalCompilerError("record receiver is missing its stored shape"); + shapeId = obj.type.shapeId; + shape = actualShape; + } + if (litKey !== null) { + const field = shape.fields.find((f) => f.name === litKey); + if (field) { + const get: IrExpr = { + kind: "recordGet", + obj, + shapeId, + field: field.name, + type: field.type, + loc, + }; + return lowerer.maybeNarrow(get, expr); + } + } + let key = + litKey !== null + ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) + : lowerer.lowerExpr(keyNode); + // Optional string/number keys need ToPropertyKey, not a boxed copy of + // the receiver. Keep record reads on their native storage so nested + // references retain identity across index-signature reads. + if ( + key.type.kind === "union" && + !lowerer.unions + .get(key.type.unionId) + ?.arms.some((arm) => arm.kind === "symbol" || arm.kind === "dyn") + ) { + key = lowerRecordPropertyKey(lowerer, key, keyNode); + } + if ( + (key.type.kind === "symbol" || key.type.kind === "dyn" || key.type.kind === "union") && + lowerer.dynConvertible(key.type) && + lowerer.dynConvertible(obj.type) + ) { + const read: IrExpr = { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(obj, DYN), + key: lowerer.coerceToExpected(key, DYN), + type: DYN, + loc, + }; + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); + } + // Runtime NUMBER keys ride the JS-exact formatter (o[n] is o[String(n)] + // in JS — the number-keyed-signature access path). + key = lowerRecordPropertyKey(lowerer, key, keyNode); + if (key.type.kind !== "string") { + lowerer.unsupported("SC1090", keyNode, "indexing records with non-string or non-number keys"); + } + if (shape.fields.length === 0 && shape.indexValue === undefined) { + const result = dynUndefinedExpr(loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "exprStmt", expr: obj, loc }, + { kind: "exprStmt", expr: key, loc }, + ], + result, + type: DYN, + loc, + }; + } + // The result type of the access — the index signature's value type + // armed with undefined, or the declared fields' one common type on + // signature-free shapes (tsc's keyof check proved membership; a + // smuggled miss traps). On signature-free shapes the checker may have + // NARROWED this occurrence (assignment CFA on literal keys) — maybeNarrow + // bridges to the narrowed arm exactly like a field read. + let declared: IrType | null = null; + // An index-signature read is a JavaScript property read: a missing key + // answers undefined even without noUncheckedIndexedAccess. The read keeps + // that explicit optional union, as an ordinary array read does. + let optionalRead = false; + // A read whose every consumer treats undefined like NaN (see + // nan-coded-reads.ts) is a plain number that is NaN for a missing key. + const nanCoded = + !includeUndefined && + shape.indexValue?.kind === "f64" && + lowerer.nanCodedReads?.isNumericRead(expr) === true; + if (shape.indexValue) { + declared = shape.indexValue; + if (declared.kind !== "dyn" && !nanCoded) { + declared = lowerer.withUndefinedArmOf(declared); + if (!declared) lowerer.badType(expr, lowerer.typeOf(expr)); + optionalRead = true; + } + } else if ( + shape.fields.length > 0 && + shape.fields.every((f) => typeEquals(f.type, shape.fields[0]!.type)) + ) { + declared = shape.fields[0]!.type; + } + if (!declared && isJsSourceFile(expr.getSourceFile()) && recordKeyResultOk(lowerer, shape, DYN)) + declared = DYN; + if (!declared) { + lowerer.unsupported( + "SC1090", + expr, + `dynamic keyed reads of '${lowerer.fmt({ kind: "record", shapeId })}' (the declared fields have no one common type)`, + ); + } + // A LITERAL key naming no declared field can only hit the overflow: + // the declared-field surfacing constraint doesn't apply. + const overflowOnly = litKey !== null && !!shape.indexValue; + if (!recordKeyResultOk(lowerer, overflowOnly ? { ...shape, fields: [] } : shape, declared)) { + lowerer.unsupported( + "SC1090", + expr, + `dynamic keyed reads of '${lowerer.fmt({ kind: "record", shapeId })}' as '${lowerer.fmt(declared)}' (every declared field must be readable at that type)`, + ); + } + const read: IrExpr = { + kind: "recordKeyGet", + obj, + shapeId, + key, + ...(overflowOnly ? { overflowOnly: true as const } : {}), + ...(nanCoded ? { missNaN: true as const } : {}), + type: declared, + loc, + }; + if (nanCoded) return read; + return includeUndefined || optionalRead ? read : lowerer.maybeNarrow(read, expr); +} + +/** ToPropertyKey for represented record keys. Missing array-derived string + * values remain real undefined values, whose JavaScript property spelling + * is the string "undefined". */ +function lowerRecordPropertyKey(lowerer: Lowerer, key: IrExpr, node: ts.Expression): IrExpr { + if (key.type.kind === "union" && lowerer.dynConvertible(key.type)) { + return { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [lowerer.coerceInto(node, key, DYN)], + type: STRING, + loc: key.loc, + }; + } + if ( + key.type.kind === "f64" || + key.type.kind === "bool" || + key.type.kind === "dyn" || + lowerer.runtimeOptionalWidening(key.type, STRING) !== null + ) { + return lowerer.ensureString(key, node); + } + return key; +} + +/** The compile-time string spelling of a record key literal: a string + * literal's text, or a NON-NEGATIVE numeric literal's canonical JS + * spelling (`1` → "1", `1e21` → "1e+21" — String(Number(text)), which is + * exactly the key JS derives; negative/computed keys stay runtime + * expressions and canonicalize through ensureString instead). Null for + * everything else. */ +function recordKeyLiteralText(node: ts.Expression): string | null { + if (ts.isStringLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return String(Number(node.text)); + return null; +} + +/** The compile-time key an IDENTIFIER's TYPE proves: a key variable whose + * (narrowed) type is one string/number literal — `const k: "a" = …; o[k]`, + * and the generic-body form where k's type is a keyof-constrained + * parameter bound to a literal (`pick(o, "a")`'s instance reads `o[k]` + * with K = "a", resolved through typeParamTsBindings). Identifier reads + * are pure, so the static field read may skip evaluating the key exactly + * like a syntactic literal. Null anywhere the type keeps more than one + * key. */ +function recordKeyTypeLiteralText(lowerer: Lowerer, node: ts.Expression): string | null { + if (!ts.isIdentifier(node)) return null; + let t: ts.Type = lowerer.typeOf(node); + if (t.flags & ts.TypeFlags.TypeParameter) { + const bound = lowerer.typeParamTsResolver(t); + if (!bound) return null; + t = bound; + } + if (t.isStringLiteralType()) return t.value; + if (t.isNumberLiteralType()) { + const n = t.value; + return Number.isFinite(n) && n >= 0 ? String(n) : null; + } + return null; +} + +/** The literal index of a tuple access, or null when the expression isn't + * a non-negative integer literal (parentheses tolerated — tsc's own + * literal-index typing accepts them). */ +function tupleLiteralIndex(node: ts.Expression): number | null { + let e: ts.Expression = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isNumericLiteral(e)) return null; + const n = Number(e.text); + return Number.isInteger(n) && n >= 0 ? n : null; +} + +export function lowerElementCompoundParts( + lowerer: Lowerer, + expr: ts.BinaryExpression, + op: CompoundOp, + statement: boolean, +): { expr: IrExpr | null; stmts: IrStmt[] | null } { + const target = expr.left as ts.ElementAccessExpression; + fenceNodeModuleMutation(lowerer, target, "assignment"); + const loc = locOf(expr); + const checked = lowerer.lowerExpr(target.expression); + if (checked?.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { + const stmts: IrStmt[] = []; + const save = (value: IrExpr, name: string): IrExpr => { + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, value.type, loc); + }; + const receiver = save(checked, "%compoundReceiver"); + const key = save(lowerer.lowerExprExpecting(target.argumentExpression, DYN), "%compoundKey"); + const old = save({ kind: "dynKeyGet", value: receiver, key, type: DYN, loc }, "%compoundOld"); + const rhs = lowerer.lowerExprExpecting(expr.right, DYN); + const numeric = ["-", "*", "/", "%", "**"].includes(op); + const value = save( + { + kind: "libCall", + fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise", + args: op === "+" ? [old, rhs] : [old, rhs, strLit(op, loc)], + type: DYN, + loc, + }, + "%compoundResult", + ); + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySetComputed", + args: [receiver, key, value], + type: VOID, + loc, + }, + loc, + }); + return { expr: { kind: "seqExpr", stmts, result: value, type: DYN, loc }, stmts: null }; + } + const receiverType = + checked.type.kind === "array" || checked.type.kind === "bytes" + ? checked.type + : lowerer.mapTypeOf(lowerer.typeOf(target.expression)); + if (receiverType?.kind !== "array" && receiverType?.kind !== "bytes") { + lowerer.unsupported("SC1090", target, "compound assignment to non-array elements"); + } + let receiver = checked; + if (receiver.type.kind === "union" && lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0) { + const present = lowerer.stripUndefinedArm(receiver.type); + const helper = + present.kind === receiverType.kind + ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(target.expression)) + : null; + if (helper) + receiver = lowerer.checkedNarrowCall(helper, receiver, present, locOf(target.expression)); + } + if (receiver.type.kind !== receiverType.kind) { + lowerer.unsupported( + "SC1090", + target.expression, + "compound assignment through a non-native array or byte view", + ); + } + const index = lowerer.lowerExpr(target.argumentExpression); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); + const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); + const indexLocal = lowerer.declareHiddenLocal("%compoundIndex", F64); + const receiverRef = (): IrExpr => varRef(receiverLocal.id, receiver.type, loc); + const indexRef = (): IrExpr => varRef(indexLocal.id, F64, loc); + const oldValue: IrExpr = + receiver.type.kind === "array" + ? receiver.type.elem.kind === "f64" + ? // Every numeric operator applies ToNumber to the old value, so a + // missing element is NaN without materializing `number | undefined`. + arrayNumberRead(receiverRef(), indexRef(), locOf(target)) + : receiver.type.elem.kind === "string" && op === "+" + ? arrayStringOperand(receiverRef(), indexRef(), locOf(target)) + : arrayValueRead(lowerer, receiverRef(), indexRef(), receiver.type.elem, locOf(target)) + : { + // An invalid index reads undefined in Node, which every numeric + // compound operator turns into NaN; the write is then ignored. + kind: "bytesIntrinsic", + method: "get", + receiver: receiverRef(), + args: [indexRef()], + type: F64, + loc: locOf(target), + invalidNaN: true, + }; + const oldLocal = lowerer.declareHiddenLocal("%compoundOld", oldValue.type); + const oldRef = (): IrExpr => varRef(oldLocal.id, oldValue.type, loc); + const rhs = lowerer.lowerExpr(expr.right); + const numericRhs = + rhs.type.kind === "dyn" && + isJsSourceFile(expr.getSourceFile()) && + receiver.type.kind === "bytes" + ? { kind: "libCall" as const, fn: "dyn.toNumberCoerce" as const, args: [rhs], type: F64, loc } + : lowerOptionalNumber(lowerer, rhs, loc); + const elementType = receiver.type.kind === "array" ? receiver.type.elem : F64; + const valueType = + elementType.kind === "union" && lowerer.armTag(elementType.unionId, UNDEFINED_T) >= 0 + ? lowerer.stripUndefinedArm(elementType) + : elementType; + if ( + statement && + op === "+" && + receiver.type.kind === "array" && + receiver.type.elem.kind === "string" + ) { + // Receiver, index, old element (read inside the concat), rhs, write. + const appended: IrExpr = { + kind: "strConcat", + left: oldValue, + right: concatOperand(lowerer, rhs, expr.right), + type: STRING, + loc, + }; + return { + expr: null, + stmts: [ + { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + { kind: "varDecl", localId: indexLocal.id, init: index, loc }, + { kind: "arraySet", arr: receiverRef(), index: indexRef(), value: appended, loc }, + ], + }; + } + let computed: IrExpr; + if (op === "+" && valueType.kind === "string") { + computed = { + kind: "strConcat", + left: lowerer.ensureString(oldRef(), target), + right: concatOperand(lowerer, rhs, expr.right), + type: STRING, + loc, + }; + } else if (valueType.kind === "f64" && numericRhs.type.kind === "f64") { + computed = { + kind: "bin", + op, + left: lowerOptionalNumber(lowerer, oldRef(), loc), + right: numericRhs, + type: F64, + loc, + }; + } else if (valueType.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { + const left = lowerer.coerceToExpected(oldRef(), DYN), + right = lowerer.coerceToExpected(rhs, DYN); + computed = dynCompoundValue(op, left, right, loc); + } else if ( + rhs.type.kind === "dyn" && + receiver.type.kind === "array" && + lowerer.dynConvertible(oldValue.type) && + lowerer.dynConvertible(valueType) + ) { + // An untyped right operand: the expanded `a[i] = a[i] op rhs`, checked + // back into the element type. + computed = lowerer.coerceInto( + expr, + dynCompoundValue( + op, + lowerer.coerceToExpected(oldRef(), DYN), + lowerer.coerceToExpected(rhs, DYN), + loc, + ), + valueType, + ); + } else { + compoundUnsupported(lowerer, expr, op, oldValue.type, rhs.type); + } + const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type); + const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc); + const write: IrStmt = + receiver.type.kind === "array" + ? arrayValueStore(lowerer, receiverRef(), indexRef(), resultRef(), receiver.type.elem, loc) + : { kind: "bytesSet", arr: receiverRef(), index: indexRef(), value: resultRef(), loc }; + return { + expr: { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + { kind: "varDecl", localId: indexLocal.id, init: index, loc }, + { kind: "varDecl", localId: oldLocal.id, init: oldValue, loc }, + { kind: "varDecl", localId: resultLocal.id, init: computed, loc }, + write, + ], + result: resultRef(), + type: computed.type, + loc, + }, + stmts: null, + }; +} + +/** ToString of one stabilized `string[]` element read for `+=`: a missing + * element concatenates as "undefined". */ +function arrayStringOperand(arr: IrExpr, index: IrExpr, loc: SrcLoc): IrExpr { + return { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr, index, type: F64, loc }, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: BOOL, + loc, + }, + then: { kind: "arrayGet", arr, index, type: STRING, loc }, + else_: strLit("undefined", loc), + type: STRING, + loc, + }; +} + +export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): IrStmt { + const prototype = lowerClassPrototypeComputedAssignment(lowerer, expr); + if (prototype) return { kind: "exprStmt", expr: prototype, loc: locOf(expr) }; + const target = expr.left as ts.ElementAccessExpression; + fenceNodeModuleMutation(lowerer, target, "assignment"); + const globalKey = globalSymbolKey(lowerer, target.expression, target.argumentExpression); + if (globalKey) { + const value = lowerer.lowerExprExpecting(expr.right, DYN); + const loc = locOf(expr); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.globalSymbolSet", + args: [globalKey, value], + type: VOID, + loc, + }, + loc, + }; + } + // `process.env[key] = v` — the computed twin of the dotted env write: + // setenv(3), string keys and string values only. + if (lowerer.isProcessEnv(target.expression) && !target.questionDotToken) { + const loc = locOf(expr); + const key = lowerEnvironmentKey(lowerer, target.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing process.env with non-string keys", + ); + } + const value = lowerer.lowerExpr(expr.right); + if (value.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + expr.right, + `assigning '${lowerer.fmt(value.type)}' values to process.env (env values are strings — convert first: \`\${v}\`)`, + ); + } + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "process.envSet", args: [key, value], type: VOID, loc }, + loc, + }; + } + // Symbol-keyed property WRITES (`this[kLimit] = v`): the read path's + // twin — a statically-resolvable unique-symbol key declared on the + // receiver's class writes the hidden field slot; everything else keeps + // the fence (static shapes have no symbol-keyed storage). + if (lowerer.mapTypeOf(lowerer.typeOf(target.argumentExpression))?.kind === "symbol") { + const fieldT = symbolFieldTarget(lowerer, target); + if (fieldT) { + const value = lowerer.lowerExprExpecting(expr.right, fieldT.fieldType); + return lowerer.fieldSetStmt(fieldT, value, locOf(expr), target); + } + const receiver = lowerer.lowerExpr(target.expression); + if (receiver.type.kind === "dyn") + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, receiver), + loc: locOf(expr), + }; + if ( + isJsSourceFile(expr.getSourceFile()) && + receiver.type.kind === "object" && + lowerer.dynConvertible(receiver.type) + ) { + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, lowerer.coerceToExpected(receiver, DYN)), + loc: locOf(expr), + }; + } + lowerer.unsupported( + "SC1090", + target, + "symbol-keyed property writes outside class fields keyed by a stable module-level literal Symbol()/Symbol.for() (other static shapes have no symbol-keyed storage)", + ); + } + const checkedReceiver = tryLowerExpression(lowerer, target.expression); + if (checkedReceiver?.type.kind === "dyn") { + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, checkedReceiver), + loc: locOf(expr), + }; + } + if (checkedReceiver?.type.kind === "object" && isJsSourceFile(expr.getSourceFile())) { + const info = lowerer.classes.get(checkedReceiver.type.className); + const key = + recordKeyLiteralText(target.argumentExpression) ?? + recordKeyTypeLiteralText(lowerer, target.argumentExpression); + if (info && key !== null && isClassCallback(lowerer, info, key)) { + const receiver = lowerer.coerceInto(target.expression, checkedReceiver, DYN); + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment(lowerer, expr, receiver), + loc: locOf(expr), + }; + } + } + let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(target.expression)); + if (isJsSourceFile(expr.getSourceFile()) && checkedReceiver) { + const represented = + checkedReceiver.type.kind === "union" + ? lowerer.stripUndefinedArm(checkedReceiver.type) + : checkedReceiver.type; + if (represented.kind === "array" || represented.kind === "bytes") receiverIr = represented; + } + if (receiverIr === null || receiverIr.kind === "dyn") { + const receiver = tryLowerExpression(lowerer, target.expression); + if (receiver?.type.kind === "array") receiverIr = receiver.type; + } + + // A checked-dynamic receiver TYPE (`unknown[]` and the collapsed + // `(string | object)[]` map to DYN wholesale now): the dyn keyed + // write — dyn.keySet. Number keys canonicalize through the JS-exact + // formatter; ARR receivers take canonical index keys as element + // set/extend (undefined-hole padding, JS's length growth exactly), + // OBJ receivers set the member, and the runtime throws Node's + // TypeErrors on every other kind. Values convert into the checked-dynamic tree; + // unconvertible ones fence per site (the dynFrom stance). + if (receiverIr?.kind === "dyn") { + const obj = lowerer.lowerExpr(target.expression); + if (obj.type.kind === "dyn") { + const loc = locOf(expr); + const key = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + target.argumentExpression, + ); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing with non-string or non-number keys", + ); + } + const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); + if (value.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + expr.right, + `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic array (the value cannot convert into the checked-dynamic tree)`, + ); + } + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "dyn.keySet", args: [obj, key, value], type: VOID, loc }, + loc, + }; + } + } + if (receiverIr?.kind === "object" || receiverIr?.kind === "record") { + const key = + recordKeyLiteralText(target.argumentExpression) ?? + recordKeyTypeLiteralText(lowerer, target.argumentExpression); + if (key !== null) { + const receiver = lowerer.lowerExpr(target.expression); + const field = representedClassFieldTarget(lowerer, target.expression, key, receiver); + if (field) { + const value = lowerer.lowerExprExpecting(expr.right, field.fieldType); + return lowerer.fieldSetStmt(field, value, locOf(expr), target); + } + if (hasClassPayload(lowerer, receiver.type)) { + if (receiver.type.kind === "object" && receiver.type.className === "%Error") { + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment( + lowerer, + expr, + lowerer.coerceInto(target.expression, receiver, DYN), + ), + loc: locOf(expr), + }; + } + lowerer.unsupported( + "SC1090", + target, + "writing undeclared properties through structural views of class instances", + ); + } + } + } + // Typed-array element write `b[i] = v` — bytesSet: the value is an f64 + // the runtime coerces JS-exactly; invalid indices are ignored. + if (receiverIr?.kind === "bytes") { + const recv = lowerer.lowerExpr(target.expression); + // The checker's flow type is a typed array, but the storage is wider. + if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); + const index = lowerer.lowerExpr(target.argumentExpression); + if (index.type.kind === "dyn" && recv.type.kind === "bytes") { + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment( + lowerer, + expr, + lowerer.coerceInto(target.expression, recv, DYN), + ), + loc: locOf(expr), + }; + } + if (index.type.kind !== "f64") { + lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); + } + const value = lowerer.lowerExprExpecting(expr.right, F64); + return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; + } + // Tuple element write `t[0] = v`: a positional-field write (recordSet) + // — literal indices only, like the read. + if (receiverIr?.kind === "record") { + const shape = lowerer.shapes.get(receiverIr.shapeId); + + if (shape) { + const obj = lowerer.lowerExpr(target.expression); + if (hasClassPayload(lowerer, obj.type)) { + lowerer.unsupported( + "SC1090", + target, + "computed property writes through structural views of class instances (use a declared literal key)", + ); + } + + // A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a + // JS file-scope object-literal global): the checked-dynamic keyed + // write — dyn.keySet (later writes win, insertion order; Node's + // TypeErrors on non-object receivers), the value converting into + // the checked-dynamic tree. Literal and runtime keys alike: the checked-dynamic tree's key set is + // open, so no declared-field collision analysis applies. + if (obj.type.kind === "dyn") { + const loc = locOf(expr); + const litKey = recordKeyLiteralText(target.argumentExpression); + let key: IrExpr = + litKey !== null + ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } + : lowerer.lowerExpr(target.argumentExpression); + key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing records with non-string or non-number keys", + ); + } + const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); + if (value.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + expr.right, + `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, + ); + } + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "dyn.keySet", args: [obj, key, value], type: VOID, loc }, + loc, + }; + } + } + if (shape?.tuple) { + const idx = tupleLiteralIndex(target.argumentExpression); + if (idx === null) { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "tuple indexing with a non-literal index (tuples are fixed-shape — use t[0], t[1], ...)", + ); + } + const fieldType = shape.fields.find((f) => f.name === String(idx))?.type; + if (fieldType === undefined) lowerer.badType(target, lowerer.typeOf(target)); + const obj = lowerer.lowerExpr(target.expression); + const value = lowerer.lowerExprExpecting(expr.right, fieldType); + return { + kind: "recordSet", + obj, + shapeId: receiverIr.shapeId, + field: String(idx), + value, + loc: locOf(expr), + }; + } + // Dynamic keyed write `r[k] = v` — index-signature shapes only (a + // declared-only shape's writable key set is closed; spell the field). + // A LITERAL declared key is the bracket spelling of a field write. + // Values flow into the index-value slot (dyn slots convert, typed + // slots coerce); declared-key collisions at runtime validate against + // the field's type through the emitted helper (a mismatch throws the + // catchable TypeError — SEMANTICS.md). + if (shape && !shape.tuple) { + const guarded = lowerGuardedRecordWrite(lowerer, expr, target); + if (guarded) return guarded; + // Literal keys — string literals AND numeric literals in their + // canonical string spelling (`b[1] = v` writes field/key "1", JS's + // own key derivation) — name declared fields directly. A key + // IDENTIFIER whose TYPE proves one literal (a literal-typed const, + // a keyof-constrained type parameter bound to a literal inside a + // generic instance — `set(o, "a", v)`'s body writing `o[k] = v`) + // is the same static field write. + const litKey = + recordKeyLiteralText(target.argumentExpression) ?? + recordKeyTypeLiteralText(lowerer, target.argumentExpression); + if (litKey !== null) { + const field = shape.fields.find((f) => f.name === litKey); + if (field) { + const obj = lowerer.lowerExpr(target.expression); + const value = lowerer.lowerExprExpecting(expr.right, field.type); + return { + kind: "recordSet", + obj, + shapeId: receiverIr.shapeId, + field: litKey, + value, + loc: locOf(expr), + }; + } + } + if (!shape.indexValue) { + // A SIGNATURE-FREE shape whose declared fields share ONE type + // writes through the same per-shape dispatch (the mockable-clock + // module shape: `mocked[functionality] = implementation` over a + // table of same-signature closures). A key naming no declared + // field throws the catchable TypeError — JS would ADD the + // property, which a monomorphic struct cannot (the SEMANTICS.md + // keyed-write-miss divergence). Mixed-type and accessor-carrying + // shapes keep the fence. + const common = + shape.fields.length > 0 && + !shapeHasAccessorSlots(shape) && + shape.fields.every((f) => typeEquals(f.type, shape.fields[0]!.type)) + ? shape.fields[0]!.type + : null; + if (!common) { + lowerer.unsupported( + "SC1090", + target, + "dynamic keyed writes to records without an index signature (declared fields of ONE shared type dispatch by key — spell the field name otherwise)", + ); + } + const obj = lowerer.lowerExpr(target.expression); + let key: IrExpr = + litKey !== null + ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } + : lowerer.lowerExpr(target.argumentExpression); + // Runtime number/boolean/dyn keys stringify — ToPropertyKey, + // exactly the index-signature path's rule. + key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing records with non-string or non-number keys", + ); + } + const value = lowerer.coerceInto(expr.right, lowerer.lowerExpr(expr.right), common); + if (!typeEquals(value.type, common)) + lowerer.badType(expr.right, lowerer.typeOf(expr.right)); + return { + kind: "recordKeySet", + obj, + shapeId: receiverIr.shapeId, + key, + value, + loc: locOf(expr), + }; + } + // A LITERAL key naming no declared field is a pure overflow insert + // — no collision to validate. Runtime keys must be able to collide + // with every declared field: dyn slots validate through the + // dynCheck walker (fields must be dyn-convertible — the same + // convertibility the read needs); typed slots write through + // directly, so every field must BE the index-value type. + const overflowOnly = litKey !== null; + const writable = + overflowOnly || + (shape.indexValue.kind === "dyn" + ? shape.fields.every((f) => lowerer.dynConvertible(f.type)) + : shape.fields.every((f) => typeEquals(f.type, shape.indexValue!))); + if (!writable) { + lowerer.unsupported( + "SC1090", + target, + `dynamic keyed writes to '${lowerer.fmt(receiverIr)}' (a declared field's type cannot take the index signature's value at runtime)`, + ); + } + const obj = lowerer.lowerExpr(target.expression); + let key = + litKey !== null + ? ({ + kind: "strLit", + value: litKey, + type: STRING, + loc: locOf(target.argumentExpression), + } satisfies IrExpr) + : lowerer.lowerExpr(target.argumentExpression); + // Runtime NUMBER keys canonicalize through the JS-exact formatter + // (o[n] = v writes o[String(n)] — the number-keyed-signature path). + key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing records with non-string or non-number keys", + ); + } + const value = lowerer.intoIndexValueSlot( + lowerer.lowerExpr(expr.right), + shape.indexValue, + expr.right, + ); + return { + kind: "recordKeySet", + obj, + shapeId: receiverIr.shapeId, + key, + value, + ...(overflowOnly ? { overflowOnly: true as const } : {}), + loc: locOf(expr), + }; + } + } + // Keyed write `d[k] = v` on a CHECKED-DYNAMIC receiver (a JS `any` + // dictionary — the Object.create(null) memo-table idiom writes + // `result[key] = value`): dyn.keySet, exactly the record-mapped dyn + // arm above — later writes win in insertion order, number keys + // canonicalize through the JS-exact formatter (ToPropertyKey's string + // side), index keys on dyn arrays set/extend elements, and non-object + // receivers throw Node's TypeErrors at runtime. An unmapped checker type + // probes the receiver's lowered representation: a dyn value takes the same + // write, anything else falls through to the fences. + if (receiverIr?.kind === "dyn" || receiverIr?.kind === "object" || receiverIr === null) { + const obj = + receiverIr !== null + ? lowerer.lowerExpr(target.expression) + : tryLowerExpression(lowerer, target.expression); + if ( + obj !== null && + (obj.type.kind === "dyn" || + (obj.type.kind === "object" && obj.type.className === "%Error") || + isDynTypedRefType(obj.type)) + ) { + const loc = locOf(expr); + const litKey = recordKeyLiteralText(target.argumentExpression); + let key: IrExpr = + litKey !== null + ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } + : lowerer.lowerExpr(target.argumentExpression); + key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); + if (key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing checked-dynamic values with non-string or non-number keys", + ); + } + const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); + if (value.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + expr.right, + `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, + ); + } + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [lowerer.coerceToExpected(obj, DYN), key, value], + type: VOID, + loc, + }, + loc, + }; + } + } + if (receiverIr?.kind !== "array") { + lowerer.unsupported("SC1090", target, "assignment to non-array elements"); + } + let arr = lowerer.lowerExpr(target.expression); + + if ( + arr.type.kind === "union" && + lowerer.armTag(arr.type.unionId, UNDEFINED_T) >= 0 && + receiverIr.kind === "array" + ) { + const present = lowerer.stripUndefinedArm(arr.type); + const helper = + present.kind === "array" + ? lowerer.narrowedArmHelper(arr.type.unionId, present, locOf(target.expression)) + : null; + if (helper) { + arr = lowerer.checkedNarrowCall(helper, arr, present, locOf(target.expression)); + } + } + if (arr.type.kind !== "array") { + lowerer.unsupported( + "SC1090", + target.expression, + "assignment through a possibly missing nested array receiver", + ); + } + const index = lowerOptionalNumber( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + locOf(target.argumentExpression), + target.argumentExpression, + ); + if (index.type.kind === "dyn" && lowerer.dynConvertible(arr.type)) { + return { + kind: "exprStmt", + expr: lowerDynMemberAssignment( + lowerer, + expr, + lowerer.coerceInto(target.expression, arr, DYN), + ), + loc: locOf(expr), + }; + } + if (index.type.kind !== "f64") { + lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); + } + // Optional array reads carry the source array's payload type plus an + // undefined arm. arrayValueStore turns that arm into the runtime's + // UNDEFINED state while keeping number[]/string[] payload storage scalar. + let literal = expr.right; + while (ts.isParenthesizedExpression(literal) || ts.isSatisfiesExpression(literal)) + literal = literal.expression; + const value = + ts.isObjectLiteralExpression(literal) || ts.isArrayLiteralExpression(literal) + ? lowerer.lowerExprExpecting(literal, arr.type.elem) + : lowerer.lowerExpr(expr.right); + // Validate the payload before emitting a native store. A dormant JS + // method can contradict its JSDoc element type; retain its call-time + // refusal instead of leaving invalid IR in a newly reachable method. + const expected = + value.type.kind === "undefinedT" || + (value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0) + ? arrayValueType(lowerer, arr.type.elem) + : arr.type.elem; + return arrayValueStore( + lowerer, + arr, + index, + lowerer.coerceInto(expr.right, value, expected), + arr.type.elem, + locOf(expr), + ); +} + +/** Environment property names use ToPrimitive with the string hint, so + * checked objects must execute their own conversion hooks. */ +export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrExpr { + const key = lowerer.lowerExpr(node); + if (key.type.kind === "dyn") + return { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc }; + return lowerRecordPropertyKey(lowerer, key, node); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts new file mode 100644 index 000000000..5a8169300 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -0,0 +1,922 @@ +/* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */ +import * as ts from "../../ts7/adapter.js"; +import * as posix from "node:path/posix"; +import { type Lowerer, PoisonError } from "../lowerer.js"; +import { + BOOL, + BYTES_ELEMENT_NAME, + DYN, + F64, + type IrExpr, + type IrType, + STRING, + type SrcLoc, + UNDEFINED_T, + arrayOf, + funcOf, + isUnitType, + recordTextCodecClass, +} from "../../../ir/ir.js"; +import { lowerNodeModuleIdentifier } from "../lower-node-module.js"; +import { lowerCjsExportsObjectValue } from "../cjs-exports-object.js"; +import { captureContextArguments } from "../function-context.js"; +import { isCjsExportTableLiteral, isJsSourceFile, isNodeEsmFile, locOf } from "../../program.js"; +import { builtinClassAliasOwnsNoSlot, runtimeClassValueOf } from "../builtins/class-values.js"; +import { + cjsAccessorCall, + primitiveCtorClosure, + runtimeOptionalElementKey, + runtimeOptionalUseOf, +} from "../lower-exprs.js"; +import { runtimeOptionalReceiverRead } from "./narrowing.js"; +import { varRef } from "../../../ir/build.js"; +import { + ambientUndefReadType, + ambientUndefinedFnSymbolOf, + contextualUndefReadType, + fenceEarlyAliasUse, + lowerNsIdentifierValue, + nsUndefRead, +} from "../lower-namespaces.js"; +import { + blockedBindingUseDiag, + unsupportedDiag, + unsupportedPackageValueDiag, +} from "../../../diagnostics/diagnostic.js"; +import { dirname } from "node:path"; +import { funcTypeFromParamShapes } from "../call-signatures.js"; +import { threadsImportOf } from "../../threads-import.js"; +import { experimentalImportOf } from "../../experimental-import.js"; +import { lowerWorkerMetadata } from "../builtins/workers.js"; +import { esmImportBindingKind } from "../builtins/module-bindings.js"; +import { + builtinConstLit, + builtinFenceHintOf, + builtinModuleConstOf, + builtinModuleFnOf, + builtinModulesArrayLit, + stdlibGlobalNameOf, +} from "../surfaces.js"; +import { builtinConstantsObject } from "../lower-builtin-values.js"; +import { lowerTlsRootCertificates } from "../lower-server.js"; +import { builtinFunctionFenceValue } from "../builtin-function-fences.js"; +import { unsupportedModuleFeatureOf } from "../../builtin-modules.js"; +import { lowerGlobalValue } from "../lower-global-value.js"; +import { fenceEnumObjectValue } from "../lower-enums.js"; +import { mixinFnOfCallee } from "../lower-mixins.js"; +import { isNavigatorGlobal, refuseNavigator } from "../builtins/navigator.js"; +import { wasiGuestPath } from "../../../wasi-paths.js"; + +export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr { + if (lowerer.isStdlibGlobal(expr, "console")) { + return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }; + } + + const moduleValue = lowerNodeModuleIdentifier(lowerer, expr); + if (moduleValue) return moduleValue; + if (expr.text === "exports") { + const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); + if (exportsValue) return exportsValue; + } + if (lowerer.isSelfReference(expr)) { + return { kind: "selfRef", type: lowerer.ctx.selfType!, loc }; + } + // `arguments` in a variadic JS function (the rest-marked form): the + // synthetic trailing dyn-array param — lambdaSignature marked the + // type and lowerLambda declared the local. + if (expr.text === "arguments") { + const binding = captureContextArguments(lowerer.fnStack); + if (binding) return { kind: "varRef", localId: binding.id, type: DYN, loc }; + } + // A compiler-projected adapter as a VALUE: the adapter has no + // runtime function object (its calls rewrite directly) — call it + // through its immutable binding instead. + { + const sym = lowerer.resolveValueSymbol(expr); + const projection = sym ? lowerer.staticCallables.get(sym) : undefined; + if ( + projection?.kind === "promisified-exec-file" || + projection?.kind === "promisified-exec" || + projection?.kind === "promisified-builtin" || + projection?.kind === "promisified-zlib" || + projection?.kind === "promisified-crypto" || + projection?.kind === "promisified-dns" + ) { + lowerer.unsupported( + "SC1090", + expr, + `a promisified builtin as a value (call '${expr.text}' directly)`, + ); + } + if (projection?.kind === "builtin-function") { + if (!isJsSourceFile(expr.getSourceFile())) { + const callable = lowerer.lowerBuiltinCallableValue( + { module: projection.module, member: projection.member }, + loc, + ); + if (callable) return callable; + } + lowerer.unsupported( + "SC1090", + expr, + `a builtin function alias as an escaping value (call '${expr.text}' directly)`, + ); + } + // `EventEmitter`, `Readable` or `TypeError` (or a const alias of + // one) as a value in a TypeScript file: the runtime class's class + // object. JavaScript files keep the identity token. + if (!isJsSourceFile(expr.getSourceFile())) { + const runtimeClass = runtimeClassValueOf(lowerer, expr); + if (runtimeClass) return lowerer.classValueRef(runtimeClass, expr); + } + // `const E = EventEmitter` owns no runtime slot: construction, + // instanceof, typeof and .name lower at their use sites. + if (sym && builtinClassAliasOwnsNoSlot(lowerer, sym)) { + lowerer.unsupported( + "SC1090", + expr, + `a builtin class alias as an escaping value (use '${expr.text}' with new, instanceof, typeof or .name)`, + ); + } + } + // Union-typed bindings read through tsc's control-flow narrowing: + // when the checker types this USE as a single arm, maybeNarrow + // bridges the tagged representation with a unionNarrow. + const local = lowerer.resolveLocal(expr); + if (local) { + // A switch can dispatch past a lexical declaration. Keep this + // previously refused codec form out until switch-scope TDZ boxes + // model that skipped initialization, including value aliases. + if ( + local.type.kind === "record" && + recordTextCodecClass(lowerer.shapes.get(local.type.shapeId)!) !== null + ) { + const symbol = lowerer.resolveValueSymbol(expr); + const decl = symbol ? symbol.valueDeclaration : undefined; + if ( + decl && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.BlockScoped) !== 0 && + ts.isVariableStatement(decl.parent.parent) + ) { + const clause = decl.parent.parent.parent; + if ( + (ts.isCaseClause(clause) || ts.isDefaultClause(clause)) && + !(expr.getStart() >= clause.getStart() && expr.end <= clause.end) + ) { + lowerer.unsupported( + "SC1090", + expr, + "codec bindings read from another switch clause before initialization is proven", + ); + } + } + } + if (local.type.kind === "caught") return lowerer.caughtRead(expr, local, loc); + const symbol = lowerer.resolveValueSymbol(expr); + const runtimeOptionalRoot = lowerer.runtimeOptionalRootOf(local); + const active = lowerer.runtimeOptionalLocals.has(runtimeOptionalRoot); + const arithmetic = lowerer.runtimeOptionalArithmeticLocals.has(runtimeOptionalRoot); + const captured = + !!symbol && + lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) && + (local.boxed === true || + lowerer.ctx.captureBySymbol.get(symbol) === local || + runtimeOptionalRoot !== local); + if (arithmetic && !runtimeOptionalUseOf(expr)) { + return { kind: "varRef", localId: local.id, type: local.type, loc }; + } + if (active || captured) { + const use = runtimeOptionalUseOf(expr); + if (use?.complex) { + lowerer.unsupported( + "SC1090", + expr, + "a logical or conditional receiver containing a runtime-optional capture", + ); + } + if (use?.optional) return { kind: "varRef", localId: local.id, type: local.type, loc }; + if (use?.kind === "property") + return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc); + if (use?.kind === "element") { + const key = runtimeOptionalElementKey(use.access.argumentExpression); + if (key === null) { + return varRef(local.id, local.type, loc); + } + return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc); + } + if (use?.kind === "call") { + if (use.comma && !use.optional) { + lowerer.unsupported( + "SC1090", + use.access, + "a direct call through a comma-wrapped runtime-optional capture", + ); + } + return runtimeOptionalReceiverRead(lowerer, expr, local, null, loc); + } + if (runtimeOptionalObjectWalkerArg(lowerer, expr)) { + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (local.type.kind === "union" && narrowed) { + const helper = + narrowed.kind === "union" + ? lowerer.narrowedRetagHelper(expr, local.type.unionId, narrowed.unionId, loc) + : !isUnitType(narrowed) + ? lowerer.narrowedArmHelper(local.type.unionId, narrowed, loc) + : null; + if (helper) { + return lowerer.checkedNarrowCall( + helper, + varRef(local.id, local.type, loc), + narrowed, + loc, + ); + } + } + } + return { kind: "varRef", localId: local.id, type: local.type, loc }; + } + const narrowed = lowerer.maybeNarrow( + { kind: "varRef", localId: local.id, type: local.type, loc }, + expr, + ); + if ( + narrowed.type.kind === "union" && + lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) && + lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "record" + ) { + // A presence guard can be followed by a predicate strengthening + // the record's fields. Its refined shape is not a stored union + // arm: extract the original layout before reading a member. + const use = runtimeOptionalUseOf(expr); + if (use && !use.complex && !use.optional) { + if (use.kind === "property") + return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc); + if (use.kind === "element") { + const key = runtimeOptionalElementKey(use.access.argumentExpression); + if (key !== null) return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc); + } + } + } + return narrowed; + } + // `import x = N.y` aliases resolve transparently through globalOf/ + // fnSigOf below; their source-order guards live here (a no-op for + // every non-import= binding — see lower-namespaces.ts). + fenceEarlyAliasUse(lowerer, expr, expr); + const g = lowerer.globalOf(expr); + if (g) { + // A global typed by a class that never REGISTERED (its collection + // fenced): the initializing assignment never lowered, so the read + // can only observe garbage — and the emitter would name a struct + // that does not exist. The blocked-binding cascade names the use; + // without this the validator's registration check ICEs on the live + // reference (`@this {T}` inference — signature 07). + const unregistered = lowerer.unregisteredClassOf(g.type, new Set()); + if (unregistered !== null) { + // The class's own rejection is the root cause the cascade names. + lowerer.flushDeferredClass(unregistered); + lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc)); + throw new PoisonError(); + } + const ref: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; + if (lowerer.isRuntimeOptionalArithmeticGlobal(g) && !runtimeOptionalUseOf(expr)) { + return ref; + } + const storageHasUndefined = + g.type.kind === "union" && lowerer.armTag(g.type.unionId, UNDEFINED_T) >= 0; + if (lowerer.isRuntimeOptionalGlobal(g) || storageHasUndefined) { + // A checker-narrowed global receiver (`if (tags !== undefined) + // tags[tags.length] = ...`, or `a ? Object.keys(a) : ...`) is + // still stored as the runtime optional union. Extract the proven + // value arm before routing the member/element operation so dynamic + // keys and builtin record walkers do not see a union receiver. + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const arithmeticUnion = + g.type.kind === "union" && + lowerer.unions.get(g.type.unionId)?.arms.length === 2 && + lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "f64") && + lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "string"); + const symbol = lowerer.resolveValueSymbol(expr); + if ( + !arithmeticUnion && + !(symbol !== null && lowerer.earlyReadVars.has(symbol)) && + g.type.kind === "union" && + narrowed && + narrowed.kind !== "union" && + !isUnitType(narrowed) && + !( + narrowed.kind === "record" && + recordTextCodecClass(lowerer.shapes.get(narrowed.shapeId)!) !== null + ) && + narrowed.kind !== "f64" && + narrowed.kind !== "string" && + narrowed.kind !== "bool" && + lowerer.armTag(g.type.unionId, narrowed) >= 0 + ) { + const helper = lowerer.narrowedArmHelper(g.type.unionId, narrowed, loc); + if (helper) return lowerer.checkedNarrowCall(helper, ref, narrowed, loc); + } + const use = runtimeOptionalUseOf(expr); + // The enclosing logical/conditional expression consumes the union + // before any member operation runs (`(value ?? fallback).length`). + // Keep the tagged value intact and let that lazy expression decide + // its branch; extracting here would evaluate or throw too early. + if (use?.complex) return ref; + if (use?.optional) return ref; + if (use?.kind === "property") + return runtimeOptionalReceiverRead(lowerer, expr, g, use.access.name.text, loc); + if (use?.kind === "element") { + const key = runtimeOptionalElementKey(use.access.argumentExpression); + if (key === null) + lowerer.unsupported( + "SC1090", + use.access, + "a computed read from a runtime-optional global (use a literal key)", + ); + return runtimeOptionalReceiverRead(lowerer, expr, g, key, loc); + } + if (use?.kind === "call") return runtimeOptionalReceiverRead(lowerer, expr, g, null, loc); + return ref; + } + return lowerer.maybeNarrow(ref, expr); + } + // `undefined` — tsc's intrinsic global (a local shadowing the name + // resolved above): a unit literal, exactly like the `null` keyword. + if (expr.text === "undefined" && lowerer.typeOf(expr).flags & ts.TypeFlags.Undefined) { + return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; + } + // `__dirname` / `__filename` — the CommonJS module globals: per- + // MODULE compile-time constants, the containing file's location + // (Node's exact values when the same tree runs in place; WASI uses + // the equivalent guest-visible path; locals shadowing the names + // resolved above). In a file with ESM syntax Node never defines them + // (ReferenceError) — fence rather than invent a value there. + if ( + (expr.text === "__dirname" || expr.text === "__filename") && + lowerer.isStdlibGlobal(expr, expr.text) + ) { + const sf = expr.getSourceFile(); + if (isNodeEsmFile(sf, lowerer.program)) { + lowerer.unsupported( + "SC1090", + expr, + `'${expr.text}' in an ES module (Node defines the CJS module globals only in CommonJS modules)`, + ); + } + // The WASI runner mounts the host cwd at guest `/` and the host temp + // directory at `/tmp`. Bake the matching GUEST spelling so resource + // lookups relative to these CommonJS globals stay inside the exposed + // capability instead of naming an unreachable host-absolute path. + const fileName = moduleFileName(lowerer, sf); + const value = + expr.text === "__dirname" + ? lowerer.targetPlatform === "wasi" + ? posix.dirname(fileName) + : dirname(fileName) + : fileName; + return { kind: "strLit", value, type: STRING, loc }; + } + const sig = lowerer.fnSigOf(expr); + if (sig && lowerer.isTopLevelFnSymbol(expr)) { + // A declared function used as a value: a zero-capture closure. The + // backend interns one per function so `f === f` holds (JS identity). + // The value's type is the completed ABI signature; optional/default + // slots and typed rest arrays pass the value fence first. + lowerer.noteEdge(sig.name); + // dynRest slots stay out of the VALUE type's param list (fn.length + // semantics); the rest marker carries the trailing dyn-array ABI. + const funcType: IrType = funcTypeFromParamShapes(sig.params, sig.returnType); + lowerer.requireExactArityValue(expr, expr, sig.params, funcType); + return { kind: "closure", fnName: sig.name, captures: [], type: funcType, loc }; + } + { + // A generic function as a VALUE: monomorphized by flow — the + // reference's pinned concrete signature (contextual type or an + // instantiation expression) names the instance; unpinned + // references fence inside (lowerGenericFnValue). + const gfn = lowerer.genericFnOf(expr); + if (gfn) return lowerer.lowerGenericFnValue(expr, gfn); + } + // A CJS export-table ACCESSOR read (`tmpdir.path`, or a destructured + // binding aliasing one): the getter is a real function of module + // scope — its body reads module globals, so it lifts like a lambda + // (interned per declaration) and every read is a call. Node runs the + // getter per read; so does this. + { + const acc = cjsExportAccessorRead(lowerer, expr); + if (acc) return acc; + } + // A binding destructured from a baked constants object + // (http2.constants / crypto.constants): the literal — the + // declaration emitted nothing (builtinConstantsDestructureDecl). + { + const h2c = lowerer.builtinConstantBindingOf(expr); + if (h2c) return h2c; + } + // Builtin-module import bindings: constants (path.sep, os.EOL) read + // as interned string literals; functions have no closure + // representation (they lower to libCall at call sites only); members + // with no lowering at all fence with the module-qualified name. + { + const member = threadsImportOf(lowerer.program, expr); + // scriptc delivers published graphs by reference. + if (member === "sharesPublishedGraphs") + return { kind: "boolLit", value: true, type: BOOL, loc }; + if (member === "publish") + lowerer.noLowering( + "publish (@scriptc/threads) as a value", + expr, + "call publish(value) directly", + ); + if (experimentalImportOf(lowerer.program, expr) === "LifetimeRegion") + lowerer.noLowering( + "LifetimeRegion (@scriptc/experimental) as a value", + expr, + "construct regions with new LifetimeRegion()", + ); + if (experimentalImportOf(lowerer.program, expr) === "InternTable") + lowerer.noLowering( + "InternTable (@scriptc/experimental) as a value", + expr, + 'open tables with new InternTable("name")', + ); + if (experimentalImportOf(lowerer.program, expr) === "ThreadSlots") + lowerer.noLowering( + "ThreadSlots (@scriptc/experimental) as a value", + expr, + 'open slots with new ThreadSlots("name")', + ); + } + { + const bi = lowerer.builtinImportOf(expr); + if (bi) { + if (bi.module === "worker_threads") { + const value = lowerWorkerMetadata(bi.member, loc); + if (value) return value; + } + // A named import of defaultMaxListeners reads the ES module + // namespace's copy. A CommonJS destructured or member binding holds + // the value read when its declaration ran, which these bindings do + // not store. + if (bi.module === "events" && bi.member === "defaultMaxListeners") { + if (esmImportBindingKind(lowerer, expr) === "named") { + return { kind: "libCall", fn: "emitter.esmDefaultMax", args: [], type: F64, loc }; + } + lowerer.noLowering( + "events.defaultMaxListeners through a CommonJS destructured or member binding", + expr, + "read require('events').defaultMaxListeners or EventEmitter.defaultMaxListeners where the value is needed", + ); + } + const c = builtinModuleConstOf(lowerer, bi.module, bi.member); + if (c !== undefined) return builtinConstLit(c, loc); + // module.builtinModules — the baked Node v24 list, a fresh + // string[] per read. + if (bi.module === "module" && bi.member === "builtinModules") { + return builtinModulesArrayLit(loc); + } + if (bi.module === "http" && bi.member === "METHODS") { + return { kind: "libCall", fn: "http.methods", args: [], type: arrayOf(STRING), loc }; + } + if ((bi.module === "http" || bi.module === "https") && bi.member === "globalAgent") { + return { + kind: "libCall", + fn: "http.globalAgent", + args: [{ kind: "boolLit", value: bi.module === "https", type: BOOL, loc }], + type: DYN, + loc, + }; + } + if (bi.module === "http" && bi.member === "STATUS_CODES") { + return { kind: "libCall", fn: "http.statusCodes", args: [], type: DYN, loc }; + } + if (bi.module === "os" && bi.member === "constants") { + return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc }; + } + if (bi.module === "fs" && bi.member === "constants") + return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; + // `import { O_RDONLY } from "constants"`: the member of the frozen + // module object. + if (bi.module === "constants") { + return { + kind: "dynKeyGet", + value: builtinConstantsObject(lowerer, loc), + key: { kind: "strLit", value: bi.member, type: STRING, loc }, + type: DYN, + loc, + }; + } + // tls.rootCertificates: a runtime-valued module constant (the + // cached bundled-CA array) — the one member read that lowers + // to a libCall instead of a baked literal. + { + const roots = lowerTlsRootCertificates(lowerer, bi, loc); + if (roots) return roots; + } + { + const callable = lowerer.lowerBuiltinCallableValue(bi, loc); + if (callable) return callable; + } + // JavaScript sources: an otherwise unsupported builtin VALUE is + // the same identity-token story as stdlib globals above (the + // harness adds worker_threads.Worker to its identity Set). + if (isJsSourceFile(expr.getSourceFile())) { + // A function member is a function value that fences when called. + if (builtinModuleFnOf(lowerer, bi.module, bi.member)) + return builtinFunctionFenceValue(lowerer, bi, expr, loc); + return { + kind: "strLit", + value: `[builtin ${bi.module}.${bi.member}]`, + type: STRING, + loc, + }; + } + if (builtinModuleFnOf(lowerer, bi.module, bi.member)) { + lowerer.unsupported( + "SC1090", + expr, + `library functions as values (call '${expr.text}' directly)`, + ); + } + lowerer.noLowering( + `${bi.module}.${bi.member}`, + expr, + builtinFenceHintOf(bi.module, bi.member), + lowerer.resolveValueSymbol(expr), + ); + } + } + // A builtin namespace import used as a bare VALUE (`const f = fs`): + // the namespace object has no representation — members lower at + // their access sites only. The CommonJS namespace binding + // (`const lib = require("./lib.js")`) gets the same fence. + const builtinNamespace = lowerer.builtinNamespaceModuleOf(expr); + if (builtinNamespace === "constants") return builtinConstantsObject(lowerer, loc); + if (builtinNamespace !== null) { + const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNamespace}` }; + return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; + } + if (lowerer.cjsLocalModuleBindingOf(expr)) { + lowerer.unsupported( + "SC1090", + expr, + `module namespace objects as values (access '${expr.text}' members directly)`, + ); + } + if (lowerer.stdlibGlobalFnOf(expr)) { + lowerer.unsupported( + "SC1090", + expr, + `library functions as values (call '${expr.text}' directly)`, + ); + } + // A declaration-only npm binding has no compiled runtime value. + { + const pkg = lowerer.npmPackageOfSymbol(lowerer.resolveValueSymbol(expr) ?? undefined); + if (pkg) { + lowerer.pushDiag(unsupportedPackageValueDiag(pkg, loc)); + throw new PoisonError(); + } + } + // Bindings imported from an UNSUPPORTED builtin module: coverage + // analyzes past import fences, so uses of `spawn` from + // child_process reach lowering — the use site reports the same + // "the '' module" diagnostic as the import line, and the + // report groups them into one blocker. (Builds never get here: the + // import already failed preflight.) + { + const spec = lowerer.fencedBuiltinImportOf(expr); + if (spec !== null) { + lowerer.pushDiag(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); + throw new PoisonError(); + } + } + // The globals `Infinity` and `NaN` (lib-declared, provenance-checked + // like every stdlib name): non-finite numLits — `-Infinity` arrives + // through the unary-minus literal fold. Arithmetic, comparisons, and + // formatting are IEEE-exact in C (String(Infinity) is "Infinity" and + // String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds + // because f64 compares are IEEE compares). + if (expr.text === "Infinity" || expr.text === "NaN") { + const sym = lowerer.checker.getSymbolAtLocation(expr); + if (lowerer.isStdlibSymbol(sym)) { + return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc }; + } + } + // The primitive constructors as VALUES (`const f = String`, an + // option table's `type: Boolean` field, `opt.type === Number`): the + // interned coercion closure — one synthesized module function per + // constructor per program, so every reference is the SAME zero- + // capture closure and `===` is JS identity (the type mapping in + // type-mapper.ts pins the one concrete signature `(value: string) => + // primitive`; direct calls `String(x)` never reach here — the call + // lowering intercepts them with the wider static coercions). + const jsPrimitiveCtorSelector = isJsSourceFile(expr.getSourceFile()); + const primitiveName = stdlibGlobalNameOf(lowerer, expr); + if ( + primitiveName === "String" || + primitiveName === "Number" || + primitiveName === "Boolean" || + primitiveName === "BigInt" + ) { + return primitiveCtorClosure(lowerer, primitiveName, loc, jsPrimitiveCtorSelector); + } + if (lowerer.isStdlibGlobal(expr, "ArrayBuffer")) + return { kind: "libCall", fn: "arrayBuffer.constructor", args: [], type: DYN, loc }; + if (isJsSourceFile(expr.getSourceFile()) && lowerer.isStdlibGlobal(expr, "Array")) + return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc }; + if ( + Object.values(BYTES_ELEMENT_NAME).includes(expr.text) && + lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr)) + ) { + return { + kind: "libCall", + fn: "bytes.constructor", + args: [{ kind: "strLit", value: expr.text, type: STRING, loc }], + type: DYN, + loc, + }; + } + if ( + (expr.text === "encodeURIComponent" || + expr.text === "encodeURI" || + expr.text === "decodeURIComponent" || + expr.text === "decodeURI") && + lowerer.isStdlibGlobal(expr, expr.text) + ) { + const name = `%builtin.${expr.text}`; + const type = funcOf([DYN], STRING); + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const value: IrExpr = { + kind: "libCall", + fn: "dyn.toStringCoerce", + args: [varRef("value", DYN, loc)], + type: STRING, + loc, + }; + const fn = + expr.text === "encodeURIComponent" + ? "str.encodeUriComponent" + : expr.text === "encodeURI" + ? "str.encodeUri" + : expr.text === "decodeURI" + ? "str.decodeUri" + : "str.decodeUriComponent"; + lowerer.liftedFns.push({ + name, + params: [{ localId: "value", name: "value", type: DYN }], + locals: [{ id: "value", name: "value", type: DYN, mutable: false }], + returnType: STRING, + body: [ + { + kind: "return", + value: { kind: "libCall", fn, args: [value], type: STRING, loc }, + loc, + }, + ], + loc, + }); + } + return { kind: "closure", fnName: name, captures: [], type, loc }; + } + if (lowerer.isStdlibGlobal(expr, "crypto")) + return { kind: "libCall", fn: "crypto.native", args: [], type: DYN, loc }; + const nativeAlias = stdlibGlobalNameOf(lowerer, expr); + if (nativeAlias === "Buffer" && expr.text !== "Buffer") { + return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc }; + } + if (lowerer.isStdlibGlobal(expr, "fetch")) { + return { kind: "libCall", fn: "fetch.function", args: [], type: DYN, loc }; + } + // The lib fence's IDENTIFIER chokepoint: the real standard library + // resolves names the old minimal ambient world never declared + // (Symbol, Reflect, Infinity, Date, ...) — and the adopted + // @types/node resolves its whole global surface (Buffer, fetch, + // setInterval, URL, ...). Reaching one that no lowering above + // claimed is SC2020, never an ICE — EXCEPT in JavaScript sources, + // where a stdlib global taken as a VALUE (never called through this + // path — call sites lower earlier) becomes an opaque IDENTITY TOKEN: + // an interned string naming the global. Identity flows (the + // harness's knownGlobals Set, === comparisons) are exact — one + // global, one token; what a token cannot do (be called, answer + // typeof "function") meets per-site fences/divergences, and + // SEMANTICS.md documents the stance. + if (stdlibGlobalNameOf(lowerer, expr) === "globalThis") { + return lowerGlobalValue(lowerer, expr); + } + { + const sym = lowerer.checker.getSymbolAtLocation(expr); + if (lowerer.isStdlibSymbol(sym) || expr.text === "globalThis") { + if (isJsSourceFile(expr.getSourceFile())) { + const canonical = stdlibGlobalNameOf(lowerer, expr) ?? expr.text; + return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; + } + // The families with a WHY: each hint states what makes the + // surface genuinely non-static (or what to use instead). + const globalHints: Record = { + Proxy: + "Proxy constructor values have no native lowering; use a direct new Proxy with checked-native plain objects", + Reflect: + "reflective property access has no static lowering — read and call members directly", + Intl: 'native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported', + SharedArrayBuffer: "use direct new SharedArrayBuffer(length) for fixed shared storage", + ArrayBuffer: + "ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage", + WeakRef: + "deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead", + FinalizationRegistry: + "finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead", + eval: "runtime code evaluation cannot be compiled ahead of time", + }; + lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined); + } + } + // An ambient global nothing defines compiles to exactly what Node does + // at the access: the catchable ReferenceError " is not defined". + // Stdlib/@types/node ambients never reach here (their chokepoint + // is above); only user-file declares do. + { + const sym = lowerer.resolveValueSymbol(expr); + const decl = sym ? sym.declarations[0] : undefined; + if ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.initializer === undefined && + ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Ambient + ) { + const declared = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (declared && declared.kind !== "void") { + return { + kind: "libCall", + fn: "global.undefRead", + args: [{ kind: "strLit", value: expr.text, type: STRING, loc }], + type: declared, + loc, + }; + } + } + } + // An ambient `declare function` nothing defines, taken as a VALUE + // (call sites lower earlier, in lowerCall): the same story as the + // ambient `declare const` above — Node erases the declaration and + // the read throws the catchable ReferenceError at the access. + { + if (ambientUndefinedFnSymbolOf(lowerer, expr)) { + const t = ambientUndefReadType(lowerer, expr); + if (t) return nsUndefRead(lowerer, expr.text, expr, t); + } + } + // A read of a TRAP binding — a declaration whose own initializer + // provably threw (module init unwound there), so this reference can + // never execute: any lowering is sound, and the trap keeps the + // shape honest. Typed by the use site when it maps, the F64 dummy + // otherwise (never observed — never even reached). + { + const sym = lowerer.resolveValueSymbol(expr); + if (sym !== null && lowerer.trapBindings.has(sym)) { + const t = + ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr) ?? F64; + return nsUndefRead(lowerer, expr.text, expr, t); + } + } + // A program CLASS NAME as a value: the classRef over the class's + // immortal class object (member accesses and construction never + // reach here — their hooks claim the property/new forms first). + { + const sym = lowerer.resolveValueSymbol(expr); + const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined; + // A GENERIC class name whose CONTEXTUAL type pins one instantiation + // (`const B: new (v: string) => Counter = Counter`): the + // value is that instantiation's class object — the generic-fn + // pinning rule on the static side. Unpinned references fall + // through to classValueRef's family fence. + if (classInfo?.generic) { + const ctxT = lowerer.checker.getContextualType(expr); + const mapped = ctxT ? lowerer.mapTypeOf(ctxT) : null; + const instInfo = + mapped?.kind === "classval" ? lowerer.classes.get(mapped.className) : undefined; + if (instInfo && instInfo.genericInstance?.family === classInfo) { + return lowerer.classValueRef(instInfo, expr); + } + } + if (classInfo) { + // A decorated name a replacing decorator can REBIND: the value + // is the decoration result — the mutable classval global %init + // assigned at the class statement (TC39's name binding), never + // the declaration's own class object. + const decoratedGlobal = classInfo.classDecorators?.valueGlobalId; + if (decoratedGlobal !== undefined) { + return { + kind: "varRef", + localId: decoratedGlobal, + type: { kind: "classval", className: classInfo.def.name }, + loc, + }; + } + return lowerer.classValueRef(classInfo, expr); + } + // The enum OBJECT as a value (member reads never reach here — the + // access hooks fold them): iteration, storage, reverse lookups + // through variables all land on this pointed fence. + if (sym) fenceEnumObjectValue(lowerer, expr, sym); + // A MIXIN function as a first-class value: no runtime function + // exists — calls instantiate a class per call site + // (lower-mixins.ts). Generic mixins took the generic-fn value + // fence above; this names the non-generic spelling. + if (mixinFnOfCallee(lowerer, expr)) { + lowerer.unsupported( + "SC1090", + expr, + `the mixin function '${expr.text}' as a value (mixin calls compile per call site — call it directly)`, + ); + } + } + // A NAMESPACE object as a first-class value: members lower at their + // qualified access sites; the object itself has no runtime + // representation (ambient namespaces compile to Node's + // ReferenceError instead — the object never exists at runtime). + { + const moduleNs = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (moduleNs?.kind === "moduleNs") { + return { kind: "moduleNsRef", moduleId: moduleNs.moduleId, type: moduleNs, loc }; + } + const builtinNs = lowerer.builtinNamespaceModuleOf(expr); + if (builtinNs === "constants") return builtinConstantsObject(lowerer, loc); + if (builtinNs !== null) { + const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNs}` }; + return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; + } + const ns = lowerNsIdentifierValue(lowerer, expr); + if (ns) return ns; + } + if (isNavigatorGlobal(lowerer, expr)) { + refuseNavigator( + lowerer, + expr, + "the navigator object as a value", + lowerer.checker.getSymbolAtLocation(expr), + ); + } + // Preflight guarantees no unresolved identifiers; anything else here + // is a blocked declaration's binding (the SC2004 cascade) or a + // binding form we don't model yet. + lowerer.rejectUnresolved( + expr, + `the reference to '${expr.text}' (a binding form with no lowering)`, + ); +} + +function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string { + return lowerer.targetPlatform === "wasi" + ? (wasiGuestPath(sf.fileName) ?? sf.fileName.replace(/\\/g, "/")) + : sf.fileName; +} + +/** Object.keys/Object.entries require an object receiver, but their first + * argument is not syntactically a member receiver. Recognize that one + * standard-library position so a runtime-optional loop element can be + * checked against the checker-narrowed record arm before builtin lowering. */ +function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean { + let arg = expr; + while ( + (ts.isParenthesizedExpression(arg.parent) || + ts.isAssertionExpression(arg.parent) || + ts.isSatisfiesExpression(arg.parent) || + ts.isNonNullExpression(arg.parent)) && + arg.parent.expression === arg + ) { + arg = arg.parent; + } + const call = arg.parent; + if ( + !ts.isCallExpression(call) || + call.arguments.nodes[0] !== arg || + !ts.isPropertyAccessExpression(call.expression) + ) + return false; + const access = call.expression; + return ( + (access.name.text === "keys" || access.name.text === "entries") && + lowerer.isStdlibMember(access) + ); +} + +/** A read that resolves to a CJS export-table ACCESSOR property + * (`module.exports = { get path() {...}, set path(v) {...} }`): the + * getter lifts as a module-level function (module globals need no + * captures) — interned per declaration so every read site calls ONE + * function — and the read IS the call, Node's per-read evaluation + * exactly. Null when the identifier doesn't resolve to such a property + * (or it has no getter: a setter-only read answers undefined in Node — + * rare enough to keep fenced). */ +function cjsExportAccessorRead(lowerer: Lowerer, ident: ts.Identifier): IrExpr | null { + const symbol = lowerer.resolveValueSymbol(ident); + const getter = symbol ? symbol.declarations.find(ts.isGetAccessorDeclaration) : undefined; + if (!getter) return null; + if (!ts.isObjectLiteralExpression(getter.parent) || !isCjsExportTableLiteral(getter.parent)) { + return null; + } + return cjsAccessorCall(lowerer, getter, locOf(ident)); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts new file mode 100644 index 000000000..207000717 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -0,0 +1,1413 @@ +/* Type narrowing of lowered values, conditional and as-expressions, and runtime-optional receiver reads. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer, dynFallbackType } from "../lowerer.js"; +import { + BOOL, + CAUGHT, + DYN, + DYN_HANDLE_KINDS, + type IrExpr, + type IrLocal, + type IrType, + NULL_T, + type SrcLoc, + UNDEFINED_T, + arrayOf, + canAdaptDynFuncTo, + canDynCheckTo, + isDynTypedRefType, + isUnitType, + shapeHasAccessorSlots, + typeEquals, +} from "../../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { jsBindingHasOpenWrites } from "../lower-stmts.js"; +import { narrowClassUnion, narrowStoredClassValue, structuralNarrowing } from "../class-unions.js"; +import { checkedClassAssertion, checkedClassUnionAssertion } from "../class-assertions.js"; +import { isOptionalProcessStreamProperty } from "../builtins/process.js"; +import { literalValues } from "../../literal-values.js"; +import { literalUnionArm } from "../../union-discriminants.js"; +import { checkedArmExtraction } from "../coercions/unions.js"; +import { nodeThrowExpr, varRef } from "../../../ir/build.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { + hasClassPayload, + runtimeOptionalAssertionErases, + runtimeOptionalLocalOf, + runtimeOptionalUseOf, +} from "../lower-exprs.js"; +import { isConstAssertionTypeNode, unitOnlyUnion } from "../../type-mapper.js"; +import { assertsGlobalObject, extractionCopiesReference } from "../assertion-identity.js"; + +/** `c ? a : b`. An explicit destination constructs each branch at that + * layout before joining it. Without one, retain the expression's inferred + * type and the contextual/sibling rules below. */ +export function lowerTernary( + lowerer: Lowerer, + expr: ts.ConditionalExpression, + expected?: IrType, +): IrExpr { + const loc = locOf(expr); + // `Array.isArray(x) ? x : [x]` over a `T | readonly T[]` union: tsc + // narrows the TRUE branch to `any[]` (maybeNarrow's isArray bridge + // rides that) but leaves the FALSE branch wide — a readonly array + // is not assignable to `any[]`, so the arm never subtracts. The + // RUNTIME tag test proves the remaining arm exactly, so the false + // arm's reads of x narrow to the union's one non-array checker + // constituent (the certs configuredTlds shape). Nested functions + // stay out (they run later, when the proof is stale — tsc's own + // invalidation rule). + const falseArmNarrows: ts.Identifier[] = []; + let falseArmNarrowType: ts.Type | null = null; + { + let c: ts.Expression = expr.condition; + while (ts.isParenthesizedExpression(c)) c = c.expression; + if ( + ts.isCallExpression(c) && + ts.isPropertyAccessExpression(c.expression) && + c.arguments.nodes.length === 1 && + ts.isIdentifier(c.arguments.nodes[0]!) && + lowerer.stdlibGlobalMember(c.expression, "Array") === "isArray" + ) { + const argIdent = c.arguments.nodes[0] as ts.Identifier; + const sym = lowerer.resolveValueSymbol(argIdent); + const t = lowerer.checker.getTypeAtLocation(argIdent); + const constituents = t.isUnionType() ? ts.constituentTypes(t) : []; + const nonArray = constituents.filter( + (a) => !lowerer.checker.isArrayType(a) && !lowerer.checker.isTupleType(a), + ); + const mapped = lowerer.mapTypeOf(t); + const armCount = mapped?.kind === "union" ? lowerer.arrayValueTags(mapped.unionId).length : 0; + if (sym && nonArray.length === 1 && armCount === 1) { + falseArmNarrowType = nonArray[0]!; + const collect = (n: ts.Node): void => { + if (ts.isFunctionLike(n)) return; + if ( + ts.isIdentifier(n) && + lowerer.resolveValueSymbol(n) === sym && + !lowerer.chainNarrowedType.has(n) + ) { + falseArmNarrows.push(n); + } + n.forEachChild(collect); + }; + collect(expr.whenFalse); + } + } + } + for (const n of falseArmNarrows) lowerer.chainNarrowedType.set(n, falseArmNarrowType!); + try { + const cond = lowerer.lowerCondition(expr.condition); + // A condition the LOWERING proved constant (typeof-dyn against a + // kind no dyn box can hold — the bigint/symbol/function fold): only + // the taken arm exists at runtime, so only it lowers — which is + // exactly what lets a dual-mode arm with no static lowering (bigint + // literals) sit untaken in compiled JS. A boolLit carries no + // effects, so dropping the condition read loses nothing. + if (cond.kind === "boolLit") { + return lowerer.lowerExprExpecting(cond.value ? expr.whenTrue : expr.whenFalse, expected); + } + if (expected) { + // Construct fresh literals in their actual destination. Joining + // inferred layouts first can erase the literal discriminator or + // choose an empty array's uninhabited element representation. + // Runtime-optional proofs apply only within the selected true arm. + const then = withRuntimeOptionalNarrowed( + lowerer, + runtimeOptionalTrueIds(lowerer, expr.condition), + () => lowerer.lowerExprExpecting(expr.whenTrue, expected), + ); + const else_ = lowerer.lowerExprExpecting(expr.whenFalse, expected); + return { kind: "ternary", cond, then, else_, type: expected, loc }; + } + const ctxTs = lowerer.checker.getContextualType(expr); + const ctxMapped = ctxTs ? lowerer.mapTypeOf(ctxTs) : null; + // Array-literal arms build directly as the slot's array type when + // the ternary sits under an ARRAY context (tsc accepts each arm + // covariantly — `[stdin]` types string[] against an + // (Uint8Array | string)[] slot — but a tagged element representation + // must be BUILT as the slot's element type, the same rule element + // expressions follow inside every array literal). And an EMPTY + // array-literal arm with no usable type of its own (tsc infers + // `never[]`; no mappable contextual type reaches into the arm — the + // conditional-spread idiom `[...(c ? [x] : [])]`) adopts the SIBLING + // arm's array type: the ternary twin of the union-slot rule in + // lowerArrayLiteral, and just as unambiguous — tsc already typed the + // whole ternary by the filled arm alone. Both arms empty stays + // fenced (no element type exists anywhere). + const ctxArray = ctxMapped?.kind === "array" ? ctxMapped : null; + const armLiteral = (e: ts.Expression): ts.ArrayLiteralExpression | null => { + let x = e; + while (ts.isParenthesizedExpression(x)) x = x.expression; + return ts.isArrayLiteralExpression(x) ? x : null; + }; + const emptyUntypedArrayArm = (e: ts.Expression): boolean => { + const lit = armLiteral(e); + if (!lit || lit.elements.nodes.length !== 0) return false; + const t = lowerer.checker.getContextualType(lit) ?? lowerer.typeOf(lit); + if (!lowerer.mapTypeOf(t)) return true; + // never[] MAPS (the f64 representation for the uninhabited) but + // carries no element information — an empty literal typed that way + // still adopts the sibling arm's array type, tsc's own reading. + if (lowerer.checker.isArrayType(t)) { + const elem = lowerer.checker.getTypeArguments(t as ts.TypeReference)[0]; + if (elem !== undefined && elem.flags & ts.TypeFlags.Never) return true; + } + return false; + }; + // Inferred signature tables often join a populated argument array + // with nested empty-array branches. Carry the whole record layout + // through those branches before constructing their fresh fields. + const ownJoin = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const recordJoin = + ctxMapped?.kind === "record" ? ctxMapped : ownJoin?.kind === "record" ? ownJoin : undefined; + const lowerArm = (e: ts.Expression, siblingType?: IrType): IrExpr => { + let fresh = e; + while (ts.isParenthesizedExpression(fresh)) fresh = fresh.expression; + if (recordJoin && ts.isConditionalExpression(fresh)) + return lowerTernary(lowerer, fresh, recordJoin); + if (recordJoin && ts.isObjectLiteralExpression(fresh)) { + return lowerer.lowerObjectLiteral(fresh, recordJoin); + } + const lit = armLiteral(e); + if (lit && ctxArray) return lowerer.lowerArrayLiteral(lit, ctxArray); + if (lit && siblingType?.kind === "array") { + if (emptyUntypedArrayArm(e)) { + return { kind: "arrayLit", elems: [], type: siblingType, loc: locOf(lit) }; + } + // A FILLED literal arm whose own element type is a UNION + // carrying the sibling's element as an arm (`Array.isArray(x) ? + // x : [x]` — the false arm's x checker-types as the whole union + // even though the runtime tag proved the non-array arm): build + // as the sibling's array type — each element coerces into the + // sibling element or fences on its own. + const ownT = lowerer.mapTypeOf(lowerer.typeOf(lit)); + if ( + ownT?.kind === "array" && + ownT.elem.kind === "union" && + siblingType.elem.kind !== "union" && + lowerer.armTag(ownT.elem.unionId, siblingType.elem) >= 0 + ) { + return lowerer.lowerArrayLiteral(lit, siblingType); + } + // A FILLED literal arm whose own array type has NO lift into the + // sibling's (`isWindows ? ['cmd.exe', ['/d']] : ['pwd', []]` — + // the empty nested literal types never[], whose f64-element + // representation re-tags into nothing): the generic path could + // only fence the whole ternary, so build AS the sibling type — + // each element coerces into the sibling's element slot or fences + // on its own, and a nested empty literal adopts the slot's array + // arm (the union-slot rule). tsc already accepted the arm + // covariantly against the join, so a fitting literal is exactly + // the value the checker typed. + if ( + ownT?.kind === "array" && + !typeEquals(ownT, siblingType) && + lowerer.widthLiftPlan(ownT, siblingType) === null + ) { + return lowerer.lowerArrayLiteral(lit, siblingType); + } + } + return lowerer.lowerExpr(e); + }; + // Lower the filled arm first so an empty arm can adopt its type. + // When the checker's OWN join for the ternary maps to an ARRAY, + // literal arms build against it directly (tsc collapses the arms' + // covariant array types into one — `c ? ['sh', []] : ['cmd', ['/d']]` + // joins as (string | string[])[] whichever arm nests the empty + // literal), so neither arm's uninhabited never[] reading decides. + const ownArrayJoin = (() => { + const m = lowerer.mapTypeOf(lowerer.typeOf(expr)); + return m?.kind === "array" ? m : null; + })(); + let thenRaw: IrExpr; + let elseRaw: IrExpr; + const runtimeTrueIds = runtimeOptionalTrueIds(lowerer, expr.condition); + const lowerTrueArm = (): IrExpr => + withRuntimeOptionalNarrowed(lowerer, runtimeTrueIds, () => + lowerArm(expr.whenTrue, ownArrayJoin ?? undefined), + ); + if (!ctxArray && emptyUntypedArrayArm(expr.whenTrue) && !emptyUntypedArrayArm(expr.whenFalse)) { + elseRaw = lowerer.lowerExpr(expr.whenFalse); + thenRaw = withRuntimeOptionalNarrowed(lowerer, runtimeTrueIds, () => + lowerArm( + expr.whenTrue, + elseRaw.type.kind === "dyn" && ownArrayJoin ? ownArrayJoin : elseRaw.type, + ), + ); + } else { + thenRaw = lowerTrueArm(); + // A checked-dynamic producer (Object.entries(...).filter(...)) + // can still have a precise checker array type. Empty literal arms + // must adopt that element layout before the checked array boundary. + elseRaw = lowerArm( + expr.whenFalse, + thenRaw.type.kind === "dyn" && ownArrayJoin ? ownArrayJoin : thenRaw.type, + ); + } + // The ternary's IR type is normally the checker's own: it collapses + // same-kind literal unions ("a" | "b" → string) and forms tagged + // unions for mixed arms that map (`c ? okRec : errRec`); anything + // left unmappable gets the type fence (badType) here. A record/union CONTEXTUAL type + // takes over exactly when the own type can't carry the value — + // unmappable, or a DIFFERENT union (branch literals omitting + // DIFFERENT optional subsets make the own type a union of the + // narrower fresh shapes, and a union-typed slot can sub-union the + // same way; neither has a runtime re-tag, while tsc guarantees each + // branch is assignable to the context). A base-CLASS context absorbs + // the same case: branches producing different subclasses make the + // own type a class union (`c ? new Dog() : new Bird()` against an + // Animal slot), while each branch upcasts into the context fine. A + // collapsed own type must WIN over a wider contextual union + // (`console.log(c ? "yes" : "no")` stays string against the ambient + // string | number | boolean parameter), and an 'unknown'/'any' + // context never absorbs the ternary (`JSON.stringify(c ? 1 : 2)` + // stays f64-typed). Arms that AGREE on an array type decide it + // themselves (a context- or sibling-built literal arm can carry a + // tagged element type the checker's own type doesn't spell). + const own = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const useCtx = + (ctxMapped?.kind === "record" || + ctxMapped?.kind === "union" || + ctxMapped?.kind === "object") && + (own === null || + (own.kind === "union" && + !typeEquals(own, ctxMapped) && + !lowerer.inLogicalLeftPosition(expr))); + // Mixed dyn/unit arms under an unmappable own type (`typeof pkg.name + // === "string" ? pkg.name : null` — the lowering world types the + // unknown-receiver read `any`, which a static build cannot hold): + // dyn represents null and undefined directly, so the ternary stays + // dyn — a unit arm converts to the dyn unit value (the dynFrom form + // the index-signature lowerings already use). A FRESH literal arm + // (`... ? pkg.scripts : {}` — the default-object idiom) converts the + // same way when it is JSON-safe: nothing else aliases a literal, so + // the dyn copy is unobservable. + const dynish = (e: IrExpr): boolean => + e.type.kind === "dyn" || + e.kind === "unitLit" || + ((e.kind === "recordLit" || e.kind === "arrayLit") && lowerer.dynConvertible(e.type)); + const dynJoin = + own === null && + !(useCtx && ctxMapped) && + dynish(thenRaw) && + dynish(elseRaw) && + (thenRaw.type.kind === "dyn" || elseRaw.type.kind === "dyn"); + // A checker-`any` ternary whose lowered arms are STATIC (`rawName ? + // rawName.replace(...) : null` — the dyn-receiver string machinery + // answers a static string): the checker's `any` carries no shape, + // but the arms do — equal arms take their shared type, a unit arm + // joins the other arm as its null/undefined-armed union. Gated on + // genuine `any` so every other unmappable keeps its own diagnostic. + let anyJoin: IrType | null = null; + if ( + own === null && + !dynJoin && + !(useCtx && ctxMapped) && + (lowerer.typeOf(expr).flags & ts.TypeFlags.Any) !== 0 + ) { + const a = thenRaw.type; + const b = elseRaw.type; + const staticArm = (t: IrType): boolean => + t.kind !== "dyn" && t.kind !== "caught" && t.kind !== "void"; + if (staticArm(a) && staticArm(b)) { + if (typeEquals(a, b)) { + anyJoin = a; + } else if (isUnitType(a) !== isUnitType(b)) { + const unit = isUnitType(a) ? a : b; + const val = isUnitType(a) ? b : a; + const arms = + val.kind === "union" ? (lowerer.unions.get(val.unionId)?.arms ?? null) : [val]; + if (arms && !arms.some((x) => typeEquals(x, unit))) { + anyJoin = { kind: "union", unionId: lowerer.unions.intern([...arms, unit]) }; + } else if (arms) { + anyJoin = val; // the unit is already an arm + } + } + } + } + // JavaScript function selection preserves the chosen closure's full + // call ABI. A checker join can erase a rest/arguments pack even though + // a later call supplies additional observable arguments. + const callableJoin = + isJsSourceFile(expr.getSourceFile()) && + thenRaw.type.kind === "func" && + elseRaw.type.kind === "func" && + !typeEquals(thenRaw.type, elseRaw.type) && + lowerer.dynConvertible(thenRaw.type) && + lowerer.dynConvertible(elseRaw.type); + // JavaScript inference can discard an initially-undefined field from + // a conditional's type even after that field acquired other values. + // A checked arm must retain its actual representation at the join. + const checkedJsJoin = + isJsSourceFile(expr.getSourceFile()) && + (thenRaw.type.kind === "dyn" || elseRaw.type.kind === "dyn") && + lowerer.dynConvertible(thenRaw.type) && + lowerer.dynConvertible(elseRaw.type); + const type = + callableJoin || checkedJsJoin + ? DYN + : thenRaw.type.kind === "array" && typeEquals(thenRaw.type, elseRaw.type) + ? thenRaw.type + : dynJoin + ? DYN + : (anyJoin ?? (useCtx && ctxMapped ? ctxMapped : lowerer.irTypeOf(expr))); + // Each arm flows into the ternary's type through the slot-coercion + // path: union arms wrap, dyn slots reject the non-dyn arm with + // SC1101, mismatched record shapes get SC2002; coerceInto is + // inert when the types already agree. + const intoDyn = (e: IrExpr): IrExpr => + e.type.kind === "dyn" ? e : { kind: "dynFrom", value: e, type: DYN, loc: e.loc }; + const then = dynJoin ? intoDyn(thenRaw) : lowerer.coerceInto(expr.whenTrue, thenRaw, type); + const else_ = dynJoin ? intoDyn(elseRaw) : lowerer.coerceInto(expr.whenFalse, elseRaw, type); + if (then.type.kind !== type.kind || else_.type.kind !== type.kind) { + lowerer.badType(expr, lowerer.typeOf(expr)); + } + return { kind: "ternary", cond, then, else_, type, loc }; + } finally { + for (const n of falseArmNarrows) lowerer.chainNarrowedType.delete(n); + } +} + +/** Checker-driven union narrowing. tsc's control-flow analysis narrows a + * union-typed reference at use sites (`if (r.kind === "ok") { ...r... }` + * types `r` as the ok-arm inside the branch); the IR value is still the + * tagged union, so the read is bridged with a `unionNarrow` extracting + * the arm's payload. The extraction relies on tsc having proven the tag; + * backends verify it once and trap on a mismatch, so a wrong proof stops + * the program instead of reading another arm. A checker type that maps to + * the same union (unnarrowed use), to a SUB-union (partial narrowing — + * unrepresentable without a re-tag), or to nothing (`never` in an + * exhaustive default) leaves the expression union-typed. */ +export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrExpr { + if ( + (expr.type.kind === "union" || expr.type.kind === "dyn") && + isJsSourceFile(node.getSourceFile()) && + ts.isPropertyAccessExpression(node) + ) { + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if ( + symbol && + symbol.declarations.some( + (decl) => + ts.isPropertyDeclaration(decl) && + !decl.initializer && + !decl.type && + isJsSourceFile(decl.getSourceFile()), + ) + ) { + // Inference describes later writes, not the initial undefined. + // A use that requires the inferred type must validate the value. + return expr.type.kind === "dyn" + ? expr + : { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc }; + } + } + // A dyn read tsc narrowed to a SCALAR (a typeof test proved the kind): + // bridge with a VALIDATED extraction — dynCheck, the checked-cast + // machinery — rather than a trusted one. After the guard the check + // never fires; a read smuggled past it throws a catchable TypeError + // instead of misreading the payload (the dyn boundary's usual stance). + // Object/array narrowings stay dyn-typed and keep their fences. + if (expr.type.kind === "dyn") { + // An inferred JS member's storage may be deliberately wider than its + // declaration (prototype-selected buffers, null-initialized scratch + // objects). Extract at the consuming operation, not at the read. + if (isJsSourceFile(node.getSourceFile()) && ts.isPropertyAccessExpression(node)) { + const receiver = lowerer.mapTypeOf(lowerer.typeOf(node.expression)); + if (receiver?.kind === "object") { + const info = lowerer.classes.get(receiver.className); + if ( + info?.fields.get(node.name.text)?.kind === "dyn" || + lowerer.findMethodOn(info ?? null, `get:${node.name.text}`)?.sig.ret.kind === "dyn" + ) + return expr; + } + if (receiver?.kind === "classval") { + const info = lowerer.classes.get(receiver.className); + if (info && hasRuntimeStatics(info)) return expr; + } + } + if ( + isJsSourceFile(node.getSourceFile()) && + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + ) { + let receiver = node.expression; + while ( + ts.isPropertyAccessExpression(receiver) || + ts.isElementAccessExpression(receiver) || + ts.isParenthesizedExpression(receiver) + ) { + if (ts.isPropertyAccessExpression(receiver)) { + const owner = lowerer.mapTypeOf(lowerer.typeOf(receiver.expression)); + if ( + owner?.kind === "object" && + lowerer.classes.get(owner.className)?.fields.get(receiver.name.text)?.kind === "dyn" + ) + return expr; + } + receiver = receiver.expression; + } + if (receiver.kind === ts.SyntaxKind.ThisKeyword && lowerer.resolveThis()?.type.kind === "dyn") + return expr; + if (ts.isIdentifier(receiver)) { + const stored = lowerer.peekLocal(receiver)?.type ?? lowerer.globalOf(receiver)?.type; + if (stored?.kind === "dyn") return expr; + const symbol = lowerer.resolveValueSymbol(receiver); + const bound = symbol && lowerer.implicitParamTypes?.get(symbol); + if (bound && lowerer.mapTypeOf(bound)?.kind === "dyn") return expr; + } + } + if (isJsSourceFile(node.getSourceFile()) && ts.isIdentifier(node)) { + const symbol = lowerer.resolveValueSymbol(node); + if ( + symbol && + symbol.declarations.some( + (decl) => + ts.isParameter(decl) && + ts.isMethodDeclaration(decl.parent) && + lowerer.checkedVirtualJsMethods.has(decl.parent), + ) + ) + return expr; + if ( + symbol && + symbol.declarations.some( + (decl) => ts.isParameter(decl) && ts.isConstructorDeclaration(decl.parent), + ) + ) + return expr; + if ( + symbol && + symbol.declarations.some( + (decl) => ts.isVariableDeclaration(decl) && jsBindingHasOpenWrites(lowerer, decl), + ) + ) + return expr; + if ( + symbol && + symbol.declarations.some( + (decl) => + ts.isParameter(decl) && + decl.initializer && + !decl.type && + !/@(?:param|type)\b/.test( + decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()), + ), + ) + ) + return expr; + if ( + symbol && + symbol.declarations.some((decl) => { + if (!ts.isParameter(decl) || decl.type) return false; + const fn = decl.parent; + if (!ts.isFunctionExpression(fn) && !ts.isArrowFunction(fn)) return false; + const assignment = fn.parent; + return ( + ts.isBinaryExpression(assignment) && + assignment.right === fn && + ts.isPropertyAccessExpression(assignment.left) && + assignment.left.name.text === "write" && + lowerer.isStdlibMember(assignment.left) + ); + }) + ) + return expr; + } + // Node's TTY declarations promise booleans/numbers even when a pipe + // has no such property. Preserve the checked read for JS capability + // probes instead of treating a declaration as a runtime type guard. + if ( + isJsSourceFile(node.getSourceFile()) && + ts.isPropertyAccessExpression(node) && + ["isTTY", "columns", "rows"].includes(node.name.text) && + lowerer.isStdlibMember(node) + ) + return expr; + // A null-initialized JS field can later receive an object even when + // checker flow calls its non-null branch never. The live checked + // value remains authoritative; never is not a numeric guard. + if ( + isJsSourceFile(node.getSourceFile()) && + (lowerer.typeOf(node).flags & ts.TypeFlags.Never) !== 0 + ) + return expr; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + if ( + narrowed && + (narrowed.kind === "f64" || + narrowed.kind === "bool" || + narrowed.kind === "string" || + narrowed.kind === "bigint") + ) { + return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; + } + // An instanceof narrow retains the native view after validating its + // exact element kind. Buffer also matches Uint8Array. + if (narrowed?.kind === "bytes" || narrowed?.kind === "url") { + return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; + } + // An `instanceof Error` narrow: the checked-dynamic tree's error encoding rebuilds a + // fresh %Error (name/message/code from the marker object — a COPY, + // the unknown boundary's stance; SEMANTICS.md 67), validated like + // every dyn extraction. + if (narrowed?.kind === "object" && narrowed.className === "%Error") { + return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; + } + return expr; + } + // instanceof narrowing for classes: tsc types this USE as a subclass of + // the IR value's class (only a dynamic instanceof test narrows a class + // type), so the read bridges with a static downcast that backends verify + // at run time, like the union bridge below. + if (expr.type.kind === "object") { + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + if (narrowed?.kind === "union") { + const union = narrowClassUnion(lowerer, expr, narrowed); + if (union) return union; + } + if ( + narrowed?.kind === "object" && + narrowed.className !== expr.type.className && + lowerer.isSubclassOf(narrowed.className, expr.type.className) + ) { + const reduced = structuralNarrowing(lowerer, node, expr.type.className, narrowed); + const union = reduced ? narrowClassUnion(lowerer, expr, reduced) : null; + if (union) return union; + if (lowerer.narrowing.untrusted(node, narrowed)) { + const checked = checkedClassAssertion(lowerer, expr, narrowed, expr.loc); + if (checked) return checked; + } + return { kind: "downcast", value: expr, type: narrowed, loc: expr.loc }; + } + return expr; + } + if (expr.type.kind !== "union") return expr; + if (ts.isPropertyAccessExpression(node) && isOptionalProcessStreamProperty(lowerer, node)) + return expr; + + // Represented fields and index values can include undefined even when + // checker inference or lib.d.ts declares strings (RegExp captures). + // Only a declaration that represents the optional arm can provide a + // trustworthy flow narrowing; otherwise consumers must check it. + if ( + (expr.kind === "recordGet" || expr.kind === "recordKeyGet") && + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + ) { + // The read itself rules out a null or undefined receiver, which a + // JavaScript receiver such as `m.groups` still declares. + const receiver = lowerer.mapTypeOf( + lowerer.checker.getNonNullableType(lowerer.typeOf(node.expression)), + ); + const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined; + const declared = + expr.kind === "recordGet" + ? shape?.fields.find((field) => field.name === expr.field)?.type + : shape?.indexValue; + if ( + lowerer.armTag(expr.type.unionId, UNDEFINED_T) >= 0 && + declared && + !(declared.kind === "union" && lowerer.armTag(declared.unionId, UNDEFINED_T) >= 0) + ) + return expr; + // The same for a field holding a Date's toJSON() result, whose null + // the declared string does not represent. + if ( + expr.kind === "recordGet" && + lowerer.isRuntimeOptionalField(expr.shapeId, expr.field) && + lowerer.armTag(expr.type.unionId, NULL_T) >= 0 && + declared && + !(declared.kind === "union" && lowerer.armTag(declared.unionId, NULL_T) >= 0) + ) + return expr; + } + if (ts.isIdentifier(node)) { + const symbol = lowerer.resolveValueSymbol(node); + if (symbol && lowerer.unstableSwitchBindings.has(symbol)) return expr; + // A binding initialized by a Date's toJSON() can hold null where the + // checker says string; consumers check it. + if (symbol && lowerer.dateJsonBindings.has(symbol)) return expr; + } + if ( + ts.isPropertyAccessExpression(node) && + getterResultHoldsUndeclaredUndefined(lowerer, node, expr) + ) + return expr; + const narrowedTs = lowerer.typeOf(node); + const narrowed = lowerer.mapTypeOf(narrowedTs); + // `Array.isArray(u)` on a union with one readonly array/tuple arm: the + // lib predicate narrows to `any[]`, possibly intersected with another + // type. That mapping may be a checked value or a synthetic record, but + // the tag test proved the union's one array-valued arm, so bridge to it + // with the same trusted unionNarrow the mapped case below builds. + if (lowerer.checkerAnyArrayType(narrowedTs)) { + const def = lowerer.unions.get(expr.type.unionId); + const arrayTags = lowerer.arrayValueTags(expr.type.unionId); + if (arrayTags.length === 1) { + const arm = def!.arms[arrayTags[0]!]!; + const checked = checkedStaleArm(lowerer, node, expr, arrayTags[0]!, arm); + if (checked) return checked; + return { + kind: "unionNarrow", + unionId: expr.type.unionId, + tag: arrayTags[0]!, + value: expr, + type: arm, + loc: expr.loc, + }; + } + } + // A checker type narrowed to a UNIT arm (the `=== undefined` branch) + // also stays union-typed: a unit arm has no payload to extract, and + // nothing useful reads such a value anyway. (Standalone undefined maps + // to void and standalone null to nothing, so the isUnitType guard is + // defensive.) + if (!narrowed || narrowed.kind === "union" || narrowed.kind === "void" || isUnitType(narrowed)) { + return expr; + } + const tag = lowerer.armTag(expr.type.unionId, narrowed); + if (tag < 0) { + // The checker may refine fields inside a discriminated record too. + // That creates a different mapped shape, but does not change the + // stored payload layout. A required literal discriminator proves + // which original arm to read; retain all of that arm's field types. + const def = lowerer.unions.get(expr.type.unionId); + if (narrowed.kind === "record" && def?.discriminant) { + const property = lowerer.checker.getPropertyOfType(narrowedTs, def.discriminant.field); + if ( + property && + !( + property.flags & + (ts.SymbolFlags.Optional | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor) + ) + ) { + const values = literalValues(lowerer.checker.getTypeOfSymbol(property)); + const arm = values && literalUnionArm(def, values, (id) => lowerer.shapes.get(id)); + if (arm) { + const checked = checkedStaleArm( + lowerer, + node, + expr, + lowerer.armTag(expr.type.unionId, arm), + arm, + ); + if (checked) return checked; + return { + kind: "unionNarrow", + unionId: expr.type.unionId, + tag: lowerer.armTag(expr.type.unionId, arm), + value: expr, + type: arm, + loc: expr.loc, + }; + } + } + } + // A base-class arm can narrow to a subclass after instanceof. The + // payload retains its base-class tag even when the union also has + // records or other classes. Require one unambiguous containing arm. + const arms = lowerer.unions.get(expr.type.unionId)?.arms ?? []; + const valueTag = + narrowed.kind === "object" + ? arms.findIndex( + (arm) => + arm.kind === "object" && lowerer.isSubclassOf(narrowed.className, arm.className), + ) + : -1; + const valueType = arms[valueTag]; + if ( + narrowed.kind === "object" && + valueType?.kind === "object" && + arms.every( + (arm, i) => + i === valueTag || + arm.kind !== "object" || + !lowerer.isSubclassOf(narrowed.className, arm.className), + ) && + lowerer.isSubclassOf(narrowed.className, valueType.className) + ) { + const checked = checkedStaleArm(lowerer, node, expr, valueTag, valueType, narrowed); + if (checked) { + return checkedClassAssertion(lowerer, checked, narrowed, expr.loc) ?? checked; + } + return { + kind: "downcast", + value: { + kind: "unionNarrow", + unionId: expr.type.unionId, + tag: valueTag, + value: expr, + type: valueType, + loc: expr.loc, + }, + type: narrowed, + loc: expr.loc, + }; + } + return expr; + } + const checked = checkedStaleArm(lowerer, node, expr, tag, narrowed); + if (checked) return checked; + return { + kind: "unionNarrow", + unionId: expr.type.unionId, + tag, + value: expr, + type: narrowed, + loc: expr.loc, + }; +} + +/** A getter read whose runtime result can be undefined (an unchecked + * element read in its body, or in another member of its override family) + * although its declared type cannot. The checker's type for the read is + * then no narrowing proof, so the read keeps the getter's result. */ +function getterResultHoldsUndeclaredUndefined( + lowerer: Lowerer, + node: ts.PropertyAccessExpression, + expr: IrExpr, +): boolean { + if (expr.type.kind !== "union" || lowerer.armTag(expr.type.unionId, UNDEFINED_T) < 0) + return false; + const getter = + (expr.kind === "virtualCall" && expr.method.startsWith("get:")) || + (expr.kind === "call" && expr.callee.includes(".get:")); + if (!getter) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if (!symbol || (symbol.flags & ts.SymbolFlags.GetAccessor) === 0) return false; + const declared = lowerer.checker.getTypeOfSymbol(symbol); + const holdsUndefined = (type: ts.Type): boolean => + (type.flags & + (ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== + 0; + return !( + holdsUndefined(declared) || + (declared.isUnionType() && ts.constituentTypes(declared).some(holdsUndefined)) + ); +} + +/** The checked form of maybeNarrow's arm extraction for a narrowing the + * checker may hold past code that replaced the value (a call clearing the + * property; see NarrowingStability). Every other arm throws the TypeError + * Node raises for the operation consuming the read (`Cannot read + * properties of undefined (reading 'x')`, `o.f is not a function`), or the + * stranded-value TypeError where Node would carry the value on. Null when + * the narrowing is provably current. `narrowed` is the checker's type when + * it refines the arm (a subclass view). */ +function checkedStaleArm( + lowerer: Lowerer, + node: ts.Node, + value: IrExpr, + tag: number, + arm: IrType, + narrowed: IrType = arm, +): IrExpr | null { + if (value.type.kind !== "union" || tag < 0) return null; + if (!lowerer.narrowing.untrusted(node, narrowed)) return null; + const inPlace = checkedArmExtraction(lowerer, value, tag, arm, (found) => + staleReadMessage(node, found), + ); + if (inPlace) return inPlace; + const helper = lowerer.narrowedArmHelper(value.type.unionId, arm, value.loc); + return helper ? lowerer.checkedNarrowCall(helper, value, arm, value.loc) : null; +} + +/** V8's spelling of a callee in "is not a function": a string-literal + * element key reads as a property name (`o["a"]` is `o.a`), other keys + * keep their brackets. */ +export function v8CalleeText(node: ts.Node): string { + if (ts.isElementAccessExpression(node)) { + const key = node.argumentExpression; + const object = v8CalleeText(node.expression); + return ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key) + ? `${object}.${key.text}` + : `${object}[${key.getText()}]`; + } + if (ts.isPropertyAccessExpression(node) && !node.questionDotToken) + return `${v8CalleeText(node.expression)}.${node.name.text}`; + return node.getText(); +} + +/** Node's TypeError for the operation consuming `node` when it holds a + * value of type `found`, or null where Node would not throw. */ +function staleReadMessage(node: ts.Node, found: IrType): string | null { + let child: ts.Node = node; + let parent = node.parent; + while (ts.isParenthesizedExpression(parent)) { + child = parent; + parent = parent.parent; + } + if (ts.isCallExpression(parent) && parent.expression === child && !parent.questionDotToken) { + return found.kind === "func" ? null : `${v8CalleeText(node)} is not a function`; + } + const unit = found.kind === "undefinedT" ? "undefined" : found.kind === "nullT" ? "null" : null; + if (unit === null) return null; + if ( + !(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) || + parent.expression !== child || + parent.questionDotToken + ) + return null; + let key: string | null = null; + if (ts.isPropertyAccessExpression(parent)) key = parent.name.text; + else if (ts.isStringLiteral(parent.argumentExpression)) key = parent.argumentExpression.text; + else if (ts.isNumericLiteral(parent.argumentExpression)) + key = String(Number(parent.argumentExpression.text)); + if (key === null) return null; + const store = parent.parent; + return ts.isBinaryExpression(store) && + store.left === parent && + store.operatorToken.kind === ts.SyntaxKind.EqualsToken + ? `Cannot set properties of ${unit} (setting '${key}')` + : `Cannot read properties of ${unit} (reading '${key}')`; +} + +/** A runtime-optional slot can be assigned while a truthy guard is in + * force. TypeScript then types a direct member receiver as the plain arm, + * even though an OOB-safe assignment may have restored undefined. Check + * that hidden arm and throw the member-read TypeError Node would produce. */ +export function runtimeOptionalReceiverRead( + lowerer: Lowerer, + expr: ts.Identifier, + local: IrLocal, + property: string | null, + loc: SrcLoc, +): IrExpr { + if (local.type.kind !== "union") { + lowerer.runtimeOptionalLocals.delete(local); + lowerer.runtimeOptionalStorageLocals.delete(local); + return varRef(local.id, local.type, loc); + } + // A non-null/type assertion around the receiver carries the useful + // checker narrowing on the wrapper rather than on the identifier token + // itself (`gg!.day`). Follow only erased wrappers; the member/call node + // still receives the checked extraction built below. + let narrowedNode: ts.Expression = expr; + while ( + (ts.isParenthesizedExpression(narrowedNode.parent) || + ts.isAssertionExpression(narrowedNode.parent) || + ts.isSatisfiesExpression(narrowedNode.parent) || + ts.isNonNullExpression(narrowedNode.parent)) && + narrowedNode.parent.expression === narrowedNode + ) { + narrowedNode = narrowedNode.parent; + } + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(narrowedNode)); + if (!narrowed || isUnitType(narrowed)) { + lowerer.unsupported( + "SC1090", + expr, + "a runtime-optional capture whose narrowed value type is not representable", + ); + } + if (narrowed.kind === "union") { + const classView = narrowStoredClassValue(lowerer, varRef(local.id, local.type, loc), narrowed); + if (classView) return classView; + // The checker may keep the receiver at the storage union unchanged + // (`string | undefined` on a JS `.match()` call). There is no + // sub-union to re-tag: preserve the value for the downstream + // nullable-receiver lowering, which performs the member-specific + // checked extraction. Refusing here deferred the whole statement to + // a runtime fence before that lowering could run. + if (typeEquals(local.type, narrowed)) { + return varRef(local.id, local.type, loc); + } + // A captured array element can itself be a union of records (for + // example Circle | Square). The optional storage union has one extra + // undefined arm; re-tag it to the checker-narrowed value union with a + // checked trap for that arm, then let union property lowering inspect + // the surviving record arms. + const helper = lowerer.narrowedRetagHelper( + narrowedNode, + local.type.unionId, + narrowed.unionId, + loc, + ); + if (!helper) { + lowerer.unsupported( + "SC1090", + expr, + "a runtime-optional capture whose narrowed union is not representable", + ); + } + return { + kind: "call", + callee: helper, + args: [varRef(local.id, local.type, loc)], + type: narrowed, + loc, + }; + } + if (narrowed.kind === "object") { + const view = checkedClassAssertion(lowerer, varRef(local.id, local.type, loc), narrowed, loc); + // Preserve the member-specific missing-value error on the two-arm + // path below; mixed storage also needs to validate its concrete class. + if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view; + } + const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T); + const def = lowerer.unions.get(local.type.unionId); + if (!def || undefTag < 0) + throw new InternalCompilerError("runtime-optional local is missing its undefined arm"); + if (def.arms.length !== 2) { + // The checker may narrow a runtime-optional capture to one arm of a + // value union (`Circle | Square | undefined` -> `Circle`). The + // two-arm fast path below cannot extract that arm safely: a later + // array mutation can leave any of the stored tags in the slot. Use + // the checked helper so every other arm, including undefined, remains + // a catchable TypeError rather than an unchecked payload read. + const helper = lowerer.narrowedArmHelper(local.type.unionId, narrowed, loc); + if (!helper) { + lowerer.unsupported( + "SC1090", + expr, + "a direct read from a runtime-optional capture with multiple value arms", + ); + } + return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), narrowed, loc); + } + // A predicate can strengthen a record's fields or narrow a base class + // to a subclass without changing the slot's stored value arm. Extract + // that actual arm first: record reads need its original field layout, + // and class reads apply the ordinary downcast bridge afterward. + const valueTag = undefTag === 0 ? 1 : 0; + const valueType = def.arms[valueTag]; + if (!valueType || isUnitType(valueType)) { + lowerer.unsupported( + "SC1090", + expr, + "a runtime-optional receiver without a representable value arm", + ); + } + const message = + property === null + ? `${expr.text} is not a function` + : `Cannot read properties of undefined (reading '${property}')`; + return lowerer.maybeNarrow( + { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: local.type.unionId, + tag: undefTag, + negated: false, + value: varRef(local.id, local.type, loc), + type: BOOL, + loc, + }, + then: nodeThrowExpr(1, "", message, valueType, loc), + else_: { + kind: "unionNarrow", + unionId: local.type.unionId, + tag: valueTag, + value: varRef(local.id, local.type, loc), + type: valueType, + loc, + }, + type: valueType, + loc, + }, + narrowedNode, + ); +} + +export function runtimeOptionalTrueIds(lowerer: Lowerer, node: ts.Expression): IrLocal[] { + return runtimeOptionalGuardIds(lowerer, node, true); +} + +/** Presence proved by one outcome of a boolean guard. Negation flips + * the outcome; all operands of a true AND or false OR have that outcome. + * Assignments invalidate a syntactic proof even when checker types hide + * the undefined produced by an out-of-bounds read. */ +export function runtimeOptionalGuardIds( + lowerer: Lowerer, + node: ts.Expression, + truthy: boolean | null, +): IrLocal[] { + const candidates: IrLocal[] = []; + const collect = (expr: ts.Expression, outcome: boolean | null): void => { + if (ts.isParenthesizedExpression(expr)) return collect(expr.expression, outcome); + if (ts.isPrefixUnaryExpression(expr) && expr.operator === ts.SyntaxKind.ExclamationToken) { + return collect(expr.operand, outcome === null ? null : !outcome); + } + if ( + ts.isBinaryExpression(expr) && + ((outcome === true && expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) || + (outcome === false && expr.operatorToken.kind === ts.SyntaxKind.BarBarToken)) + ) { + collect(expr.left, outcome); + collect(expr.right, outcome); + } else if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { + // A completed ordinary member read checked its receiver even if + // the property's value was falsy. Optional chains make no such + // promise, and calls/captures retain their separate checked ABI. + const local = runtimeOptionalLocalOf(lowerer, expr.expression); + if (local !== null) candidates.push(local); + else if (ts.isPropertyAccessExpression(expr.expression)) collect(expr.expression, outcome); + } else if ( + ts.isBinaryExpression(expr) && + expr.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken && + expr.operatorToken.kind !== ts.SyntaxKind.BarBarToken && + expr.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken + ) { + // Both operands of a comparison/arithmetic operator are evaluated. + // Only member reads prove presence; bare identifiers need truth. + const member = (operand: ts.Expression): void => { + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if (ts.isPropertyAccessExpression(operand)) collect(operand, outcome); + }; + member(expr.left); + member(expr.right); + const op = expr.operatorToken.kind; + const equal = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsToken; + const unequal = + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + if ((equal && outcome === false) || (unequal && outcome === true)) { + const present = (value: ts.Expression, unit: ts.Expression): void => { + while (ts.isParenthesizedExpression(unit)) unit = unit.expression; + const excludesUndefined = + (ts.isIdentifier(unit) && + unit.text === "undefined" && + (lowerer.typeOf(unit).flags & ts.TypeFlags.Undefined) !== 0) || + (unit.kind === ts.SyntaxKind.NullKeyword && + (op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken)); + if (excludesUndefined) { + const local = runtimeOptionalLocalOf(lowerer, value); + if (local !== null) candidates.push(local); + } + }; + present(expr.left, expr.right); + present(expr.right, expr.left); + } + } else if (outcome) { + const local = runtimeOptionalLocalOf(lowerer, expr); + if (local !== null) candidates.push(local); + } + }; + collect(node, truthy); + if (candidates.length === 0) return candidates; + const assigned = new Set(); + const visit = (part: ts.Node): void => { + if (ts.isFunctionLike(part)) return; + let target: ts.Node | null = null; + if ( + ts.isBinaryExpression(part) && + part.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + part.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + target = part.left; + if ( + (ts.isPrefixUnaryExpression(part) || ts.isPostfixUnaryExpression(part)) && + (part.operator === ts.SyntaxKind.PlusPlusToken || + part.operator === ts.SyntaxKind.MinusMinusToken) + ) + target = part.operand; + if (target !== null) { + // Destructuring assignments can contain several identifiers. + const mark = (name: ts.Node): void => { + if (ts.isIdentifier(name)) { + const local = lowerer.resolveLocal(name); + if (local) assigned.add(lowerer.runtimeOptionalRootOf(local)); + } + ts.forEachChild(name, mark); + }; + mark(target); + } + ts.forEachChild(part, visit); + }; + visit(node); + return candidates.filter((local) => !assigned.has(lowerer.runtimeOptionalRootOf(local))); +} + +export function withRuntimeOptionalNarrowed( + lowerer: Lowerer, + locals: readonly IrLocal[], + fn: () => T, +): T { + if (locals.length === 0) return fn(); + for (const local of locals) + lowerer.runtimeOptionalLocals.delete(lowerer.runtimeOptionalRootOf(local)); + try { + return fn(); + } finally { + for (const local of locals) + lowerer.runtimeOptionalLocals.add(lowerer.runtimeOptionalRootOf(local)); + } +} + +/** `x as T`. Non-dyn inner values ordinarily erase; a record assertion + * changing monomorphic shape rebuilds through the established width-copy + * path so its represented value agrees with its asserted type. + * A dyn ('unknown') inner value makes this THE dynamic boundary: + * - `as unknown` (dyn → dyn) stays erasure; + * - dyn → a JSON-representable target type T compiles to `dynCheck`, a + * runtime validation that builds the typed value or THROWS a catchable + * TypeError-flavored error (JS `as` never checks — the headline + * documented divergence: a lying cast throws instead of corrupting + * memory); + * - dyn → anything else (closures, class instances, void) is rejected: + * those types cannot be found inside a JSON dyn. */ +/** `e as T` and the old-style assertion `e` — one node shape (both + * carry `.type` and `.expression`), one lowering. */ +export function lowerAsExpression( + lowerer: Lowerer, + expr: ts.AsExpression | ts.TypeAssertion, +): IrExpr { + // `[] as const` — tsgo panics computing the expression's `readonly []` + // type (the facade's fence answers `any`), but the syntax pins the + // value exactly: the empty tuple, ridden as the unit-element array + // (mapType's empty-tuple rule). Lower it directly; enclosing slots + // coerce like any other empty-array source. + if ( + ts.isAsExpression(expr) && + isConstAssertionTypeNode(expr.type) && + ts.isArrayLiteralExpression(expr.expression) && + expr.expression.elements.nodes.length === 0 + ) { + return { + kind: "arrayLit", + elems: [], + type: arrayOf(unitOnlyUnion(lowerer.unions)), + loc: locOf(expr), + }; + } + // `e as C` on a CATCH BINDING (`(err as Error).message`): the checked + // extraction — an instanceof match extracts the payload, anything else + // throws the catchable TypeError (dynCheck's trust-but-verify stance, + // extended to exception payloads). Intercepts BEFORE lowerExpr — the + // raw read would hit caughtRead's narrowness fence. + const caughtLocal = lowerer.caughtLocalOf(expr.expression); + if (caughtLocal) { + const loc = locOf(expr); + const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); + const target = lowerer.mapTypeOf(targetTs); + if (target?.kind === "object") { + const info = lowerer.classes.get(target.className); + if (info && lowerer.inHierarchy(info)) { + return { + kind: "caughtCheck", + value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc }, + className: target.className, + type: target, + loc, + }; + } + } + // Structural assertions use the ordinary checked dynamic bridge. It + // retains primitive values and native class identity before validating + // the requested shape; an assertion does not bypass runtime checks. + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const bridged = + narrowed && + (narrowed.kind === "f64" || + narrowed.kind === "bool" || + narrowed.kind === "string" || + narrowed.kind === "object" || + narrowed.kind === "dyn"); + if (!bridged) lowerer.unsupported("SC1063", expr); + } + const inner = lowerer.lowerExpr(expr.expression); + const use = runtimeOptionalUseOf(expr); + if (inner.type.kind === "union" && runtimeOptionalAssertionErases(lowerer, expr, inner, use)) + return inner; + if (inner.type.kind !== "dyn") { + const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type); + const target = lowerer.mapTypeOf(targetTs0); + if (target?.kind === "object") { + const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr)); + if (asserted) return asserted; + } + // Unknown-only record views use the checked-dynamic representation. + // A native record asserted to such a view still denotes the original + // object. Box a live reference instead of erasing the assertion and + // handing native storage to a checked-dynamic property operation. + if (target?.kind === "dyn" && (targetTs0.flags & ts.TypeFlags.Object) !== 0) { + // A class instance asserted to a class-backed protocol view (a type + // class instances flow into): the same identity-keeping box an + // implicit flow into the view uses. + if (inner.type.kind === "object" && lowerer.classImplementedProtocol(targetTs0)) + return lowerer.coerceInto(expr, inner, DYN); + const sourceShape = + inner.type.kind === "record" ? lowerer.shapes.get(inner.type.shapeId) : undefined; + if ( + hasClassPayload(lowerer, inner.type) || + (sourceShape && shapeHasAccessorSlots(sourceShape)) + ) { + lowerer.unsupported( + "SC1090", + expr, + "opaque structural views of classes or accessor records (name the property's concrete type to retain native accessor dispatch)", + ); + } + const boxed = lowerer.coerceInto(expr, inner, DYN); + // Only references box live; a scalar or unit source (`value as + // unknown as View`) is an ordinary snapshot. + const seen = new Set(); + const live = (type: IrType): boolean => { + if ( + type.kind === "record" || + type.kind === "array" || + type.kind === "bytes" || + isDynTypedRefType(type) + ) + return true; + if (type.kind !== "union" || seen.has(type.unionId)) return false; + seen.add(type.unionId); + return lowerer.unions.get(type.unionId)?.arms.some(live) ?? false; + }; + return boxed.kind === "dynFrom" && live(boxed.value.type) + ? { ...boxed, liveRef: true } + : boxed; + } + // Collection assertions are interface views only: their backing + // value must already have the same native key/element/value layout. + // Erasing a mock or a different instantiation here would let member + // lowering interpret a record (or another ABI) as a native handle. + if (target?.kind === "map" || target?.kind === "set") { + return lowerer.coerceInto(expr, inner, target); + } + // A class-derived interface still names the class's native layout. + // An assertion must not let a structural mock acquire that layout; + // unions retain their checked extraction below, and real instances + // keep the ordinary class narrowing path. + if (target?.kind === "object" && inner.type.kind !== "object" && inner.type.kind !== "union") { + return lowerer.coerceInto(expr, inner, target); + } + if ( + target?.kind === "object" && + inner.type.kind === "object" && + !typeEquals(target, inner.type) && + !lowerer.isSubclassOf(inner.type.className, target.className) && + !lowerer.isSubclassOf(target.className, inner.type.className) + ) { + return lowerer.coerceInto(expr, inner, target); + } + // Static assertions normally erase, but record layouts are + // monomorphic: a consumer selected from the asserted shape must see + // that shape physically. Reuse the ordinary slot coercion so plain + // widths and index-signature captures share their established copy + // semantics (and unsupported pairs report SC2002 at this assertion). + if ( + inner.type.kind === "record" && + target?.kind === "record" && + inner.type.shapeId !== target.shapeId + ) { + return lowerer.coerceInto(expr, inner, target); + } + // `u as Arm` on a UNION value is `u!`'s spelling with a named arm + // (`req.headers[h] as string`): the CHECKED single-arm extraction — + // the asserted arm's payload comes out, any other arm throws the + // catchable TypeError (divergence 38's lying-assertion stance; an + // erasure would just move the failure to the next typed slot as an + // opaque union-mismatch fence). Sub-union targets and same-type + // casts keep the historic erasure. + if (inner.type.kind === "union") { + if (target && target.kind !== "union" && !typeEquals(target, inner.type)) { + const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, locOf(expr)); + if (helper) { + return lowerer.checkedNarrowCall(helper, inner, target, locOf(expr)); + } + } + } + // `node.parent as Sub | undefined`: a union target whose class arm is + // narrower than the stored class gets the same checked class view as + // `node.parent as Sub`; the other arms keep their identical tags. + if ( + target?.kind === "union" && + (inner.type.kind === "union" || inner.type.kind === "object") && + !typeEquals(target, inner.type) + ) { + const asserted = checkedClassUnionAssertion(lowerer, inner, target, locOf(expr)); + if (asserted) return asserted; + } + return inner; // erasure, unchanged + } + + const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); + const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs); + if (!target) lowerer.badType(expr.type, targetTs); + if (target.kind === "dyn") return inner; // `as unknown`: erasure + // An all-`unknown`-fields record target (`err as { code?: unknown }` + // — the errno-probing idiom): there is nothing to validate (every + // field is unknown, exactly what the dyn value already answers) and + // nothing to build — the cast is pure typing, so it ERASES and the + // reads ride the dyn keyed read. + if (target.kind === "record") { + const shape = lowerer.shapes.get(target.shapeId); + if ( + shape && + !shape.tuple && + shape.fields.every((f) => f.type.kind === "dyn") && + (!shape.indexValue || shape.indexValue.kind === "dyn") + ) { + return inner; + } + } + // The global object: the checked extraction would build a separate + // value, so the assertion keeps the object and its member operations + // reach it (assertion-identity.ts). + if (extractionCopiesReference(lowerer, target) && assertsGlobalObject(lowerer, expr)) + return inner; + if ( + target.kind === "void" || + !canDynCheckTo( + target, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + // Bare undefined-armed targets pass when every OTHER arm is + // JSON-safe: the checked-dynamic tree holds a first-class undefined value now + // (index-signature overflow reads produce it for missing keys), and + // the undefined arm matches exactly it — `p[key] as string | + // undefined` is the missing-key idiom. Parsed JSON still never + // contains undefined, so casts over parse results keep failing on + // non-string values with the usual path-annotated TypeError. + if (target.kind === "union" && lowerer.dynConvertible(target)) { + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; + } + // Uint8Array targets: the checked-dynamic tree carries a bytes kind now (converted + // stdin chunks) — the extraction validates the kind and copies out. + if (target.kind === "bytes" && target.elem === "u8") { + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; + } + // Class instances round-trip only through compiler-owned typed + // references. Plain JSON objects fail the runtime class-brand check; + // exact capsules unwrap the original object by identity. + if (isDynTypedRefType(target)) { + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; + } + // ADAPTABLE function targets (`u as (x: number) => number` — the + // checked-dynamic function boundary): kind check, then exact unwrap + // or the per-target adapter shim. Non-adaptable signatures keep the + // fence below. + if ( + target.kind === "func" && + canAdaptDynFuncTo( + target, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; + } + // Runtime HANDLE targets (`u as IncomingMessage` — a boxed handle + // coming back out of an untyped wrapper): a tag-checked reference + // unwrap, identity preserved (DYN_HANDLE_KINDS). + if (DYN_HANDLE_KINDS.has(target.kind)) { + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; + } + lowerer.unsupported( + "SC1090", + expr, + `a checked cast of 'unknown' to '${target.kind === "void" ? "void" : lowerer.fmt(target)}' ` + + `(a dynamic value can only be validated against JSON-representable types: ` + + `number, string, boolean, records, arrays, and unions of those)`, + ); + } + return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts new file mode 100644 index 000000000..7f0b2ddbb --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -0,0 +1,3551 @@ +/* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer, PoisonError, jsFuncNameOf, neverTaintedJsType, own } from "../lowerer.js"; +import { + ARRAY_METHODS, + MAP_METHODS, + NARROW_FIRST, + OBJECT_CALLABLE_VALUES, + SET_METHODS, + STDLIB_SURFACE, + STRING_INDEX_METHODS, + STR_METHODS, + isChildSurfaceMember, + stdlibGlobalNameOf, +} from "../surfaces.js"; +import { + isGenericCallableMemberType, + isLegacyUrlSymbol, + isOsConstantsType, + isParseArgsDynTypeName, +} from "../../type-mapper.js"; +import { + BOOL, + BYTES_ELEMENT_NAME, + DYN, + F64, + type IrExpr, + type IrLocal, + type IrRecordShape, + type IrStmt, + type IrType, + NULL_T, + SEARCH_PARAMS_T, + STRING, + SYMBOL_T, + type SrcLoc, + UNDEFINED_T, + arrayOf, + canBoxFuncIntoDyn, + funcOf, + isDynTypedRefType, + isUnitType, + typeEquals, + typeKey, + unionContainerArmsOk, +} from "../../../ir/ir.js"; +import { lowerNavigatorMember } from "../builtins/navigator.js"; +import { NODE_COMPAT_VERSION } from "../builtins/process.js"; +import { + inlineNamespaceRequireOf, + lowerWithInlineRequireInit, + wholeExportValue, +} from "../cjs-inline-require.js"; +import { + type FieldTarget, + cjsAccessorCall, + constInitializerOf, + exactUnionWidening, + hasClassPayload, + lowerExpr, + matchAllCallbackRegexOf, + matchAllRegexOf, + namedCaptureGroupsOfPattern, + primitiveCtorClosure, + recordKeyResultOk, + representedClassFieldTarget, + staticRegexPatternOf, +} from "../lower-exprs.js"; +import { + cjsInFileExportGlobalOf, + isCjsExportsIdentifier, + lowerCjsExportsObjectValue, +} from "../cjs-exports-object.js"; +import { tscInteropDefaultValueOf } from "../cjs-tsc-interop.js"; +import { lifetimeRegionMemberOf } from "../../experimental-import.js"; +import { lowerInternTableProperty } from "../intern-tables.js"; +import { lowerThreadSlotsProperty } from "../thread-slots.js"; +import { builtinClassRefOf, runtimeClassValueOfType } from "../builtins/class-values.js"; +import { findGenericMethodOn, lowerStaticFieldRead } from "../lower-classes.js"; +import { lowerClassPrototypeData } from "../class-prototypes.js"; +import { lowerEnumAccess } from "../lower-enums.js"; +import { + hasOptionalChainGuard, + isOptionalChainTail, + isRequireMainFilename, +} from "./optional-chains.js"; +import { + isCjsModuleGlobal, + lowerNodeModuleProperty, + lowerRequireMainProperty, +} from "../lower-node-module.js"; +import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../../ir/build.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { + cjsClassExprWholeExportOf, + cjsExportAssignmentOf, + cjsExportDiscardReason, + isCjsExportTableLiteral, + isCjsJsFile, + isJsSourceFile, + isModuleExportsAccess, + isNodeEsmFile, + locOf, +} from "../../program.js"; +import { + ambientNsRootOf, + ambientUndefReadType, + ambientUndefVarRootOf, + contextualUndefReadType, + fenceEarlyNsMemberRef, + nsMemberIdentOf, + nsUndefRead, +} from "../lower-namespaces.js"; +import { expandoMemberRead } from "../lower-expando.js"; +import { lowerProtocolPropertyRead } from "../class-protocol-dispatch.js"; +import { lowerReadlineProperty } from "../builtins/readline.js"; +import { lowerEventStaticProperty } from "../builtins/numbers.js"; +import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "../lower-stream.js"; +import { lowerEventObjectProperty } from "../lower-event-target.js"; +import { iteratorResultValueRead } from "../lower-generators.js"; +import { lowerGlobalValue } from "../lower-global-value.js"; +import { nullishGenericBindingUnitOf } from "../binding-analysis.js"; +import { blockedBindingUseDiag } from "../../../diagnostics/diagnostic.js"; +import { npmStaticPackageOfPath } from "../../npm-static.js"; +import { importMetaField, isImportMeta } from "../import-meta.js"; +import { + builtinPrototypeMethod, + lowerArrayIsArrayValue, + lowerCheckedPredicateValue, + lowerNumberParserValue, + lowerObjectAssignValue, + lowerStringCodesValue, +} from "../lower-builtin-values.js"; +import { lowerArrayFromValue } from "../containers/array-construction.js"; +import { lowerNetAddressStaticRead } from "../builtins/net-address.js"; +import { STATS_TYPE_TESTS } from "../builtins/filesystem.js"; +import { arrayValueRead, arrayValueType } from "../array-values.js"; +import { lowerClassMethodValue } from "../class-method-values.js"; +import { + objLitGenericFnInfoOf, + objLitGenericFnNodeOf, + requireObjLitGenericReceiver, +} from "../generic-functions.js"; +import { narrowStoredClassValue, structuralReadType } from "../class-unions.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { lowerUnionFieldRead } from "./union-fields.js"; +import { errorPropertyRead } from "../error-methods.js"; +import { isSafeToRepeat } from "./evaluation-safety.js"; +import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/compression.js"; +import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js"; + +/** True when a property access names an ABSTRACT property declaration + * (`abstract p: number` — a PropertyDeclaration, not an accessor). + * These uses need concrete property dispatch because the base has no slot. */ +export function abstractPropertyDeclOf( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): boolean { + const sym = lowerer.checker.getSymbolAtLocation(expr.name); + if (!sym) return false; + return sym.declarations.some( + (d) => + ts.isPropertyDeclaration(d) && + ts.getModifiers(d)?.some((m) => m.kind === ts.SyntaxKind.AbstractKeyword) === true, + ); +} + +/** A field declared by node:util's parseArgs checked-dynamic type family. + * Narrowing a ParseArgsToken exposes one anonymous union arm, so its alias + * identity no longer reaches mapType; declaration ancestry is the stable + * provenance check. */ +/** The Uint8Array base64/hex prototype methods (ES2026) Node 24 lacks. */ +const UINT8ARRAY_BASE64_METHODS: ReadonlySet = new Set([ + "toBase64", + "toHex", + "setFromBase64", + "setFromHex", +]); + +/** `Uint8Array.prototype.` read off the global. */ +function isUint8ArrayBase64Probe(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + if (!UINT8ARRAY_BASE64_METHODS.has(expr.name.text)) return false; + const proto = expr.expression; + return ( + ts.isPropertyAccessExpression(proto) && + !proto.questionDotToken && + proto.name.text === "prototype" && + stdlibGlobalNameOf(lowerer, proto.expression) === "Uint8Array" + ); +} + +function isParseArgsDynProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + const sym = lowerer.checker.getSymbolAtLocation(expr.name); + if (!sym) return false; + return sym.declarations.some((d) => { + if (!lowerer.isStdlibFile(d.getSourceFile())) return false; + let inParseArgsType = false; + for (let node: ts.Node | undefined = d.parent; node; node = node.parent) { + if ( + (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && + isParseArgsDynTypeName(node.name.text) + ) { + inParseArgsType = true; + } + if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + return inParseArgsType && (node.name.text === "util" || node.name.text === "node:util"); + } + } + return false; + }); +} + +function isStatFsDynProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { + if ( + !["type", "bsize", "blocks", "bfree", "bavail", "files", "ffree", "bigint"].includes( + expr.name.text, + ) + ) + return false; + const symbol = lowerer.checker.getSymbolAtLocation(expr.name); + return ( + !!symbol && + symbol.declarations.some((declaration) => { + if (!lowerer.isStdlibFile(declaration.getSourceFile())) return false; + const parent = declaration.parent; + if (!ts.isInterfaceDeclaration(parent)) return false; + if ( + expr.name.text === "bigint" + ? parent.name.text !== "StatFsOptions" + : !["StatsFsBase", "StatsFs", "BigIntStatsFs"].includes(parent.name.text) + ) + return false; + for (let node: ts.Node | undefined = parent.parent; node; node = node.parent) { + if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) + return node.name.text === "fs" || node.name.text === "node:fs"; + } + return false; + }) + ); +} + +export function lowerPropertyAccessExpr( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + loc: SrcLoc, +): IrExpr { + if (!expr.questionDotToken) { + const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION); + if (navigator) return navigator; + } + // `require("./x").member` over a program module's export table: the + // module's %init at this position, then the member read exactly like + // a `const m = require("./x")` binding's (cjs-inline-require.ts). + if (!expr.questionDotToken) { + const req = inlineNamespaceRequireOf(lowerer, expr.expression); + if (req !== null && !lowerer.inlineRequireInitDone.has(req.call)) { + return lowerWithInlineRequireInit(lowerer, req, () => lowerExpr(lowerer, expr)); + } + // `module.exports` compared by identity inside its own module, or + // refused by name as any other value (cjs-exports-object.ts). + if (expr.name.text === "exports") { + const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); + if (exportsValue) return exportsValue; + } + // tsc's `x_1.default` over a dependency whose whole export is one + // value: that value (cjs-tsc-interop.ts). + const interop = tscInteropDefaultValueOf(lowerer, expr); + if (interop !== null) { + const value = wholeExportValue(lowerer, interop.dep, expr); + if (value !== null) return value; + } + } + { + const member = lifetimeRegionMemberOf(lowerer.program, expr, lowerer.experimentalFiles); + if (member !== null) + lowerer.noLowering( + `LifetimeRegion.${member} (@scriptc/experimental) as a value`, + expr, + `call region.${member}(${member === "run" ? "fn" : ""}) directly`, + ); + } + { + const table = lowerInternTableProperty(lowerer, expr); + if (table) return table; + } + lowerThreadSlotsProperty(lowerer, expr); + // `EventEmitter.name`, `zlib.Gzip.name`, `C.name` over a builtin + // class alias. + if (expr.name.text === "name" && !expr.questionDotToken) { + const ref = builtinClassRefOf(lowerer, expr.expression); + if (ref !== null) return { kind: "strLit", value: ref.name, type: STRING, loc }; + } + // `C.name` through a runtime-provided class VALUE (a `typeof Readable` + // parameter): the class object's stored name. + if ( + expr.name.text === "name" && + !expr.questionDotToken && + runtimeClassValueOfType(lowerer, lowerer.typeOf(expr.expression)) !== null + ) { + const read = lowerer.lowerFieldRead(expr); + if (read) return read; + } + // Expando statics may still be absent even when JS inference sees + // the eventual assignment's scalar type. Preserve the checked value. + const staticField = lowerStaticFieldRead(lowerer, expr); + if (staticField) return staticField; + const prototypeData = lowerClassPrototypeData(lowerer, expr); + if (prototypeData) return prototypeData; + if (ts.isPropertyAccessExpression(expr.expression) && expr.expression.name.text === "prototype") { + const prototype = lowerClassPrototypeData(lowerer, expr.expression); + if (prototype) + return { + kind: "dynKeyGet", + value: prototype, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + } + // `super.x`: the base chain's GETTER, called directly (super + // dispatch is static in JS — never through the dynamic class). + // super.method() calls are routed at the call site; a bare super + // property read lands here. + if (expr.expression.kind === ts.SyntaxKind.SuperKeyword) { + return lowerer.lowerSuperAccessorRead(expr); + } + // Enum member reads (`E.A`) fold to their compile-time constants — + // claimed FIRST so no receiver-shaped path (records, the + // identifier fence on the enum object) sees them. lower-enums.ts. + { + const en = lowerEnumAccess(lowerer, expr); + if (en) return en; + } + // `import.meta.` — the file-backed ESM metadata that survives + // compilation as a constant. Only direct, identifier-named reads are + // claimed here; the bare meta object, computed access, writes, and + // unsupported members continue through the generic module-loader fence + // below. The source file is the module identity, so imported modules + // receive their own filename/dirname and only the entry receives main. + const importMeta = importMetaProperty(lowerer, expr); + if (importMeta) return importMeta; + // `require.main.filename` / `require.main?.filename` — CommonJS + // entry-module identity: in a compiled binary require.main IS the + // entry module (never undefined in a CJS graph, so the chain's guard + // folds away), and its filename is the ENTRY file's real path — + // Node's exact value when the same tree runs in place, exactly the + // __filename stance. Checked BEFORE the optional-chain gate: the + // checker types the chain `string | undefined`, but the value is a + // compile-time string. + if (isRequireMainFilename(lowerer, expr)) { + return { kind: "strLit", value: lowerer.entry.fileName, type: STRING, loc }; + } + const requireMain = lowerRequireMainProperty(lowerer, expr); + if (requireMain) return requireMain; + const moduleProperty = lowerNodeModuleProperty(lowerer, expr); + if (moduleProperty) return moduleProperty; + if ( + stdlibGlobalNameOf(lowerer, expr.expression) === "Symbol" && + [ + "iterator", + "asyncIterator", + "toStringTag", + "toPrimitive", + "hasInstance", + "isConcatSpreadable", + "match", + "matchAll", + "replace", + "search", + "split", + "species", + "unscopables", + "dispose", + "asyncDispose", + ].includes(expr.name.text) + ) { + return { + kind: "libCall", + fn: "sym.wellKnown", + args: [{ kind: "strLit", value: expr.name.text, type: STRING, loc }], + type: { kind: "symbol" }, + loc, + }; + } + if (lowerer.stdlibGlobalMember(expr, "Object") === "freeze") { + const name = "%builtin.Object.freeze"; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) + lowerer.liftedFns.push({ + name, + params: [{ localId: "value", name: "value", type: DYN }], + locals: [{ id: "value", name: "value", type: DYN, mutable: false }], + returnType: DYN, + body: [ + { + kind: "return", + value: { + kind: "libCall", + fn: "dyn.freeze", + args: [varRef("value", DYN, loc)], + type: DYN, + loc, + }, + loc, + }, + ], + loc, + }); + return { kind: "closure", fnName: name, captures: [], type: funcOf([DYN], DYN), loc }; + } + // An Hmac has no copy() (Hash does): Node answers undefined, which + // `typeof mac.copy` feature checks read. TypeScript rejects the read. + if ( + expr.name.text === "copy" && + lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "cryptoHmac" + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(expr.expression), loc }], + result: dynUndefinedExpr(loc), + type: DYN, + loc, + }; + } + if (lowerer.stdlibGlobalMember(expr, "globalThis") === "console") { + return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }; + } + if (lowerer.stdlibGlobalMember(expr, "console") !== null) { + return { + kind: "dynKeyGet", + value: { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + } + // Missing host globals (Bun/Deno capability probes) have no binding + // in a native program. Declared globals retain their own lowering; + // arbitrary global-object mutation remains fenced. Preserve a dyn + // undefined value so stored probes compose with optional calls. + if ( + stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis" && + !lowerer.checker.getPropertyOfType(lowerer.typeOf(expr.expression), expr.name.text) + ) { + return dynUndefinedExpr(loc); + } + // `Uint8Array.prototype.toBase64` and its siblings, the base64/hex + // feature probes libraries such as jose make: the methods are newer + // than Node 24, whose Uint8Array prototype answers undefined for them. + if (isUint8ArrayBase64Probe(lowerer, expr)) return dynUndefinedExpr(loc); + if (expr.questionDotToken && expr.name.text === "electron") { + const processProperty = lowerer.lowerProcessProperty(expr); + if (processProperty) return processProperty; + } + // Optional chaining `a?.b`: the guard lowers here (a tag test around + // the plain property lowering below); the handled marker keeps this + // re-entrant dispatch from looping. + if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) { + return lowerer.lowerOptionalChain(expr); + } + // `a?.b.c` — the tail of a chain whose token sits deeper: the whole + // tail short-circuits with the guard. + if (!expr.questionDotToken && isOptionalChainTail(lowerer, expr)) { + return lowerer.lowerOptionalChain(expr); + } + // Known Web handles retain their declared surface even when their + // storage uses checked values. Apply this before generic reads. + lowerer.fenceStaticAbortControllerMemberRead(expr); + lowerer.fenceStaticResponseMember(expr, "read"); + lowerer.fenceStaticHeadersMember(expr, "read"); + lowerer.fenceStaticReadableStreamMember(expr, "read"); + + // — reading off null/undefined — bridged catchably like everything + // at the boundary). `o?.x` arrives here too, through the chain + // lowering's re-dispatch (a questionDotToken past the gate above is + // always chain-handled; the receiver reads back as the chain's + // bound handle). + + // `m.index` on a for-of-over-matchAll binding reads the companion- + // index array at this iteration's cursor — always a number: every + // drained row matched, so the lib's `.index` is never undefined + // here (lowerForOfMatchAll). + if (expr.name.text === "index" && !expr.questionDotToken && ts.isIdentifier(expr.expression)) { + const sym = lowerer.checker.getSymbolAtLocation(expr.expression); + const companion = sym !== undefined ? lowerer.matchAllIndexBindings.get(sym) : undefined; + if (companion !== undefined) { + return { + kind: "arrayGet", + arr: { kind: "varRef", localId: companion.idxsLocalId, type: arrayOf(F64), loc }, + index: { kind: "varRef", localId: companion.curLocalId, type: F64, loc }, + type: F64, + loc, + }; + } + } + if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) { + const represented = tryLowerExpression(lowerer, expr.expression); + if ( + represented && + isMatchSliceType(lowerer, represented.type) && + (lowerer.isStdlibMember(expr) || isJsSourceFile(expr.getSourceFile())) + ) { + const recv = represented; + const key: IrExpr = { kind: "strLit", value: expr.name.text, type: STRING, loc }; + // A plain slice answers from its match source without a dynamic copy. + const read: IrExpr = typeEquals(recv.type, arrayOf(STRING)) + ? { kind: "libCall", fn: "regex.resultProp", args: [recv, key], type: DYN, loc } + : { kind: "dynKeyGet", value: lowerer.coerceToExpected(recv, DYN), key, type: DYN, loc }; + return lowerer.maybeNarrow(read, expr); + } + } + // `m.groups` on a match result whose regex is statically known: + // the compile-time record projection over the honest slice (or + // Node's undefined when the pattern has no named groups) — see + // lowerMatchGroupsRead. + { + const g = lowerMatchGroupsRead(lowerer, expr); + if (g !== null) return g; + } + // `arguments.length` in a TYPED function whose signature is + // FIXED-ARITY (no optional/default/rest parameters): tsc enforces + // exact arity at every call site and call/apply/bind indirection is + // fenced, so the actual count IS the declared count — a compile-time + // constant. Arrows don't bind `arguments` (the walk skips them, + // exactly JS's scoping); variable-arity signatures keep the fence + // (the count would need a hidden argc). The JS-source variadic + // `arguments` object (dynRest) was claimed at identifier lowering. + if ( + expr.name.text === "length" && + !expr.questionDotToken && + ts.isIdentifier(expr.expression) && + expr.expression.text === "arguments" && + !lowerer.ctx.argumentsLocal && + lowerer.typeOf(expr.expression).getSymbol()?.name === "IArguments" && + lowerer.isStdlibSymbol(lowerer.typeOf(expr.expression).getSymbol()) + ) { + let fn: ts.Node | undefined = expr.parent; + while ( + fn !== undefined && + !ts.isFunctionDeclaration(fn) && + !ts.isFunctionExpression(fn) && + !ts.isMethodDeclaration(fn) && + !ts.isConstructorDeclaration(fn) && + !ts.isGetAccessorDeclaration(fn) && + !ts.isSetAccessorDeclaration(fn) + ) { + fn = ts.isSourceFile(fn) ? undefined : fn.parent; + } + if (fn !== undefined) { + const fixedArity = (fn as ts.FunctionLikeDeclaration).parameters.nodes.every( + (p) => + p.questionToken === undefined && + p.initializer === undefined && + p.dotDotDotToken === undefined, + ); + if (fixedArity) { + return { + kind: "numLit", + value: (fn as ts.FunctionLikeDeclaration).parameters.nodes.length, + type: F64, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + "'arguments.length' in functions with optional, default, or rest parameters (the count varies per call — a fixed-arity signature folds to a constant)", + ); + } + } + // CommonJS namespace binding (`const lib = require("./lib.js")`): + // `lib.member` IS the member — the export table is alias plumbing, + // so the NAME resolves exactly like a bare identifier reference + // (resolveValueSymbol lands on the exporter's declaration and every + // existing path — globals, function values, classes — applies). + if (!expr.questionDotToken && lowerer.cjsLocalModuleBindingOf(expr.expression)) { + // A binding whose dep is a class-expression WHOLE export + // (`module.exports = class {…}`): the member surface is the + // CLASS's — `C.name` folds to NamedEvaluation's answer and own + // statics read their globals (lowerStaticFieldRead resolves the + // binding through the expression's own symbol). The plain + // member-name delegation below would resolve `name` against the + // stdlib var instead — a wrong VALUE, not a fence. Unknown + // members fall through to the delegation (expando statics ride + // their pre-registered export globals). + const viaClass = lowerStaticFieldRead(lowerer, expr); + if (viaClass) return viaClass; + return lowerExpr(lowerer, expr.name); + } + // `module.exports` READ in a CommonJS file whose whole export IS a + // class expression (`module.exports = class …{}`): the read answers + // the class VALUE — `new module.exports()` inside the module is the + // requirer's `new C()` spelled locally. Top-level reads ABOVE the + // assignment fall through to their existing fences (Node still + // answers the original export object there); function bodies + // resolve unconditionally — they run after the module evaluated, + // the same approximation identifier exports already make. Every + // other module.exports read keeps its existing story. + if ( + !expr.questionDotToken && + isModuleExportsAccess(expr) && + isCjsJsFile(expr.getSourceFile(), lowerer.program) + ) { + const whole = cjsClassExprWholeExportOf(expr.getSourceFile()); + if (whole) { + let inBody = false; + for ( + let p: ts.Node | undefined = expr.parent; + p !== undefined && !ts.isSourceFile(p); + p = p.parent + ) { + if (ts.isFunctionLike(p) || ts.isClassDeclaration(p) || ts.isClassExpression(p)) { + inBody = true; + break; + } + } + if (inBody || expr.getStart() > whole.stmt.getEnd()) { + return lowerer.classValueRef(lowerer.lowerClassExpressionInfo(whole.classExpr), expr); + } + } + } + // `module.exports.Sub` / `exports.Sub` READ in the same module: an + // EXPRESSION-valued member rides its pre-registered export global — + // exactly what requirers see through `require('./x').Sub`, and the + // global IS the storage every `exports.Sub =` statement assigns, so + // the read is Node's live member. Identifier-valued members have no + // global (pure alias plumbing) and fall through unchanged; write + // positions belong to the export-assignment machinery. + { + const writePos = + ts.isBinaryExpression(expr.parent) && + expr.parent.left === expr && + expr.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + const g = cjsInFileExportGlobalOf(lowerer, expr); + if (g && !writePos) return { kind: "varRef", localId: g.id, type: g.type, loc }; + } + // Namespace-qualified reads (`N.x`, `A.B.C.f`, import= alias + // chains): the member is a compile-time-known declaration of a + // lowered namespace block — it resolves exactly like a bare + // identifier reference (globals, function values, classes), guarded + // by the namespace source-order fences (lower-namespaces.ts). + if (!expr.questionDotToken) { + const nsMember = nsMemberIdentOf(lowerer, expr); + if (nsMember) { + const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); + if (memberSym) fenceEarlyNsMemberRef(lowerer, expr, memberSym); + return lowerExpr(lowerer, nsMember); + } + // Expando function members (`foo.bar` after `foo.bar = 12`): the + // member's module global (lower-expando.ts), before the ambient- + // namespace fence — a declare-namespace merge over a real function + // (`declare namespace Foo { var baz: number }` + `function Foo`) + // reads its assigned members, not the ambient ReferenceError. + { + const ex = expandoMemberRead(lowerer, expr); + if (ex) return lowerer.maybeNarrow(ex, expr); + } + // An AMBIENT namespace receiver (`M.x` where only `declare + // namespace M` exists): the namespace object never exists at + // runtime — Node's exact catchable ReferenceError at the access, + // the ambient `declare const` stance. + const ambientRoot = ambientNsRootOf(lowerer, expr.expression); + if (ambientRoot !== null) { + const t = ambientUndefReadType(lowerer, expr); + if (t) return nsUndefRead(lowerer, ambientRoot.text, expr, t); + } + } + // `exports.` READS in a module whose export object was + // REPLACED (`module.exports = ...`): `exports` still references the + // ORIGINAL object — Node's aliasing rule — so when nothing ever + // attached this name to it (`exports. =` nowhere in the + // file), the honest answer is undefined. tsc types the read off the + // REPLACEMENT (its CJS model is identity-blind), which is exactly + // the undiagnosed divergence this lowering closes. Reads in modules with + // real `exports. =` attachments keep their fences. + { + const ex = lowerReplacedExportsRead(lowerer, expr); + if (ex) return ex; + } + // `this.` inside a CJS export-table getter: Node binds the + // receiver to module.exports — the read is the sibling getter's + // lifted call. + { + const tm = lowerCjsExportTableThisMember(lowerer, expr); + if (tm) return tm; + } + // Property signatures of class-backed protocols read implementing + // classes' fields directly. + if (!hasOptionalChainGuard(expr.expression)) { + const typed = lowerProtocolPropertyRead(lowerer, expr); + if (typed) return typed; + } + const handled = + lowerer.lowerProcessEnvGet(expr) ?? + lowerer.lowerServerProperty(expr) ?? + // diagnostics_channel Channel receivers — name/hasSubscribers + // over the f64 channel handle. + lowerer.lowerDiagnosticsChannelProperty(expr) ?? + // readline Interface receivers — the history and option members + // over the f64 interface handle. + lowerReadlineProperty(lowerer, expr) ?? + // TracingChannel receivers — the five event channels and + // hasSubscribers over the f64 tracing handle. + lowerer.lowerTracingChannelProperty(expr) ?? + lowerer.lowerTestCtxProperty(expr) ?? + lowerer.lowerProcessProperty(expr) ?? + lowerer.lowerProcessStreamProperty(expr) ?? + lowerer.lowerFsConstantsProperty(expr) ?? + // http2.constants.NGHTTP2_CANCEL (any constants-object spelling): + // the baked-literal table. + lowerer.lowerBuiltinConstantsProperty(expr) ?? + // Builtin namespace imports (`path.sep`, `os.EOL`, + // `fs.constants.R_OK` where the root is `import * as ...`): the + // same constants and per-member fences as named builtin imports. + lowerer.lowerNamespaceBuiltinProperty(expr) ?? + lowerer.lowerJsonProperty(expr) ?? + lowerer.lowerErrorCodeProperty(expr) ?? + lowerer.lowerNumberStaticProperty(expr) ?? + lowerEventStaticProperty(lowerer, expr) ?? + lowerer.lowerMathProperty(expr) ?? + lowerer.lowerIntrinsicProperty(expr) ?? + // The _readableState/_writableState scalar READS (the suite's + // asserts) — checked before the generic stream property surface: + // the inner access maps no type of its own. + lowerStreamStateProperty(lowerer, expr) ?? + lowerStreamObjectProperty(lowerer, expr) ?? + lowerEventObjectProperty(lowerer, expr) ?? + lowerer.lowerUnionProperty(expr) ?? + lowerer.lowerFieldRead(expr); + // A union-typed field read narrows like an identifier when the + // checker has narrowed this use to one arm. + if (handled) { + if ( + handled.type.kind === "union" && + lowerer.armTag(handled.type.unionId, UNDEFINED_T) >= 0 && + handled.kind === "recordGet" && + lowerer.isRuntimeOptionalField(handled.shapeId, handled.field) + ) + return handled; + // A class field assigned a missing read: the checker narrows this + // read to the assigned value's bare type, but the stored undefined + // arm is real (`obj.n = bytes[i]; obj.n` is undefined in Node). + if (runtimeOptionalClassField(lowerer, handled, expr)) return handled; + return iteratorResultValueRead(lowerer, handled, expr) ?? lowerer.maybeNarrow(handled, expr); + } + // `globalThis.` that no lowering above claimed + // (globalThis.crypto, globalThis.SubtleCrypto, globalThis.localStorage + // — the harness's capability-conditional knownGlobals adds): the + // PROPERTY spelling of the identifier chokepoint's JS rule — a stdlib + // global taken as a VALUE in a JavaScript source is the same opaque + // IDENTITY TOKEN the bare spelling answers (`globalThis.crypto` IS + // `crypto` — one global, one token, or identity flows through Sets + // and === would disagree between the two spellings). TypeScript + // sources keep the SC2020 member fence below, like the bare form. + if ( + !expr.questionDotToken && + stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis" && + isJsSourceFile(expr.getSourceFile()) + ) { + const canonical = stdlibGlobalNameOf(lowerer, expr); + if (canonical !== null) { + if (canonical === "globalThis") return lowerGlobalValue(lowerer, expr); + if (canonical === "ArrayBuffer") + return { kind: "libCall", fn: "arrayBuffer.constructor", args: [], type: DYN, loc }; + if (canonical === "fetch") + return { kind: "libCall", fn: "fetch.function", args: [], type: DYN, loc }; + if (Object.values(BYTES_ELEMENT_NAME).includes(canonical)) + return { + kind: "libCall", + fn: "bytes.constructor", + args: [{ kind: "strLit", value: canonical, type: STRING, loc }], + type: DYN, + loc, + }; + return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; + } + } + // A JS receiver whose CHECKER type has no mapping (`mustCallChecks + // .length` where the binding is an evolving any[] living as a + // checked-dynamic global): the stdlib member fence below would blame + // the unmappable checker type (`any[].length`), but the VALUE is a + // dyn node — lower the receiver first and read through the dyn keyed + // read (ARR answers length; everything else JS's own-property + // answer). Mapped receivers keep the fence-first order: their + // members' gaps are real surface gaps ([1,2].entries), not + // representation artifacts. `.length` on a receiver whose checker + // ARRAY type lowers to the checked-dynamic representation — + // `unknown[]`, the collapsed `(string | object)[]`, and the `any[]` + // an Array.isArray guard narrows a collapsed union to — is the checked-dynamic tree + // array's OWN length (a keyed read the ARR kind answers exactly), + // never an `Array.prototype` surface gap; both source languages. + // Prototype-method VALUE reads (`ps.map` unparenthesized) keep the + // fence-first order: a stored-member undefined would mis-answer + // them, and calls dispatch through the dyn method machinery + // instead. + if ( + (isJsSourceFile(expr.getSourceFile()) && + (lowerer.mapTypeOf(lowerer.typeOf(expr.expression)) === null || + (lowerer.typeOf(expr.expression).flags & ts.TypeFlags.Never) !== 0 || + // A never-tainted receiver type maps (never rides as f64) but + // its VALUE lowered checked-dynamic — same dyn read. + neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)))) || + (expr.name.text === "length" && + (lowerer.checkerAnyArray(expr.expression) || + (lowerer.checker.isArrayType(lowerer.typeOf(expr.expression)) && + lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "dyn"))) + ) { + const recv = lowerer.lowerExpr(expr.expression); + if (recv.type.kind === "dyn") { + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + const opt = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + key, + ...(opt ? { optional: true as const } : {}), + value: recv, + type: DYN, + loc, + }, + expr, + ); + } + if ( + expr.name.text === "length" && + (recv.type.kind === "array" || recv.type.kind === "string") + ) { + return recv.type.kind === "array" + ? { kind: "arrIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc } + : { kind: "strIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc }; + } + } + if (isJsSourceFile(expr.getSourceFile()) && expr.name.text === "constructor") { + const receiver = tryLowerExpression(lowerer, expr.expression); + if ( + receiver?.type.kind === "dyn" || + (receiver?.type.kind === "object" && + !lowerer.classes.get(receiver.type.className)?.fields.has("constructor")) + ) { + return { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(receiver, DYN), + key: { kind: "strLit", value: "constructor", type: STRING, loc }, + type: DYN, + loc, + }; + } + } + // `f.name` / `f.length` / own properties on a FUNCTION-typed JS + // value (the mustCall wrapper's function-instance members): read + // through the dyn box — the closure's own-property table answers + // first (defineProperties writes land there), then the box's + // best-effort static name and the declared arity; anything else is + // the own-property answer, undefined. Function.prototype METHOD + // names stay fenced (a stored-member undefined would mis-answer + // `f.call` as a value). Static callable values keep the existing TS fence; checked callable + // storage can expose its own name and length in both source languages. + if ( + !["apply", "bind", "call", "toString", "caller", "arguments"].includes(expr.name.text) && + (isJsSourceFile(expr.getSourceFile()) || + expr.name.text === "name" || + expr.name.text === "length" || + expr.name.text === "constructor") + ) { + const probed = tryLowerExpression(lowerer, expr.expression); + // Inferred callable returns can use checked storage even when the + // checker still describes a function. Read its actual dyn value; + // the ambient Function member must not hide its own properties. + const receiverType = lowerer.typeOf(expr.expression); + const receiverSymbol = receiverType.getSymbol(); + const checkedFunction = + lowerer.checker.getCallSignatures(receiverType).length > 0 || + (receiverSymbol?.name === "Function" && lowerer.isStdlibSymbol(receiverSymbol)); + if (probed?.type.kind === "dyn" && checkedFunction) { + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + const opt = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + key, + value: probed, + ...(opt ? { optional: true as const } : {}), + type: DYN, + loc, + }, + expr, + ); + } + if ( + (isJsSourceFile(expr.getSourceFile()) || expr.name.text === "constructor") && + probed?.type.kind === "func" && + canBoxFuncIntoDyn( + probed.type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + const fnName = jsFuncNameOf(expr.expression); + const boxed: IrExpr = { + kind: "dynFrom", + value: probed, + type: DYN, + ...(fnName !== null ? { fnName } : {}), + loc, + }; + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + return lowerer.maybeNarrow({ kind: "dynKeyGet", key, value: boxed, type: DYN, loc }, expr); + } + } + // A NARROWED IteratorResult receiver (`if (!r.done) r.value` — the + // checker narrows to IteratorYieldResult/IteratorReturnResult, whose + // own type cannot map: the shared record shape needs BOTH channels): + // the receiver's LOWERED record still carries the field, so the read + // is an ordinary recordGet — maybeNarrow then bridges the union slot + // to the checker's narrowed arm, exactly like a union field read. + if (expr.name.text === "done" || expr.name.text === "value") { + const receiverType = lowerer.typeOf(expr.expression); + const recvSym = + receiverType.getAliasSymbol()?.name === "IteratorResult" + ? receiverType.getAliasSymbol() + : receiverType.getSymbol(); + if ( + (recvSym?.name === "IteratorResult" || + recvSym?.name === "IteratorYieldResult" || + recvSym?.name === "IteratorReturnResult") && + recvSym.declarations.some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isTypeAliasDeclaration(d)) && + lowerer.isStdlibFile(d.getSourceFile()), + ) + ) { + const recv = lowerer.lowerExpr(expr.expression); + if (recv.type.kind === "dyn") + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: recv, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }, + expr, + ); + if (recv.type.kind === "record") { + const shape = lowerer.shapes.get(recv.type.shapeId); + const field = shape?.fields.find((f) => f.name === expr.name.text); + if (field) { + const read: IrExpr = { + kind: "recordGet", + obj: recv, + shapeId: recv.type.shapeId, + field: field.name, + type: field.type, + loc, + }; + return iteratorResultValueRead(lowerer, read, expr) ?? lowerer.maybeNarrow(read, expr); + } + } + } + } + // A chain rooted at an initializer-less ambient `declare const/var` + // whose declared type has NO mapping (mappable roots compose through + // the bare-read undefRead and never reach here): Node throws the + // catchable ReferenceError at the ROOT read before any member + // matters, so the whole access lowers to that throw — typed by the + // use site, or by the context it flows into (the throw never + // returns, so the dummy is never observed). + { + const ambientRoot = ambientUndefVarRootOf(lowerer, expr); + if (ambientRoot !== null) { + const t = ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr); + if (t) return nsUndefRead(lowerer, ambientRoot.text, expr, t); + } + } + // Checked-dynamic stdlib results retain ambient property symbols, + // including parseArgs tokens narrowed to anonymous union arms. Only + // members owned by these implemented families bypass the stdlib fence. + const segmentDataSymbol = lowerer.typeOf(expr.expression).getSymbol(); + const segmentDataProperty = + segmentDataSymbol?.name === "SegmentData" && + lowerer.isStdlibSymbol(segmentDataSymbol) && + ["segment", "index", "input", "isWordLike"].includes(expr.name.text); + // os.constants members vary by platform (the declared groups list + // every platform's names): a number member reads as `number | + // undefined`, like an array element, and a group stays checked. + const osConstants = isOsConstantsType(lowerer.typeOf(expr.expression), lowerer); + if ( + isParseArgsDynProperty(lowerer, expr) || + isStatFsDynProperty(lowerer, expr) || + segmentDataProperty || + osConstants || + isLegacyUrlSymbol(lowerer.checker.getSymbolAtLocation(expr.name), lowerer) + ) { + const recv = lowerer.lowerExpr(expr.expression); + if (recv.type.kind === "dyn") { + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + const opt = hasOptionalChainGuard(expr.expression); + const read: IrExpr = { + kind: "dynKeyGet", + key, + ...(opt ? { optional: true as const } : {}), + value: recv, + type: DYN, + loc, + }; + if (osConstants) { + return lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "f64" + ? lowerer.coerceToExpected(read, lowerer.withUndefinedArm(F64)) + : read; + } + return lowerer.maybeNarrow(read, expr); + } + } + // A member read through a NULLISH generic binding (`const i: I = null as any; const _i: I = i.something` — the receiver + // provably holds null/undefined forever): the read throws Node's + // exact TypeError at the access. + if (ts.isIdentifier(expr.expression) && expr.questionDotToken === undefined) { + const unit = nullishGenericBindingUnitOf(lowerer, lowerer.resolveValueSymbol(expr.expression)); + if (unit !== null) { + const t = + ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr) ?? F64; + return nodeThrowExpr( + 1, + "", + `Cannot read properties of ${unit} (reading '${expr.name.text}')`, + t, + loc, + ); + } + } + // The lib fence's PROPERTY chokepoint: a stdlib-declared member that + // no lowering above claimed ([1,2].entries, Math.SQRT2, Promise.all, + // re.exec as a value, ...) reports SC2020 here. + lowerer.stdlibMemberFence(expr); + // The npm chokepoint: a member on a package-typed receiver in a + // static build — attributed to the package, like every other site. + lowerer.npmMemberFence(expr); + // A property read rooted at a BLOCKED binding: the declaration + // carries the real diagnostic — the SC2004 cascade, not a generic + // "property access" rejection. + { + let root: ts.Expression = expr.expression; + while (ts.isPropertyAccessExpression(root)) root = root.expression; + if (ts.isIdentifier(root) && lowerer.isBlockedBinding(lowerer.resolveValueSymbol(root))) { + lowerer.pushDiag(blockedBindingUseDiag(root.text, loc)); + throw new PoisonError(); + } + } + // Nothing claimed the member. Lower the RECEIVER before rejecting: + // when the receiver itself is the blocker (`(await import(x)).y` — + // the dynamic import is the unsupported part), ITS diagnostic is the + // honest one, not a generic recitation about the outer dot. A + // receiver that lowers cleanly means the MEMBER is the gap — name + // the property and the receiver's type instead of "property access". + const recvLowered = lowerer.lowerExpr(expr.expression); + // A dyn receiver — JSON.parse's `any`, or `unknown` the checker + // narrowed to `object` — reads through the dyn keyed read: member + // or undefined (JS's own-property answer), throwing JS's TypeError + // on undefined/null receivers unless an earlier `?.` guards the + // chain. Scalar-narrowed occurrences bridge via maybeNarrow's + // validated dynCheck as usual. + if ( + recvLowered.type.kind === "dyn" || + (isJsSourceFile(expr.getSourceFile()) && + (["f64", "bool", "bigint", "string", "symbol"].includes(recvLowered.type.kind) || + (recvLowered.type.kind === "union" && lowerer.dynConvertible(recvLowered.type)))) + ) { + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + const opt = hasOptionalChainGuard(expr.expression); + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + key, + ...(opt ? { optional: true as const } : {}), + value: lowerer.coerceToExpected(recvLowered, DYN), + type: DYN, + loc, + }, + expr, + ); + } + // The lowered receiver is a RECORD the checker spelled wider — + // `s.match(re).groups.key` in a JS file: the checker says + // `{ [key: string]: string } | undefined`, but the groups + // projection already answered the record arm (its null trap + // included). Declared fields read directly; an index-signature + // shape serves undeclared keys through the overflow read, exactly + // the dot-access rule on checker-spelled hybrids. + if (recvLowered.type.kind === "record") { + const recvShape = lowerer.shapes.get(recvLowered.type.shapeId); + const field = recvShape?.fields.find((f) => f.name === expr.name.text); + if (field) { + return lowerer.maybeNarrow( + { + kind: "recordGet", + obj: recvLowered, + shapeId: recvLowered.type.shapeId, + field: field.name, + type: field.type, + loc, + }, + expr, + ); + } + if (recvShape?.indexValue !== undefined && !recvShape.tuple) { + return lowerer.maybeNarrow( + { + kind: "recordKeyGet", + obj: recvLowered, + shapeId: recvLowered.type.shapeId, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + overflowOnly: true, + type: recvShape.indexValue, + loc, + }, + expr, + ); + } + // An inferred package-JS call specializes a parameter to the + // caller's own record shape. Optional option names omitted by that + // caller read undefined; the missing field is not a failed type + // assertion. Known prototype members were handled/fenced above. + if ( + recvShape && + !recvShape.tuple && + lowerer.implicitParamTypes !== null && + isJsSourceFile(expr.getSourceFile()) && + npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null + ) { + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: recvLowered, loc }], + result: dynUndefinedExpr(loc), + type: DYN, + loc, + }; + } + } + // An ABSTRACT property through an abstract-typed receiver: the + // declaration is erased at runtime — Node defines no field for it, + // each concrete subclass declares its OWN (at its own layout + // position), so no shared base slot exists to read. Abstract + // ACCESSORS are the supported spelling: they declare a vtable slot. + if (recvLowered.type.kind === "object" && abstractPropertyDeclOf(lowerer, expr)) { + lowerer.unsupported( + "SC1090", + expr, + `reading the abstract property '${expr.name.text}' through a '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'-typed receiver (abstract property declarations are erased at runtime, so no shared slot exists — type the receiver as the concrete class, or declare an abstract getter instead)`, + ); + } + // Array reads may add an undefined arm even when the checker still + // reports a class. Its property bag must remain reachable, while + // an absent element takes the normal checked-value TypeError. + const nativeReceiver = lowerer.stripUndefinedArm(recvLowered.type); + if (ts.isPrivateIdentifier(expr.name) && recvLowered.type.kind === "object") { + const field = lowerer.classes.get(recvLowered.type.className)?.fields.get(expr.name.text); + if (field) + return { + kind: "fieldGet", + obj: recvLowered, + className: recvLowered.type.className, + field: expr.name.text, + type: field, + loc, + }; + } + if (nativeReceiver.kind === "object" && nativeReceiver.className === "%Error") { + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(recvLowered, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }, + expr, + ); + } + if (isDynTypedRefType(nativeReceiver)) { + const info = lowerer.classes.get(nativeReceiver.className); + if ( + info && + !info.def.runtime && + !info.builtinError && + !info.builtinEmitter && + !info.builtinStream + ) { + return lowerer.maybeNarrow( + { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(recvLowered, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }, + expr, + ); + } + } + // A member a JavaScript module attached to one of its functions + // (`status.message = codes; module.exports = status`), read from + // TypeScript: JavaScript stores such members on the function's own + // property table (`fn.member = v` writes through the function's + // checked-dynamic face), so the read takes the same path and enters + // the static code at the type inference gave the member. + if (recvLowered.type.kind === "func" && jsFunctionExpandoMember(lowerer, expr)) { + const read: IrExpr = { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(recvLowered, DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + type: DYN, + loc, + }; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + return mapped && mapped.kind !== "void" ? lowerer.coerceInto(expr, read, mapped) : read; + } + lowerer.unsupported( + "SC1090", + expr, + `reading '${expr.name.text}' from a value of type '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'`, + ); +} + +function importMetaProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { + return isImportMeta(expr.expression) ? importMetaField(lowerer, expr, expr.name.text) : null; +} + +/** The TypeError of a deferred-init field read that finds the field + * unassigned where its declared type cannot hold undefined. */ +export const DEFERRED_READ_MESSAGE = + "undefined is not representable in the target union (a value narrowed or asserted past it still held it)"; + +/** Property access on a string or array receiver: `.length` lowers to the + * matching intrinsic; a bare method reference (`const f = s.slice` — a + * function value) is rejected with a specific message. Returns null for + * other receivers (the generic property-access rejection applies). Both + * the receiver type AND the ambient-file provenance of the member are + * verified — the name alone proves nothing. */ +export function lowerIntrinsicProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + for (const name of ["String", "Number", "Boolean", "BigInt"] as ( + | "String" + | "Number" + | "Boolean" + | "BigInt" + )[]) { + if (!lowerer.chainBlocked(expr) && lowerer.isStdlibGlobal(expr, name)) { + return primitiveCtorClosure(lowerer, name, locOf(expr), isJsSourceFile(expr.getSourceFile())); + } + } + if (lowerer.stdlibGlobalMember(expr, "Reflect") === "ownKeys") { + const value = lowerer.lowerNativeCallableValue( + { + fn: "dyn.ownKeys", + params: [DYN], + result: DYN, + valueParams: [{ mode: "required", type: DYN }], + }, + "Reflect.ownKeys", + locOf(expr), + )!; + return { kind: "dynFrom", value, fnName: "ownKeys", type: DYN, loc: locOf(expr) }; + } + if (lowerer.chainBlocked(expr)) return null; + const builtinPrototype = builtinPrototypeMethod(lowerer, expr); + if (builtinPrototype) + return { + kind: "libCall", + fn: "dyn.builtinMethod", + args: [ + { kind: "strLit", value: builtinPrototype, type: STRING, loc: locOf(expr) }, + { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + ], + type: DYN, + loc: locOf(expr), + }; + if ( + ts.isPropertyAccessExpression(expr.expression) && + lowerer.stdlibGlobalMember(expr.expression, "Array") === "prototype" + ) { + return { + kind: "dynKeyGet", + value: { + kind: "libCall", + fn: "dyn.arrayPrototype", + args: [], + type: DYN, + loc: locOf(expr.expression), + }, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + type: DYN, + loc: locOf(expr), + }; + } + if ( + expr.name.text === "apply" && + ts.isPropertyAccessExpression(expr.expression) && + lowerer.stdlibGlobalMember(expr.expression, "Function") === "prototype" + ) + return { kind: "libCall", fn: "dyn.functionApply", args: [], type: DYN, loc: locOf(expr) }; + if (lowerer.stdlibGlobalMember(expr, "Array") === "prototype") + return { kind: "libCall", fn: "dyn.arrayPrototype", args: [], type: DYN, loc: locOf(expr) }; + if (lowerer.stdlibGlobalMember(expr, "Object") === "prototype") + return { kind: "libCall", fn: "dyn.objectPrototype", args: [], type: DYN, loc: locOf(expr) }; + // Function.prototype as a VALUE (a prototype-chain link: + // `Object.setPrototypeOf(F.prototype, Function.prototype)`): one + // anonymous function answering undefined, length 0 — a zero-capture + // closure of a compiler helper, so every read is the same function + // (closure identity) and shares its property table. Reads through it + // find the function members the checked runtime models for every + // function (apply, constructor, name, length). Member forms keep their + // own lowerings. + if (lowerer.stdlibGlobalMember(expr, "Function") === "prototype") { + const loc = locOf(expr); + const name = "%dyn.functionPrototype"; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [{ kind: "return", value: dynUndefinedExpr(loc), loc }], + loc, + }); + return { + kind: "dynFrom", + value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, + fnName: "", + type: DYN, + loc, + }; + } + if (lowerer.stdlibGlobalMember(expr, "Array") === "isArray") + return lowerArrayIsArrayValue(lowerer, locOf(expr)); + if (lowerer.stdlibGlobalMember(expr, "Array") === "from") + return lowerArrayFromValue(lowerer, locOf(expr)); + const stringMember = lowerer.stdlibGlobalMember(expr, "String"); + if (stringMember === "fromCharCode" || stringMember === "fromCodePoint") + return lowerStringCodesValue(lowerer, stringMember, locOf(expr)); + const numberMember = lowerer.stdlibGlobalMember(expr, "Number"); + if (numberMember === "parseInt" || numberMember === "parseFloat") + return lowerNumberParserValue(lowerer, numberMember, locOf(expr)); + if (lowerer.stdlibGlobalMember(expr, "Buffer") === "poolSize") + return { kind: "libCall", fn: "buffer.poolSize", args: [], type: F64, loc: locOf(expr) }; + const netAddressStatic = lowerNetAddressStaticRead(lowerer, expr); + if (netAddressStatic) return netAddressStatic; + if (lowerer.stdlibGlobalMember(expr, "Buffer") === "isBuffer") + return lowerCheckedPredicateValue(lowerer, "Buffer.isBuffer", "buffer", locOf(expr)); + if (lowerer.stdlibGlobalMember(expr, "Date") === "now") + return lowerer.lowerNativeCallableValue( + { + fn: "date.now", + params: [], + result: F64, + valueParams: [], + }, + "Date.now", + locOf(expr), + ); + if (stdlibGlobalNameOf(lowerer, expr.expression) === "Object") { + if (expr.name.text === "assign") return lowerObjectAssignValue(lowerer, locOf(expr)); + const fn = own(OBJECT_CALLABLE_VALUES, expr.name.text); + if (fn) return lowerer.lowerNativeCallableValue(fn, `Object.${expr.name.text}`, locOf(expr)); + } + // A never-tainted JS receiver type lowered checked-dynamic + // (neverTaintedJsType — `cmd.length` on `const cmd = ['pwd', []]`): + // stand down so the dyn keyed read below the chain answers, instead + // of an array libCall over a dyn receiver hitting the boundary fence. + let kind = neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)) + ? undefined + : lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind; + // A checker-`any[]` receiver (the readonly-array Array.isArray quirk) + // whose VALUE lowers to a real static array (maybeNarrow's isArray + // bridge): `.length` and friends ride the array path on the lowered + // value (re-lowering is pure IR construction). + if (kind === undefined && lowerer.checkerAnyArray(expr.expression)) { + const probe = lowerer.lowerExpr(expr.expression); + if (probe.type.kind === "array") kind = "array"; + } + if ( + kind !== "string" && + kind !== "array" && + kind !== "map" && + kind !== "set" && + kind !== "f64" && + kind !== "date" && + kind !== "regex" && + kind !== "url" && + kind !== "searchParams" && + kind !== "stats" && + kind !== "spawnRes" && + kind !== "child" && + kind !== "bytes" && + kind !== "symbol" + ) { + return null; + } + // child receivers also admit the user's own child-shaped interface + // members (the NgrokChildProcess duck rule — see isChildSurfaceMember). + // The EMPTY tuple `[]` rides the array REPRESENTATION (type-mapper.ts), but + // its `length` member is the tuple's own synthesized property, not + // Array.prototype's — provenance alone would refuse it. It reads the + // runtime length (always 0) through the array intrinsic like any other + // array; non-empty tuples never reach here (they map to records and + // fold their arity constant on the record path). + const tupleLengthOnArray = + kind === "array" && + expr.name.text === "length" && + lowerer.checker.isTupleType( + lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(expr.expression)), + ); + if ( + kind === "child" + ? !isChildSurfaceMember(lowerer, expr) + : !tupleLengthOnArray && !lowerer.isStdlibMember(expr) + ) + return null; + const name = expr.name.text; + if (kind === "array" && name === "constructor" && isJsSourceFile(expr.getSourceFile())) { + const receiver = lowerer.lowerExpr(expr.expression); + if (receiver.type.kind === "dyn") + return { + kind: "dynKeyGet", + value: receiver, + key: { kind: "strLit", value: name, type: STRING, loc: locOf(expr) }, + type: DYN, + loc: locOf(expr), + }; + const result: IrExpr = { + kind: "libCall", + fn: "dyn.arrayConstructor", + args: [], + type: DYN, + loc: locOf(expr), + }; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: receiver, loc: locOf(expr) }], + result, + type: DYN, + loc: locOf(expr), + }; + } + const strictReceiver = (expected: IrType): IrExpr => { + const value = lowerer.lowerExpr(expr.expression); + if (value.type.kind === "dyn") { + if (isJsSourceFile(expr.getSourceFile()) && name === "length") return value; + return lowerer.coerceInto(expr.expression, value, expected); + } + return lowerer.runtimeOptionalPropertyReceiver(expr.expression, value, expected, name) ?? value; + }; + if (kind === "child") { + const loc = locOf(expr); + // The lifecycle reads, Node's exact shapes (SEMANTICS.md pins the + // matrix): pid is `number | undefined` (undefined = spawn failure), + // exitCode `number | null` (null while running and after a signal + // death; -errno once a spawn failure settled), killed the + // sent-a-signal flag. The unions build type-directedly in the + // backend (the spawnRes.status pattern); a checker-narrowed read + // extracts through maybeNarrow like any union member. + if (name === "pid") { + const receiver = lowerer.lowerExpr(expr.expression); + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, UNDEFINED_T]) }; + return { kind: "libCall", fn: "child.pid", args: [receiver], type, loc }; + } + if (name === "exitCode") { + const receiver = lowerer.lowerExpr(expr.expression); + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, NULL_T]) }; + return { kind: "libCall", fn: "child.exitCode", args: [receiver], type, loc }; + } + if (name === "killed") { + const receiver = lowerer.lowerExpr(expr.expression); + return { kind: "libCall", fn: "child.killed", args: [receiver], type: BOOL, loc }; + } + if (name === "on" || name === "once" || name === "kill" || name === "unref") { + lowerer.unsupported("SC1090", expr, `child methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `ChildProcess.${name}`, + expr, + 'on/once("exit" | "close" | "error", cb), pid, exitCode, killed, kill(signal?), and unref() are the supported ChildProcess members', + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "spawnRes") { + // The spawnSync-result reads. status is the interned `number | null` + // union (null = signal death or spawn failure); stdout/stderr are + // the captured utf8 strings — under @types/node WITHOUT the + // {encoding: "utf8"} options argument the checker types them Buffer, + // which is re-fenced here (the call site already said so). + const loc = locOf(expr); + if (name === "status") { + // Always the interned `number | null` union; a checker-NARROWED + // read (`missing.status === null ? ... : ${missing.status}`) + // bridges through maybeNarrow like any union read. + const receiver = lowerer.lowerExpr(expr.expression); + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, NULL_T]) }; + const read: IrExpr = { kind: "libCall", fn: "spawnRes.status", args: [receiver], type, loc }; + return lowerer.maybeNarrow(read, expr); + } + if (name === "stdout" || name === "stderr") { + if (lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind !== "string") { + lowerer.noLowering( + `spawnSync's ${name} without the "utf8" encoding`, + expr, + 'Buffer captures are not representable — call spawnSync(cmd, args, { encoding: "utf8" }) so the outputs are strings', + ); + } + const receiver = lowerer.lowerExpr(expr.expression); + const fn = name === "stdout" ? "spawnRes.stdout" : "spawnRes.stderr"; + return { kind: "libCall", fn, args: [receiver], type: STRING, loc }; + } + if (name === "error") { + // Node's spawn-failure carrier: `Error | undefined` — a fresh + // %Error ("spawnSync ENOENT", `code` stamped) when the + // spawn itself failed, the undefined arm otherwise. The libCall + // always carries the interned union (the declared `error?: Error`), + // and a checker-NARROWED read (`if (r.error) r.error.message`) + // bridges through maybeNarrow like any union read. + const receiver = lowerer.lowerExpr(expr.expression); + const type = lowerer.withUndefinedArmOf({ kind: "object", className: "%Error" }); + if (!type) lowerer.badType(expr, lowerer.typeOf(expr)); + const read: IrExpr = { kind: "libCall", fn: "spawnRes.error", args: [receiver], type, loc }; + return lowerer.maybeNarrow(read, expr); + } + lowerer.noLowering( + `SpawnSyncReturns.${name}`, + expr, + "status, stdout, stderr, and error are the supported spawnSync-result members", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "stats") { + if (name === "size") { + const receiver = lowerer.lowerExpr(expr.expression); + return { kind: "libCall", fn: "stats.size", args: [receiver], type: F64, loc: locOf(expr) }; + } + if (Object.hasOwn(STATS_TYPE_TESTS, name)) { + lowerer.unsupported("SC1090", expr, `Stats methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `Stats.${name}`, + expr, + "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "date") { + const methods = new Set([ + "getTime", + "valueOf", + "toISOString", + "getFullYear", + "getUTCFullYear", + "getMonth", + "getUTCMonth", + "getDate", + "getUTCDate", + "getDay", + "getUTCDay", + "getHours", + "getUTCHours", + "getMinutes", + "getUTCMinutes", + "getSeconds", + "getUTCSeconds", + "getMilliseconds", + "getUTCMilliseconds", + "getTimezoneOffset", + ]); + if (methods.has(name)) { + lowerer.unsupported("SC1090", expr, `Date methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `Date.prototype.${name}`, + expr, + "getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "symbol") { + const loc = locOf(expr); + // `.description`: the checker's `string | undefined` — undefined + // exactly for the description-less Symbol()/Symbol(undefined) forms + // (Symbol("") answers the EMPTY STRING arm, like Node). The interned + // union builds in the backend from the runtime's +1-or-NULL answer + // (the child.stdout pattern); a checker-narrowed read extracts + // through maybeNarrow like any union member. + if (name === "description") { + const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), SYMBOL_T); + const type: IrType = { kind: "union", unionId: lowerer.unions.intern([STRING, UNDEFINED_T]) }; + return { kind: "libCall", fn: "sym.desc", args: [receiver], type, loc }; + } + if (name === "toString" || name === "valueOf") { + lowerer.unsupported("SC1090", expr, `symbol methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `Symbol.prototype.${name}`, + expr, + "description and toString() are the supported symbol members", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "url") { + // The supported URL getters. Everything else the lib declares + // (searchParams, setters-as-reads, ...) fences with the + // member-qualified name and the supported list. `host` is the WHATWG + // serialization: lowercased hostname, `:port` appended exactly when + // a non-default port is present (scr_url_host — Node-exact, + // opaque-path URLs answer ""); `hostname` is the stored port-less + // host field verbatim, including IPv6 brackets. + if ( + name === "protocol" || + name === "origin" || + name === "username" || + name === "password" || + name === "pathname" || + name === "href" || + name === "host" || + name === "hostname" || + name === "port" || + name === "search" || + name === "hash" + ) { + const receiver = lowerer.lowerExpr(expr.expression); + const fn = + name === "protocol" + ? "url.protocol" + : name === "origin" + ? "url.origin" + : name === "username" + ? "url.username" + : name === "password" + ? "url.password" + : name === "pathname" + ? "url.pathname" + : name === "host" + ? "url.host" + : name === "hostname" + ? "url.hostname" + : name === "port" + ? "url.port" + : name === "search" + ? "url.search" + : name === "hash" + ? "url.hash" + : "url.href"; + return { kind: "libCall", fn, args: [receiver], type: STRING, loc: locOf(expr) }; + } + // `u.searchParams`: the LIVE cached view (one identity per URL — + // mutations through it re-serialize into the URL's query, so href + // reflects immediately; Node's binding exactly). + if (name === "searchParams") { + const receiver = lowerer.lowerExpr(expr.expression); + return { + kind: "libCall", + fn: "url.searchParams", + args: [receiver], + type: SEARCH_PARAMS_T, + loc: locOf(expr), + }; + } + if (name === "toString" || name === "toJSON") { + lowerer.unsupported("SC1090", expr, `URL methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `URL.${name}`, + expr, + "protocol, origin, username, password, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "searchParams") { + const SEARCH_PARAMS_METHODS = new Set([ + "get", + "getAll", + "set", + "append", + "delete", + "has", + "sort", + "toString", + "forEach", + "keys", + "values", + "entries", + ]); + // The one data property; every method lowers at its CALL + // (lowerSearchParamsMethodCall) — bare method references fence. + if (name === "size") { + const receiver = lowerer.lowerExpr(expr.expression); + return { kind: "libCall", fn: "sp.size", args: [receiver], type: F64, loc: locOf(expr) }; + } + if (SEARCH_PARAMS_METHODS.has(name)) { + lowerer.unsupported( + "SC1090", + expr, + `URLSearchParams methods as values (call '${name}' directly)`, + ); + } + lowerer.noLowering( + `URLSearchParams.${name}`, + expr, + "get, getAll, set, append, delete, has, sort, size, toString(), forEach, and for-of iteration are the supported URLSearchParams members", + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } + if (kind === "regex") { + if (name === "lastIndex") { + return { + kind: "regexIntrinsic", + method: "lastIndex", + receiver: strictReceiver({ kind: "regex" }), + args: [], + type: F64, + loc: locOf(expr), + }; + } + if (name === "source" || name === "flags") { + const receiver = strictReceiver({ kind: "regex" }); + return { + kind: "regexIntrinsic", + method: name, + receiver, + args: [], + type: STRING, + loc: locOf(expr), + }; + } + // The flag getters answer whether `flags` holds the flag's letter. + const flag = REGEX_FLAG_GETTERS.get(name); + if (flag !== undefined) { + const loc = locOf(expr); + const flags: IrExpr = { + kind: "regexIntrinsic", + method: "flags", + receiver: strictReceiver({ kind: "regex" }), + args: [], + type: STRING, + loc, + }; + return { + kind: "strIntrinsic", + method: "includes", + receiver: flags, + args: [{ kind: "strLit", value: flag, type: STRING, loc }], + type: BOOL, + loc, + }; + } + if (name === "test") { + lowerer.unsupported("SC1090", expr, "regex methods as values (call 'test' directly)"); + } + return null; + } + if (kind === "f64") { + if (own(STDLIB_SURFACE.number, name) !== undefined) { + lowerer.unsupported("SC1090", expr, `number methods as values (call '${name}' directly)`); + } + return null; + } + if (kind === "bytes") { + const loc = locOf(expr); + if (name === "constructor") { + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const receiver = + expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + return { + kind: "dynKeyGet", + value: lowerer.coerceInto(expr.expression, receiver, DYN), + key: { kind: "strLit", value: name, type: STRING, loc }, + type: DYN, + loc, + }; + } + if (name === "length" || name === "byteLength") { + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + let receiver = + expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + if (receiver.type.kind === "dyn") + return { + kind: "dynCheck", + value: { + kind: "dynKeyGet", + value: receiver, + key: { kind: "strLit", value: name, type: STRING, loc: locOf(expr) }, + type: DYN, + loc: locOf(expr), + }, + type: F64, + loc: locOf(expr), + }; + if ( + receiver.type.kind === "union" && + lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0 + ) { + const present = lowerer.stripUndefinedArm(receiver.type); + const helper = + present.kind === "bytes" + ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(expr.expression)) + : null; + receiver = helper + ? lowerer.checkedNarrowCall(helper, receiver, present, locOf(expr.expression)) + : lowerer.maybeNarrow(receiver, expr.expression); + } + + // property read, exiting at the declared number type (the array + + return { kind: "bytesIntrinsic", method: name, receiver, args: [], type: F64, loc }; + } + if (name === "byteOffset") { + // 0 for owners (scriptc typed arrays own their whole storage — + // SEMANTICS.md notes the divergence from Node's Buffer pooling), + // the view's real offset for a DataView. A runtime read, so the + // receiver's evaluation is never discarded. + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const receiver = + expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + return { kind: "bytesIntrinsic", method: "byteOffset", receiver, args: [], type: F64, loc }; + } + if (name === "buffer") { + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const receiver = + expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + return { kind: "bytesIntrinsic", method: "buffer", receiver, args: [], type: DYN, loc }; + } + if (name === "slice" || name === "subarray" || name === "set" || name === "toString") { + lowerer.unsupported( + "SC1090", + expr, + `typed-array methods as values (call '${name}' directly)`, + ); + } + return null; // fill, reverse, ... → the SC2020 member fence + } + if (kind === "map") { + if (name === "size") { + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const receiver = + expected?.kind === "map" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + return { + kind: "mapIntrinsic", + method: "size", + receiver, + args: [], + type: F64, + loc: locOf(expr), + }; + } + if (MAP_METHODS.has(name) || name === "forEach") { + lowerer.unsupported("SC1090", expr, `Map methods as values (call '${name}' directly)`); + } + return null; + } + if (kind === "set") { + if (name === "size") { + const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + const receiver = + expected?.kind === "set" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); + return { + kind: "setIntrinsic", + method: "size", + receiver, + args: [], + type: F64, + loc: locOf(expr), + }; + } + if (SET_METHODS.has(name)) { + lowerer.unsupported("SC1090", expr, `Set methods as values (call '${name}' directly)`); + } + return null; + } + if (name === "length") { + const expected = + kind === "string" ? STRING : lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + let receiver = lowerer.lowerExpr(expr.expression); + if ( + receiver.type.kind !== "dyn" && + expected && + (expected.kind === "string" || expected.kind === "array") + ) { + receiver = + lowerer.runtimeOptionalPropertyReceiver(expr.expression, receiver, expected, name) ?? + receiver; + } + if ( + receiver.type.kind === "union" && + lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0 && + kind === "array" + ) { + const present = lowerer.stripUndefinedArm(receiver.type); + const helper = + present.kind === "array" + ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(expr.expression)) + : null; + receiver = helper + ? lowerer.checkedNarrowCall(helper, receiver, present, locOf(expr.expression)) + : lowerer.maybeNarrow(receiver, expr.expression); + } + + // A CHECKED-DYNAMIC value behind an array-typed checker spelling + // (`Object.keys(u).length` — the dyn walk answers a dyn array while + // tsc spells string[]): the runtime-world dispatch — a dyn keyed + // read validated into the declared number (a lying length throws + // the catchable TypeError, the dynCheck stance). + if (receiver.type.kind === "dyn") { + const read: IrExpr = { + kind: "dynKeyGet", + key: { kind: "strLit", value: "length", type: STRING, loc: locOf(expr) }, + ...(expr.questionDotToken ? { optional: true as const } : {}), + value: receiver, + type: DYN, + loc: locOf(expr), + }; + if (expr.questionDotToken) return read; + return { kind: "dynCheck", value: read, type: F64, loc: locOf(expr) }; + } + return kind === "string" + ? { kind: "strIntrinsic", method: "length", receiver, args: [], type: F64, loc: locOf(expr) } + : { kind: "arrIntrinsic", method: "length", receiver, args: [], type: F64, loc: locOf(expr) }; + } + if ( + kind === "string" + ? own(STR_METHODS, name) !== undefined || + STRING_INDEX_METHODS.has(name) || + own(STDLIB_SURFACE.string, name) !== undefined + : ARRAY_METHODS.has(name) + ) { + lowerer.unsupported("SC1090", expr, `${kind} methods as values (call '${name}' directly)`); + } + return null; +} + +/** A `T | undefined` class field read that the checker narrowed to `T` + * where the field was assigned a value that may be missing (an unchecked + * indexed read). TypeScript's assignment narrowing is not a runtime fact + * there, so the read keeps the stored union instead of extracting `T`. */ +function runtimeOptionalClassField( + lowerer: Lowerer, + value: IrExpr, + node: ts.PropertyAccessExpression, +): boolean { + if (value.kind !== "fieldGet" || value.type.kind !== "union") return false; + if (lowerer.armTag(value.type.unionId, UNDEFINED_T) < 0) return false; + if ((lowerer.unions.get(value.type.unionId)?.arms.length ?? 0) !== 2) return false; + const symbol = lowerer.checker.getSymbolAtLocation(node.name); + if (!symbol || !lowerer.runtimeOptionalAssignedSymbols.has(symbol)) return false; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); + return narrowed !== null && typeEquals(narrowed, lowerer.stripUndefinedArm(value.type)); +} + +/** `exports.` in a JS module whose `module.exports =` REPLACED the + * export object, with no `exports. =` attachment anywhere in the + * file: the read observes the ORIGINAL (empty-here) exports object, so + * the honest value is undefined (Node's object identity exactly; tsc's + * CJS model types the read off the replacement — identity-blind). Null + * everywhere else: no replacement, a real attachment of this name, a + * shadowing binding named `exports`, or write position. */ +function lowerReplacedExportsRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (expr.questionDotToken) return null; + const recv = expr.expression; + if (!ts.isIdentifier(recv) || recv.text !== "exports") return null; + const sf = expr.getSourceFile(); + if (!isJsSourceFile(sf) || isNodeEsmFile(sf, lowerer.program)) return null; + if (lowerer.peekLocal(recv) || lowerer.globalOf(recv)) return null; // a user binding shadows + // Write position is the export-assignment machinery's territory. + if ( + ts.isBinaryExpression(expr.parent) && + expr.parent.left === expr && + expr.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + return null; + } + let replaced = false; + for (const stmt of sf.statements.nodes) { + const cjs = cjsExportAssignmentOf(stmt); + if (!cjs) continue; + if (cjs.kind === "table" && cjsExportDiscardReason(stmt) === null) replaced = true; + if (cjs.kind === "member" && ts.isIdentifier(cjs.name) && cjs.name.text === expr.name.text) { + return null; // a real attachment of this name exists somewhere + } + } + if (!replaced) return null; + return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(expr) }; +} + +/** `this.` INSIDE a CJS export-table accessor (test/common's + * `get localhostIPv4() { if (this.inFreeBSDJail) ... }`): Node binds the + * getter's receiver to module.exports, so the read is the SIBLING table + * property — a sibling GETTER's lifted call (per-read evaluation, like + * any accessor read). Only arrows inherit the binding (a nested function + * expression's `this` is its own). Null everywhere else — non-getter + * siblings and absent names keep their per-site fences (none of the + * probed suite shapes read them). */ +function lowerCjsExportTableThisMember( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (expr.expression.kind !== ts.SyntaxKind.ThisKeyword || expr.questionDotToken) return null; + if (!isJsSourceFile(expr.getSourceFile())) return null; + // The enclosing accessor, crossing only arrow boundaries (arrows + // inherit `this`; any other function form rebinds it). + let n: ts.Node | undefined = expr.parent; + while (n !== undefined && !ts.isGetAccessorDeclaration(n) && !ts.isSetAccessorDeclaration(n)) { + if (ts.isFunctionLike(n) && !ts.isArrowFunction(n)) return null; + if (ts.isSourceFile(n)) return null; + n = n.parent; + } + if ( + n === undefined || + !ts.isObjectLiteralExpression(n.parent) || + !isCjsExportTableLiteral(n.parent) + ) { + return null; + } + const sibling = n.parent.properties.nodes.find( + (p): p is ts.GetAccessorDeclaration => + ts.isGetAccessorDeclaration(p) && ts.isIdentifier(p.name) && p.name.text === expr.name.text, + ); + if (!sibling) return null; + return cjsAccessorCall(lowerer, sibling, locOf(expr)); +} + +/** RegExp.prototype's flag getters and the flag each one reads. */ +const REGEX_FLAG_GETTERS: ReadonlyMap = new Map([ + ["hasIndices", "d"], + ["global", "g"], + ["ignoreCase", "i"], + ["multiline", "m"], + ["dotAll", "s"], + ["unicode", "u"], + ["unicodeSets", "v"], + ["sticky", "y"], +]); + +/** The named-group table of the regex that PRODUCED a match-result + * expression — the `.groups` desugar's provenance question. Traces + * (through parens, `!`, and const bindings): + * - `re.exec(s)` / `s.match(re)` → re's static pattern + * - a matchAll ROW: `rows[i]`, the for-of binding over a direct + * `s.matchAll(re)` or a stored const drain → re's static pattern + * Answers null when the producing regex isn't statically known (the + * caller keeps its fence), or the groups list (possibly empty — a + * traced regex WITHOUT named groups is the Node-undefined case). */ +export function matchResultNamedGroupsOf( + lowerer: Lowerer, + e: ts.Expression, +): { name: string; index: number }[] | null { + let producer = e; + while (ts.isParenthesizedExpression(producer) || ts.isNonNullExpression(producer)) + producer = producer.expression; + if (ts.isIdentifier(producer)) { + const init = constInitializerOf(lowerer, producer); + if (init !== null) return matchResultNamedGroupsOf(lowerer, init); + } + const reExpr = matchProducerRegexOf(lowerer, e); + if (reExpr === null) return null; + if ( + ts.isCallExpression(producer) && + ts.isPropertyAccessExpression(producer.expression) && + producer.expression.name.text === "match" + ) { + const flags = staticRegexFlagsOf(lowerer, reExpr); + if (flags === null) return null; + // Global String.match returns whole matches without a groups + // property, even when the pattern contains named captures. + if (flags.includes("g")) return []; + } + const pattern = staticRegexPatternOf(lowerer, reExpr); + if (pattern === null) return null; + return namedCaptureGroupsOfPattern(pattern); +} + +function staticRegexFlagsOf(lowerer: Lowerer, node: ts.Expression): string | null { + let expr = node; + while (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) expr = expr.expression; + if (ts.isRegularExpressionLiteral(expr)) return expr.text.slice(expr.text.lastIndexOf("/") + 1); + if (ts.isIdentifier(expr)) { + const init = constInitializerOf(lowerer, expr); + return init !== null ? staticRegexFlagsOf(lowerer, init) : null; + } + if ( + ts.isNewExpression(expr) && + ts.isIdentifier(expr.expression) && + lowerer.isStdlibGlobal(expr.expression, "RegExp") + ) { + const flags = expr.arguments?.nodes[1]; + if (!flags) return ""; + if (ts.isStringLiteral(flags) || ts.isNoSubstitutionTemplateLiteral(flags)) return flags.text; + } + return null; +} + +/** The regex EXPRESSION whose match produced `e` (see + * matchResultNamedGroupsOf for the traced shapes). */ +function matchProducerRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | null { + let expr = e; + for (;;) { + if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { + expr = expr.expression; + continue; + } + break; + } + // The direct producers: re.exec(s) / s.match(re) — the receiver's + // mapped type pins the STDLIB operation (a regex for exec, a string + // or its nullable spelling for match — the claim string-and-regexp + // makes), so a user method that happens to be named `match` with a + // regex argument never traces. + if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression)) { + const name = expr.expression.name.text; + const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression.expression)); + if (name === "exec" && expr.arguments.nodes.length === 1 && recvT?.kind === "regex") { + return expr.expression.expression; + } + if ( + name === "match" && + expr.arguments.nodes.length === 1 && + (recvT?.kind === "string" || + (recvT !== null && recvT !== undefined && nullableStringType(lowerer, recvT))) && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "regex" + ) { + return expr.arguments.nodes[0]!; + } + } + // A matchAll ROW by element access: rows[i] with rows tracing to the + // drain call — or the direct s.matchAll(re)[i] spelling. + if (ts.isElementAccessExpression(expr)) { + return matchAllRegexOf(lowerer, expr.expression); + } + // A row picked by `rows.find(cb)`, `rows.findLast(cb)` or `rows.at(i)`. + if ( + ts.isCallExpression(expr) && + ts.isPropertyAccessExpression(expr.expression) && + MATCH_ROW_PICK_METHODS.has(expr.expression.name.text) && + lowerer.isStdlibMember(expr.expression) + ) { + return matchAllRegexOf(lowerer, expr.expression.expression); + } + if (ts.isIdentifier(expr)) { + // The for-of binding over a matchAll drain: `for (const m of + // s.matchAll(re))` (or over a stored const rows). + const sym = lowerer.resolveValueSymbol(expr); + const decl = sym ? sym.valueDeclaration : undefined; + if ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + ts.isForOfStatement(decl.parent.parent) + ) { + return matchAllRegexOf(lowerer, decl.parent.parent.expression); + } + // A stored const match result: `const m = re.exec(s)`. + const init = constInitializerOf(lowerer, expr); + if (init !== null) return matchProducerRegexOf(lowerer, init); + // The element parameter of an array callback over matchAll rows: + // `[...s.matchAll(re)].map((m) => m.groups)`, and Array.from's + // mapping function over `s.matchAll(re)`. + if (decl !== undefined && ts.isParameter(decl)) return matchAllCallbackRegexOf(lowerer, decl); + } + return null; +} + +/** Array methods that return one row of the receiver. */ +const MATCH_ROW_PICK_METHODS = new Set(["find", "findLast", "at"]); + +/** `m.groups` on a match result whose producing regex is statically + * known: the honest slice already HOLDS every named group's value at + * its capture index, so the groups object is a compile-time record + * projection — `{ year: m[1], month: m[2] }` — built by one interned + * helper per (shape, index list, receiver type). Node's exact shape + * decisions: + * - no named groups → `undefined` (the identifier-receiver shapes — + * a call receiver would lose its own null trap, so those keep the + * member fence); + * - a nullable receiver (the JS `s.match(re).groups` idiom) throws + * Node's exact TypeError on the unit arms; + * - every declared name is a key (nonparticipating groups hold undefined, + * exactly the slice elements they project); + * - ES2025 duplicate names (distinct alternatives) project the first + * participating occurrence, including a participating empty string; + * - key order is group declaration order (declaredOrder), Node's own. + * The record has a null prototype, like Node's groups object. A + * JavaScript callback parameter holds the row as a dynamic array; the + * projection reads its elements through dynamic reads. Null when the + * receiver isn't a match slice or the regex isn't traceable — the + * caller's member fence (with the groups hint) names the gap. */ +function lowerMatchGroupsRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { + if (expr.name.text !== "groups") return null; + // `m?.groups` re-dispatches through the optional-chain machinery, which + // narrows the receiver to its match arm first. + if (expr.questionDotToken !== undefined && !lowerer.chainHandled.has(expr)) return null; + const loc = locOf(expr); + const strArr = arrayOf(STRING); + const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if (!recvT || !isMatchSliceType(lowerer, recvT) || !lowerer.isStdlibMember(expr)) return null; + const groups = matchResultNamedGroupsOf(lowerer, expr.expression); + // An untraced regex: TypeScript reaches the member fence; JavaScript + // would otherwise read the slice's absent expando as undefined. + if (groups === null && isJsSourceFile(expr.getSourceFile())) lowerer.stdlibMemberFence(expr); + if (groups === null) return null; + if (groups.length === 0) { + // Node: a match result of a group-less regex answers undefined — + // the unit literal, exactly the `undefined` identifier's lowering. + // Identifier receivers only (evaluation-free): a CALL receiver's + // exec/match must still run and null-trap, which a folded unit + // cannot carry — those keep the fence. + let r: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(r) || ts.isNonNullExpression(r)) r = r.expression; + if (ts.isIdentifier(r) && typeEquals(recvT, strArr)) { + return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; + } + return null; + } + const recv = lowerer.lowerExpr(expr.expression); + // A JavaScript callback parameter holds the row as a dynamic array. + const dynRow = recv.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()); + if (!dynRow && !isMatchSliceType(lowerer, recv.type)) return null; + return lowerGroupsProjection(lowerer, recv, groups, loc); +} + +/** True when the IR type is a string-or-units union — the Dict + * member spelling (`process.versions.openssl`), match's nullable + * receiver claim. */ +function nullableStringType(lowerer: Lowerer, t: IrType): boolean { + if (t.kind !== "union") return false; + const arms = lowerer.unions.get(t.unionId)?.arms ?? []; + return ( + arms.some((a) => a.kind === "string") && arms.every((a) => a.kind === "string" || isUnitType(a)) + ); +} + +/** True when the IR type is the honest match slice — `string[]`, or a + * union of it with unit arms only (`string[] | null`). The groups + * projection's receiver gate, shared with the destructure interception. */ +export function isMatchSliceType(lowerer: Lowerer, t: IrType): boolean { + if (typeEquals(t, arrayOf(STRING))) return true; + return t.kind === "union" && matchSliceUnionArms(lowerer, t.unionId) !== null; +} + +/** The union-arm layout of a nullable match slice (`string[] | null`, + * matchAll's checker spellings with undefined arms included): the + * string[] arm's tag plus each unit arm's tag and spelling. Null when + * the union holds anything else. */ +function matchSliceUnionArms( + lowerer: Lowerer, + unionId: string, +): { arrTag: number; units: { tag: number; unit: "null" | "undefined" }[] } | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + const strArr = arrayOf(STRING); + const arrTag = def.arms.findIndex((a) => typeEquals(a, strArr)); + if (arrTag < 0) return null; + const units: { tag: number; unit: "null" | "undefined" }[] = []; + for (let i = 0; i < def.arms.length; i++) { + if (i === arrTag) continue; + const a = def.arms[i]!; + if (!isUnitType(a)) return null; + units.push({ tag: i, unit: a.kind === "nullT" ? "null" : "undefined" }); + } + return { arrTag, units }; +} + +/** Node's TypeError for `.groups` read off null or undefined. */ +function groupsReadTypeError(unit: "null" | "undefined", loc: SrcLoc): IrStmt { + return { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [strLit(`Cannot read properties of ${unit} (reading 'groups')`, loc)], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }; +} + +/** The groups-record projection holds string | undefined values, including + * an own overflow entry for every nonparticipating capture. One lifted helper per + * (index list, receiver representation) builds it; see + * lowerMatchGroupsRead for the semantics the body encodes. */ +export function lowerGroupsProjection( + lowerer: Lowerer, + recv: IrExpr, + groups: { name: string; index: number }[], + loc: SrcLoc, +): IrExpr { + // Name → its capture indices, in declaration order (duplicates keep + // every occurrence — the projection picks the participating one). + const order: string[] = []; + const indicesByName = new Map(); + for (const g of groups) { + const got = indicesByName.get(g.name); + if (got) { + got.push(g.index); + } else { + indicesByName.set(g.name, [g.index]); + order.push(g.name); + } + } + const valueType = arrayValueType(lowerer, STRING); + const shapeId = lowerer.shapes.intern([], false, valueType); + const recordT: IrType = { kind: "record", shapeId }; + const recvKey = + recv.type.kind === "union" ? recv.type.unionId : recv.type.kind === "dyn" ? "dyn" : "arr"; + const key = `regexgroups:${shapeId}:${groups.map((g) => `${g.name}=${g.index}`).join(",")}:${recvKey}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%regex.groups.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const body: IrStmt[] = []; + const locals: IrLocal[] = [{ id: "m.0", name: "m", type: recv.type, mutable: true }]; + let mRef: IrExpr = { kind: "varRef", localId: "m.0", type: recv.type, loc }; + if (recv.type.kind === "union") { + // Node's exact TypeError per unit arm, then the checked narrow. + const arms = matchSliceUnionArms(lowerer, recv.type.unionId)!; + for (const u of arms.units) { + body.push({ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: recv.type.unionId, + tag: u.tag, + negated: false, + value: mRef, + type: BOOL, + loc, + }, + then: [groupsReadTypeError(u.unit, loc)], + else_: null, + loc, + }); + } + const narrowed: IrExpr = { + kind: "unionNarrow", + unionId: recv.type.unionId, + tag: arms.arrTag, + value: mRef, + type: arrayOf(STRING), + loc, + }; + locals.push({ id: "arr.0", name: "arr", type: arrayOf(STRING), mutable: false }); + body.push({ kind: "varDecl", localId: "arr.0", init: narrowed, loc }); + mRef = { kind: "varRef", localId: "arr.0", type: arrayOf(STRING), loc }; + } else if (recv.type.kind === "dyn") { + // A JavaScript callback parameter holds the row as a dynamic array: + // Node's TypeError for null and undefined, then dynamic element reads. + for (const unit of ["null", "undefined"] as const) { + body.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: mRef, type: BOOL, loc }, + then: [groupsReadTypeError(unit, loc)], + else_: null, + loc, + }); + } + } + + const elem = (index: number): IrExpr => + mRef.type.kind === "dyn" + ? lowerer.coerceToExpected( + { + kind: "dynKeyGet", + value: mRef, + key: strLit(String(index), loc), + type: DYN, + loc, + }, + valueType, + ) + : arrayValueRead(lowerer, mRef, numLit(index, loc), STRING, loc); + const values = new Map(); + order.forEach((name, i) => { + const idxs = indicesByName.get(name)!; + if (idxs.length === 1) { + values.set(name, elem(idxs[0]!)); + return; + } + // Duplicates: the first participating occurrence. Empty strings + // participate; undefined means this alternative did not match. + const id = `g${i}.0`; + locals.push({ id, name: `g${i}`, type: valueType, mutable: true }); + body.push({ kind: "varDecl", localId: id, init: elem(idxs[0]!), loc }); + const gRef: IrExpr = { kind: "varRef", localId: id, type: valueType, loc }; + for (const idx of idxs.slice(1)) { + body.push({ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: valueType.kind === "union" ? valueType.unionId : "", + tag: valueType.kind === "union" ? lowerer.armTag(valueType.unionId, UNDEFINED_T) : -1, + negated: false, + value: gRef, + type: BOOL, + loc, + }, + then: [{ kind: "assign", localId: id, value: elem(idx), loc }], + else_: null, + loc, + }); + } + values.set(name, gRef); + }); + body.push({ + kind: "return", + value: { + kind: "recordLit", + fields: order.map((name) => ({ name, value: values.get(name)!, overflow: true as const })), + nullProto: true, + type: recordT, + loc, + }, + loc, + }); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "m.0", name: "m", type: recv.type }], + returnType: recordT, + locals, + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [recv], type: recordT, loc }; +} + +/** Field reads use the shared FieldTarget union. Native method reads select + * an interned unbound callable; invocation supplies its receiver separately. */ +export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { + // A checked receiver already owns its property table. An ambient + // generic method signature must not divert its value into native + // object-literal specialization (Array.prototype.map is one example). + if (ts.isIdentifier(expr.expression)) { + const stored = lowerer.peekLocal(expr.expression) ?? lowerer.globalOf(expr.expression); + if (stored?.type.kind === "dyn") { + const value = lowerer.lowerExpr(expr.expression); + if (value.type.kind === "dyn") + return { + kind: "dynKeyGet", + value, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + ...(expr.questionDotToken || hasOptionalChainGuard(expr.expression) + ? { optional: true as const } + : {}), + type: DYN, + loc: locOf(expr), + }; + } + } + const target = lowerer.fieldTarget(expr); + if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr); + if (expr.questionDotToken) return null; + let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + // The readonly-tuple Array.isArray narrow has no directly mappable + // checker type (`Result & any[]`), but maybeNarrow extracted the tuple + // record behind it. Route tuple properties such as `.length` from that + // lowered value, the element-access bridge's twin. + const checkerArray = lowerer.checkerArrayValue(expr.expression); + if (checkerArray?.type.kind === "record") receiverIr = checkerArray.type; + if ( + receiverIr?.kind === "object" && + (lowerer.findMethodOn(lowerer.classes.get(receiverIr.className) ?? null, expr.name.text) || + findGenericMethodOn( + lowerer, + lowerer.classes.get(receiverIr.className) ?? null, + expr.name.text, + )) + ) { + const info = lowerer.classes.get(receiverIr.className); + if (info) return lowerClassMethodValue(lowerer, expr, info); + } + // An object-literal GENERIC method as a VALUE (`o.m` — the member is + // excluded from the record shape): the pinned-value rule verbatim when + // the receiver is the defining literal's own const binding (the value + // is the pinned instance's closure — no `this` exists); anything else + // fences by name inside the helpers. + { + const propSym = lowerer.checker.getPropertyOfType( + lowerer.typeOf(expr.expression), + expr.name.text, + ); + if ( + receiverIr?.kind !== "object" && + propSym && + isGenericCallableMemberType(lowerer.checker.getTypeOfSymbol(propSym), lowerer.checker) && + // CLASS members keep the class-path fences (a poisoned class's own + // diagnostics, the bound-method fence above). + !propSym.declarations.some( + (d) => + d.parent !== undefined && + (ts.isClassDeclaration(d.parent) || ts.isClassExpression(d.parent)), + ) + ) { + const found = objLitGenericFnNodeOf(lowerer, propSym); + if (!found) { + lowerer.unsupported( + "SC1090", + expr, + `the generic method '${expr.name.text}' as a value with no defining object literal (only methods declared with a body in an object literal compile)`, + ); + } + requireObjLitGenericReceiver(lowerer, expr, expr.expression, found.literal, expr.name.text); + return lowerer.lowerGenericFnValue( + expr, + objLitGenericFnInfoOf(lowerer, expr, expr.name.text, found), + ); + } + } + // Dot access to an UNDECLARED key of an index-signature shape + // (`bag.count` on `Record`): tsc allows it without + // noPropertyAccessFromIndexSignature, but the bracket spelling is the + // canonical index-signature form here — point at it. + if (receiverIr?.kind === "record") { + const shape = lowerer.shapes.get(receiverIr.shapeId); + if (shape?.indexValue && !shape.fields.some((f) => f.name === expr.name.text)) { + const actual = tryLowerExpression(lowerer, expr.expression); + if (actual?.type.kind === "dyn") + return { + kind: "dynKeyGet", + value: actual, + key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, + type: DYN, + loc: locOf(expr), + }; + if (actual?.type.kind === "record") { + const field = lowerer.shapes + .get(actual.type.shapeId) + ?.fields.find((field) => field.name === expr.name.text); + if (field) + return { + kind: "recordGet", + obj: actual, + shapeId: actual.type.shapeId, + field: field.name, + type: field.type, + loc: locOf(expr), + }; + } + lowerer.unsupported( + "SC1090", + expr, + `dot access to index-signature keys (spell it r["${expr.name.text}"] — brackets are the index-signature form)`, + ); + } + } + // `t.length` on a tuple: the arity CONSTANT (tuples are fixed-shape — + // the checker types it as the literal arity too). Folding discards the + // receiver's evaluation, so only side-effect-free receivers fold; + // anything else (a call result) binds to a const first. + if (receiverIr?.kind === "record" && expr.name.text === "length") { + const shape = lowerer.shapes.get(receiverIr.shapeId); + if (shape?.tuple) { + let root: ts.Expression = expr.expression; + while (ts.isPropertyAccessExpression(root)) root = root.expression; + if (!ts.isIdentifier(root) && root.kind !== ts.SyntaxKind.ThisKeyword) { + lowerer.unsupported( + "SC1090", + expr, + "'.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first)", + ); + } + return { kind: "numLit", value: shape.fields.length, type: F64, loc: locOf(expr) }; + } + } + return null; +} + +/** Shared-field read `r.f` on a UNION receiver: supported exactly when + * every arm is a record/class possessing the field with ONE shared IR + * type — the discriminant pattern (`r.kind`, primitive) and the + * shared-payload pattern (`spec.config` where every ServiceSpec arm + * carries the same record). Lowers to `unionDisc` (the backend switches + * on the runtime tag and reads the field from the concrete arm), which + * composes with existing strEq/bin/switch nodes, so `r.kind === "ok"` + * and `switch (r.kind)` work without dedicated test nodes. Anything else + * on a union receiver is rejected specifically (narrow first). */ +export function lowerUnionProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (lowerer.chainBlocked(expr)) return null; + const receiverIr = + structuralReadType(lowerer, expr.expression) ?? + lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if (receiverIr?.kind !== "union") return null; + // Lower the receiver first: its runtime value can still carry the full + // binding union even when the checker has removed several variants. + let value = lowerer.lowerExpr(expr.expression); + // A guard can remove several variants without selecting one record. + // Materialize that sub-union before looking for common fields; arms + // ruled out by the guard must not veto a field shared by the survivors. + if (value.type.kind === "union" && !typeEquals(value.type, receiverIr)) { + const helper = lowerer.narrowedRetagHelper( + expr.expression, + value.type.unionId, + receiverIr.unionId, + locOf(expr), + ); + if (helper) + value = { kind: "call", callee: helper, args: [value], type: receiverIr, loc: locOf(expr) }; + // Optional storage of a base class (a runtime-optional binding holding + // `Base | undefined`) that `instanceof` narrowed to several subclasses: + // extract the instance and test its class, as typed slots do. + else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value; + } + // A checker-union receiver whose VALUE lowered to a plain RECORD (the + // merged-signature fiction — `runner(cmd, args)` where runner joined + // a structural runner type with spawnSync's, and the local adopted + // the record arm): read the record field directly, the dyn-receiver + // fallback's discipline. + // A checker-union receiver whose VALUE lowered checked-dynamic (a + // never-tainted JS chain — `cmd[1].length` on `const cmd = ['pwd', + // []]`, where the element read stayed a dyn node): read through the + // dyn keyed read like the unmappable-receiver path below the chain. + if (value.type.kind === "dyn") { + const key: IrExpr = { + kind: "strLit", + value: expr.name.text, + type: STRING, + loc: locOf(expr.name), + }; + return { kind: "dynKeyGet", key, value, type: DYN, loc: locOf(expr) }; + } + if (value.type.kind === "record") { + const shape = lowerer.shapes.get(value.type.shapeId); + const f = shape?.fields.find((x) => x.name === expr.name.text); + if (f) { + return { + kind: "recordGet", + obj: value, + shapeId: value.type.shapeId, + field: f.name, + type: f.type, + loc: locOf(expr), + }; + } + return null; + } + // A class instance the checker sees through a wider union (`(concrete + // as A | B).value`): the value is that class's instance, so read its + // field directly. + if (value.type.kind === "object") { + const className = value.type.className; + const info = lowerer.classes.get(className) ?? null; + const field = expr.name.text; + const fieldType = info?.fields.get(field); + if (fieldType !== undefined) + return lowerer.fieldGetExpr( + { container: "class", obj: value, className, field, fieldType }, + locOf(expr), + expr, + ); + const getter = lowerer.findMethodOn(info, `get:${field}`); + if (getter) + return lowerer.fieldGetExpr( + { container: "accessor", obj: value, className, field, fieldType: getter.sig.ret }, + locOf(expr), + expr, + ); + } + if (value.type.kind !== "union") { + lowerer.unsupported( + "SC1090", + expr, + "shared property access without a represented union receiver", + ); + } + const def = lowerer.unions.get(value.type.unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`); + const field = expr.name.text; + if (field === "length") { + const presentArms = def.arms.filter((arm) => !isUnitType(arm)); + const present = presentArms.length === 1 ? presentArms[0]! : null; + if (present?.kind === "string" || present?.kind === "array") { + const receiver = lowerer.runtimeOptionalPropertyReceiver( + expr.expression, + value, + present, + field, + ); + if (receiver !== null) { + return present.kind === "string" + ? { + kind: "strIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + } + : { + kind: "arrIntrinsic", + method: "length", + receiver, + args: [], + type: F64, + loc: locOf(expr), + }; + } + } + } + const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm)); + if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") { + const objectType = nonUnitArms[0]; + const fieldType = lowerer.classes.get(objectType.className)?.fields.get(field); + if (fieldType !== undefined) { + let receiver = lowerer.runtimeOptionalPropertyReceiver( + expr.expression, + value, + objectType, + field, + ); + if (receiver === null) { + const helper = lowerer.narrowedArmHelper( + value.type.unionId, + objectType, + locOf(expr.expression), + ); + if (helper !== null) + receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression)); + } + if (receiver !== null) { + return { + kind: "fieldGet", + obj: receiver, + className: objectType.className, + field, + type: fieldType, + loc: locOf(expr), + }; + } + } + } + let common: IrType | null = null; + for (const arm of def.arms) { + let ft: IrType | undefined; + if (arm.kind === "record") { + ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === field)?.type; + } else if (arm.kind === "object") { + ft = lowerer.classes.get(arm.className)?.fields.get(field); + } + if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { + common = null; + break; + } + common = ft; + } + if (common) { + return { + kind: "unionDisc", + unionId: value.type.unionId, + field, + value, + type: common, + loc: locOf(expr), + }; + } + // The arms answer DIFFERENT types (or through index signatures / unit + // arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv | + // Record`, the tail read of `loaded?.config.script`. + const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) }; + const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field); + if (keyed) return keyed; + // Class arms declaring the field with compatible but different types + // (`T | undefined` beside `T`): per-arm slot reads widened to one type. + const widened = lowerUnionFieldRead(lowerer, expr, value); + if (widened) return widened; + // JavaScript overloads commonly inspect a class brand on a scalar-or- + // instance argument (e.g. Matrix4.makeTranslation). Preserve primitive + // missing-property semantics and dispatch class data through its native + // capsule instead of demanding a field shared by every union arm. + if (isJsSourceFile(expr.getSourceFile()) && lowerer.dynConvertible(value.type)) { + return { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(value, DYN), + key, + ...(hasOptionalChainGuard(expr.expression) ? { optional: true as const } : {}), + type: DYN, + loc: locOf(expr), + }; + } + lowerer.unsupported( + "SC1090", + expr, + `reading '${field}' on a union-typed value (every arm must be an object/record ` + + `with a same-typed field '${field}'; ` + + `${NARROW_FIRST})`, + ); +} + +/** The unionDisc generalization: a keyed read on a union receiver whose + * arms answer DIFFERENT (but joinable) types. Each arm contributes its + * declared answer — an array element's type (number keys), a declared field's type (literal keys), an + * index-signature arm's value type (plus its declared fields' types for + * runtime keys, which reach them through the keyed-read helper), and + * UNDEFINED for unit arms (reachable only through optional-chain tails, + * where JS short-circuits to undefined; a unit arm the checker narrowed + * away is simply unreachable). The result type is the JOIN of those + * answers; every arm's answer must be the join itself, one of its + * arms, or a sub-union whose payload representations are unchanged. + * Array reads retain the ordinary invalid-index trap policy. + * Returns null when any arm cannot answer — the caller owns the fence + * message. The caller (the property/element dispatch) maybeNarrows. */ +export function lowerUnionKeyedRead( + lowerer: Lowerer, + expr: ts.Expression, + unionId: string, + value: IrExpr, + key: IrExpr, + literalField: string | null, +): IrExpr | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + // Pass 1: per-arm declared answers, joined into the result type. + const joinArms: IrType[] = []; + const seen = new Set(); + const push = (t: IrType): void => { + const k = typeKey(t); + if (!seen.has(k)) { + seen.add(k); + joinArms.push(t); + } + }; + const pushAnswer = (t: IrType): boolean => { + if (t.kind === "void") return false; + if (t.kind === "union") { + const inner = lowerer.unions.get(t.unionId); + if (!inner) return false; + for (const a of inner.arms) push(a); + return true; + } + push(t); + return true; + }; + for (const arm of def.arms) { + if (isUnitType(arm)) { + push(UNDEFINED_T); + continue; + } + if (key.type.kind === "f64") { + if (arm.kind !== "array" || !pushAnswer(arm.elem)) return null; + continue; + } + if (arm.kind !== "record") return null; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple) return null; + const declared = + literalField !== null ? shape.fields.find((f) => f.name === literalField)?.type : undefined; + if (declared) { + if (!pushAnswer(declared)) return null; + continue; + } + // Runtime keys can reach the declared fields through the keyed-read + // helper's string switch — every one joins. + if (literalField === null) { + for (const f of shape.fields) if (!pushAnswer(f.type)) return null; + } + if (!shape.indexValue) { + if (literalField === null && shape.fields.length > 0) continue; + return null; + } + if (!pushAnswer(shape.indexValue)) return null; + } + if (joinArms.length === 0) return null; + // The join must satisfy native union invariants. The caller owns the + // diagnostic when a container or opaque arm prevents that join. + if ( + joinArms.length > 1 && + (!unionContainerArmsOk(joinArms) || + joinArms.some((a) => a.kind === "dyn" || a.kind === "generator" || a.kind === "caught")) + ) { + return null; + } + joinArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const type: IrType = + joinArms.length === 1 + ? joinArms[0]! + : { kind: "union", unionId: lowerer.unions.intern(joinArms) }; + // Pass 2: every arm's answer must SURFACE as the join, and index arms + // must pass the single-record keyed-read constraints (the helper is + // shared with recordKeyGet, its missing-key policy included). + const surfaces = (t: IrType): boolean => + typeEquals(t, type) || + (type.kind === "union" && lowerer.armTag(type.unionId, t) >= 0) || + exactUnionWidening(lowerer, t, type); + for (const arm of def.arms) { + if (isUnitType(arm)) { + if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; + continue; + } + if (key.type.kind === "f64") { + if (arm.kind !== "array" || !surfaces(arm.elem)) return null; + continue; + } + if (arm.kind !== "record") return null; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape) return null; + const declared = + literalField !== null ? shape.fields.find((f) => f.name === literalField)?.type : undefined; + if (declared) { + if (!surfaces(declared)) return null; + continue; + } + const ovfShape: IrRecordShape = + literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; + if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; + } + return { kind: "unionKeyGet", unionId, key, value, type, loc: locOf(expr) }; +} + +/** A structural TypeScript view does not change a nominal object's layout + * or accessor dispatch. Resolve the property from the represented class; + * the optional checker view must not turn its getter into a record read. */ +export function classFieldTarget( + lowerer: Lowerer, + receiverNode: ts.Expression, + receiverType: IrType & { kind: "object" }, + fieldName: string, + saved?: IrExpr, +): FieldTarget | null { + const represented = saved ?? lowerer.lowerExpr(receiverNode); + if ( + represented.type.kind === "dyn" && + isJsSourceFile(receiverNode.getSourceFile()) && + !fieldName.startsWith("#") + ) { + return { container: "dynamic", obj: represented, field: fieldName, fieldType: DYN }; + } + const info = lowerer.classes.get(receiverType.className); + if (!info) { + // A receiver typed as a class whose collection deferred: the + // deferred diagnostics are what explains the miss. + lowerer.flushDeferredClass(receiverType.className); + return null; + } + const fieldType = info.fields.get(fieldName); + const lowerObjectReceiver = (): IrExpr => { + let obj = represented; + if (obj.type.kind === "union") { + // A receiver that may hold a missing value (a field assigned an + // unchecked read): Node's member-read TypeError for the unit arm. + const present = lowerer.runtimeOptionalPropertyReceiver( + receiverNode, + obj, + receiverType, + fieldName, + ); + if (present) return present; + const helper = lowerer.narrowedArmHelper(obj.type.unionId, receiverType, locOf(receiverNode)); + obj = helper + ? lowerer.checkedNarrowCall(helper, obj, receiverType, locOf(receiverNode)) + : lowerer.maybeNarrow(obj, receiverNode); + } + return lowerer.coerceInto(receiverNode, obj, receiverType); + }; + if (fieldName === "cause" && receiverType.className !== "%DOMException") { + let root = info; + while (root.base) root = root.base; + if (root.def.name === "%Error") { + return { + container: "errorCause", + obj: lowerObjectReceiver(), + field: "cause", + fieldType: DYN, + }; + } + } + if (fieldType) { + const obj = lowerObjectReceiver(); + if ( + isJsSourceFile(receiverNode.getSourceFile()) && + !fieldName.startsWith("#") && + [...lowerer.classes.values()].some( + (candidate) => + lowerer.isSubclassOf(candidate.def.name, receiverType.className) && + (candidate.methods.has(`get:${fieldName}`) || candidate.methods.has(`set:${fieldName}`)), + ) + ) { + return { + container: "dynamic", + obj: lowerer.coerceToExpected(obj, DYN), + field: fieldName, + fieldType: DYN, + }; + } + return { + container: "class", + obj, + className: receiverType.className, + field: fieldName, + fieldType, + }; + } + // Accessor property: either half declared anywhere on the chain + // makes the name an accessor target (fields and accessors share a + // namespace — tsc rejects mixing them, so the halves agree on kind). + const getF = lowerer.findMethodOn(info, `get:${fieldName}`); + const setF = lowerer.findMethodOn(info, `set:${fieldName}`); + if (getF || setF) { + const obj = lowerObjectReceiver(); + return { + container: "accessor", + obj, + className: receiverType.className, + field: fieldName, + fieldType: getF ? getF.sig.ret : setF!.sig.params[0]!.type, + }; + } + if (info.decl && isJsSourceFile(info.decl.getSourceFile())) { + const property = lowerer.checker.getPropertyOfType( + lowerer.checker.getTypeAtLocation(receiverNode), + fieldName, + ); + if ( + property && + property.declarations.some( + (declaration) => + ts.isPropertyAccessExpression(declaration) || + ts.isBinaryExpression(declaration) || + ts.isElementAccessExpression(declaration), + ) + ) { + return { + container: "dynamic", + obj: lowerer.coerceToExpected(lowerObjectReceiver(), DYN), + field: fieldName, + fieldType: DYN, + }; + } + } + return null; +} + +/** Recognizes `obj.field` as an assignable field target: receiver is a + * known class instance OR a record, and the member is a field or (class + * receivers) a declared accessor property. Returns the pieces of a + * fieldSet/recordSet/accessor-call (minus value/kind) or null. */ +export function fieldTarget( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): FieldTarget | null { + if (lowerer.cjsLocalModuleBindingOf(access.expression)) return null; + // `module.exports` / `module.id`: CommonJS module surface, never a field. + if (isCjsModuleGlobal(lowerer, access.expression)) return null; + if (isCjsExportsIdentifier(lowerer, access.expression)) return null; + if (lowerer.chainBlocked(access)) return null; + if ( + access.name.text === "stackTraceLimit" && + lowerer.isStdlibGlobal(access.expression, "Error") + ) { + return { + container: "errorStackLimit", + obj: { kind: "numLit", value: 0, type: F64, loc: locOf(access) }, + field: "stackTraceLimit", + fieldType: F64, + }; + } + const stored = ts.isIdentifier(access.expression) + ? (lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type) + : undefined; + const narrowed = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + // Structural record views retain their storage layout, while a nominal + // subclass guard proves a more specific layout of the same object. + const receiverIr = + stored?.kind === "record" + ? stored + : stored?.kind === "object" && + !( + narrowed?.kind === "object" && + lowerer.isSubclassOf(narrowed.className, stored.className) + ) + ? stored + : narrowed; + if (receiverIr?.kind === "object") { + const target = classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); + if (target) return target; + // Abstract properties declare no base storage. Resolve the concrete + // instance's live field or accessor through the existing class bridge; + // inventing a shared field offset would misread subclass layouts. + if (abstractPropertyDeclOf(lowerer, access)) { + return { + container: "dynamic", + obj: lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), DYN), + field: access.name.text, + fieldType: lowerer.irTypeOf(access), + }; + } + return null; + } + if (receiverIr?.kind === "union" && hasClassPayload(lowerer, receiverIr)) { + return representedClassFieldTarget( + lowerer, + access.expression, + access.name.text, + lowerer.lowerExpr(access.expression), + ); + } + if (receiverIr?.kind === "record") { + const shape = lowerer.shapes.get(receiverIr.shapeId); + const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; + if (fieldType) { + let obj = lowerer.lowerExpr(access.expression); + const nominal = representedClassFieldTarget( + lowerer, + access.expression, + access.name.text, + obj, + ); + if (nominal) return nominal; + // A checker-record receiver whose VALUE stayed dyn (the erased + // all-unknown-fields cast — `(err as { code?: unknown }).code`): + // decline, and the dyn keyed-read fallback answers. + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { + // A nested property read such as `rows[i].value` or + // `table[k].value`: the outer read can miss. Validate the record + // arm before fieldGet/recordGet so a missing outer value raises + // Node's TypeError rather than reaching the backend with a union + // receiver. + const present = lowerer.stripUndefinedArm(obj.type); + const parent = access.parent; + const written = + parent !== undefined && + ts.isBinaryExpression(parent) && + parent.left === access && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; + const helper = + present.kind === "record" + ? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(access.expression)) + : null; + obj = + (present.kind === "record" + ? lowerer.runtimeOptionalPropertyReceiver( + access.expression, + obj, + present, + access.name.text, + written ? "set" : "read", + ) + : null) ?? + (helper + ? lowerer.checkedNarrowCall(helper, obj, present, locOf(access.expression)) + : lowerer.maybeNarrow(obj, access.expression)); + } + if (obj.type.kind !== "record") return null; + const actualShape = lowerer.shapes.get(obj.type.shapeId); + const actualField = actualShape?.fields.find((f) => f.name === access.name.text); + return { + container: "record", + obj, + shapeId: obj.type.shapeId, + field: access.name.text, + fieldType: actualField?.type ?? fieldType, + }; + } + // A RECORD accessor property: either slot present makes the name an + // accessor target (get/set share the property namespace — tsc + // rejects mixing an accessor with a data property, so the halves + // agree). Reads dispatch the %get: closure, writes the %set: one. + { + const getSlot = shape?.fields.find((f) => f.name === `%get:${access.name.text}`)?.type; + const setSlot = shape?.fields.find((f) => f.name === `%set:${access.name.text}`)?.type; + if (getSlot?.kind === "func" || setSlot?.kind === "func") { + const obj = lowerer.lowerExpr(access.expression); + if (obj.type.kind !== "record") return null; // dyn-valued receiver: the keyed fallback answers + const getType = getSlot?.kind === "func" ? getSlot : undefined; + const setType = setSlot?.kind === "func" ? setSlot : undefined; + return { + container: "recordAccessor", + obj, + shapeId: receiverIr.shapeId, + field: access.name.text, + fieldType: getType ? getType.ret : setType!.params[0]!, + ...(getType ? { getType } : {}), + ...(setType ? { setType } : {}), + }; + } + } + // Dot access to an UNDECLARED key of an index-signature shape: tsc + // types it through the signature — the access resolves to NO property + // symbol (mapped types like Record) or to the signature's + // own `__index` symbol (interface-declared signatures). A real member + // symbol means a lib member like `toString` — not an index access; + // those keep their fences below. It IS the bracket access in dot + // spelling — the same overflow path. + const nameSym = lowerer.checker.getSymbolAtLocation(access.name); + if (shape?.indexValue && !shape.tuple) { + // A member the shape CANONICALIZATION dropped into the overflow + // (the header family: @types declares `host?: string` on + // IncomingHttpHeaders while the canonical shape is a pure index + // record) is the bracket access in dot spelling too: a PROPERTY + // declaration whose own type fits within the index value. METHOD + // members (`toString` — the lib's inherited surface) keep their + // fences: JS finds the prototype function, never an overflow miss. + const canonicalized = (): boolean => { + if (!nameSym) return false; + const nameDecls = nameSym.declarations; + if (!nameDecls.length) return false; + if (!nameDecls.every((d) => ts.isPropertySignature(d))) return false; + const declared = lowerer.mapTypeOf(lowerer.typeOf(access)); + if (!declared) return false; + const iv = shape.indexValue!; + if (typeEquals(declared, iv)) return true; + if (iv.kind !== "union") return false; + const ivArms = lowerer.unions.get(iv.unionId)?.arms; + if (!ivArms) return false; + const declaredArms = + declared.kind === "union" + ? (lowerer.unions.get(declared.unionId)?.arms ?? [declared]) + : [declared]; + return declaredArms.every((a) => ivArms.some((b) => typeEquals(a, b))); + }; + if (!nameSym || nameSym.name === ts.InternalSymbolName.Index || canonicalized()) { + let obj = lowerer.lowerExpr(access.expression); + if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { + const present = lowerer.stripUndefinedArm(obj.type); + obj = + present.kind === "record" + ? (lowerer.runtimeOptionalPropertyReceiver( + access.expression, + obj, + present, + access.name.text, + ) ?? obj) + : lowerer.maybeNarrow(obj, access.expression); + } + if (obj.type.kind !== "record") return null; + const actualShape = lowerer.shapes.get(obj.type.shapeId); + if (!actualShape?.indexValue) return null; + return { + container: "recordOvf", + obj, + shapeId: obj.type.shapeId, + field: access.name.text, + fieldType: actualShape.indexValue, + }; + } + } + return null; + } + return null; +} + +/** The read expression for a field target (fieldGet / recordGet / + * getter call). `blame` locates the rejection of a setter-only read — + * tsc-clean (the property types as the setter's param), but Node yields + * undefined, which these property types cannot represent. */ +export function fieldGetExpr( + lowerer: Lowerer, + target: FieldTarget, + loc: SrcLoc, + blame: ts.Node, +): IrExpr { + // A member read through an unassigned deferred-init field throws Node's + // member-read TypeError instead of the generic checked extraction. + if ( + target.container === "class" && + ts.isPropertyAccessExpression(blame) && + target.obj.type.kind === "object" && + lowerer.deferredFieldSlot(target.obj) !== null + ) { + const checked = lowerer.runtimeOptionalPropertyReceiver( + blame.expression, + target.obj, + target.obj.type, + target.field, + ); + if (checked) target = { ...target, obj: checked }; + } + if (target.container === "dynamic") + return lowerer.coerceInto( + blame, + { + kind: "dynKeyGet", + value: target.obj, + key: { kind: "strLit", value: target.field, type: STRING, loc }, + type: DYN, + loc, + }, + target.fieldType, + ); + if ( + target.container === "class" && + (target.field === "message" || target.field === "name") && + (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) + ) + return errorPropertyRead(lowerer, target.obj, target.field); + if (target.container === "errorStackLimit") { + return { kind: "libCall", fn: "error.stackLimitGet", args: [], type: F64, loc }; + } + if (target.container === "errorCause") { + return { kind: "libCall", fn: "error.cause", args: [target.obj], type: DYN, loc }; + } + // A record-shaped CHECKER target whose receiver VALUE lives in the checked-dynamic tree + // (a JS file-scope object-literal global): the checked-dynamic keyed + // read — dynKeyGet (a missing key answers the dyn undefined, exactly + // JS); consumers validate (dynCheck) where a static type is required. + if ( + (target.container === "record" || target.container === "recordOvf") && + target.obj.type.kind === "dyn" + ) { + return { + kind: "dynKeyGet", + key: { kind: "strLit", value: target.field, type: STRING, loc }, + value: target.obj, + type: DYN, + loc, + }; + } + if (target.container === "accessor") { + const getF = lowerer.findMethodOn( + lowerer.classes.get(target.className) ?? null, + `get:${target.field}`, + ); + if (!getF) { + lowerer.unsupported( + "SC1090", + blame, + `reading a property that has only a setter ('${target.field}' — Node would yield undefined)`, + ); + } + if ( + !target.field.startsWith("#") && + isJsSourceFile(blame.getSourceFile()) && + lowerer.dynConvertible(getF.sig.ret) + ) { + const value: IrExpr = { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(target.obj, DYN), + key: { kind: "strLit", value: target.field, type: STRING, loc }, + type: DYN, + loc, + }; + return lowerer.coerceToExpected(value, getF.sig.ret); + } + return lowerer.accessorCall( + target.className, + `get:${target.field}`, + target.obj, + [], + getF.sig.ret, + loc, + ); + } + // Record accessor properties: the read IS a call of the %get: closure + // — once per read, side effects and all (JS's evaluation). The + // setter-only read keeps the class-path fence: the property types as + // the setter's param where Node yields undefined. + if (target.container === "recordAccessor") { + if (!target.getType) { + lowerer.unsupported( + "SC1090", + blame, + `reading a property that has only a setter ('${target.field}' — Node would yield undefined)`, + ); + } + const closure: IrExpr = { + kind: "recordGet", + obj: target.obj, + shapeId: target.shapeId, + field: `%get:${target.field}`, + type: target.getType, + loc, + }; + return { kind: "callValue", callee: closure, args: [], type: target.getType.ret, loc }; + } + // Overflow dot reads: exactly the bracket read with a literal key — + // recordKeyGet, overflowOnly (the name declares no field by + // construction), typed as the index value armed with undefined + // (mirroring lowerRecordKeyRead). + if (target.container === "recordOvf") { + let obj = target.obj; + if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) { + const present = lowerer.stripUndefinedArm(obj.type); + if (present.kind === "record") { + obj = + lowerer.runtimeOptionalPropertyReceiver(blame.expression, obj, present, target.field) ?? + obj; + } + } + if (obj.type.kind !== "record") { + lowerer.unsupported( + "SC1090", + blame, + `reading '${target.field}' on a non-record receiver (narrow first)`, + ); + } + const shape = lowerer.shapes.get(obj.type.shapeId); + if (!shape?.indexValue) + lowerer.unsupported( + "SC1090", + blame, + `reading '${target.field}' on a record without an index signature`, + ); + let t: IrType = shape.indexValue; + if (t.kind !== "dyn") { + const armed = lowerer.withUndefinedArmOf(t); + if (!armed) lowerer.badType(blame, lowerer.typeOf(blame)); + t = armed; + } + return { + kind: "recordKeyGet", + obj, + shapeId: obj.type.shapeId, + key: { kind: "strLit", value: target.field, type: STRING, loc }, + overflowOnly: true, + type: t, + loc, + }; + } + if (target.container === "class") { + const read: IrExpr = { + kind: "fieldGet", + obj: target.obj, + className: target.className, + field: target.field, + type: target.fieldType, + loc, + }; + // DEFERRED-INIT fields (`stream!: T` assigned past the constructor's + // top level — ClassInfo.deferredInitFields): the SLOT is the + // undefined-armed union; the read CHECKED-extracts the declared type + // — a genuinely unassigned read throws the catchable TypeError + // where Node reads an undefined the declared type cannot hold. + if ( + lowerer.classes.get(target.className)?.deferredInitFields?.has(target.field) === true && + target.fieldType.kind === "union" + ) { + const inner = lowerer.stripUndefinedArm(target.fieldType); + // Reference arms over a stable receiver check inline: the present + // arm is a plain projection of the slot, so ordinary reads keep their + // borrowed form and only an unassigned slot reaches the throw. + const unionId = target.fieldType.unionId; + const presentTag = lowerer.armTag(unionId, inner); + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + if ( + inner.kind === "object" && + presentTag >= 0 && + undefTag >= 0 && + lowerer.unions.get(unionId)?.arms.length === 2 && + isSafeToRepeat(read) + ) { + return { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + negated: false, + value: read, + type: BOOL, + loc, + }, + then: nodeThrowExpr(1, "", DEFERRED_READ_MESSAGE, inner, loc), + else_: { kind: "unionNarrow", unionId, tag: presentTag, value: read, type: inner, loc }, + type: inner, + loc, + }; + } + const helper = lowerer.deferredReadHelper(target.fieldType.unionId, inner, loc); + if (helper) return { kind: "call", callee: helper, args: [read], type: inner, loc }; + } + return read; + } + return { + kind: "recordGet", + obj: target.obj, + shapeId: target.shapeId, + field: target.field, + type: target.fieldType, + loc, + }; +} + +/** Stream-rooted receivers' property surface (readableEnded, destroyed, + * ...): the receiver's mapped class dispatches into lower-stream.ts. + * Null off the stream hierarchy or for names the spoke does not own. */ +function lowerStreamObjectProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) return null; + const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if (recvT?.kind !== "object") return null; + const info = lowerer.classes.get(recvT.className); + if (!info || streamSidesOf(lowerer, info) === null) return null; + if (expr.name.text === "bytesWritten" && isZlibStreamMember(lowerer, expr)) { + return lowerZlibStreamBytesWritten(lowerer, expr); + } + if (isFsStreamMember(lowerer, expr)) { + const member = lowerFsStreamProperty(lowerer, expr); + if (member) return member; + } + return lowerStreamProperty(lowerer, expr, info); +} + +/** True when `access` reads a member that JavaScript sources attach to a + * function by assignment (`fn.member = value`): the receiver's type is + * callable and every declaration of the member is such an assignment in a + * JS file. */ +function jsFunctionExpandoMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { + if (access.questionDotToken || !ts.isIdentifier(access.name)) return false; + const recvType = lowerer.typeOf(access.expression); + if (lowerer.checker.getCallSignatures(recvType).length === 0) return false; + const member = lowerer.checker.getPropertyOfType(recvType, access.name.text); + const decls = member ? member.declarations : []; + return ( + decls.length > 0 && + decls.every((d) => { + if (!isJsSourceFile(d.getSourceFile())) return false; + const assign = ts.isBinaryExpression(d) ? d : d.parent; + return ( + assign !== undefined && + ts.isBinaryExpression(assign) && + assign.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(assign.left) + ); + }) + ); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts new file mode 100644 index 000000000..55baa8014 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -0,0 +1,1649 @@ +/* instanceof and in, including private-name and runtime-key in. */ +import * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; +import { + BOOL, + BYTES_ELEMENT_NAME, + CAUGHT, + DYN, + F64, + type IrBytesElem, + type IrExpr, + type IrStmt, + type IrType, + RUNTIME_ERROR_CLASSES, + STRING, + type SrcLoc, + UNDEFINED_T, + isUnitType, + typeEquals, +} from "../../../ir/ir.js"; +import { refuseWebAssemblyUse } from "../builtins/webassembly.js"; +import { lowerSocketInstanceOf } from "../lower-server.js"; +import { builtinClassRefOf } from "../builtins/class-values.js"; +import { lowerZlibInstanceOf } from "../builtins/compression.js"; +import { varRef } from "../../../ir/build.js"; +import { lowerDisposableStackInstanceOf } from "../builtins/disposable-stack.js"; +import { lowerBlobInstanceof } from "../builtins/blob.js"; +import { lowerKeyObjectInstanceof } from "../builtins/key-object.js"; +import { lowerCryptoKeyInstanceof } from "../builtins/webcrypto.js"; +import { lowerNetAddressInstanceof } from "../builtins/net-address.js"; +import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js"; +import { maybePromiseInstanceOf } from "../maybe-promise.js"; +import { isSafeToDiscard } from "./evaluation-safety.js"; +import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; +import { classInstanceOf } from "../class-dynamic-dispatch.js"; +import { InternalCompilerError } from "../../../errors.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { checkedClassInstanceOf } from "../class-construction.js"; +import { globalSymbolKey } from "./global-symbols.js"; +import { foldedStringKeyOf } from "./object-literals.js"; +import { NARROW_FIRST, sideEffectFreeOptionValue } from "../surfaces.js"; +import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js"; + +/** `x instanceof C` for a program-declared class C. When x's static + * class and C are both in extends-hierarchies the test is dynamic — the + * O(1) preorder-interval check against the vtable (`instanceOf` node). + * Everything else is decided by the static class graph alone and folds + * to the honest constant: `x instanceof C` is always true when x's + * static class IS C or descends from it, and always false when the + * classes are unrelated (a standalone class has no other relatives). + * Folding is limited to side-effect-free operands — dropping a computed + * operand would skip its effects, so those are rejected instead. */ +export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc): IrExpr { + refuseWebAssemblyUse(lowerer, expr.right); + // `x instanceof net.Socket` over a union with a netSocket arm — the + // h2 compat 'connect' narrowing (lower-server.ts): a union tag test. + const sockTest = lowerSocketInstanceOf(lowerer, expr, loc); + if (sockTest !== null) return sockTest; + const rightClass = builtinClassRefOf(lowerer, expr.right); + if (rightClass?.kind === "zlib") return lowerZlibInstanceOf(lowerer, expr, rightClass.mode, loc); + + if (rightClass?.kind === "collection") { + const collection = rightClass.which === "Map" ? "map" : "set"; + const value = lowerer.lowerExpr(expr.left); + if (value.type.kind === "union") { + const union = lowerer.unions.get(value.type.unionId); + if (union) { + const local = lowerer.declareHiddenLocal("%collectionTest", value.type); + const reference = varRef(local.id, value.type, loc); + let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + union.arms.forEach((arm, tag) => { + if (arm.kind !== collection) return; + const test: IrExpr = { + kind: "unionIsTag", + unionId: union.id, + value: reference, + tag, + negated: false, + type: BOOL, + loc, + }; + result = + result.kind === "boolLit" + ? test + : { kind: "logical", op: "||", left: result, right: test, type: BOOL, loc }; + }); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], + result, + type: BOOL, + loc, + }; + } + } + if (value.type.kind === "dyn") + return { + kind: "libCall", + fn: collection === "map" ? "dyn.nativeMapIs" : "dyn.nativeSetIs", + args: [value], + type: BOOL, + loc, + }; + if (value.type.kind === collection) + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: value, loc }], + result: { kind: "boolLit", value: true, type: BOOL, loc }, + type: BOOL, + loc, + }; + return { + kind: "libCall", + fn: collection === "map" ? "dyn.nativeMapIs" : "dyn.nativeSetIs", + args: [lowerer.coerceInto(expr.left, value, DYN)], + type: BOOL, + loc, + }; + } + if ( + lowerer.isStdlibGlobal(expr.right, "WeakMap") || + lowerer.isStdlibGlobal(expr.right, "WeakSet") + ) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { + kind: "libCall", + fn: lowerer.isStdlibGlobal(expr.right, "WeakMap") ? "weakMap.is" : "weakSet.is", + args: [value], + type: BOOL, + loc, + }; + } + const rhsBuiltin = ts.isIdentifier(expr.right) + ? lowerer.builtinImportOf(expr.right) + : ts.isPropertyAccessExpression(expr.right) + ? lowerer.builtinMemberOf(expr.right) + : null; + if ( + lowerer.isStdlibGlobal(expr.right, "URL") || + (rhsBuiltin?.module === "url" && rhsBuiltin.member === "URL") + ) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { kind: "libCall", fn: "dyn.nativeUrlIs", args: [value], type: BOOL, loc }; + } + { + const stack = lowerDisposableStackInstanceOf(lowerer, expr); + if (stack) return stack; + } + if (lowerer.isStdlibGlobal(expr.right, "DataView")) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { kind: "libCall", fn: "dyn.dataViewIs", args: [value], type: BOOL, loc }; + } + { + const eventKind = ["Event", "CustomEvent", "EventTarget"].findIndex((name) => + lowerer.isStdlibGlobal(expr.right, name), + ); + if (eventKind >= 0) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + const kind: IrExpr = { kind: "numLit", value: eventKind, type: F64, loc }; + return { kind: "libCall", fn: "event.is", args: [value, kind], type: BOOL, loc }; + } + } + { + const blob = lowerBlobInstanceof(lowerer, expr); + if (blob) return blob; + const keyObject = lowerKeyObjectInstanceof(lowerer, expr); + if (keyObject) return keyObject; + const cryptoKey = lowerCryptoKeyInstanceof(lowerer, expr); + if (cryptoKey) return cryptoKey; + const netAddress = lowerNetAddressInstanceof(lowerer, expr); + if (netAddress) return netAddress; + } + if (lowerer.isStdlibGlobal(expr.right, "SharedArrayBuffer")) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { kind: "libCall", fn: "sharedArrayBuffer.is", args: [value], type: BOOL, loc }; + } + if (lowerer.isStdlibGlobal(expr.right, "ArrayBuffer")) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { kind: "libCall", fn: "arrayBuffer.is", args: [value], type: BOOL, loc }; + } + const rhsSymbol = ts.isIdentifier(expr.right) ? lowerer.resolveValueSymbol(expr.right) : null; + // `x instanceof events.EventEmitter` — the namespace-member spelling + // resolves to the same ambient class as the named import. + const rhsMemberSymbol = (() => { + if (!ts.isPropertyAccessExpression(expr.right) || !ts.isIdentifier(expr.right.name)) + return null; + const sym = lowerer.checker.getSymbolAtLocation(expr.right.name); + return sym && sym.flags & ts.SymbolFlags.Alias + ? lowerer.checker.getAliasedSymbol(sym) + : (sym ?? null); + })(); + // `x instanceof N.C` — the USER-namespace-qualified spelling: the + // member resolves to the registered program class, with the + // source-order guard (an init-position test above the class's block + // would read an uninitialized member in Node). + const rhsNsClass = (() => { + if (!ts.isPropertyAccessExpression(expr.right)) return undefined; + const nsMember = nsMemberIdentOf(lowerer, expr.right); + if (!nsMember) return undefined; + const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); + if (memberSym) fenceEarlyNsMemberRef(lowerer, expr.right, memberSym); + const classSym = lowerer.resolveValueSymbol(nsMember); + const nsInfo = classSym ? lowerer.classBySymbol.get(classSym) : undefined; + // Qualified spellings of a rebindable decorated class (import= + // alias chains) must not fold against the declaration — the + // decoration result decides at runtime. + return nsInfo?.classDecorators?.valueGlobalId !== undefined ? undefined : nsInfo; + })(); + // A rebindable decorated name as the RHS: the runtime target is the + // decoration result — fall to the class-VALUE path below, whose + // instanceOfValue reads the interval off the bound class object. + const directRhs = rhsSymbol ? lowerer.classBySymbol.get(rhsSymbol) : undefined; + const target = + (directRhs?.localClass || directRhs?.classDecorators?.valueGlobalId !== undefined + ? undefined + : directRhs) ?? + rhsNsClass ?? + lowerer.builtinErrorInfoOf(rhsSymbol) ?? + lowerer.builtinErrorInfoOf(rhsMemberSymbol) ?? + lowerer.builtinEmitterInfoOf(rhsSymbol) ?? + lowerer.builtinEmitterInfoOf(rhsMemberSymbol) ?? + lowerer.builtinStreamInfoOf(rhsSymbol) ?? + lowerer.builtinStreamInfoOf(rhsMemberSymbol) ?? + undefined; + if (!target) { + const date = lowerer.isStdlibGlobal(expr.right, "Date"); + const url = lowerer.isStdlibGlobal(expr.right, "URL"); + if ((date || url) && !lowerer.caughtLocalOf(expr.left)) { + const value = lowerer.lowerExpr(expr.left); + if (value.type.kind === "dyn") + return { + kind: "libCall", + fn: date ? "dyn.nativeDateIs" : "dyn.nativeUrlIs", + args: [value], + type: BOOL, + loc, + }; + if (value.type.kind === "union") + return { + kind: "libCall", + fn: date ? "dyn.nativeDateIs" : "dyn.nativeUrlIs", + args: [lowerer.coerceInto(expr.left, value, DYN)], + type: BOOL, + loc, + }; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: value, loc }], + result: { + kind: "boolLit", + value: value.type.kind === (date ? "date" : "url"), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + const webBrand = ["Request", "Response", "Headers"].find((name) => + lowerer.isStdlibGlobal(expr.right, name), + ); + if (webBrand) + return { + kind: "libCall", + fn: "fetch.webIs", + args: [ + lowerer.lowerExprExpecting(expr.left, DYN), + { kind: "strLit", value: webBrand, type: STRING, loc }, + ], + type: BOOL, + loc, + }; + if (lowerer.isStdlibGlobal(expr.right, "ReadableStream")) { + const value = lowerer.lowerExprExpecting(expr.left, DYN); + return { kind: "libCall", fn: "fetch.streamIs", args: [value], type: BOOL, loc }; + } + // Unknown storage retains each numeric typed array's exact native brand. + if (!lowerer.caughtLocalOf(expr.left)) { + const elem = (Object.keys(BYTES_ELEMENT_NAME) as IrBytesElem[]).find((kind) => + lowerer.isStdlibGlobal(expr.right, BYTES_ELEMENT_NAME[kind]), + ); + if (elem !== undefined) { + const left = lowerer.lowerExpr(expr.left); + if (left.type.kind === "dyn") { + return { kind: "dynTest", test: "bytes", bytesElem: elem, value: left, type: BOOL, loc }; + } + return { + kind: "dynTest", + test: "bytes", + bytesElem: elem, + value: lowerer.coerceInto(expr.left, left, DYN), + type: BOOL, + loc, + }; + } + } + // `x instanceof Promise` over a promise-armed union (the maybe-promise + // `T | Promise` dispatch): a union tag test — the data arms are + // never promises (scriptc promises are all native, and no data arm + // can hold one). A promise-typed operand answers true after its + // evaluation; a checked-dynamic one asks the runtime. + if ( + ts.isIdentifier(expr.right) && + lowerer.isStdlibGlobal(expr.right, "Promise") && + !lowerer.caughtLocalOf(expr.left) + ) { + const left = lowerer.lowerExpr(expr.left); + const tagTest = maybePromiseInstanceOf(lowerer, left, loc); + if (tagTest) return tagTest; + if (left.type.kind === "dyn") + return { kind: "dynTest", test: "promise", value: left, type: BOOL, loc }; + if (left.type.kind === "promise") { + const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; + if (isSafeToDiscard(left)) return yes; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: left, loc }], + result: yes, + type: BOOL, + loc, + }; + } + lowerer.unsupported( + "SC1090", + expr, + `'instanceof Promise' on '${lowerer.fmt(left.type)}' values (only promises, promise-armed unions, and checked-dynamic values have a lowering)`, + ); + } + // `x instanceof RegExp` over a union with a regex arm (the + // skip-utility `string | RegExp` dispatch): a union tag test — the + // socket-narrowing shape; the checker types the branches and + // maybeNarrow bridges the reads. A plain regex-typed LHS folds + // true; the fold keeps the operand-purity rule of the class folds + // (identifier reads only). + if ( + ts.isIdentifier(expr.right) && + lowerer.isStdlibGlobal(expr.right, "RegExp") && + !lowerer.caughtLocalOf(expr.left) + ) { + const left = lowerer.lowerExpr(expr.left); + if (left.type.kind === "dyn") + return { kind: "libCall", fn: "dyn.nativeRegexIs", args: [left], type: BOOL, loc }; + if (left.type.kind === "union") { + const def = lowerer.unions.get(left.type.unionId); + const tag = def ? def.arms.findIndex((a) => a.kind === "regex") : -1; + if (tag >= 0) { + return { + kind: "unionIsTag", + unionId: left.type.unionId, + tag, + negated: false, + value: left, + type: BOOL, + loc, + }; + } + } + return { + kind: "libCall", + fn: "dyn.nativeRegexIs", + args: [lowerer.coerceInto(expr.left, left, DYN)], + type: BOOL, + loc, + }; + } + // `x instanceof X` where X is a class VALUE (a classval-typed + // binding): the target is DYNAMIC — a hierarchy target reads its + // interval from the class object at runtime (instanceOfValue); a + // STANDALONE target class has exactly one possible runtime value + // (itself — no descendants can flow into the slot), so the answer + // folds statically exactly like the named-target folds below. + const rhsClassval = storedClassValueType(lowerer, expr.right); + if (rhsClassval?.kind === "classval" && !lowerer.caughtLocalOf(expr.left)) { + const targetInfo = lowerer.classes.get(rhsClassval.className); + if (!targetInfo) { + // The type world names a class the lowering never registered + // (a fenced class expression, a deferred declaration): flush + // its diagnostics and poison the test site, never an ICE. + lowerer.flushDeferredClass(rhsClassval.className); + lowerer.unsupported( + "SC1090", + expr, + "'instanceof' against a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic)", + ); + } + const left = lowerer.lowerExpr(expr.left); + if (left.type.kind === "dyn") { + if (targetInfo.def.runtime) { + lowerer.unsupported( + "SC1090", + expr, + "'instanceof' through a runtime-provided class value on an untyped value (give the operand a class type first)", + ); + } + const classValue = lowerer.lowerExpr(expr.right); + return classInstanceOf(lowerer, left, targetInfo, loc, classValue); + } + // A union of class instances against a runtime class value (`new K()` + // through a widened `[Readable, Writable]` element). + if (left.type.kind === "union" && targetInfo.def.runtime) { + const tested = unionClassValueInstanceOf(lowerer, left, expr.right, targetInfo, loc); + if (tested) return tested; + } + if (left.type.kind !== "object") { + lowerer.unsupported( + "SC1090", + expr, + "'instanceof' on values other than class instances (narrow union-typed values first)", + ); + } + const lhsInfo = lowerer.classes.get(left.type.className); + if (!lhsInfo) + throw new InternalCompilerError(`lowerer bug: unknown class ${left.type.className}`); + if ( + (lowerer.inHierarchy(targetInfo) && lowerer.inHierarchy(lhsInfo)) || + (targetInfo.localClass !== undefined && lhsInfo.localClass !== undefined) + ) { + const classValue = lowerer.lowerExpr(expr.right); + if (classValue.type.kind !== "classval") + lowerer.badType(expr.right, lowerer.typeOf(expr.right)); + const writableTest = writableClassValueTest( + lowerer, + left, + classValue, + targetInfo, + lhsInfo, + loc, + ); + if (writableTest) return writableTest; + return { kind: "instanceOfValue", value: left, classValue, type: BOOL, loc }; + } + // A standalone side: the answer is static (the target slot can + // only hold the named class; a standalone operand's runtime class + // IS its static class). Folding discards the operands' evaluation, + // so only side-effect-free shapes fold — the named-target rule. + const value = + left.type.className === targetInfo.def.name || + lowerer.isSubclassOf(left.type.className, targetInfo.def.name); + if ( + left.kind === "varRef" && + (ts.isIdentifier(expr.right) || expr.right.kind === ts.SyntaxKind.ThisKeyword) + ) { + return readsClassAfter(lowerer, targetInfo, expr.right, [], { + kind: "boolLit", + value, + type: BOOL, + loc, + }); + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'instanceof' on computed operands (bind the values to variables first)", + ); + } + const constructor = tryLowerExpression(lowerer, expr.right); + if (constructor?.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return checkedClassInstanceOf(lowerer, value, constructor, loc); + } + lowerer.unsupported( + "SC1090", + expr.right, + "'instanceof' right-hand sides other than classes declared in the program", + ); + } + // `x instanceof Writable`: Node's Writable[Symbol.hasInstance] also + // answers true for streams with a writable side that do not inherit from + // Writable, which are Duplex and its subclasses (Transform, PassThrough, + // the zlib streams). Subclasses of Writable keep the prototype test. + const duplex = + target.def.name === "%Writable" && target.builtinStream !== undefined + ? lowerer.classes.get("%Duplex") + : undefined; + // The classes whose interval test can answer true for a value of static + // class `className`: Writable and Duplex are disjoint, so a value typed + // as one side needs only that side's test. + const runtimeTests = (className: string): ClassInfo[] => { + if (!duplex) return [target]; + const mayHold = (info: ClassInfo): boolean => + className === info.def.name || + lowerer.isSubclassOf(className, info.def.name) || + lowerer.isSubclassOf(info.def.name, className); + const tests = [target, duplex].filter(mayHold); + return tests.length > 0 ? tests : [target]; + }; + // A catch binding on the left: the runtime test against the class's + // preorder interval (false for non-hierarchy-object payloads). tsc's + // narrowing then types the branches; reads bridge through caughtRead. + const caughtLocal = lowerer.caughtLocalOf(expr.left); + if (caughtLocal) { + const caughtInstanceOf = (info: ClassInfo): IrExpr => { + if (!lowerer.inHierarchy(info)) { + const value = varRef(caughtLocal.id, CAUGHT, loc); + return { + kind: "ternary", + cond: { + kind: "caughtTest", + value, + test: "instanceof", + className: info.def.name, + type: BOOL, + loc, + }, + then: { kind: "boolLit", value: true, type: BOOL, loc }, + else_: classInstanceOf( + lowerer, + { kind: "caughtToDyn", value, type: DYN, loc }, + info, + loc, + ), + type: BOOL, + loc, + }; + } + return { + kind: "caughtTest", + value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc }, + test: "instanceof", + className: info.def.name, + type: BOOL, + loc, + }; + }; + const test = caughtInstanceOf(target); + return duplex + ? { kind: "logical", op: "||", left: test, right: caughtInstanceOf(duplex), type: BOOL, loc } + : test; + } + const left = lowerer.lowerExpr(expr.left); + // `u instanceof Error` on an `unknown` value: the checked-dynamic tree's error encoding + // (the shape caughtToDyn builds for Error payloads — the reserved + // "%error" marker) answers the test, so a caught Error passed through + // an unknown slot narrows like Node (`error instanceof Error ? + // error.message : String(error)` — the LAN-monitor handler). ROOT + // only: the marker cannot honestly answer subclass prototype chains + // (name strings are user-writable), so `u instanceof TypeError` keeps + // the fence. Reads past the narrow bridge through maybeNarrow's + // validated %Error extraction. SEMANTICS.md 67. + if ( + left.type.kind === "dyn" && + !target.def.runtime && + !target.builtinError && + !target.builtinEmitter && + !target.builtinStream && + !target.localClass + ) { + return classInstanceOf(lowerer, left, target, loc); + } + if (left.type.kind === "dyn" && target.def.name === "%Error") { + return { kind: "dynTest", test: "error", value: left, type: BOOL, loc }; + } + // `u instanceof TypeError` (and the other BUILTIN error classes) on a + // dyn value: the from_error cache holds the checked-dynamic tree↔error identity edge, + // so the runtime resolves the encoding back to its error and asks the + // vtable's stamped interval — exact for every error that crossed the + // boundary (a hand-built {%error} literal answers false: subclass + // identity is unknowable there). User subclasses keep the fence. + if (left.type.kind === "dyn" && RUNTIME_ERROR_CLASSES.has(target.def.name)) { + const rec = RUNTIME_ERROR_CLASSES.get(target.def.name)!; + return { + kind: "libCall", + fn: "dyn.errInstanceof", + args: [left, { kind: "numLit", value: rec.kind, type: F64, loc }], + type: BOOL, + loc, + }; + } + if (left.type.kind === "dyn") { + lowerer.unsupported( + "SC1090", + expr, + `'instanceof ${target.def.name.replace(/^%/, "")}' on 'unknown' values (only the Error classes answer — test 'instanceof Error' and read '.name')`, + ); + } + if (left.type.kind === "union") { + const unionId = left.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + // Ordinary records, arrays and scalar values do not carry a class + // prototype (no program class extends Array). Dynamic/opaque runtime + // objects keep their existing instanceof fences. + if ( + arms?.every( + (arm) => + arm.kind === "object" || + arm.kind === "record" || + arm.kind === "array" || + arm.kind === "f64" || + arm.kind === "string" || + arm.kind === "bool" || + arm.kind === "bigint" || + arm.kind === "symbol" || + isUnitType(arm), + ) + ) { + const key = `instanceof.union:${unionId}:${target.def.name}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%instanceof.union.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const value: IrExpr = { kind: "varRef", localId: "value.0", type: left.type, loc }; + const body: IrStmt[] = []; + arms.forEach((arm, tag) => { + if (arm.kind !== "object") return; + const lhsInfo = lowerer.classes.get(arm.className); + if (!lhsInfo) + throw new InternalCompilerError(`lowerer bug: unknown class ${arm.className}`); + const armTest = (info: ClassInfo): IrExpr => + lowerer.inHierarchy(lhsInfo) && lowerer.inHierarchy(info) + ? { + kind: "instanceOf", + value: { kind: "unionNarrow", unionId, tag, value, type: arm, loc }, + className: info.def.name, + type: BOOL, + loc, + } + : { + kind: "boolLit", + value: + arm.className === info.def.name || + lowerer.isSubclassOf(arm.className, info.def.name), + type: BOOL, + loc, + }; + const result = runtimeTests(arm.className) + .map(armTest) + .reduce((left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "logical", + op: "||", + left, + right, + type: BOOL, + loc, + })); + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value, type: BOOL, loc }, + then: [{ kind: "return", value: result, loc }], + else_: null, + loc, + }); + }); + body.push({ + kind: "return", + value: { kind: "boolLit", value: false, type: BOOL, loc }, + loc, + }); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: "value.0", name: "value", type: left.type }], + returnType: BOOL, + locals: [{ id: "value.0", name: "value", type: left.type, mutable: false }], + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [left], type: BOOL, loc }; + } + // A class arm could hold an instance: the test cannot be decided + // statically. + if (arms?.some((arm) => arm.kind === "object")) + lowerer.unsupported( + "SC1090", + expr, + "'instanceof' on a union of class instances and values without a class prototype test", + ); + } + if (left.type.kind !== "object") { + return readsClassAfter(lowerer, target, expr.right, [{ kind: "exprStmt", expr: left, loc }], { + kind: "boolLit", + value: false, + type: BOOL, + loc, + }); + } + const lhsInfo = lowerer.classes.get(left.type.className); + if (!lhsInfo) + throw new InternalCompilerError(`lowerer bug: unknown class ${left.type.className}`); + if (lowerer.inHierarchy(lhsInfo) && lowerer.inHierarchy(target)) { + const tests = runtimeTests(left.type.className); + if (tests.length === 1) { + return { kind: "instanceOf", value: left, className: tests[0]!.def.name, type: BOOL, loc }; + } + const local = lowerer.declareHiddenLocal("%writableTest", left.type); + const ref = varRef(local.id, left.type, loc); + const test = (className: string): IrExpr => ({ + kind: "instanceOf", + value: ref, + className, + type: BOOL, + loc, + }); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: left, loc }], + result: { + kind: "logical", + op: "||", + left: test(tests[0]!.def.name), + right: test(tests[1]!.def.name), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + const leftClass = left.type.className; + const isInstance = (className: string): boolean => + leftClass === className || lowerer.isSubclassOf(leftClass, className); + const value = + isInstance(target.def.name) || (duplex !== undefined && isInstance(duplex.def.name)); + if (left.kind === "varRef") { + return readsClassAfter(lowerer, target, expr.right, [], { + kind: "boolLit", + value, + type: BOOL, + loc, + }); + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'instanceof' on computed operands (bind the value to a variable first)", + ); +} + +/** `x instanceof K` for a union-typed operand whose arms are class + * instances or unit values: each instance arm tests against the class + * value (with the Writable rule), unit arms answer false. Null when an arm + * is anything else. */ +function unionClassValueInstanceOf( + lowerer: Lowerer, + left: IrExpr, + right: ts.Expression, + targetInfo: ClassInfo, + loc: SrcLoc, +): IrExpr | null { + if (left.type.kind !== "union") return null; + const unionId = left.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms; + if ( + !arms?.every((arm) => { + if (arm.kind !== "object") return isUnitType(arm); + const info = lowerer.classes.get(arm.className); + return info !== undefined && lowerer.inHierarchy(info); + }) + ) + return null; + const classValue = lowerer.lowerExpr(right); + if (classValue.type.kind !== "classval") lowerer.badType(right, lowerer.typeOf(right)); + const value = lowerer.declareHiddenLocal("%instanceValue", left.type); + const cls = lowerer.declareHiddenLocal("%instanceClass", classValue.type); + const valueRef = varRef(value.id, left.type, loc); + const clsRef = varRef(cls.id, classValue.type, loc); + let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + arms.forEach((arm, tag) => { + if (arm.kind !== "object") return; + const narrowed: IrExpr = { kind: "unionNarrow", unionId, tag, value: valueRef, type: arm, loc }; + const lhsInfo = lowerer.classes.get(arm.className)!; + const test: IrExpr = writableClassValueTest( + lowerer, + narrowed, + clsRef, + targetInfo, + lhsInfo, + loc, + ) ?? { kind: "instanceOfValue", value: narrowed, classValue: clsRef, type: BOOL, loc }; + result = { + kind: "ternary", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: valueRef, type: BOOL, loc }, + then: test, + else_: result, + type: BOOL, + loc, + }; + }); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: value.id, init: left, loc }, + { kind: "varDecl", localId: cls.id, init: classValue, loc }, + ], + result, + type: BOOL, + loc, + }; +} + +/** `x instanceof K` where K is a class value that may hold Writable (a + * `typeof Writable` or `typeof EventEmitter` slot): Node's + * Writable[Symbol.hasInstance] also answers true for streams with a + * writable side that do not inherit from Writable (Duplex and its + * subclasses), so when K is Writable the Duplex interval answers too. + * Null when the slot cannot hold Writable or the operand cannot be a + * Duplex. */ +function writableClassValueTest( + lowerer: Lowerer, + left: IrExpr, + classValue: IrExpr, + targetInfo: ClassInfo, + lhsInfo: ClassInfo, + loc: SrcLoc, +): IrExpr | null { + const writable = lowerer.classes.get("%Writable"); + const duplex = lowerer.classes.get("%Duplex"); + if (!writable || !duplex || classValue.type.kind !== "classval" || left.type.kind !== "object") + return null; + const slotHoldsWritable = + targetInfo === writable || lowerer.isSubclassOf("%Writable", targetInfo.def.name); + const operandMayBeDuplex = + lhsInfo === duplex || + lowerer.isSubclassOf(lhsInfo.def.name, "%Duplex") || + lowerer.isSubclassOf("%Duplex", lhsInfo.def.name); + if (!slotHoldsWritable || !operandMayBeDuplex) return null; + lowerer.defineClassValueConstruct(writable); + const value = lowerer.declareHiddenLocal("%instanceValue", left.type); + const cls = lowerer.declareHiddenLocal("%instanceClass", classValue.type); + const valueRef = varRef(value.id, left.type, loc); + const clsRef = varRef(cls.id, classValue.type, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: value.id, init: left, loc }, + { kind: "varDecl", localId: cls.id, init: classValue, loc }, + ], + result: { + kind: "logical", + op: "||", + left: { kind: "instanceOfValue", value: valueRef, classValue: clsRef, type: BOOL, loc }, + right: { + kind: "logical", + op: "&&", + left: { + kind: "bin", + op: "===", + left: clsRef, + right: { + kind: "classRef", + className: "%Writable", + type: { kind: "classval", className: "%Writable" }, + loc, + }, + type: BOOL, + loc, + }, + right: { kind: "instanceOf", value: valueRef, className: "%Duplex", type: BOOL, loc }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; +} + +/** A statically decided `instanceof` still reads its class after the left + * operand: a class with an initialization flag throws there before its + * declaration ran. */ +function readsClassAfter( + lowerer: Lowerer, + target: ClassInfo, + right: ts.Expression, + stmts: IrStmt[], + result: IrExpr, +): IrExpr { + if (!lowerer.classInitFlags.has(target.def.name)) { + if (stmts.length === 0) return result; + return { kind: "seqExpr", stmts, result, type: result.type, loc: result.loc }; + } + const read = lowerer.lowerExpr(right); + return { + kind: "seqExpr", + stmts: [...stmts, { kind: "exprStmt", expr: read, loc: locOf(right) }], + result, + type: result.type, + loc: result.loc, + }; +} + +/** `#name in obj` — the ergonomic brand check. In this closed world a + * brand is held by exactly the instances of the declaring class + * (subclasses included — construction always runs the declaring class's + * own initializers), so the test IS `obj instanceof `: + * statically decided when the receiver's class sits at/below the + * declarer (true) or in a disjoint subtree (false) — both folds under + * the instanceof purity rule — and a runtime interval test when the + * declarer sits strictly BELOW the receiver's static class (the + * narrowing use; tsc types the true branch at the class, and reads + * bridge through maybeNarrow's downcast exactly like instanceof). tsc + * confines the spelling to the declaring class's body and rejects + * primitive/unknown receivers, so Node's in-operator TypeError is + * unreachable in compilable programs. Timing residue: JS installs + * brands DURING construction, so a check reachable from a base + * constructor can observe false mid-construction where this answers + * true (SEMANTICS.md). */ +function lowerPrivateIn( + lowerer: Lowerer, + expr: ts.BinaryExpression, + priv: ts.PrivateIdentifier, + loc: SrcLoc, +): IrExpr { + const pname = priv.text; + // The declaring class: the nearest enclosing class declaring the name + // (JS scoping — an inner class's spelling shadows an outer one's). + let classDecl: ts.ClassLikeDeclaration | null = null; + let staticBrand = false; + for (let n: ts.Node | undefined = priv.parent; n; n = n.parent) { + if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) { + const owner = n.members.nodes.find((m) => { + const name = (m as { name?: ts.PropertyName }).name; + return name !== undefined && ts.isPrivateIdentifier(name) && name.text === pname; + }); + if (owner) { + classDecl = n; + staticBrand = + ts.canHaveModifiers(owner) && + ts.getModifiers(owner)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true; + break; + } + } + } + // tsc rejects the spelling outside a declaring class body; defensive. + if (!classDecl) + lowerer.unsupported("SC1090", expr, `'${pname} in …' outside a class declaring '${pname}'`); + if (staticBrand) { + lowerer.unsupported( + "SC1090", + expr, + `'${pname} in …' brand checks for private STATICS (JS brands the declaring class OBJECT, not instances — compare against the class value directly)`, + ); + } + const info = + lowerer.currentClass?.decl === classDecl + ? lowerer.currentClass + : (() => { + const sym = classDecl.name + ? lowerer.checker.getSymbolAtLocation(classDecl.name) + : undefined; + return sym ? lowerer.classBySymbol.get(sym) : undefined; + })(); + if (!info) { + lowerer.unsupported( + "SC1090", + expr, + `'${pname} in …' where the declaring class has no lowering (see the class declaration's own diagnostic)`, + ); + } + // A GENERIC family: JS has ONE runtime class and ONE brand for every + // instantiation, while these layouts mint one class per instantiation + // — a cross-instantiation check would answer false where Node says + // true, so the family reports SC1090 instead of splitting the brand. + if (info.generic || info.genericInstance) { + lowerer.unsupported( + "SC1090", + expr, + `'${pname} in …' inside a generic class (JS shares one brand across every instantiation; these layouts mint one class per instantiation)`, + ); + } + const recv = lowerer.lowerExpr(expr.right); + const declName = info.def.name; + // A UNION of class instances (`o: Counter | Helper` — the + // discriminating use): every object arm answers membership STATICALLY + // (at/below the declarer → true, disjoint subtree → false), so the + // whole test collapses to runtime TAG tests — the record-shape 'in' + // discrimination, brand form. An arm ABOVE the declarer answers per + // VALUE (its slot can hold branded and unbranded instances), and + // non-object arms have no brand story — both fence. + if (recv.type.kind === "union") { + const unionId = recv.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms ?? []; + const answers: { tag: number; has: boolean }[] = []; + let staticAnswers = arms.length > 0; + for (const arm of arms) { + const tag = lowerer.armTag(unionId, arm); + if (tag < 0 || arm.kind !== "object" || lowerer.isSubclassOf(declName, arm.className)) { + staticAnswers = false; + break; + } + answers.push({ + tag, + has: arm.className === declName || lowerer.isSubclassOf(arm.className, declName), + }); + } + if (staticAnswers) { + const pureRecv = + recv.kind === "varRef" || recv.kind === "recordGet" || recv.kind === "fieldGet"; + const isTag = (tag: number, negated: boolean): IrExpr => ({ + kind: "unionIsTag", + unionId, + tag, + negated, + value: recv, + type: BOOL, + loc, + }); + const trues = answers.filter((a) => a.has); + if (trues.length === answers.length || trues.length === 0) { + const ans = trues.length !== 0; + if (pureRecv) return { kind: "boolLit", value: ans, type: BOOL, loc }; + // Constant either way, receiver still evaluates once: one tag + // test whose branches agree (the record-'in' rule verbatim). + return { + kind: "ternary", + cond: isTag(answers[0]!.tag, false), + then: { kind: "boolLit", value: ans, type: BOOL, loc }, + else_: { kind: "boolLit", value: ans, type: BOOL, loc }, + type: BOOL, + loc, + }; + } + if (trues.length === 1) return isTag(trues[0]!.tag, false); + const falses = answers.filter((a) => !a.has); + if (falses.length === 1) return isTag(falses[0]!.tag, true); + if (pureRecv) { + let out: IrExpr = isTag(trues[0]!.tag, false); + for (const t of trues.slice(1)) { + out = { + kind: "logical", + op: "||", + left: out, + right: isTag(t.tag, false), + type: BOOL, + loc, + }; + } + return out; + } + lowerer.unsupported( + "SC1090", + expr, + `statically-decided '${pname} in …' on computed receivers (bind the value to a variable first)`, + ); + } + } + if (recv.type.kind !== "object") { + lowerer.unsupported( + "SC1090", + expr, + `'${pname} in …' on '${lowerer.fmt(recv.type)}' receivers (only class-instance receivers — and unions of classes below or beside the declarer — have a static brand answer; narrow first)`, + ); + } + const lhsName = recv.type.className; + const lhsInfo = lowerer.classes.get(lhsName); + if (!lhsInfo) throw new InternalCompilerError(`lowerer bug: unknown class ${lhsName}`); + if (lowerer.isSubclassOf(declName, lhsName)) { + // The narrowing direction: the declarer strictly below the + // receiver's static class — a strict subclass relation puts both in + // a hierarchy, so the vtable interval test always exists. + return { kind: "instanceOf", value: recv, className: declName, type: BOOL, loc }; + } + const value = lhsName === declName || lowerer.isSubclassOf(lhsName, declName); + if (recv.kind === "varRef") return { kind: "boolLit", value, type: BOOL, loc }; + lowerer.unsupported( + "SC1090", + expr, + `statically-decided '${pname} in …' on computed operands (bind the value to a variable first)`, + ); +} + +/** The string keys every record inherits from Object.prototype. */ +const OBJECT_PROTOTYPE_KEYS: ReadonlySet = new Set([ + "constructor", + "__defineGetter__", + "__defineSetter__", + "hasOwnProperty", + "__lookupGetter__", + "__lookupSetter__", + "isPrototypeOf", + "propertyIsEnumerable", + "toString", + "valueOf", + "__proto__", + "toLocaleString", +]); + +/** `"key" in v` — the key-presence test. Three lowered receivers: + * process.env (getenv(3) presence — Node-exact, an empty value still + * counts as present), and monomorphic record shapes, where the answer is + * type-directed: a declared non-optional field is a compile-time `true`, + * a missing field (no index signature) a compile-time `false` unless + * Object.prototype has it — both + * folds limited to side-effect-free receivers, the lowerInstanceOf rule — + * and an OPTIONAL field (undefined-armed union) is a runtime presence + * test on the slot: an omitted or deleted property is absent, while a + * property explicitly holding `undefined` is present, as in JS + * (`"a" in {a: undefined}` is true). Union receivers (the `in`-narrowing + * idiom over multiple shapes), index-signature keys, class instances, + * and dyn/unknown stay fenced. Keys are literal strings — a computed key + * over a shape would need the runtime key table. */ +export function lowerInExpression( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr { + if ( + lowerer.isStdlibGlobal(expr.right, "process") && + lowerer.foldedStringKeyOf(expr.left) === "hrtime" + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + const globalKey = globalSymbolKey(lowerer, expr.right, expr.left); + if (globalKey) + return { kind: "libCall", fn: "dyn.globalSymbolHas", args: [globalKey], type: BOOL, loc }; + // `#name in obj` — the ergonomic brand check (ES2022) — resolves + // before any string-key machinery: the left operand is a private + // NAME, not a value. + if (ts.isPrivateIdentifier(expr.left)) { + return lowerPrivateIn(lowerer, expr, expr.left, loc); + } + // Compile-time-known STRING keys fold — literals, and the same + // const/enum-literal and template folding computed property keys get + // (foldedStringKeyOf); runtime-valued keys keep the fence. + // NUMERIC literal keys answer on ARRAY receivers through the shared + // presence query. Arrays retain holes independently from length, and + // noncanonical numeric keys live in their ordinary-property table, so + // a length comparison is not an honest `in` answer. + { + let kNode = expr.left; + while (ts.isParenthesizedExpression(kNode)) kNode = kNode.expression; + if ( + ts.isNumericLiteral(kNode) && + lowerer.mapTypeOf(lowerer.typeOf(expr.right))?.kind === "array" + ) { + const recvArr = lowerer.lowerExpr(expr.right); + if (recvArr.type.kind === "array") { + const n = Number(kNode.text); + return { + kind: "arrayHas", + arr: recvArr, + index: { kind: "numLit", value: n, type: F64, loc }, + type: BOOL, + loc, + }; + } + } + } + // A RUNTIME numeric key over an ARRAY receiver (`if (i in arr)` in a + // loop) asks the same slot/property presence query as the literal fold + // above and `Object.hasOwn`. The checked-dynamic fallback below would + // box the receiver and materialize every element on each evaluation, + // which makes such loops quadratic. A Number key never names an + // inherited property: neither Array.prototype nor Object.prototype has + // index-like or numeric-string keys. JS evaluates the key before the + // receiver, so the key is stabilized in a hidden local first. + if ( + lowerer.mapTypeOf(lowerer.typeOf(expr.left))?.kind === "f64" && + lowerer.mapTypeOf(lowerer.typeOf(expr.right))?.kind === "array" + ) { + const k = lowerer.lowerExpr(expr.left); + const recvArr = lowerer.lowerExpr(expr.right); + if (k.type.kind === "f64" && recvArr.type.kind === "array") { + const keyLocal = lowerer.declareHiddenLocal("%inIndex", F64); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: keyLocal.id, init: k, loc }], + result: { + kind: "arrayHas", + arr: recvArr, + index: varRef(keyLocal.id, F64, loc), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + } + const key = foldedStringKeyOf(lowerer, expr.left); + if (key === null) { + // A RUNTIME string key over an INDEX-SIGNATURE record receiver (the + // `names.filter((k) => k in config)` idiom): the interned key- + // presence helper — declared fields answer statically per name + // (optional slots by their runtime presence), then the overflow + // map's live keys. Other receivers keep the + // fence: fixed shapes want the literal folds above, and there is no + // runtime key table to ask. + const rIn = lowerRuntimeKeyIn(lowerer, expr, loc); + if (rIn) return rIn; + // A runtime key over a CHECKED-DYNAMIC receiver (`name in + // agent.sockets` — both sides computed; the checker may type the + // receiver as a Dict while the VALUE lives in the checked-dynamic tree, so the + // LOWERED type decides): the dyn presence answer, with the key + // stringified like every property key (o[k] is o[String(k)] in JS; + // `in` shares the coercion). + { + const probed = tryLowerExpression(lowerer, expr.right); + if (probed && lowerer.dynConvertible(probed.type)) { + const k = lowerer.lowerExpr(expr.left); // JS order: the key evaluates first + if (lowerer.dynConvertible(k.type)) { + const boxed = lowerer.coerceToExpected(k, DYN); + const keyLocal = lowerer.declareHiddenLocal("%inKey", DYN); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: keyLocal.id, init: boxed, loc }], + result: { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [ + lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN), + varRef(keyLocal.id, DYN, loc), + ], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + } + } + lowerer.unsupported( + "SC1090", + expr.left, + "'in' with computed (non-literal) keys (a const whose type is one string literal folds; a string-typed key answers over index-signature record receivers)", + ); + } + // A receiver re-read (the static folds below) is safe for plain reads + // AND for side-effect-free expressions (an object literal of literals + // — the `"a" in { a: true }` shape). + const pureRecvNode = sideEffectFreeOptionValue(expr.right); + if (lowerer.isProcessEnv(expr.right)) { + // `"NO_COLOR" in process.env`: presence via the one envGet intrinsic — + // getenv(3) returning non-NULL is Node's `in` on process.env exactly. + const envType = lowerer.envValueType(); + if (envType.kind !== "union") + throw new InternalCompilerError("lowerer bug: env value type is not a union"); + const undefTag = lowerer.armTag(envType.unionId, UNDEFINED_T); + const read: IrExpr = { + kind: "libCall", + fn: "process.envGet", + args: [{ kind: "strLit", value: key, type: STRING, loc }], + type: envType, + loc, + }; + return { + kind: "unionIsTag", + unionId: envType.unionId, + tag: undefTag, + negated: true, + value: read, + type: BOOL, + loc, + }; + } + if (key === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.right))) { + refuseExecExceptionCode(lowerer, expr); + } + let recv = lowerer.lowerExpr(expr.right); + const classInfo = + recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined; + if ( + (classInfo && (classInfo.callableBase || hasRuntimeStatics(classInfo))) || + (isJsSourceFile(expr.getSourceFile()) && + recv.type.kind === "func" && + lowerer.dynConvertible(recv.type)) + ) { + recv = lowerer.coerceToExpected(recv, DYN); + } + const siteType = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); + if ( + recv.type.kind === "union" && + siteType?.kind === "union" && + !typeEquals(recv.type, siteType) + ) { + const helper = lowerer.narrowedRetagHelper( + expr.right, + recv.type.unionId, + siteType.unionId, + loc, + ); + if (helper) recv = { kind: "call", callee: helper, args: [recv], type: siteType, loc }; + } + // Error-rooted receivers (builtin or user subclass — the isErrnoException + // predicate's `err instanceof Error && "code" in err` shape): `code` + // answers from the runtime error's code slot (stamped by fs/system/ + // event/spawn errors, absent on plain constructions — exactly Node's + // own-property answer), and the always-declared members fold true. + // Everything else fences: the runtime object carries no other dynamic + // properties to ask (`stack` would be a lie — Node has one, we don't). + if (recv.type.kind === "object") { + // %DOMException first: `'cause' in e` answers the options form's + // own-property record (the runtime slot), and code/name/message are + // always present. + if (recv.type.className === "%DOMException") { + if (key === "cause") { + return { kind: "libCall", fn: "error.domHasCause", args: [recv], type: BOOL, loc }; + } + if ( + (key === "code" || key === "message" || key === "name") && + (recv.kind === "varRef" || recv.kind === "caughtNarrow") + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + } + let info = lowerer.classes.get(recv.type.className) ?? null; + while (info && info.base) info = info.base; + if (info?.def.name === "%Error") { + if (key === "cause") { + return { kind: "libCall", fn: "error.hasCause", args: [recv], type: BOOL, loc }; + } + if (key === "code") { + const codeType = lowerer.envValueType(); + if (codeType.kind !== "union") + throw new InternalCompilerError("lowerer bug: error code type is not a union"); + const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T); + const read: IrExpr = { + kind: "libCall", + fn: "error.code", + args: [recv], + type: codeType, + loc, + }; + return { + kind: "unionIsTag", + unionId: codeType.unionId, + tag: undefTag, + negated: true, + value: read, + type: BOOL, + loc, + }; + } + if ( + (key === "message" || key === "name") && + (recv.kind === "varRef" || recv.kind === "caughtNarrow") + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + lowerer.unsupported( + "SC1090", + expr, + `'in' with the key '${key}' on Error receivers (code answers from the error's code slot; message and name are always present)`, + ); + } + } + // A dyn receiver (`"portless" in pkg` after the `typeof pkg === + // "object"` guard): own-member presence on the checked-dynamic tree — tsc admits `in` + // only on object-typed operands, so unit receivers are unreachable. + if (recv.type.kind === "dyn") { + return { kind: "dynHasKey", key, value: recv, type: BOOL, loc }; + } + // `"k" in u` over a UNION whose arms are FIXED record shapes: every + // arm answers membership STATICALLY (a declared non-optional field is + // always present, an undeclared name never is), so the whole test + // collapses to runtime TAG tests — tsc's own narrowing then types the + // branches (the discriminating-`in` idiom). Optional (undefined-armed) + // fields and tuple/index-signature arms answer per VALUE, not per arm + // — those unions keep the fence below. + if (recv.type.kind === "union") { + const unionId = recv.type.unionId; + const arms = lowerer.unions.get(unionId)?.arms ?? []; + const answers: { tag: number; has: boolean }[] = []; + let staticAnswers = arms.length > 0; + for (const arm of arms) { + const tag = lowerer.armTag(unionId, arm); + const shape = arm.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; + if (tag < 0 || !shape || shape.tuple || shape.indexValue) { + staticAnswers = false; + break; + } + const f = shape.fields.find((x) => x.name === key); + const inherited = OBJECT_PROTOTYPE_KEYS.has(key); + if ( + f && + !inherited && + f.type.kind === "union" && + lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 + ) { + staticAnswers = false; // an optional slot: presence is per-value + break; + } + // Accessor properties are own properties to `in` (Node answers + // true without invoking the getter) — either slot present makes + // the name a member. + const acc = shape.fields.some((x) => x.name === `%get:${key}` || x.name === `%set:${key}`); + answers.push({ tag, has: f !== undefined || acc || inherited }); + } + if (staticAnswers) { + const pureRecv = + recv.kind === "varRef" || + recv.kind === "recordGet" || + recv.kind === "fieldGet" || + pureRecvNode; + const isTag = (tag: number, negated: boolean): IrExpr => ({ + kind: "unionIsTag", + unionId, + tag, + negated, + value: recv, + type: BOOL, + loc, + }); + const trues = answers.filter((a) => a.has); + if (trues.length === answers.length || trues.length === 0) { + // Constant either way — but JS still EVALUATES the operand (it + // may throw: an ambient-const read is a ReferenceError). Pure + // reads fold; anything else rides one tag test whose branches + // agree, evaluating the receiver exactly once. + const ans = trues.length !== 0; + if (pureRecv) return { kind: "boolLit", value: ans, type: BOOL, loc }; + return { + kind: "ternary", + cond: isTag(answers[0]!.tag, false), + then: { kind: "boolLit", value: ans, type: BOOL, loc }, + else_: { kind: "boolLit", value: ans, type: BOOL, loc }, + type: BOOL, + loc, + }; + } + // One deciding arm (either polarity): a single tag test, receiver + // evaluated once — no purity requirement. + if (trues.length === 1) return isTag(trues[0]!.tag, false); + const falses = answers.filter((a) => !a.has); + if (falses.length === 1) return isTag(falses[0]!.tag, true); + // Several arms on each side: the OR chain re-reads the receiver, + // so only side-effect-free reads qualify. + if (pureRecv) { + let out: IrExpr = isTag(trues[0]!.tag, false); + for (const t of trues.slice(1)) { + out = { + kind: "logical", + op: "||", + left: out, + right: isTag(t.tag, false), + type: BOOL, + loc, + }; + } + return out; + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'in' on computed receivers (bind the value to a variable first)", + ); + } + } + if (recv.type.kind !== "record") { + lowerer.unsupported( + "SC1090", + expr, + `'in' on '${lowerer.fmt(recv.type)}' receivers (only process.env, Error instances, record-typed values, and unions of fixed record shapes answer; ${NARROW_FIRST})`, + ); + } + const shape = lowerer.shapes.get(recv.type.shapeId); + if (!shape) throw new InternalCompilerError(`lowerer bug: unknown shape ${recv.type.shapeId}`); + const field = shape.fields.find((f) => f.name === key); + if (field) { + if ( + !OBJECT_PROTOTYPE_KEYS.has(key) && + field.type.kind === "union" && + lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0 + ) { + // Optional slot: the key is present unless the field is absent + // (omitted or deleted); an explicit undefined is present. + return lowerer.recordFieldPresent(recv, recv.type.shapeId, key, loc); + } + // A declared non-optional field always exists on every value of the + // shape — statically true, but folding may only drop a + // side-effect-free receiver read. + if ( + recv.kind === "varRef" || + recv.kind === "recordGet" || + recv.kind === "fieldGet" || + pureRecvNode + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'in' on computed receivers (bind the value to a variable first)", + ); + } + // Accessor properties answer `in` as own members (Node: true, getter + // NOT invoked) — statically true under the same purity discipline as + // declared non-optional fields. + if (shape.fields.some((f) => f.name === `%get:${key}` || f.name === `%set:${key}`)) { + if ( + recv.kind === "varRef" || + recv.kind === "recordGet" || + recv.kind === "fieldGet" || + pureRecvNode + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'in' on computed receivers (bind the value to a variable first)", + ); + } + // Records are ordinary objects: Object.prototype's members are in them. + const inherited = OBJECT_PROTOTYPE_KEYS.has(key); + if (shape.indexValue && !inherited) { + lowerer.unsupported( + "SC1090", + expr, + "'in' over index-signature keys (read the key and test '!== undefined' instead)", + ); + } + if ( + recv.kind === "varRef" || + recv.kind === "recordGet" || + recv.kind === "fieldGet" || + pureRecvNode + ) { + // A null-prototype record (Object.groupBy) has no inherited names. + const nullProtoT = inherited ? nullProtoRecordShape(lowerer, recv.type) : null; + if (nullProtoT) { + return { + kind: "unary", + op: "!", + operand: recordNullProtoTest(recv, nullProtoT.shapeId, loc), + type: BOOL, + loc, + }; + } + return { kind: "boolLit", value: inherited, type: BOOL, loc }; + } + lowerer.unsupported( + "SC1090", + expr, + "statically-decided 'in' on computed receivers (bind the value to a variable first)", + ); +} + +/** The runtime-key `in` (see lowerInExpression): `k in r` where k is a + * runtime string and r an index-signature record — an interned + * `%rec.haskey.(k, r)` walks the declared names (a string-equality + * chain: non-optional fields and accessor slots answer true, optional + * slots answer their per-value tag test) and then probes the overflow + * map's hashed key set. Null when the pair is outside that shape (the + * caller keeps its fence). */ +function lowerRuntimeKeyIn( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + if (lowerer.mapTypeOf(lowerer.typeOf(expr.left))?.kind !== "string") return null; + const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); + if (recvT?.kind !== "record") return null; + const shape = lowerer.shapes.get(recvT.shapeId); + if (!shape?.indexValue || shape.tuple) return null; + const keyIr = lowerer.lowerExprExpecting(expr.left, STRING); + const recv = lowerer.lowerExprExpecting(expr.right, recvT); + const hkey = `haskey:${recvT.shapeId}`; + let helper = lowerer.valueHelpers.get(hkey); + if (!helper) { + helper = `%rec.haskey.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(hkey, helper); + const recT: IrType = { kind: "record", shapeId: recvT.shapeId }; + + const k = varRef("k.0", STRING, loc); + const r = varRef("r.0", recT, loc); + const body: IrStmt[] = []; + const ret = (value: IrExpr): IrStmt => ({ kind: "return", value, loc }); + for (const f of shape.fields) { + const accessor = f.name.startsWith("%get:") || f.name.startsWith("%set:"); + if (f.name.startsWith("%") && !accessor) continue; + const name = accessor ? f.name.slice(5) : f.name; + const eq: IrExpr = { + kind: "strEq", + negated: false, + left: k, + right: { kind: "strLit", value: name, type: STRING, loc }, + type: BOOL, + loc, + }; + const answer: IrExpr = accessor + ? { kind: "boolLit", value: true, type: BOOL, loc } + : lowerer.recordFieldPresent(r, recvT.shapeId, f.name, loc); + body.push({ kind: "if", cond: eq, then: [ret(answer)], else_: null, loc }); + } + // Overflow keys answer through the map's hashed presence probe (a + // stored undefined value is still present), not a scan over a fresh + // key snapshot, which made `k in dict` loops quadratic. + const ovfHas: IrExpr = { + kind: "recordOvfHas", + obj: r, + shapeId: recvT.shapeId, + key: k, + type: BOOL, + loc, + }; + // A miss in the record's own keys still finds Object.prototype's, + // unless the record has a null prototype (Object.groupBy). + let protoName: IrExpr | null = null; + for (const name of OBJECT_PROTOTYPE_KEYS) { + if (shape.fields.some((f) => f.name === name)) continue; + const eq: IrExpr = { + kind: "strEq", + negated: false, + left: k, + right: { kind: "strLit", value: name, type: STRING, loc }, + type: BOOL, + loc, + }; + protoName = + protoName === null + ? eq + : { kind: "logical", op: "||", left: protoName, right: eq, type: BOOL, loc }; + } + if (protoName !== null && nullProtoRecordShape(lowerer, recT)) { + protoName = { + kind: "logical", + op: "&&", + left: protoName, + right: { + kind: "unary", + op: "!", + operand: recordNullProtoTest(r, recvT.shapeId, loc), + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + body.push( + ret( + protoName === null + ? ovfHas + : { kind: "logical", op: "||", left: ovfHas, right: protoName, type: BOOL, loc }, + ), + ); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "k.0", name: "k", type: STRING }, + { localId: "r.0", name: "r", type: recT }, + ], + returnType: BOOL, + locals: [ + { id: "k.0", name: "k", type: STRING, mutable: true }, + { id: "r.0", name: "r", type: recT, mutable: true }, + ], + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts new file mode 100644 index 000000000..30d24e22a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -0,0 +1,528 @@ +/* typeof expressions and the typeof tests they lower to. */ +import * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; +import { + BOOL, + CAUGHT, + DYN, + type IrExpr, + type IrStmt, + STRING, + type SrcLoc, + UNDEFINED_T, +} from "../../../ir/ir.js"; +import { lowerWebAssemblyTypeof } from "../builtins/webassembly.js"; +import { isNavigatorGlobal } from "../builtins/navigator.js"; +import { lowerPerfHooksTypeof } from "../builtins/performance.js"; +import { isNodeModuleValue } from "../lower-node-module.js"; +import { builtinClassRefOf } from "../builtins/class-values.js"; +import { STRING_INDEX_METHODS, builtinModuleConstOf, stdlibGlobalNameOf } from "../surfaces.js"; +import { + constructorGlobalTypeof, + deferredSlotOr, + errorRootedObjectOf, + lowerPromiseThenPresence, + operandPairs, + runtimeOptionalStorageOperand, + typeofAnswer, +} from "../lower-exprs.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { isJsSourceFile } from "../../program.js"; +import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; +import { varRef } from "../../../ir/build.js"; +import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js"; +import { InternalCompilerError } from "../../../errors.js"; + +export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc: SrcLoc): IrExpr { + const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression); + if (webAssembly) return webAssembly; + if (lowerer.isStdlibGlobal(expr.expression, "process")) + return { kind: "strLit", value: "object", type: STRING, loc }; + if (lowerer.isStdlibGlobal(expr.expression, "fetch")) + return { kind: "strLit", value: "function", type: STRING, loc }; + if (isNavigatorGlobal(lowerer, expr.expression)) + return { kind: "strLit", value: "object", type: STRING, loc }; + const performance = lowerPerfHooksTypeof(lowerer, expr.expression); + if (performance) return performance; + if (isNodeModuleValue(lowerer, expr.expression)) { + return { kind: "strLit", value: "object", type: STRING, loc }; + } + // `typeof EventEmitter`, `typeof zlib.Gzip`, `typeof C` over a + // builtin class alias. + if (builtinClassRefOf(lowerer, expr.expression) !== null) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + if (ts.isPropertyAccessExpression(expr.expression)) { + const member = expr.expression; + const prototype = member.expression; + if ( + STRING_INDEX_METHODS.has(member.name.text) && + ts.isPropertyAccessExpression(prototype) && + prototype.name.text === "prototype" && + ts.isIdentifier(prototype.expression) && + lowerer.isStdlibGlobal(prototype.expression, "String") && + lowerer.isStdlibMember(member) + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + if ( + member.name.text === "at" && + ts.isPropertyAccessExpression(prototype) && + prototype.name.text === "prototype" && + ts.isIdentifier(prototype.expression) && + lowerer.isStdlibGlobal(prototype.expression, "Array") && + lowerer.isStdlibMember(member) + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + const presence = lowerPromiseThenPresence(lowerer, expr.expression, { + kind: "strLit", + value: "function", + type: STRING, + loc, + }); + if (presence !== null) return presence; + } + // `typeof queueMicrotask` / `typeof DOMException` on a STDLIB global + // whose declared type is callable or constructable: folds to + // "function" BEFORE the operand lowers — the identity-token story + // (JS files) deliberately represents these values as strings, and + // the TS-file fence would name a value the program never needs; an + // identifier read has no side effects to preserve. Node's answer for + // every function and constructor global is "function" (the harness's + // `typeof queueMicrotask === 'function'` probes). Shadowing locals + // have non-stdlib symbols and keep the ordinary path. + let tested = expr.expression; + while (ts.isParenthesizedExpression(tested)) tested = tested.expression; + // `typeof zlib.Inflate` / `typeof zlib.createGzip`: an export of a + // Node builtin module read through a plain binding (an import or + // require result). The read has no side effects, and Node's answer for + // every exported function and class is "function". + if ( + ts.isPropertyAccessExpression(tested) && + ts.isIdentifier(tested.expression) && + lowerer.isStdlibMember(tested) + ) { + const sym = lowerer.checker.getSymbolAtLocation(tested.name); + const decls = sym ? sym.declarations : []; + if ( + decls.some(isBuiltinModuleValueDecl) && + decls.every((decl) => builtinModuleExportParent(decl) !== undefined) + ) { + const t = lowerer.typeOf(tested); + if ( + lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0 + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } + } + // `typeof globalThis.AbortSignal` / `typeof Headers`: Node 24's Web + // platform constructor globals are functions. JavaScript sources + // represent such a value as an identity token, and the fallback + // declarations type some (AbortSignal) without a construct signature, + // so neither the operand nor the type can answer; the read has no + // effects. + if (constructorGlobalTypeof(lowerer, tested) !== null) + return { kind: "strLit", value: "function", type: STRING, loc }; + // `typeof globalThis.AbortSignal.any` (ky's feature probe): a static + // member of a standard-library global, declared callable, whose value + // has no representation. The read has no effects and Node's answer + // for every such function is "function"; members that lower as values + // keep their own answer. + if (ts.isPropertyAccessExpression(tested) && !tested.questionDotToken) { + const owner = stdlibGlobalNameOf(lowerer, tested.expression); + if (owner !== null && lowerer.isStdlibMember(tested)) { + const t = lowerer.typeOf(tested); + if ( + (lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0) && + tryLowerExpression(lowerer, tested) === null + ) + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } + if (ts.isIdentifier(tested)) { + const sym = lowerer.checker.getSymbolAtLocation(tested); + if (!sym) { + return { kind: "strLit", value: "undefined", type: STRING, loc }; + } + // `const { inspect } = require("util")` / `import { inspect } from + // "node:util"`: a binding of a builtin module's exported function, + // class or object. JavaScript sources represent such a value without + // its own lowering as an identity token (a string), so the operand's + // representation cannot answer. Node's answer follows the export's + // kind, and the binding read has no effects. + const imported = isJsSourceFile(tested.getSourceFile()) + ? lowerer.builtinImportOf(tested) + : null; + if (imported && builtinModuleConstOf(lowerer, imported.module, imported.member) === undefined) { + const t = lowerer.typeOf(tested); + if ( + lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0 + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + if ((t.flags & ts.TypeFlags.Object) !== 0) { + return { kind: "strLit", value: "object", type: STRING, loc }; + } + } + if (lowerer.isStdlibSymbol(sym)) { + const t = lowerer.typeOf(expr.expression); + if ( + lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0 + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } + } + // A known static result type fixes typeof's answer, but its producer + // still evaluates: calls, getters, and checked reads can have effects + // or throw. Only trivial operands may disappear. + let operand = deferredSlotOr( + lowerer, + runtimeOptionalStorageOperand(lowerer, expr.expression) ?? lowerer.lowerExpr(expr.expression), + ); + + // A checker-narrowed dyn read arrives as a VALIDATED extraction + // (maybeNarrow's dynCheck bridge). typeof needs no extraction — the + // dyn kind table answers the question directly, and answers it even + // where the flow type lied (the extraction would throw instead) — + // so unwrap back to the dyn value and take the dyn.typeof path. + if (operand.kind === "dynCheck" && operand.value.type.kind === "dyn") { + operand = operand.value; + } + const FOLD: Partial> = { + f64: "number", + bigint: "bigint", + string: "string", + bool: "boolean", + func: "function", + array: "object", + object: "object", + record: "object", + symbol: "symbol", + map: "object", + set: "object", + promise: "object", + bytes: "object", + regex: "object", + generator: "object", + classval: "function", + moduleNs: "object", + undefinedT: "undefined", + nullT: "object", + }; + const folded = FOLD[operand.type.kind]; + if (folded) { + const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc }; + return isSafeToDiscard(operand) + ? result + : { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: operand, loc }], + result, + type: STRING, + loc, + }; + } + // A union operand: every arm's typeof answer is static, so the value + // form is a ternary chain over runtime TAG tests (arms grouped by + // answer; the last group needs no test). The operand rides several + // tests, so only side-effect-free reads compose — and when every arm + // agrees the whole expression folds to that one string (dropping only + // the pure read, the trust-the-checker bet lowerUnitComparison makes). + // An effectful producer (a getter) evaluates once into a hidden local. + const prelude: IrStmt[] = []; + if (operand.type.kind === "union" && !isSafeToRepeat(operand)) { + const local = lowerer.declareHiddenLocal("%typeofOperand", operand.type); + prelude.push({ kind: "varDecl", localId: local.id, init: operand, loc }); + operand = varRef(local.id, operand.type, loc); + } + if (operand.type.kind === "union" && isSafeToRepeat(operand)) { + const def = lowerer.unions.get(operand.type.unionId); + const answers = def?.arms.map(typeofAnswer); + if (def && answers && answers.every((s): s is string => s !== null)) { + const groups = new Map(); + answers.forEach((s, i) => groups.set(s, [...(groups.get(s) ?? []), i])); + const ordered = [...groups.entries()]; + let result: IrExpr = { + kind: "strLit", + value: ordered[ordered.length - 1]![0], + type: STRING, + loc, + }; + for (const [answer, tags] of ordered.slice(0, -1).reverse()) { + let test: IrExpr = { + kind: "unionIsTag", + unionId: operand.type.unionId, + tag: tags[0]!, + negated: false, + value: operand, + type: BOOL, + loc, + }; + for (const t of tags.slice(1)) { + test = { + kind: "logical", + op: "||", + left: test, + right: { + kind: "unionIsTag", + unionId: operand.type.unionId, + tag: t, + negated: false, + value: operand, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; + } + result = { + kind: "ternary", + cond: test, + then: { kind: "strLit", value: answer, type: STRING, loc }, + else_: result, + type: STRING, + loc, + }; + } + return prelude.length > 0 + ? { kind: "seqExpr", stmts: prelude, result, type: STRING, loc } + : result; + } + } + if (operand.type.kind === "dyn") { + // Bare typeof on a dyn value: the runtime's kind→string table + // (null answers "object", boxed closures "function" — JS-exact + // for every dyn kind; "bigint"/"symbol" have no producers). + return { kind: "libCall", fn: "dyn.typeof", args: [operand], type: STRING, loc }; + } + lowerer.unsupported("SC1090", expr, "typeof expressions on statically-typed values"); +} + +/** `typeof e.code === "string"` / `"undefined"` (and the `!==` forms) + * where e — through parens and as-casts — is a catch binding or a value + * of %Error-rooted type: the runtime error's code slot is a string + * exactly when present, so the typeof test IS the presence test (the + * isErrnoException predicate's third conjunct, + * `typeof (err as Record).code === "string"`). The + * as-cast changes the expression's TYPE, never the value — peeled like + * lowerProcessStreamProperty's receiver match. Null for every other + * shape, so the sibling typeof lowerings keep trying. */ +export function lowerErrorCodeTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a)) continue; + let prop: ts.Expression = a.expression; + while (ts.isParenthesizedExpression(prop)) prop = prop.expression; + if (!ts.isPropertyAccessExpression(prop) || prop.questionDotToken) continue; + if (prop.name.text !== "code") continue; + let recv: ts.Expression = prop.expression; + while ( + ts.isParenthesizedExpression(recv) || + ts.isAsExpression(recv) || + ts.isTypeAssertion(recv) + ) + recv = recv.expression; + const errT = errorRootedObjectOf(lowerer, lowerer.typeOf(recv)); + if (!errT) continue; + if (isExecExceptionType(lowerer, lowerer.typeOf(recv))) refuseExecExceptionCode(lowerer, prop); + const caught = lowerer.caughtLocalOf(recv); + let receiver: IrExpr; + if (caught) { + // The checker proved the Error narrowing (errT above), so the + // trusted extraction is sound — caughtRead itself would fence on + // the intersection spelling. + receiver = { + kind: "caughtNarrow", + value: { kind: "varRef", localId: caught.id, type: CAUGHT, loc }, + type: errT, + loc, + }; + } else { + receiver = lowerer.lowerExpr(recv); + if (receiver.type.kind !== "object") continue; + } + if (!ts.isStringLiteral(b)) continue; + if (b.text !== "string" && b.text !== "undefined") continue; + const codeType = lowerer.envValueType(); + if (codeType.kind !== "union") + throw new InternalCompilerError("lowerer bug: error code type is not a union"); + const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T); + const read: IrExpr = { + kind: "libCall", + fn: "error.code", + args: [receiver], + type: codeType, + loc, + }; + // typeof === "string" ⇔ the code slot is present (NOT the undefined + // arm); === "undefined" ⇔ absent. `!==` flips either. + const isNotUndef = b.text === "string" ? !negated : negated; + return { + kind: "unionIsTag", + unionId: codeType.unionId, + tag: undefTag, + ...(isNotUndef ? { negated: true as const } : {}), + value: read, + type: BOOL, + loc, + }; + } + return null; +} + +/** `typeof v === "lit"` / `typeof v !== "lit"` where v is `unknown`-typed + * (dyn): the runtime kind test over the dyn node's tag. Only the kinds a + * later read can bridge narrow ("string"/"number"/"boolean") plus + * "undefined" (no payload to read); "object"/"function" tests keep the + * fence — an object-narrowed `unknown` read has no lowering anyway (a + * checked cast `v as T` is the supported extraction). Null when neither + * side is a typeof over a dyn-typed operand (not this pattern). */ +export function lowerDynTypeofTest( + lowerer: Lowerer, + expr: ts.BinaryExpression, + loc: SrcLoc, +): IrExpr | null { + const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; + for (const [a, b] of operandPairs(expr)) { + if (!ts.isTypeOfExpression(a)) continue; + if (lowerer.isStdlibGlobal(a.expression, "fetch")) continue; + // Non-literal comparisons use the ordinary typeof string value, + // preserving both operands' evaluation order without a narrowing test. + if (!ts.isStringLiteral(b)) continue; + const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression)); + let value: IrExpr; + if (declared?.kind === "dyn") { + value = lowerer.lowerExpr(a.expression); + if (value.type.kind !== "dyn") continue; // defensive: an already-narrowed use + } else if (lowerer.caughtLocalOf(a.expression)) { + continue; // the caught lowering's territory (it ran first and declined) + } else { + // `typeof (value as T).field === "string"` — the raw-object + // validation idiom (routes.ts's isValidRoute): the `as` is erasure + // in JS, so the READ is the dyn KEYED read of the un-cast value — + // a missing key answers undefined and the guard goes false, + // Node-exact — never the checked cast (validating T here would + // throw on exactly the values the guard exists to reject). + const unparen = (e: ts.Expression): ts.Expression => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; + }; + let keyed: IrExpr | null = null; + const pn = unparen(a.expression); + if (ts.isPropertyAccessExpression(pn) && !pn.questionDotToken && ts.isIdentifier(pn.name)) { + const recv = unparen(pn.expression); + if (ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) { + const inner = unparen(recv.expression); + const obj = tryLowerExpression(lowerer, inner); + if (obj?.type.kind === "dyn") { + keyed = { + kind: "dynKeyGet", + key: { kind: "strLit", value: pn.name.text, type: STRING, loc }, + value: obj, + type: DYN, + loc, + }; + } + } + } + if (keyed) { + value = keyed; + } else { + // A narrowing residue of `unknown`: a `!== undefined` (or truthy) + // guard ahead of the typeof test spells the operand `{}`/`{} | + // null` — no useful static mapping, but the READ is still the dyn + // value (`if (obj.name !== undefined) { if (typeof obj.name !== + // "string") ... }`, the validateConfig idiom). Probe the lowering + // and claim exactly the dyn results; anything else keeps its + // sibling lowerings and fences untouched. + const probed = tryLowerExpression(lowerer, a.expression); + if (probed?.type.kind !== "dyn") continue; + value = probed; + } + } + if ( + b.text === "string" || + b.text === "number" || + b.text === "boolean" || + b.text === "undefined" || + b.text === "bigint" || + b.text === "symbol" + ) { + return { + kind: "dynTest", + test: b.text, + ...(negated ? { negated: true as const } : {}), + value, + type: BOOL, + loc, + }; + } + // `typeof v === "object"`: the dyn answers exactly (object, array, + // bytes, and null kinds — JS's oldest wart preserved). tsc narrows + // the branch to `object | null`; reads past the narrow take the + // checked-cast path per site. + if (b.text === "object") { + return { + kind: "dynTest", + test: "object", + ...(negated ? { negated: true as const } : {}), + value, + type: BOOL, + loc, + }; + } + // `typeof v === "function"`: a REAL runtime test since the checked-dynamic tree's + // function kind exists (boxed closures — the mustCall guard + // `if (typeof fn !== 'function') throw` answers honestly). + if (b.text === "function") { + return { + kind: "dynTest", + test: "function", + ...(negated ? { negated: true as const } : {}), + value, + type: BOOL, + loc, + }; + } + // Native checked values do not yet carry symbols. + if (b.text === "symbol") { + return { kind: "boolLit", value: negated, type: BOOL, loc }; + } + lowerer.unsupported( + "SC1090", + b, + `'typeof' tests against "${b.text}" on 'unknown' values (only "string"/"number"/` + + `"boolean"/"undefined"/"object" narrow; use a checked cast — 'v as T' — to extract)`, + ); + } + return null; +} + +/** The `declare module "..."` body (or nested namespace body) a module + * export's declaration sits directly in, or undefined. */ +function builtinModuleExportParent(decl: ts.Node): ts.ModuleBlock | undefined { + const parent = ts.isVariableDeclaration(decl) ? decl.parent?.parent?.parent : decl.parent; + return parent !== undefined && ts.isModuleBlock(parent) ? parent : undefined; +} + +/** A function, class or variable exported by a builtin module's body. */ +function isBuiltinModuleValueDecl(decl: ts.Node): boolean { + return ( + builtinModuleExportParent(decl) !== undefined && + (ts.isFunctionDeclaration(decl) || + ts.isClassDeclaration(decl) || + ts.isVariableDeclaration(decl)) + ); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts new file mode 100644 index 000000000..61943eff4 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -0,0 +1,955 @@ +/* Increment, decrement and prefix unary operators, and compound field updates. */ +import * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; +import { + type FieldTarget, + bigintCompoundValue, + compoundUnsupported, + concatOperand, + deferredSlotOr, + dynCompoundValue, + lowerOptionalNumber, + operatorUnsupported, + runtimeOptionalStorageOperand, + symbolFieldTarget, +} from "../lower-exprs.js"; +import { + BIGINT_T, + BOOL, + DYN, + F64, + type IrExpr, + type IrStmt, + type IrType, + STRING, + type SrcLoc, + UNDEFINED_T, + VOID, +} from "../../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../../program.js"; +import { classToNumberConversion } from "../class-to-primitive.js"; +import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js"; +import { fenceNodeModuleMutation } from "../lower-node-module.js"; +import { staticFieldWriteTarget } from "../lower-classes.js"; +import { expandoWritableTarget } from "../lower-expando.js"; +import { nsWritableTarget } from "../lower-namespaces.js"; +import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-values.js"; +import { isSafeToRepeat } from "./evaluation-safety.js"; +import { errorPropertyWrite } from "../error-methods.js"; +import type { CompoundOp } from "../surfaces.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { hasRuntimeStatics } from "../class-runtime-statics.js"; +import { declareContextLocal } from "../function-context.js"; + +/** Read-modify-write updates read the member first: an unassigned + * deferred-init receiver throws Node's member-read TypeError. */ +function checkDeferredMemberReceiver( + lowerer: Lowerer, + target: FieldTarget, + access: ts.Expression, +): void { + if ( + target.container !== "class" || + !ts.isPropertyAccessExpression(access) || + target.obj.type.kind !== "object" || + lowerer.deferredFieldSlot(target.obj) === null + ) + return; + target.obj = + lowerer.runtimeOptionalPropertyReceiver( + access.expression, + target.obj, + target.obj.type, + target.field, + ) ?? target.obj; +} + +/** Whether evaluating `expr` cannot store anything or run other code: + * reads, arithmetic, tests and checked extractions (which at most throw). */ +function writeFree(expr: IrExpr): boolean { + switch (expr.kind) { + case "numLit": + case "strLit": + case "boolLit": + case "unitLit": + case "varRef": + return true; + case "fieldGet": + case "recordGet": + return writeFree(expr.obj); + case "bin": + case "logical": + return writeFree(expr.left) && writeFree(expr.right); + case "unary": + case "toBool": + return writeFree(expr.operand); + case "ternary": + return writeFree(expr.cond) && writeFree(expr.then) && writeFree(expr.else_); + case "unionIsTag": + case "unionNarrow": + case "unionWrap": + return writeFree(expr.value); + case "libCall": + return expr.fn === "error.nodeThrow"; + default: + return false; + } +} + +export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpression): IrExpr { + const loc = locOf(expr); + switch (expr.operator) { + case ts.SyntaxKind.MinusToken: { + const raw = lowerer.lowerExpr(expr.operand); + + if (raw.type.kind === "bigint") { + return { kind: "libCall", fn: "bigint.neg", args: [raw], type: BIGINT_T, loc }; + } + // Match the checked numeric boundary used by JS binary arithmetic. + const operand = + raw.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) + ? { kind: "dynCheck" as const, value: raw, type: F64, loc } + : lowerOptionalNumber(lowerer, raw, loc, expr.operand); + if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]); + if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc }; + return { kind: "unary", op: "-", operand, type: F64, loc }; + } + case ts.SyntaxKind.PlusToken: { + const raw = lowerer.lowerExpr(expr.operand); + if (raw.type.kind === "dyn") { + return { kind: "libCall", fn: "dyn.toNumberCoerce", args: [raw], type: F64, loc }; + } + // Unary + is ToNumber; on an already-number operand it's identity, + // and a STRING operand runs the runtime's ECMA-exact StringToNumber + // (num.fromString — Number(aString)'s lowering, scr_string.c). + + if (raw.type.kind === "string") { + return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc }; + } + const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); + if (classNumber) return classNumber; + const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); + if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]); + return operand; + } + case ts.SyntaxKind.ExclamationToken: { + // `!x` is ToBoolean-then-negate: f64/string operands go through toBool. + const operand = lowerer.ensureBool( + deferredSlotOr( + lowerer, + runtimeOptionalStorageOperand(lowerer, expr.operand) ?? lowerer.lowerExpr(expr.operand), + ), + expr.operand, + ); + return { kind: "unary", op: "!", operand, type: BOOL, loc }; + } + case ts.SyntaxKind.TildeToken: { + // `~x`: ToInt32, complement, back to f64 (JS-exact, incl. NaN → -1). + const raw = lowerer.lowerExpr(expr.operand); + if (raw.type.kind === "bigint") { + return { kind: "libCall", fn: "bigint.not", args: [raw], type: BIGINT_T, loc }; + } + if (raw.type.kind === "dyn") + return { + kind: "libCall", + fn: "dyn.bitwise", + args: [raw, dynUndefinedExpr(loc), { kind: "strLit", value: "~", type: STRING, loc }], + type: DYN, + loc, + }; + // A checked-dynamic value retains enough JavaScript structure to + // run exact ToNumber, including the object valueOf/toString + // protocol. This is the static `any`/JS-lane answer for idioms + // such as `while (~index)`, where inference may have widened an + // otherwise numeric slot to the checked-dynamic tree. + const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); + if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "~", [raw.type]); + return { kind: "unary", op: "~", operand, type: F64, loc }; + } + case ts.SyntaxKind.PlusPlusToken: + case ts.SyntaxKind.MinusMinusToken: + // `++x` / `--x` in expression position: yields the NEW value. + return lowerIncDec(lowerer, expr, true); + } + lowerer.unsupported("SC1090", expr, `syntax '${ts.syntaxKindName(expr.kind)}'`); +} + +/** Member updates evaluate the reference once, convert its old value with + * ToNumeric, store the new value, then yield the old or new numeric value. */ +export function lowerIncDec( + lowerer: Lowerer, + expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression, + prefix: boolean, +): IrExpr { + const loc = locOf(expr); + const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; + if (!ts.isIdentifier(expr.operand)) { + const access = expr.operand; + if ( + (!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) || + access.questionDotToken + ) { + lowerer.unsupported("SC1045", expr, "increment/decrement of this target"); + } + fenceNodeModuleMutation(lowerer, access, "assignment"); + if (ts.isPropertyAccessExpression(access)) { + const target = + staticFieldWriteTarget(lowerer, access) ?? + expandoWritableTarget(lowerer, access) ?? + nsWritableTarget(lowerer, access); + if (target) { + return lowerIncDecTarget(lowerer, expr, target, prefix); + } + } + const stmts: IrStmt[] = []; + const save = (value: IrExpr, name: string): IrExpr => { + const local = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); + return varRef(local.id, value.type, loc); + }; + const finish = (read: IrExpr, write: (value: IrExpr) => IrStmt): IrExpr => { + if (read.type.kind === "bigint") { + const old = save(read, "%incrementOld"); + const next = save(bigintStep(op, old, loc), "%incrementNext"); + stmts.push(write(next)); + const result = prefix ? next : old; + return { kind: "seqExpr", stmts, result, type: result.type, loc }; + } + const old = save( + read.type.kind === "f64" + ? read + : { + kind: "libCall", + fn: "dyn.toNumeric", + args: [lowerer.coerceInto(access, read, DYN)], + type: DYN, + loc, + }, + "%incrementOld", + ); + const next = save( + old.type.kind === "f64" + ? { + kind: "bin", + op, + left: old, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + } + : { + kind: "libCall", + fn: "dyn.increment", + args: [old, { kind: "boolLit", value: op === "+", type: BOOL, loc }], + type: DYN, + loc, + }, + "%incrementNext", + ); + stmts.push(write(next)); + const result = prefix ? next : old; + return { kind: "seqExpr", stmts, result, type: result.type, loc }; + }; + const raw = lowerer.lowerExpr(access.expression); + // Keep native array/byte storage: boxing a native array can create a + // checked view, which is not a substitute for updating its slot. + if ( + ts.isElementAccessExpression(access) && + (raw.type.kind === "array" || raw.type.kind === "bytes") + ) { + const receiver = save(raw, "%incrementArray"); + const rawIndex = lowerer.lowerExpr(access.argumentExpression); + const index = save( + rawIndex.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) + ? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc } + : rawIndex, + "%incrementIndex", + ); + if (index.type.kind !== "f64") + lowerer.unsupported("SC1090", access, "incrementing non-number array keys"); + const type = raw.type; + const read: IrExpr = + type.kind === "array" + ? type.elem.kind === "f64" + ? arrayNumberRead(receiver, index, loc) + : arrayValueRead(lowerer, receiver, index, type.elem, loc) + : { + kind: "bytesIntrinsic", + method: "get", + receiver, + args: [index], + type: F64, + loc, + invalidNaN: true, + }; + return finish(read, (value) => + type.kind === "array" + ? arrayValueStore( + lowerer, + receiver, + index, + lowerer.coerceInto(access, value, type.elem), + type.elem, + loc, + ) + : { + kind: "bytesSet", + arr: receiver, + index, + value: lowerer.coerceInto(access, value, F64), + loc, + }, + ); + } + // A TS member statically typed number on a checked-dynamic receiver (a + // class-backed protocol view): the expanded form `o.v = o.v + 1`, its + // read checked into number like any other read of the member. + const checkedNumber = + !isJsSourceFile(expr.getSourceFile()) && + raw.type.kind === "dyn" && + lowerer.mapTypeOf(lowerer.typeOf(access))?.kind === "f64"; + if ((isJsSourceFile(expr.getSourceFile()) || checkedNumber) && raw.type.kind === "dyn") { + const receiver = save(raw, "%incrementReceiver"); + const key = save( + ts.isElementAccessExpression(access) + ? lowerer.lowerExprExpecting(access.argumentExpression, DYN) + : lowerer.coerceInto( + access, + { kind: "strLit", value: access.name.text, type: STRING, loc }, + DYN, + ), + "%incrementKey", + ); + const read: IrExpr = { kind: "dynKeyGet", value: receiver, key, type: DYN, loc }; + return finish(checkedNumber ? lowerer.coerceInto(access, read, F64) : read, (value) => ({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySetComputed", + args: [receiver, key, lowerer.coerceInto(access, value, DYN)], + type: VOID, + loc, + }, + loc, + })); + } + const target = ts.isPropertyAccessExpression(access) + ? lowerer.fieldTarget(access) + : symbolFieldTarget(lowerer, access); + if (target && lowerer.dynConvertible(target.fieldType)) { + checkDeferredMemberReceiver(lowerer, target, access); + target.obj = save(target.obj, "%incrementReceiver"); + return finish(lowerer.fieldGetExpr(target, loc, access), (value) => + lowerer.fieldSetStmt( + target, + lowerer.coerceInto(access, value, target.fieldType), + loc, + access, + ), + ); + } + if (isJsSourceFile(expr.getSourceFile()) && lowerer.dynConvertible(raw.type)) { + const receiver = save(lowerer.coerceInto(access.expression, raw, DYN), "%incrementReceiver"); + const key = save( + ts.isElementAccessExpression(access) + ? lowerer.lowerExprExpecting(access.argumentExpression, DYN) + : lowerer.coerceInto( + access, + { kind: "strLit", value: access.name.text, type: STRING, loc }, + DYN, + ), + "%incrementKey", + ); + return finish({ kind: "dynKeyGet", value: receiver, key, type: DYN, loc }, (value) => ({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySetComputed", + args: [receiver, key, lowerer.coerceInto(access, value, DYN)], + type: VOID, + loc, + }, + loc, + })); + } + lowerer.unsupported("SC1045", expr, "increment/decrement of this member target"); + } + const target = lowerer.resolveWritable(expr.operand); + if (!target) { + lowerer.rejectUnresolved( + expr.operand, + `increment/decrement of '${expr.operand.text}' (not a writable local or module global)`, + ); + } + return lowerIncDecTarget(lowerer, expr, target, prefix); +} + +/** Numeric updates read the represented slot once and write the computed + * value back in that same representation. In particular, an unchecked + * indexed initializer can leave undefined in an otherwise numeric binding. */ +export function lowerIncDecTarget( + lowerer: Lowerer, + expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression, + target: { id: string; type: IrType }, + prefix: boolean, +): IrExpr { + const loc = locOf(expr); + const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; + if (target.type.kind === "dyn") { + const old = lowerer.declareHiddenLocal("%numericOld", DYN); + const next = lowerer.declareHiddenLocal("%numericNext", DYN); + const read = varRef(target.id, target.type, loc); + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: old.id, + init: { kind: "libCall", fn: "dyn.toNumeric", args: [read], type: DYN, loc }, + loc, + }, + { + kind: "varDecl", + localId: next.id, + init: { + kind: "libCall", + fn: "dyn.increment", + args: [ + varRef(old.id, DYN, loc), + { kind: "boolLit", value: op === "+", type: BOOL, loc }, + ], + type: DYN, + loc, + }, + loc, + }, + { kind: "assign", localId: target.id, value: varRef(next.id, DYN, loc), loc }, + ], + result: varRef(prefix ? next.id : old.id, DYN, loc), + type: DYN, + loc, + }; + } + if (target.type.kind === "union") { + const read = lowerOptionalNumber( + lowerer, + varRef(target.id, target.type, loc), + loc, + expr.operand, + ); + if (read.type.kind === "f64") { + const old = lowerer.declareHiddenLocal("%numericOld", F64); + const next = lowerer.declareHiddenLocal("%numericNext", F64); + const result = varRef(prefix ? next.id : old.id, F64, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: old.id, init: read, loc }, + { + kind: "varDecl", + localId: next.id, + init: { + kind: "bin", + op, + left: varRef(old.id, F64, loc), + right: numLit(1, loc), + type: F64, + loc, + }, + loc, + }, + { + kind: "assign", + localId: target.id, + value: lowerer.coerceInto(expr.operand, varRef(next.id, F64, loc), target.type), + loc, + }, + ], + result, + type: F64, + loc, + }; + } + } + if (target.type.kind === "bigint") { + const old = lowerer.declareHiddenLocal("%bigintOld", BIGINT_T); + const next = lowerer.declareHiddenLocal("%bigintNext", BIGINT_T); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: old.id, init: varRef(target.id, BIGINT_T, loc), loc }, + { + kind: "varDecl", + localId: next.id, + init: bigintStep(op, varRef(old.id, BIGINT_T, loc), loc), + loc, + }, + { kind: "assign", localId: target.id, value: varRef(next.id, BIGINT_T, loc), loc }, + ], + result: varRef(prefix ? next.id : old.id, BIGINT_T, loc), + type: BIGINT_T, + loc, + }; + } + if (target.type.kind !== "f64") + operatorUnsupported(lowerer, expr, op === "+" ? "++" : "--", [target.type]); + return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc }; +} + +/** `++`/`--` on a bigint value: old ± 1n. */ +function bigintStep(op: "+" | "-", old: IrExpr, loc: SrcLoc): IrExpr { + const one: IrExpr = { + kind: "libCall", + fn: "bigint.parse", + args: [{ kind: "strLit", value: "1", type: STRING, loc }], + type: BIGINT_T, + loc, + }; + return { + kind: "libCall", + fn: op === "+" ? "bigint.add" : "bigint.sub", + args: [old, one], + type: BIGINT_T, + loc, + }; +} + +/** The write statement for a field target (fieldSet / recordSet / setter + * call). A write to a getter-only property never gets here in a clean + * program (tsc's TS2540 is the fence); the rejection is the backstop. */ +export function fieldSetStmt( + lowerer: Lowerer, + target: FieldTarget, + value: IrExpr, + loc: SrcLoc, + blame: ts.Node, +): IrStmt { + // A member write through an unassigned deferred-init field: the value + // still evaluates first, then the write throws Node's TypeError. + const deferredSlot = + target.container === "class" && + !( + (target.field === "message" || target.field === "name") && + (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) + ) + ? lowerer.deferredFieldSlot(target.obj) + : null; + if (target.container === "class" && deferredSlot?.type.kind === "union") { + const unionId = deferredSlot.type.unionId; + const undefTag = lowerer.armTag(unionId, UNDEFINED_T); + const presentTag = lowerer.armTag(unionId, target.obj.type); + if (undefTag >= 0 && presentTag >= 0) { + // A value that cannot write anything leaves the field unchanged, so + // the field is read after it (no snapshot of the slot). + const late = isSafeToRepeat(deferredSlot) && writeFree(value); + const receiver = late + ? null + : lowerer.declareHiddenLocal("%deferredWriteObject", deferredSlot.type); + const stored = lowerer.declareHiddenLocal("%deferredWriteValue", value.type); + const receiverRef = receiver ? varRef(receiver.id, receiver.type, loc) : deferredSlot; + const result = varRef(stored.id, stored.type, loc); + const write: IrStmt = { + kind: "fieldSet", + obj: { + kind: "unionNarrow", + unionId, + tag: presentTag, + value: receiverRef, + type: target.obj.type, + loc, + }, + className: target.className, + field: target.field, + value: result, + loc, + }; + return { + kind: "exprStmt", + expr: { + kind: "seqExpr", + stmts: [ + ...(receiver + ? [{ kind: "varDecl" as const, localId: receiver.id, init: deferredSlot, loc }] + : []), + { kind: "varDecl", localId: stored.id, init: value, loc }, + ], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag: undefTag, + value: receiverRef, + negated: false, + type: BOOL, + loc, + }, + then: nodeThrowExpr( + 1, + "", + `Cannot set properties of undefined (setting '${target.field}')`, + result.type, + loc, + ), + else_: { kind: "seqExpr", stmts: [write], result, type: result.type, loc }, + type: result.type, + loc, + }, + type: result.type, + loc, + }, + loc, + }; + } + } + if (target.container === "dynamic") + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [ + target.obj, + { kind: "strLit", value: target.field, type: STRING, loc }, + lowerer.coerceToExpected(value, DYN), + ], + type: VOID, + loc, + }, + loc, + }; + if ( + target.container === "class" && + (target.field === "message" || target.field === "name") && + (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) + ) + return errorPropertyWrite(lowerer, target.obj, value, target.field); + if (target.container === "errorStackLimit") { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "error.stackLimitSet", args: [value], type: VOID, loc }, + loc, + }; + } + if (target.container === "errorCause") { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "error.setCause", args: [target.obj, value], type: VOID, loc }, + loc, + }; + } + // A record-shaped CHECKER target whose receiver VALUE lives in the checked-dynamic tree: + // the checked-dynamic keyed write — dyn.keySet (later writes win, + // insertion order; Node's TypeErrors on non-object receivers), the + // value converting into the checked-dynamic tree. + if ( + (target.container === "record" || target.container === "recordOvf") && + target.obj.type.kind === "dyn" + ) { + const v = lowerer.coerceToExpected(value, DYN); + if (v.type.kind !== "dyn") { + lowerer.unsupported( + "SC1101", + blame, + `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, + ); + } + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [target.obj, { kind: "strLit", value: target.field, type: STRING, loc }, v], + type: VOID, + loc, + }, + loc, + }; + } + if (target.container === "accessor") { + const setF = lowerer.findMethodOn( + lowerer.classes.get(target.className) ?? null, + `set:${target.field}`, + ); + if (!setF) + lowerer.unsupported( + "SC1090", + blame, + `assignment to the getter-only property '${target.field}'`, + ); + return { + kind: "exprStmt", + expr: lowerer.accessorCall( + target.className, + `set:${target.field}`, + target.obj, + [value], + VOID, + loc, + ), + loc, + }; + } + // Record accessor properties: the write calls the %set: closure with + // the coerced value. A getter-only write never gets here in a clean + // program (tsc's TS2540); the rejection is the backstop. + if (target.container === "recordAccessor") { + if (!target.setType) { + lowerer.unsupported( + "SC1090", + blame, + `assignment to the getter-only property '${target.field}'`, + ); + } + const closure: IrExpr = { + kind: "recordGet", + obj: target.obj, + shapeId: target.shapeId, + field: `%set:${target.field}`, + type: target.setType, + loc, + }; + return { + kind: "exprStmt", + expr: { kind: "callValue", callee: closure, args: [value], type: VOID, loc }, + loc, + }; + } + // Overflow dot writes: the bracket write with a literal key — a pure + // overflow insert (recordKeySet, overflowOnly: the name declares no + // field, so no declared collision exists to validate). The value was + // coerced into the index-value slot type by the caller. + if (target.container === "recordOvf") { + return { + kind: "recordKeySet", + obj: target.obj, + shapeId: target.shapeId, + key: { kind: "strLit", value: target.field, type: STRING, loc }, + value, + overflowOnly: true, + loc, + }; + } + return target.container === "class" + ? { + kind: "fieldSet", + obj: target.obj, + className: target.className, + field: target.field, + value, + loc, + } + : { + kind: "recordSet", + obj: target.obj, + shapeId: target.shapeId, + field: target.field, + value, + loc, + }; +} + +export function lowerFieldCompoundValue( + lowerer: Lowerer, + access: ts.PropertyAccessExpression | ts.ElementAccessExpression, + op: CompoundOp, + rhsNode: ts.Expression | null, + loc: SrcLoc, +): IrExpr { + if (access.expression.kind === ts.SyntaxKind.SuperKeyword) { + lowerer.unsupported( + "SC1090", + access, + "compound assignment through 'super' (read and write separately)", + ); + } + const body: IrStmt[] = []; + const save = (value: IrExpr, name: string): IrExpr => { + const local = lowerer.declareHiddenLocal(name, value.type); + body.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); + return varRef(local.id, value.type, value.loc); + }; + // A CHECKED-DYNAMIC receiver (`context.actual++` — test/common's call + // accounting; dot spelling only — symbol-keyed element targets are + // static fields): read the member (dynKeyGet), VALIDATE it as a + // number (dynCheck — a non-number member throws the catchable + // TypeError where JS would ToNumber-coerce; it throws instead of + // producing NaN — SEMANTICS.md), combine, write back (dyn.keySet). The + // receiver and member read are saved before evaluating the RHS. + if (ts.isPropertyAccessExpression(access)) { + let probed = tryLowerExpression(lowerer, access.expression); + const staticClass = + probed?.type.kind === "classval" ? lowerer.classes.get(probed.type.className) : undefined; + if (probed && staticClass && hasRuntimeStatics(staticClass)) + probed = lowerer.coerceToExpected(probed, DYN); + if (probed?.type.kind === "dyn") { + const receiver = save(probed, "%compoundReceiver"); + const key: IrExpr = { + kind: "strLit", + value: access.name.text, + type: STRING, + loc: locOf(access.name), + }; + const read = save( + { kind: "dynKeyGet", key, value: receiver, type: DYN, loc }, + "%compoundOld", + ); + const cur: IrExpr = { kind: "dynCheck", value: read, type: F64, loc }; + const rhs = save( + rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc }, + "%compoundRhs", + ); + if (isJsSourceFile(access.getSourceFile()) && lowerer.dynConvertible(rhs.type)) { + const numeric = ["-", "*", "/", "%", "**"].includes(op); + const right = lowerer.coerceToExpected(rhs, DYN); + const value = save( + { + kind: "libCall", + fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise", + args: + op === "+" + ? [read, right] + : [read, right, { kind: "strLit", value: op, type: STRING, loc }], + type: DYN, + loc, + }, + "%compoundResult", + ); + body.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [receiver, key, value], + type: VOID, + loc, + }, + loc, + }); + return { kind: "seqExpr", stmts: body, result: value, type: DYN, loc }; + } + const numericRhs: IrExpr = + rhs.type.kind === "dyn" + ? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc } + : lowerOptionalNumber(lowerer, rhs, loc); + if (numericRhs.type.kind !== "f64") + compoundUnsupported(lowerer, access, op, F64, numericRhs.type); + const value = save( + { kind: "bin", op, left: cur, right: numericRhs, type: F64, loc }, + "%compoundResult", + ); + const boxed: IrExpr = { kind: "dynFrom", value, type: DYN, loc }; + body.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.keySet", + args: [receiver, { ...key }, boxed], + type: VOID, + loc, + }, + loc, + }); + return { kind: "seqExpr", stmts: body, result: value, type: value.type, loc }; + } + } + const targetOf = (): FieldTarget | null => + ts.isPropertyAccessExpression(access) + ? lowerer.fieldTarget(access) + : symbolFieldTarget(lowerer, access); + const target = targetOf(); + if (!target) + lowerer.unsupported("SC1090", access, "compound assignment to unsupported field targets"); + checkDeferredMemberReceiver(lowerer, target, access); + target.obj = save(target.obj, "%compoundReceiver"); + // Accessors observe getter, RHS side effects, then setter, all through + // the saved receiver. + const fieldRead = lowerer.fieldGetExpr(target, locOf(access), access); + // `+=` on a stored string field reads it into a mutable local, so the + // concat below can append to that local in place. + const storedString = + op === "+" && + fieldRead.type.kind === "string" && + target.fieldType.kind === "string" && + (target.container === "class" || target.container === "record"); + let read: IrExpr; + if (storedString) { + const old = declareContextLocal( + lowerer.ctx, + "%compoundOld", + STRING, + true, + undefined, + undefined, + ); + body.push({ kind: "varDecl", localId: old.id, init: fieldRead, loc: fieldRead.loc }); + read = varRef(old.id, STRING, fieldRead.loc); + } else { + read = save(fieldRead, "%compoundOld"); + } + const rhs = save( + rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc }, + "%compoundRhs", + ); + const numericRhs: IrExpr = + rhs.type.kind === "dyn" && isJsSourceFile(access.getSourceFile()) + ? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc } + : lowerOptionalNumber(lowerer, rhs, loc); + let value: IrExpr; + let appended: IrExpr | null = null; + const numericRead = lowerOptionalNumber(lowerer, read, loc, access); + const bigintValue = rhsNode ? bigintCompoundValue(op, read, rhs, loc) : null; + if (op === "+" && read.type.kind === "string") { + appended = concatOperand(lowerer, rhs, rhsNode ?? access); + value = { kind: "strConcat", left: read, right: appended, type: STRING, loc }; + } else if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { + value = { kind: "bin", op, left: numericRead, right: numericRhs, type: F64, loc }; + } else if (bigintValue) { + value = bigintValue; + } else if ( + (target.fieldType.kind === "dyn" && isJsSourceFile(access.getSourceFile())) || + // An untyped right operand: the expanded `o.f = o.f op rhs`; the field + // write below checks the result into the declared field type. + (rhs.type.kind === "dyn" && + lowerer.dynConvertible(read.type) && + lowerer.dynConvertible(target.fieldType)) + ) { + value = dynCompoundValue( + op, + lowerer.coerceToExpected(read, DYN), + lowerer.coerceToExpected(rhs, DYN), + loc, + ); + } else { + compoundUnsupported(lowerer, access, op, read.type, rhs.type); + } + if (storedString && rhs.type.kind === "string" && read.kind === "varRef" && appended !== null) { + // The field gives up its value before the concat (no code runs in + // between: both operands are already strings), and the concat is the + // self-append `old = old + rhs`, which hands the local's reference to + // the runtime: the old string is then uniquely owned unless something + // else holds it, and is appended to in place. Repeated `this.text += s` + // is linear rather than a copy per append. + const oldId = read.localId; + const oldLoc = read.loc; + const oldRef = (): IrExpr => varRef(oldId, STRING, oldLoc); + const concat: IrExpr = { + kind: "strConcat", + left: oldRef(), + right: appended, + type: STRING, + loc, + }; + body.push( + lowerer.fieldSetStmt(target, { kind: "strLit", value: "", type: STRING, loc }, loc, access), + { kind: "assign", localId: oldId, value: concat, loc }, + lowerer.fieldSetStmt(target, oldRef(), loc, access), + ); + return { kind: "seqExpr", stmts: body, result: oldRef(), type: STRING, loc }; + } + const result = save(value, "%compoundResult"); + body.push( + lowerer.fieldSetStmt(target, lowerer.coerceInto(access, result, target.fieldType), loc, access), + ); + return { kind: "seqExpr", stmts: body, result, type: result.type, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 2a939d748..ac51cfbe6 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,94 +1,23 @@ -import { dateJsonType, isDateJsonSource } from "./builtins/dates.js"; -import { guardNullProto, nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js"; -import { lowerProtocolPropertyRead } from "./class-protocol-dispatch.js"; -import { generatorDrain } from "./iterator-adapters.js"; -import { lowerEventObjectProperty, lowerEventObjectPropertyWrite } from "./lower-event-target.js"; -import { lowerDisposableStackInstanceOf } from "./builtins/disposable-stack.js"; -import { lowerEventStaticProperty } from "./builtins/numbers.js"; -import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; -import { isOptionalProcessStreamProperty, NODE_COMPAT_VERSION } from "./builtins/process.js"; -import { lowerWorkerMetadata } from "./builtins/workers.js"; -import { esmImportBindingKind } from "./builtins/module-bindings.js"; -import { lowerReadlineProperty } from "./builtins/readline.js"; -import { lowerBlobInstanceof } from "./builtins/blob.js"; -import { lowerWebAssemblyTypeof, refuseWebAssemblyUse } from "./builtins/webassembly.js"; -import { lowerKeyObjectInstanceof } from "./builtins/key-object.js"; -import { lowerCryptoKeyInstanceof } from "./builtins/webcrypto.js"; -import { lowerNetAddressInstanceof, lowerNetAddressStaticRead } from "./builtins/net-address.js"; -import { isExecExceptionType, refuseExecExceptionCode } from "./builtins/errors.js"; -import { STATS_TYPE_TESTS } from "./builtins/filesystem.js"; -import { threadsImportOf } from "../threads-import.js"; -import { experimentalImportOf, lifetimeRegionMemberOf } from "../experimental-import.js"; -import { lowerInternTableProperty } from "./intern-tables.js"; -import { lowerThreadSlotsProperty } from "./thread-slots.js"; +import { refuseWebAssemblyUse } from "./builtins/webassembly.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; -import { SYMBOL_T } from "../../ir/ir.js"; -import { literalValues } from "../literal-values.js"; -import { literalUnionArm } from "../union-discriminants.js"; /* Expression lowering: the expression dispatch (lowerExpr), literals * (array/object/regex/template), operators (binary incl. compound targets, * unary, instanceof, caught-typeof tests), union narrowing and unit * comparisons, nullish coalescing and optional chains, conditions and * ToBoolean/ToString coercion helpers, and field/element reads and writes * (FieldTarget). */ -import { isFsStreamMember, lowerFsStreamProperty } from "./builtins/fs-streams.js"; import * as ts from "../ts7/adapter.js"; -import { builtinFunctionFenceValue } from "./builtin-function-fences.js"; -import { - isZlibStreamMember, - lowerZlibInstanceOf, - lowerZlibStreamBytesWritten, -} from "./builtins/compression.js"; -import { - runtimeClassValueOf, - runtimeClassValueOfType, - builtinClassAliasOwnsNoSlot, - builtinClassRefOf, - sameBuiltinClass, -} from "./builtins/class-values.js"; import { constituentTypes } from "../ts7/checker.js"; -import { dirname } from "node:path"; -import * as posix from "node:path/posix"; import type { Lowerer } from "./lowerer.js"; -import { - inlineNamespaceRequireOf, - lowerWithInlineRequireInit, - wholeExportValue, -} from "./cjs-inline-require.js"; -import { tscInteropDefaultValueOf } from "./cjs-tsc-interop.js"; -import { - cjsExportMemberWriteGlobalOf, - cjsInFileExportGlobalOf, - isCjsExportsIdentifier, - lowerCjsExportsObjectValue, -} from "./cjs-exports-object.js"; -import { - lowerMaybePromiseAwait, - maybePromiseInstanceOf, - maybePromiseUnionOf, -} from "./maybe-promise.js"; -import { checkedClassAssertion, checkedClassUnionAssertion } from "./class-assertions.js"; -import { checkedArmExtraction } from "./coercions/unions.js"; -import { - narrowClassUnion, - narrowStoredClassValue, - structuralNarrowing, - structuralReadType, -} from "./class-unions.js"; -import { lowerUnionFieldRead, lowerUnionFieldWrite } from "./expressions/union-fields.js"; -import { captureContextArguments, declareContextLocal } from "./function-context.js"; -import { OBJECT_CALLABLE_VALUES } from "./surfaces.js"; -import { wasiGuestPath } from "../../wasi-paths.js"; +import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; +import { checkedClassUnionAssertion } from "./class-assertions.js"; import { BIGINT_T, - BYTES_ELEMENT_NAME, BOOL, CAUGHT, DYN, - DYN_HANDLE_KINDS, F64, - type IrBytesElem, type IrExpr, type IrFunction, type IrLocal, @@ -98,190 +27,103 @@ import { NULL_T, REF_TRUTHY_KINDS, REGEX, - RUNTIME_ERROR_CLASSES, - SEARCH_PARAMS_T, STRING, type SrcLoc, UNDEFINED_T, VOID, arrayOf, - canAdaptDynFuncTo, - canDynCheckTo, - canBoxFuncIntoDyn, funcOf, - isDynTypedRefType, - isSupportedArrayElem, isUnitType, - shapeHasAccessorSlots, typeEquals, typeKey, - unionContainerArmsOk, } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; import { - cjsClassExprWholeExportOf, - cjsExportAssignmentOf, - cjsExportDiscardReason, - isCjsExportTableLiteral, - isCjsJsFile, - isJsSourceFile, - isModuleExportsAccess, - isNodeEsmFile, - locOf, -} from "../program.js"; -import { - ARRAY_METHODS, - builtinConstLit, - builtinFenceHintOf, - builtinModuleConstOf, - builtinModulesArrayLit, - builtinModuleFnOf, - COMPOUND_ASSIGN_OPS, type CompoundOp, - STDLIB_SURFACE, - isChildSurfaceMember, - MAP_METHODS, NARROW_FIRST, - SET_METHODS, - STRING_INDEX_METHODS, - STR_METHODS, UNSUPPORTED_EXPR, sideEffectFreeOptionValue, stdlibGlobalNameOf, } from "./surfaces.js"; -import { - UNSUPPORTED, - blockedBindingUseDiag, - unsupportedPackageValueDiag, - unsupportedDiag, -} from "../../diagnostics/diagnostic.js"; -import { PoisonError, dynFallbackType, jsFuncNameOf, neverTaintedJsType, own } from "./lowerer.js"; -import { assertsGlobalObject, extractionCopiesReference } from "./assertion-identity.js"; -import { lowerCollectionSpread } from "./containers/collection-methods.js"; +import { UNSUPPORTED } from "../../diagnostics/diagnostic.js"; import { lowerHelperReturnCall } from "./containers/iterator-helpers.js"; import { - arrayNumberRead, - arrayValueRead, - arrayValueStore, - arrayValueType, lowerSafeIndexRead, tryLowerIndexTruthiness, tryLowerNumericIndexRead, - tryLowerBytesReadNumberEquality, - lowerSafeBytesRead, } from "./array-values.js"; -import { strCharsCall } from "./containers/array-construction.js"; -import { lowerOptionalStringIndex, lowerUnionStringIndex } from "./string-index.js"; -import { tryLowerIndexedComparison } from "./indexed-comparison.js"; -import { npmStaticPackageOfPath } from "../npm-static.js"; -import { unsupportedModuleFeatureOf } from "../builtin-modules.js"; -import { fenceEnumObjectValue, lowerEnumAccess } from "./lower-enums.js"; -import { - ambientNsRootOf, - ambientUndefReadType, - ambientUndefVarRootOf, - ambientUndefinedFnSymbolOf, - contextualUndefReadType, - fenceEarlyAliasUse, - fenceEarlyNsMemberRef, - lowerNsIdentifierValue, - nsMemberIdentOf, - nsUndefRead, - nsWritableTarget, -} from "./lower-namespaces.js"; -import { expandoMemberRead, expandoWritableTarget } from "./lower-expando.js"; -import { lowerSocketInstanceOf, lowerTlsRootCertificates } from "./lower-server.js"; -import { - type ClassInfo, - findGenericMethodOn, - lowerStaticFieldRead, - staticFieldWriteTarget, - storedClassValueType, -} from "./lower-classes.js"; -import { hasRuntimeStatics } from "./class-runtime-statics.js"; -import { bindingNeverReassigned, nullishGenericBindingUnitOf } from "./binding-analysis.js"; -import { funcTypeFromParamShapes } from "./call-signatures.js"; -import { - implicitMonoFile, - objLitGenericFnInfoOf, - objLitGenericFnNodeOf, - requireObjLitGenericReceiver, -} from "./generic-functions.js"; +import { nsMemberIdentOf } from "./lower-namespaces.js"; +import { bindingNeverReassigned } from "./binding-analysis.js"; +import { implicitMonoFile } from "./generic-functions.js"; import { lowerTaggedTemplate } from "./lower-calls.js"; -import { mixinFnOfCallee } from "./lower-mixins.js"; -import { - isConstAssertionTypeNode, - isGenericCallableMemberType, - isLegacyUrlSymbol, - isOsConstantsType, - isParseArgsDynTypeName, - underConstAssertion, - unitOnlyUnion, - withUnitArm, -} from "../type-mapper.js"; -import { iteratorResultValueRead, lowerYield } from "./lower-generators.js"; -import { errorPropertyRead, errorPropertyWrite, errorToStringCall } from "./error-methods.js"; -import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "./lower-stream.js"; +import { unitOnlyUnion } from "../type-mapper.js"; +import { lowerYield } from "./lower-generators.js"; import { unionWideningTags } from "../../ir/analysis.js"; import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js"; -import { isNavigatorGlobal, lowerNavigatorMember, refuseNavigator } from "./builtins/navigator.js"; -import { globalSymbolKey } from "./expressions/global-symbols.js"; -import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js"; -import { - hasOptionalChainGuard, - isOptionalChainTail, - isRequireMainFilename, -} from "./expressions/optional-chains.js"; -import { foldedStringKeyOf } from "./expressions/object-literals.js"; -import { tryLowerExpression } from "./expressions/try-lower-expression.js"; -import { - classToNumberConversion, - classToStringConversion, - classUnionToString, -} from "./class-to-primitive.js"; -import { - fenceNodeModuleMutation, - isNodeModuleValue, - lowerCjsTopLevelThis, - lowerNodeModuleIdentifier, - isCjsModuleGlobal, - lowerNodeModuleProperty, - lowerRequireCacheElement, - lowerRequireCacheHas, - lowerRequireMainProperty, -} from "./lower-node-module.js"; -import { lowerAbstractEquality } from "./abstract-equality.js"; +import { hasOptionalChainGuard, isOptionalChainTail } from "./expressions/optional-chains.js"; +import { classToStringConversion, classUnionToString } from "./class-to-primitive.js"; +import { lowerCjsTopLevelThis, lowerRequireCacheElement } from "./lower-node-module.js"; import { coerceStringSearchValue, defaultAfterUndefined, lowerStaticallyUndefinedArgument, } from "./optional-arguments.js"; -import { recordTextCodecClass } from "../../ir/ir.js"; import { classSymbolKeyOf } from "./symbol-fields.js"; -import { lowerClassMethodValue } from "./class-method-values.js"; -import { classInstanceOf } from "./class-dynamic-dispatch.js"; -import { lowerGlobalValue } from "./lower-global-value.js"; -import { importMetaField, isImportMeta } from "./import-meta.js"; +import { lowerTypeofExpr } from "./expressions/typeof.js"; +import { lowerIdentifierExpr } from "./expressions/identifiers.js"; +import { lowerFieldCompoundValue, lowerIncDec } from "./expressions/updates.js"; +import { lowerTernary } from "./expressions/narrowing.js"; import { - lowerClassPrototypeData, - lowerClassPrototypeComputedAssignment, -} from "./class-prototypes.js"; -import { isClassCallback } from "./class-callbacks.js"; -import { - builtinConstantsObject, - builtinPrototypeMethod, - lowerArrayIsArrayValue, - lowerCheckedPredicateValue, - lowerNumberParserValue, - lowerObjectAssignValue, - lowerStringCodesValue, -} from "./lower-builtin-values.js"; -import { lowerArrayFromValue } from "./containers/array-construction.js"; -import { lowerPerfHooksTypeof } from "./builtins/performance.js"; -import { isSearchParamsIterable, lowerUrlAssignment } from "./builtins/url.js"; -import { jsBindingHasOpenWrites } from "./lower-stmts.js"; -import { checkedClassInstanceOf } from "./class-construction.js"; -import { lowerModuleNamespaceElement } from "./module-namespace-elements.js"; -import { isPerfEntryType, perfEntryTag } from "./builtins/performance.js"; + classFieldTarget, + lowerPropertyAccessExpr, + lowerUnionKeyedRead, +} from "./expressions/property-access.js"; +import { lowerNullishPair } from "./expressions/binary.js"; +import { lowerElementCompoundParts } from "./expressions/element-access.js"; +export { + DEFERRED_READ_MESSAGE, + abstractPropertyDeclOf, + fieldGetExpr, + fieldTarget, + isMatchSliceType, + lowerFieldRead, + lowerGroupsProjection, + lowerIntrinsicProperty, + lowerUnionProperty, + matchResultNamedGroupsOf, +} from "./expressions/property-access.js"; +export { + lowerAsExpression, + lowerTernary, + maybeNarrow, + runtimeOptionalGuardIds, + runtimeOptionalTrueIds, + v8CalleeText, + withRuntimeOptionalNarrowed, +} from "./expressions/narrowing.js"; +export { + ensureString, + lowerBinary, + lowerCompoundValueToTarget, + lowerElementCompound, + lowerNativeFunctionAssignment, + lowerStrictEquality, + lowerUnitComparison, +} from "./expressions/binary.js"; +export { jsHoldsDynObject, lowerArrayLiteral } from "./expressions/array-literals.js"; +export { + lowerElementAccess, + lowerElementWrite, + lowerEnvironmentKey, + lowerRecordKeyRead, +} from "./expressions/element-access.js"; +export { + fieldSetStmt, + lowerIncDec, + lowerIncDecTarget, + lowerPrefixUnary, +} from "./expressions/updates.js"; +export { lowerInstanceOf } from "./expressions/relational.js"; /** An assignable `obj.field` target — a class field, a record field, or a * class ACCESSOR property (reads become getter calls, writes setter calls; @@ -345,96 +187,6 @@ export function templateRawTextOf( const LOWER_EXPR_MAX_DEPTH = 200; let lowerExprDepth = 0; -/** True when a property access names an ABSTRACT property declaration - * (`abstract p: number` — a PropertyDeclaration, not an accessor). - * These uses need concrete property dispatch because the base has no slot. */ -export function abstractPropertyDeclOf( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): boolean { - const sym = lowerer.checker.getSymbolAtLocation(expr.name); - if (!sym) return false; - return sym.declarations.some( - (d) => - ts.isPropertyDeclaration(d) && - ts.getModifiers(d)?.some((m) => m.kind === ts.SyntaxKind.AbstractKeyword) === true, - ); -} - -/** A field declared by node:util's parseArgs checked-dynamic type family. - * Narrowing a ParseArgsToken exposes one anonymous union arm, so its alias - * identity no longer reaches mapType; declaration ancestry is the stable - * provenance check. */ -/** The Uint8Array base64/hex prototype methods (ES2026) Node 24 lacks. */ -const UINT8ARRAY_BASE64_METHODS: ReadonlySet = new Set([ - "toBase64", - "toHex", - "setFromBase64", - "setFromHex", -]); - -/** `Uint8Array.prototype.` read off the global. */ -function isUint8ArrayBase64Probe(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { - if (!UINT8ARRAY_BASE64_METHODS.has(expr.name.text)) return false; - const proto = expr.expression; - return ( - ts.isPropertyAccessExpression(proto) && - !proto.questionDotToken && - proto.name.text === "prototype" && - stdlibGlobalNameOf(lowerer, proto.expression) === "Uint8Array" - ); -} - -function isParseArgsDynProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { - const sym = lowerer.checker.getSymbolAtLocation(expr.name); - if (!sym) return false; - return sym.declarations.some((d) => { - if (!lowerer.isStdlibFile(d.getSourceFile())) return false; - let inParseArgsType = false; - for (let node: ts.Node | undefined = d.parent; node; node = node.parent) { - if ( - (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) && - isParseArgsDynTypeName(node.name.text) - ) { - inParseArgsType = true; - } - if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { - return inParseArgsType && (node.name.text === "util" || node.name.text === "node:util"); - } - } - return false; - }); -} - -function isStatFsDynProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): boolean { - if ( - !["type", "bsize", "blocks", "bfree", "bavail", "files", "ffree", "bigint"].includes( - expr.name.text, - ) - ) - return false; - const symbol = lowerer.checker.getSymbolAtLocation(expr.name); - return ( - !!symbol && - symbol.declarations.some((declaration) => { - if (!lowerer.isStdlibFile(declaration.getSourceFile())) return false; - const parent = declaration.parent; - if (!ts.isInterfaceDeclaration(parent)) return false; - if ( - expr.name.text === "bigint" - ? parent.name.text !== "StatFsOptions" - : !["StatsFsBase", "StatsFs", "BigIntStatsFs"].includes(parent.name.text) - ) - return false; - for (let node: ts.Node | undefined = parent.parent; node; node = node.parent) { - if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) - return node.name.text === "fs" || node.name.text === "node:fs"; - } - return false; - }) - ); -} - export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (lowerExprDepth >= LOWER_EXPR_MAX_DEPTH) { lowerer.unsupported( @@ -666,1081 +418,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { "'void' with an effectful operand in value position (hoist the operand to its own statement — statement-position 'void e' and void-returning arrow bodies compile)", ); } - if (ts.isTypeOfExpression(expr)) { - const webAssembly = lowerWebAssemblyTypeof(lowerer, expr.expression); - if (webAssembly) return webAssembly; - if (lowerer.isStdlibGlobal(expr.expression, "process")) - return { kind: "strLit", value: "object", type: STRING, loc }; - if (lowerer.isStdlibGlobal(expr.expression, "fetch")) - return { kind: "strLit", value: "function", type: STRING, loc }; - if (isNavigatorGlobal(lowerer, expr.expression)) - return { kind: "strLit", value: "object", type: STRING, loc }; - const performance = lowerPerfHooksTypeof(lowerer, expr.expression); - if (performance) return performance; - if (isNodeModuleValue(lowerer, expr.expression)) { - return { kind: "strLit", value: "object", type: STRING, loc }; - } - // `typeof EventEmitter`, `typeof zlib.Gzip`, `typeof C` over a - // builtin class alias. - if (builtinClassRefOf(lowerer, expr.expression) !== null) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - if (ts.isPropertyAccessExpression(expr.expression)) { - const member = expr.expression; - const prototype = member.expression; - if ( - STRING_INDEX_METHODS.has(member.name.text) && - ts.isPropertyAccessExpression(prototype) && - prototype.name.text === "prototype" && - ts.isIdentifier(prototype.expression) && - lowerer.isStdlibGlobal(prototype.expression, "String") && - lowerer.isStdlibMember(member) - ) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - if ( - member.name.text === "at" && - ts.isPropertyAccessExpression(prototype) && - prototype.name.text === "prototype" && - ts.isIdentifier(prototype.expression) && - lowerer.isStdlibGlobal(prototype.expression, "Array") && - lowerer.isStdlibMember(member) - ) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - const presence = lowerPromiseThenPresence(lowerer, expr.expression, { - kind: "strLit", - value: "function", - type: STRING, - loc, - }); - if (presence !== null) return presence; - } - // `typeof queueMicrotask` / `typeof DOMException` on a STDLIB global - // whose declared type is callable or constructable: folds to - // "function" BEFORE the operand lowers — the identity-token story - // (JS files) deliberately represents these values as strings, and - // the TS-file fence would name a value the program never needs; an - // identifier read has no side effects to preserve. Node's answer for - // every function and constructor global is "function" (the harness's - // `typeof queueMicrotask === 'function'` probes). Shadowing locals - // have non-stdlib symbols and keep the ordinary path. - let tested = expr.expression; - while (ts.isParenthesizedExpression(tested)) tested = tested.expression; - // `typeof zlib.Inflate` / `typeof zlib.createGzip`: an export of a - // Node builtin module read through a plain binding (an import or - // require result). The read has no side effects, and Node's answer for - // every exported function and class is "function". - if ( - ts.isPropertyAccessExpression(tested) && - ts.isIdentifier(tested.expression) && - lowerer.isStdlibMember(tested) - ) { - const sym = lowerer.checker.getSymbolAtLocation(tested.name); - const decls = sym ? sym.declarations : []; - if ( - decls.some(isBuiltinModuleValueDecl) && - decls.every((decl) => builtinModuleExportParent(decl) !== undefined) - ) { - const t = lowerer.typeOf(tested); - if ( - lowerer.checker.getCallSignatures(t).length > 0 || - lowerer.checker.getConstructSignatures(t).length > 0 - ) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - } - } - // `typeof globalThis.AbortSignal` / `typeof Headers`: Node 24's Web - // platform constructor globals are functions. JavaScript sources - // represent such a value as an identity token, and the fallback - // declarations type some (AbortSignal) without a construct signature, - // so neither the operand nor the type can answer; the read has no - // effects. - if (constructorGlobalTypeof(lowerer, tested) !== null) - return { kind: "strLit", value: "function", type: STRING, loc }; - // `typeof globalThis.AbortSignal.any` (ky's feature probe): a static - // member of a standard-library global, declared callable, whose value - // has no representation. The read has no effects and Node's answer - // for every such function is "function"; members that lower as values - // keep their own answer. - if (ts.isPropertyAccessExpression(tested) && !tested.questionDotToken) { - const owner = stdlibGlobalNameOf(lowerer, tested.expression); - if (owner !== null && lowerer.isStdlibMember(tested)) { - const t = lowerer.typeOf(tested); - if ( - (lowerer.checker.getCallSignatures(t).length > 0 || - lowerer.checker.getConstructSignatures(t).length > 0) && - tryLowerExpression(lowerer, tested) === null - ) - return { kind: "strLit", value: "function", type: STRING, loc }; - } - } - if (ts.isIdentifier(tested)) { - const sym = lowerer.checker.getSymbolAtLocation(tested); - if (!sym) { - return { kind: "strLit", value: "undefined", type: STRING, loc }; - } - // `const { inspect } = require("util")` / `import { inspect } from - // "node:util"`: a binding of a builtin module's exported function, - // class or object. JavaScript sources represent such a value without - // its own lowering as an identity token (a string), so the operand's - // representation cannot answer. Node's answer follows the export's - // kind, and the binding read has no effects. - const imported = isJsSourceFile(tested.getSourceFile()) - ? lowerer.builtinImportOf(tested) - : null; - if ( - imported && - builtinModuleConstOf(lowerer, imported.module, imported.member) === undefined - ) { - const t = lowerer.typeOf(tested); - if ( - lowerer.checker.getCallSignatures(t).length > 0 || - lowerer.checker.getConstructSignatures(t).length > 0 - ) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - if ((t.flags & ts.TypeFlags.Object) !== 0) { - return { kind: "strLit", value: "object", type: STRING, loc }; - } - } - if (lowerer.isStdlibSymbol(sym)) { - const t = lowerer.typeOf(expr.expression); - if ( - lowerer.checker.getCallSignatures(t).length > 0 || - lowerer.checker.getConstructSignatures(t).length > 0 - ) { - return { kind: "strLit", value: "function", type: STRING, loc }; - } - } - } - // A known static result type fixes typeof's answer, but its producer - // still evaluates: calls, getters, and checked reads can have effects - // or throw. Only trivial operands may disappear. - let operand = deferredSlotOr( - lowerer, - runtimeOptionalStorageOperand(lowerer, expr.expression) ?? lowerer.lowerExpr(expr.expression), - ); - - // A checker-narrowed dyn read arrives as a VALIDATED extraction - // (maybeNarrow's dynCheck bridge). typeof needs no extraction — the - // dyn kind table answers the question directly, and answers it even - // where the flow type lied (the extraction would throw instead) — - // so unwrap back to the dyn value and take the dyn.typeof path. - if (operand.kind === "dynCheck" && operand.value.type.kind === "dyn") { - operand = operand.value; - } - const FOLD: Partial> = { - f64: "number", - bigint: "bigint", - string: "string", - bool: "boolean", - func: "function", - array: "object", - object: "object", - record: "object", - symbol: "symbol", - map: "object", - set: "object", - promise: "object", - bytes: "object", - regex: "object", - generator: "object", - classval: "function", - moduleNs: "object", - undefinedT: "undefined", - nullT: "object", - }; - const folded = FOLD[operand.type.kind]; - if (folded) { - const result: IrExpr = { kind: "strLit", value: folded, type: STRING, loc }; - return isSafeToDiscard(operand) - ? result - : { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: operand, loc }], - result, - type: STRING, - loc, - }; - } - // A union operand: every arm's typeof answer is static, so the value - // form is a ternary chain over runtime TAG tests (arms grouped by - // answer; the last group needs no test). The operand rides several - // tests, so only side-effect-free reads compose — and when every arm - // agrees the whole expression folds to that one string (dropping only - // the pure read, the trust-the-checker bet lowerUnitComparison makes). - // An effectful producer (a getter) evaluates once into a hidden local. - const prelude: IrStmt[] = []; - if (operand.type.kind === "union" && !isSafeToRepeat(operand)) { - const local = lowerer.declareHiddenLocal("%typeofOperand", operand.type); - prelude.push({ kind: "varDecl", localId: local.id, init: operand, loc }); - operand = varRef(local.id, operand.type, loc); - } - if (operand.type.kind === "union" && isSafeToRepeat(operand)) { - const def = lowerer.unions.get(operand.type.unionId); - const answers = def?.arms.map(typeofAnswer); - if (def && answers && answers.every((s): s is string => s !== null)) { - const groups = new Map(); - answers.forEach((s, i) => groups.set(s, [...(groups.get(s) ?? []), i])); - const ordered = [...groups.entries()]; - let result: IrExpr = { - kind: "strLit", - value: ordered[ordered.length - 1]![0], - type: STRING, - loc, - }; - for (const [answer, tags] of ordered.slice(0, -1).reverse()) { - let test: IrExpr = { - kind: "unionIsTag", - unionId: operand.type.unionId, - tag: tags[0]!, - negated: false, - value: operand, - type: BOOL, - loc, - }; - for (const t of tags.slice(1)) { - test = { - kind: "logical", - op: "||", - left: test, - right: { - kind: "unionIsTag", - unionId: operand.type.unionId, - tag: t, - negated: false, - value: operand, - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - result = { - kind: "ternary", - cond: test, - then: { kind: "strLit", value: answer, type: STRING, loc }, - else_: result, - type: STRING, - loc, - }; - } - return prelude.length > 0 - ? { kind: "seqExpr", stmts: prelude, result, type: STRING, loc } - : result; - } - } - if (operand.type.kind === "dyn") { - // Bare typeof on a dyn value: the runtime's kind→string table - // (null answers "object", boxed closures "function" — JS-exact - // for every dyn kind; "bigint"/"symbol" have no producers). - return { kind: "libCall", fn: "dyn.typeof", args: [operand], type: STRING, loc }; - } - lowerer.unsupported("SC1090", expr, "typeof expressions on statically-typed values"); - } - if (ts.isIdentifier(expr)) { - if (lowerer.isStdlibGlobal(expr, "console")) { - return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }; - } - - const moduleValue = lowerNodeModuleIdentifier(lowerer, expr); - if (moduleValue) return moduleValue; - if (expr.text === "exports") { - const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); - if (exportsValue) return exportsValue; - } - if (lowerer.isSelfReference(expr)) { - return { kind: "selfRef", type: lowerer.ctx.selfType!, loc }; - } - // `arguments` in a variadic JS function (the rest-marked form): the - // synthetic trailing dyn-array param — lambdaSignature marked the - // type and lowerLambda declared the local. - if (expr.text === "arguments") { - const binding = captureContextArguments(lowerer.fnStack); - if (binding) return { kind: "varRef", localId: binding.id, type: DYN, loc }; - } - // A compiler-projected adapter as a VALUE: the adapter has no - // runtime function object (its calls rewrite directly) — call it - // through its immutable binding instead. - { - const sym = lowerer.resolveValueSymbol(expr); - const projection = sym ? lowerer.staticCallables.get(sym) : undefined; - if ( - projection?.kind === "promisified-exec-file" || - projection?.kind === "promisified-exec" || - projection?.kind === "promisified-builtin" || - projection?.kind === "promisified-zlib" || - projection?.kind === "promisified-crypto" || - projection?.kind === "promisified-dns" - ) { - lowerer.unsupported( - "SC1090", - expr, - `a promisified builtin as a value (call '${expr.text}' directly)`, - ); - } - if (projection?.kind === "builtin-function") { - if (!isJsSourceFile(expr.getSourceFile())) { - const callable = lowerer.lowerBuiltinCallableValue( - { module: projection.module, member: projection.member }, - loc, - ); - if (callable) return callable; - } - lowerer.unsupported( - "SC1090", - expr, - `a builtin function alias as an escaping value (call '${expr.text}' directly)`, - ); - } - // `EventEmitter`, `Readable` or `TypeError` (or a const alias of - // one) as a value in a TypeScript file: the runtime class's class - // object. JavaScript files keep the identity token. - if (!isJsSourceFile(expr.getSourceFile())) { - const runtimeClass = runtimeClassValueOf(lowerer, expr); - if (runtimeClass) return lowerer.classValueRef(runtimeClass, expr); - } - // `const E = EventEmitter` owns no runtime slot: construction, - // instanceof, typeof and .name lower at their use sites. - if (sym && builtinClassAliasOwnsNoSlot(lowerer, sym)) { - lowerer.unsupported( - "SC1090", - expr, - `a builtin class alias as an escaping value (use '${expr.text}' with new, instanceof, typeof or .name)`, - ); - } - } - // Union-typed bindings read through tsc's control-flow narrowing: - // when the checker types this USE as a single arm, maybeNarrow - // bridges the tagged representation with a unionNarrow. - const local = lowerer.resolveLocal(expr); - if (local) { - // A switch can dispatch past a lexical declaration. Keep this - // previously refused codec form out until switch-scope TDZ boxes - // model that skipped initialization, including value aliases. - if ( - local.type.kind === "record" && - recordTextCodecClass(lowerer.shapes.get(local.type.shapeId)!) !== null - ) { - const symbol = lowerer.resolveValueSymbol(expr); - const decl = symbol ? symbol.valueDeclaration : undefined; - if ( - decl && - ts.isVariableDeclaration(decl) && - ts.isVariableDeclarationList(decl.parent) && - (decl.parent.flags & ts.NodeFlags.BlockScoped) !== 0 && - ts.isVariableStatement(decl.parent.parent) - ) { - const clause = decl.parent.parent.parent; - if ( - (ts.isCaseClause(clause) || ts.isDefaultClause(clause)) && - !(expr.getStart() >= clause.getStart() && expr.end <= clause.end) - ) { - lowerer.unsupported( - "SC1090", - expr, - "codec bindings read from another switch clause before initialization is proven", - ); - } - } - } - if (local.type.kind === "caught") return lowerer.caughtRead(expr, local, loc); - const symbol = lowerer.resolveValueSymbol(expr); - const runtimeOptionalRoot = lowerer.runtimeOptionalRootOf(local); - const active = lowerer.runtimeOptionalLocals.has(runtimeOptionalRoot); - const arithmetic = lowerer.runtimeOptionalArithmeticLocals.has(runtimeOptionalRoot); - const captured = - !!symbol && - lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) && - (local.boxed === true || - lowerer.ctx.captureBySymbol.get(symbol) === local || - runtimeOptionalRoot !== local); - if (arithmetic && !runtimeOptionalUseOf(expr)) { - return { kind: "varRef", localId: local.id, type: local.type, loc }; - } - if (active || captured) { - const use = runtimeOptionalUseOf(expr); - if (use?.complex) { - lowerer.unsupported( - "SC1090", - expr, - "a logical or conditional receiver containing a runtime-optional capture", - ); - } - if (use?.optional) return { kind: "varRef", localId: local.id, type: local.type, loc }; - if (use?.kind === "property") - return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc); - if (use?.kind === "element") { - const key = runtimeOptionalElementKey(use.access.argumentExpression); - if (key === null) { - return varRef(local.id, local.type, loc); - } - return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc); - } - if (use?.kind === "call") { - if (use.comma && !use.optional) { - lowerer.unsupported( - "SC1090", - use.access, - "a direct call through a comma-wrapped runtime-optional capture", - ); - } - return runtimeOptionalReceiverRead(lowerer, expr, local, null, loc); - } - if (runtimeOptionalObjectWalkerArg(lowerer, expr)) { - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr)); - if (local.type.kind === "union" && narrowed) { - const helper = - narrowed.kind === "union" - ? lowerer.narrowedRetagHelper(expr, local.type.unionId, narrowed.unionId, loc) - : !isUnitType(narrowed) - ? lowerer.narrowedArmHelper(local.type.unionId, narrowed, loc) - : null; - if (helper) { - return lowerer.checkedNarrowCall( - helper, - varRef(local.id, local.type, loc), - narrowed, - loc, - ); - } - } - } - return { kind: "varRef", localId: local.id, type: local.type, loc }; - } - const narrowed = lowerer.maybeNarrow( - { kind: "varRef", localId: local.id, type: local.type, loc }, - expr, - ); - if ( - narrowed.type.kind === "union" && - lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot) && - lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "record" - ) { - // A presence guard can be followed by a predicate strengthening - // the record's fields. Its refined shape is not a stored union - // arm: extract the original layout before reading a member. - const use = runtimeOptionalUseOf(expr); - if (use && !use.complex && !use.optional) { - if (use.kind === "property") - return runtimeOptionalReceiverRead(lowerer, expr, local, use.access.name.text, loc); - if (use.kind === "element") { - const key = runtimeOptionalElementKey(use.access.argumentExpression); - if (key !== null) return runtimeOptionalReceiverRead(lowerer, expr, local, key, loc); - } - } - } - return narrowed; - } - // `import x = N.y` aliases resolve transparently through globalOf/ - // fnSigOf below; their source-order guards live here (a no-op for - // every non-import= binding — see lower-namespaces.ts). - fenceEarlyAliasUse(lowerer, expr, expr); - const g = lowerer.globalOf(expr); - if (g) { - // A global typed by a class that never REGISTERED (its collection - // fenced): the initializing assignment never lowered, so the read - // can only observe garbage — and the emitter would name a struct - // that does not exist. The blocked-binding cascade names the use; - // without this the validator's registration check ICEs on the live - // reference (`@this {T}` inference — signature 07). - const unregistered = lowerer.unregisteredClassOf(g.type, new Set()); - if (unregistered !== null) { - // The class's own rejection is the root cause the cascade names. - lowerer.flushDeferredClass(unregistered); - lowerer.pushDiag(blockedBindingUseDiag(expr.text, loc)); - throw new PoisonError(); - } - const ref: IrExpr = { kind: "varRef", localId: g.id, type: g.type, loc }; - if (lowerer.isRuntimeOptionalArithmeticGlobal(g) && !runtimeOptionalUseOf(expr)) { - return ref; - } - const storageHasUndefined = - g.type.kind === "union" && lowerer.armTag(g.type.unionId, UNDEFINED_T) >= 0; - if (lowerer.isRuntimeOptionalGlobal(g) || storageHasUndefined) { - // A checker-narrowed global receiver (`if (tags !== undefined) - // tags[tags.length] = ...`, or `a ? Object.keys(a) : ...`) is - // still stored as the runtime optional union. Extract the proven - // value arm before routing the member/element operation so dynamic - // keys and builtin record walkers do not see a union receiver. - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr)); - const arithmeticUnion = - g.type.kind === "union" && - lowerer.unions.get(g.type.unionId)?.arms.length === 2 && - lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "f64") && - lowerer.unions.get(g.type.unionId)!.arms.some((a) => a.kind === "string"); - const symbol = lowerer.resolveValueSymbol(expr); - if ( - !arithmeticUnion && - !(symbol !== null && lowerer.earlyReadVars.has(symbol)) && - g.type.kind === "union" && - narrowed && - narrowed.kind !== "union" && - !isUnitType(narrowed) && - !( - narrowed.kind === "record" && - recordTextCodecClass(lowerer.shapes.get(narrowed.shapeId)!) !== null - ) && - narrowed.kind !== "f64" && - narrowed.kind !== "string" && - narrowed.kind !== "bool" && - lowerer.armTag(g.type.unionId, narrowed) >= 0 - ) { - const helper = lowerer.narrowedArmHelper(g.type.unionId, narrowed, loc); - if (helper) return lowerer.checkedNarrowCall(helper, ref, narrowed, loc); - } - const use = runtimeOptionalUseOf(expr); - // The enclosing logical/conditional expression consumes the union - // before any member operation runs (`(value ?? fallback).length`). - // Keep the tagged value intact and let that lazy expression decide - // its branch; extracting here would evaluate or throw too early. - if (use?.complex) return ref; - if (use?.optional) return ref; - if (use?.kind === "property") - return runtimeOptionalReceiverRead(lowerer, expr, g, use.access.name.text, loc); - if (use?.kind === "element") { - const key = runtimeOptionalElementKey(use.access.argumentExpression); - if (key === null) - lowerer.unsupported( - "SC1090", - use.access, - "a computed read from a runtime-optional global (use a literal key)", - ); - return runtimeOptionalReceiverRead(lowerer, expr, g, key, loc); - } - if (use?.kind === "call") return runtimeOptionalReceiverRead(lowerer, expr, g, null, loc); - return ref; - } - return lowerer.maybeNarrow(ref, expr); - } - // `undefined` — tsc's intrinsic global (a local shadowing the name - // resolved above): a unit literal, exactly like the `null` keyword. - if (expr.text === "undefined" && lowerer.typeOf(expr).flags & ts.TypeFlags.Undefined) { - return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; - } - // `__dirname` / `__filename` — the CommonJS module globals: per- - // MODULE compile-time constants, the containing file's location - // (Node's exact values when the same tree runs in place; WASI uses - // the equivalent guest-visible path; locals shadowing the names - // resolved above). In a file with ESM syntax Node never defines them - // (ReferenceError) — fence rather than invent a value there. - if ( - (expr.text === "__dirname" || expr.text === "__filename") && - lowerer.isStdlibGlobal(expr, expr.text) - ) { - const sf = expr.getSourceFile(); - if (isNodeEsmFile(sf, lowerer.program)) { - lowerer.unsupported( - "SC1090", - expr, - `'${expr.text}' in an ES module (Node defines the CJS module globals only in CommonJS modules)`, - ); - } - // The WASI runner mounts the host cwd at guest `/` and the host temp - // directory at `/tmp`. Bake the matching GUEST spelling so resource - // lookups relative to these CommonJS globals stay inside the exposed - // capability instead of naming an unreachable host-absolute path. - const fileName = moduleFileName(lowerer, sf); - const value = - expr.text === "__dirname" - ? lowerer.targetPlatform === "wasi" - ? posix.dirname(fileName) - : dirname(fileName) - : fileName; - return { kind: "strLit", value, type: STRING, loc }; - } - const sig = lowerer.fnSigOf(expr); - if (sig && lowerer.isTopLevelFnSymbol(expr)) { - // A declared function used as a value: a zero-capture closure. The - // backend interns one per function so `f === f` holds (JS identity). - // The value's type is the completed ABI signature; optional/default - // slots and typed rest arrays pass the value fence first. - lowerer.noteEdge(sig.name); - // dynRest slots stay out of the VALUE type's param list (fn.length - // semantics); the rest marker carries the trailing dyn-array ABI. - const funcType: IrType = funcTypeFromParamShapes(sig.params, sig.returnType); - lowerer.requireExactArityValue(expr, expr, sig.params, funcType); - return { kind: "closure", fnName: sig.name, captures: [], type: funcType, loc }; - } - { - // A generic function as a VALUE: monomorphized by flow — the - // reference's pinned concrete signature (contextual type or an - // instantiation expression) names the instance; unpinned - // references fence inside (lowerGenericFnValue). - const gfn = lowerer.genericFnOf(expr); - if (gfn) return lowerer.lowerGenericFnValue(expr, gfn); - } - // A CJS export-table ACCESSOR read (`tmpdir.path`, or a destructured - // binding aliasing one): the getter is a real function of module - // scope — its body reads module globals, so it lifts like a lambda - // (interned per declaration) and every read is a call. Node runs the - // getter per read; so does this. - { - const acc = cjsExportAccessorRead(lowerer, expr); - if (acc) return acc; - } - // A binding destructured from a baked constants object - // (http2.constants / crypto.constants): the literal — the - // declaration emitted nothing (builtinConstantsDestructureDecl). - { - const h2c = lowerer.builtinConstantBindingOf(expr); - if (h2c) return h2c; - } - // Builtin-module import bindings: constants (path.sep, os.EOL) read - // as interned string literals; functions have no closure - // representation (they lower to libCall at call sites only); members - // with no lowering at all fence with the module-qualified name. - { - const member = threadsImportOf(lowerer.program, expr); - // scriptc delivers published graphs by reference. - if (member === "sharesPublishedGraphs") - return { kind: "boolLit", value: true, type: BOOL, loc }; - if (member === "publish") - lowerer.noLowering( - "publish (@scriptc/threads) as a value", - expr, - "call publish(value) directly", - ); - if (experimentalImportOf(lowerer.program, expr) === "LifetimeRegion") - lowerer.noLowering( - "LifetimeRegion (@scriptc/experimental) as a value", - expr, - "construct regions with new LifetimeRegion()", - ); - if (experimentalImportOf(lowerer.program, expr) === "InternTable") - lowerer.noLowering( - "InternTable (@scriptc/experimental) as a value", - expr, - 'open tables with new InternTable("name")', - ); - if (experimentalImportOf(lowerer.program, expr) === "ThreadSlots") - lowerer.noLowering( - "ThreadSlots (@scriptc/experimental) as a value", - expr, - 'open slots with new ThreadSlots("name")', - ); - } - { - const bi = lowerer.builtinImportOf(expr); - if (bi) { - if (bi.module === "worker_threads") { - const value = lowerWorkerMetadata(bi.member, loc); - if (value) return value; - } - // A named import of defaultMaxListeners reads the ES module - // namespace's copy. A CommonJS destructured or member binding holds - // the value read when its declaration ran, which these bindings do - // not store. - if (bi.module === "events" && bi.member === "defaultMaxListeners") { - if (esmImportBindingKind(lowerer, expr) === "named") { - return { kind: "libCall", fn: "emitter.esmDefaultMax", args: [], type: F64, loc }; - } - lowerer.noLowering( - "events.defaultMaxListeners through a CommonJS destructured or member binding", - expr, - "read require('events').defaultMaxListeners or EventEmitter.defaultMaxListeners where the value is needed", - ); - } - const c = builtinModuleConstOf(lowerer, bi.module, bi.member); - if (c !== undefined) return builtinConstLit(c, loc); - // module.builtinModules — the baked Node v24 list, a fresh - // string[] per read. - if (bi.module === "module" && bi.member === "builtinModules") { - return builtinModulesArrayLit(loc); - } - if (bi.module === "http" && bi.member === "METHODS") { - return { kind: "libCall", fn: "http.methods", args: [], type: arrayOf(STRING), loc }; - } - if ((bi.module === "http" || bi.module === "https") && bi.member === "globalAgent") { - return { - kind: "libCall", - fn: "http.globalAgent", - args: [{ kind: "boolLit", value: bi.module === "https", type: BOOL, loc }], - type: DYN, - loc, - }; - } - if (bi.module === "http" && bi.member === "STATUS_CODES") { - return { kind: "libCall", fn: "http.statusCodes", args: [], type: DYN, loc }; - } - if (bi.module === "os" && bi.member === "constants") { - return { kind: "libCall", fn: "os.constants", args: [], type: DYN, loc }; - } - if (bi.module === "fs" && bi.member === "constants") - return { kind: "libCall", fn: "fs.constants", args: [], type: DYN, loc }; - // `import { O_RDONLY } from "constants"`: the member of the frozen - // module object. - if (bi.module === "constants") { - return { - kind: "dynKeyGet", - value: builtinConstantsObject(lowerer, loc), - key: { kind: "strLit", value: bi.member, type: STRING, loc }, - type: DYN, - loc, - }; - } - // tls.rootCertificates: a runtime-valued module constant (the - // cached bundled-CA array) — the one member read that lowers - // to a libCall instead of a baked literal. - { - const roots = lowerTlsRootCertificates(lowerer, bi, loc); - if (roots) return roots; - } - { - const callable = lowerer.lowerBuiltinCallableValue(bi, loc); - if (callable) return callable; - } - // JavaScript sources: an otherwise unsupported builtin VALUE is - // the same identity-token story as stdlib globals above (the - // harness adds worker_threads.Worker to its identity Set). - if (isJsSourceFile(expr.getSourceFile())) { - // A function member is a function value that fences when called. - if (builtinModuleFnOf(lowerer, bi.module, bi.member)) - return builtinFunctionFenceValue(lowerer, bi, expr, loc); - return { - kind: "strLit", - value: `[builtin ${bi.module}.${bi.member}]`, - type: STRING, - loc, - }; - } - if (builtinModuleFnOf(lowerer, bi.module, bi.member)) { - lowerer.unsupported( - "SC1090", - expr, - `library functions as values (call '${expr.text}' directly)`, - ); - } - lowerer.noLowering( - `${bi.module}.${bi.member}`, - expr, - builtinFenceHintOf(bi.module, bi.member), - lowerer.resolveValueSymbol(expr), - ); - } - } - // A builtin namespace import used as a bare VALUE (`const f = fs`): - // the namespace object has no representation — members lower at - // their access sites only. The CommonJS namespace binding - // (`const lib = require("./lib.js")`) gets the same fence. - const builtinNamespace = lowerer.builtinNamespaceModuleOf(expr); - if (builtinNamespace === "constants") return builtinConstantsObject(lowerer, loc); - if (builtinNamespace !== null) { - const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNamespace}` }; - return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; - } - if (lowerer.cjsLocalModuleBindingOf(expr)) { - lowerer.unsupported( - "SC1090", - expr, - `module namespace objects as values (access '${expr.text}' members directly)`, - ); - } - if (lowerer.stdlibGlobalFnOf(expr)) { - lowerer.unsupported( - "SC1090", - expr, - `library functions as values (call '${expr.text}' directly)`, - ); - } - // A declaration-only npm binding has no compiled runtime value. - { - const pkg = lowerer.npmPackageOfSymbol(lowerer.resolveValueSymbol(expr) ?? undefined); - if (pkg) { - lowerer.pushDiag(unsupportedPackageValueDiag(pkg, loc)); - throw new PoisonError(); - } - } - // Bindings imported from an UNSUPPORTED builtin module: coverage - // analyzes past import fences, so uses of `spawn` from - // child_process reach lowering — the use site reports the same - // "the '' module" diagnostic as the import line, and the - // report groups them into one blocker. (Builds never get here: the - // import already failed preflight.) - { - const spec = lowerer.fencedBuiltinImportOf(expr); - if (spec !== null) { - lowerer.pushDiag(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); - throw new PoisonError(); - } - } - // The globals `Infinity` and `NaN` (lib-declared, provenance-checked - // like every stdlib name): non-finite numLits — `-Infinity` arrives - // through the unary-minus literal fold. Arithmetic, comparisons, and - // formatting are IEEE-exact in C (String(Infinity) is "Infinity" and - // String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds - // because f64 compares are IEEE compares). - if (expr.text === "Infinity" || expr.text === "NaN") { - const sym = lowerer.checker.getSymbolAtLocation(expr); - if (lowerer.isStdlibSymbol(sym)) { - return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc }; - } - } - // The primitive constructors as VALUES (`const f = String`, an - // option table's `type: Boolean` field, `opt.type === Number`): the - // interned coercion closure — one synthesized module function per - // constructor per program, so every reference is the SAME zero- - // capture closure and `===` is JS identity (the type mapping in - // type-mapper.ts pins the one concrete signature `(value: string) => - // primitive`; direct calls `String(x)` never reach here — the call - // lowering intercepts them with the wider static coercions). - const jsPrimitiveCtorSelector = isJsSourceFile(expr.getSourceFile()); - const primitiveName = stdlibGlobalNameOf(lowerer, expr); - if ( - primitiveName === "String" || - primitiveName === "Number" || - primitiveName === "Boolean" || - primitiveName === "BigInt" - ) { - return primitiveCtorClosure(lowerer, primitiveName, loc, jsPrimitiveCtorSelector); - } - if (lowerer.isStdlibGlobal(expr, "ArrayBuffer")) - return { kind: "libCall", fn: "arrayBuffer.constructor", args: [], type: DYN, loc }; - if (isJsSourceFile(expr.getSourceFile()) && lowerer.isStdlibGlobal(expr, "Array")) - return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc }; - if ( - Object.values(BYTES_ELEMENT_NAME).includes(expr.text) && - lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr)) - ) { - return { - kind: "libCall", - fn: "bytes.constructor", - args: [{ kind: "strLit", value: expr.text, type: STRING, loc }], - type: DYN, - loc, - }; - } - if ( - (expr.text === "encodeURIComponent" || - expr.text === "encodeURI" || - expr.text === "decodeURIComponent" || - expr.text === "decodeURI") && - lowerer.isStdlibGlobal(expr, expr.text) - ) { - const name = `%builtin.${expr.text}`; - const type = funcOf([DYN], STRING); - if (!lowerer.liftedFns.some((fn) => fn.name === name)) { - const value: IrExpr = { - kind: "libCall", - fn: "dyn.toStringCoerce", - args: [varRef("value", DYN, loc)], - type: STRING, - loc, - }; - const fn = - expr.text === "encodeURIComponent" - ? "str.encodeUriComponent" - : expr.text === "encodeURI" - ? "str.encodeUri" - : expr.text === "decodeURI" - ? "str.decodeUri" - : "str.decodeUriComponent"; - lowerer.liftedFns.push({ - name, - params: [{ localId: "value", name: "value", type: DYN }], - locals: [{ id: "value", name: "value", type: DYN, mutable: false }], - returnType: STRING, - body: [ - { - kind: "return", - value: { kind: "libCall", fn, args: [value], type: STRING, loc }, - loc, - }, - ], - loc, - }); - } - return { kind: "closure", fnName: name, captures: [], type, loc }; - } - if (lowerer.isStdlibGlobal(expr, "crypto")) - return { kind: "libCall", fn: "crypto.native", args: [], type: DYN, loc }; - const nativeAlias = stdlibGlobalNameOf(lowerer, expr); - if (nativeAlias === "Buffer" && expr.text !== "Buffer") { - return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc }; - } - if (lowerer.isStdlibGlobal(expr, "fetch")) { - return { kind: "libCall", fn: "fetch.function", args: [], type: DYN, loc }; - } - // The lib fence's IDENTIFIER chokepoint: the real standard library - // resolves names the old minimal ambient world never declared - // (Symbol, Reflect, Infinity, Date, ...) — and the adopted - // @types/node resolves its whole global surface (Buffer, fetch, - // setInterval, URL, ...). Reaching one that no lowering above - // claimed is SC2020, never an ICE — EXCEPT in JavaScript sources, - // where a stdlib global taken as a VALUE (never called through this - // path — call sites lower earlier) becomes an opaque IDENTITY TOKEN: - // an interned string naming the global. Identity flows (the - // harness's knownGlobals Set, === comparisons) are exact — one - // global, one token; what a token cannot do (be called, answer - // typeof "function") meets per-site fences/divergences, and - // SEMANTICS.md documents the stance. - if (stdlibGlobalNameOf(lowerer, expr) === "globalThis") { - return lowerGlobalValue(lowerer, expr); - } - { - const sym = lowerer.checker.getSymbolAtLocation(expr); - if (lowerer.isStdlibSymbol(sym) || expr.text === "globalThis") { - if (isJsSourceFile(expr.getSourceFile())) { - const canonical = stdlibGlobalNameOf(lowerer, expr) ?? expr.text; - return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; - } - // The families with a WHY: each hint states what makes the - // surface genuinely non-static (or what to use instead). - const globalHints: Record = { - Proxy: - "Proxy constructor values have no native lowering; use a direct new Proxy with checked-native plain objects", - Reflect: - "reflective property access has no static lowering — read and call members directly", - Intl: 'native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported', - SharedArrayBuffer: "use direct new SharedArrayBuffer(length) for fixed shared storage", - ArrayBuffer: - "ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage", - WeakRef: - "deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead", - FinalizationRegistry: - "finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead", - eval: "runtime code evaluation cannot be compiled ahead of time", - }; - lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined); - } - } - // An ambient global nothing defines compiles to exactly what Node does - // at the access: the catchable ReferenceError " is not defined". - // Stdlib/@types/node ambients never reach here (their chokepoint - // is above); only user-file declares do. - { - const sym = lowerer.resolveValueSymbol(expr); - const decl = sym ? sym.declarations[0] : undefined; - if ( - decl !== undefined && - ts.isVariableDeclaration(decl) && - decl.initializer === undefined && - ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Ambient - ) { - const declared = lowerer.mapTypeOf(lowerer.typeOf(expr)); - if (declared && declared.kind !== "void") { - return { - kind: "libCall", - fn: "global.undefRead", - args: [{ kind: "strLit", value: expr.text, type: STRING, loc }], - type: declared, - loc, - }; - } - } - } - // An ambient `declare function` nothing defines, taken as a VALUE - // (call sites lower earlier, in lowerCall): the same story as the - // ambient `declare const` above — Node erases the declaration and - // the read throws the catchable ReferenceError at the access. - { - if (ambientUndefinedFnSymbolOf(lowerer, expr)) { - const t = ambientUndefReadType(lowerer, expr); - if (t) return nsUndefRead(lowerer, expr.text, expr, t); - } - } - // A read of a TRAP binding — a declaration whose own initializer - // provably threw (module init unwound there), so this reference can - // never execute: any lowering is sound, and the trap keeps the - // shape honest. Typed by the use site when it maps, the F64 dummy - // otherwise (never observed — never even reached). - { - const sym = lowerer.resolveValueSymbol(expr); - if (sym !== null && lowerer.trapBindings.has(sym)) { - const t = - ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr) ?? F64; - return nsUndefRead(lowerer, expr.text, expr, t); - } - } - // A program CLASS NAME as a value: the classRef over the class's - // immortal class object (member accesses and construction never - // reach here — their hooks claim the property/new forms first). - { - const sym = lowerer.resolveValueSymbol(expr); - const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined; - // A GENERIC class name whose CONTEXTUAL type pins one instantiation - // (`const B: new (v: string) => Counter = Counter`): the - // value is that instantiation's class object — the generic-fn - // pinning rule on the static side. Unpinned references fall - // through to classValueRef's family fence. - if (classInfo?.generic) { - const ctxT = lowerer.checker.getContextualType(expr); - const mapped = ctxT ? lowerer.mapTypeOf(ctxT) : null; - const instInfo = - mapped?.kind === "classval" ? lowerer.classes.get(mapped.className) : undefined; - if (instInfo && instInfo.genericInstance?.family === classInfo) { - return lowerer.classValueRef(instInfo, expr); - } - } - if (classInfo) { - // A decorated name a replacing decorator can REBIND: the value - // is the decoration result — the mutable classval global %init - // assigned at the class statement (TC39's name binding), never - // the declaration's own class object. - const decoratedGlobal = classInfo.classDecorators?.valueGlobalId; - if (decoratedGlobal !== undefined) { - return { - kind: "varRef", - localId: decoratedGlobal, - type: { kind: "classval", className: classInfo.def.name }, - loc, - }; - } - return lowerer.classValueRef(classInfo, expr); - } - // The enum OBJECT as a value (member reads never reach here — the - // access hooks fold them): iteration, storage, reverse lookups - // through variables all land on this pointed fence. - if (sym) fenceEnumObjectValue(lowerer, expr, sym); - // A MIXIN function as a first-class value: no runtime function - // exists — calls instantiate a class per call site - // (lower-mixins.ts). Generic mixins took the generic-fn value - // fence above; this names the non-generic spelling. - if (mixinFnOfCallee(lowerer, expr)) { - lowerer.unsupported( - "SC1090", - expr, - `the mixin function '${expr.text}' as a value (mixin calls compile per call site — call it directly)`, - ); - } - } - // A NAMESPACE object as a first-class value: members lower at their - // qualified access sites; the object itself has no runtime - // representation (ambient namespaces compile to Node's - // ReferenceError instead — the object never exists at runtime). - { - const moduleNs = lowerer.mapTypeOf(lowerer.typeOf(expr)); - if (moduleNs?.kind === "moduleNs") { - return { kind: "moduleNsRef", moduleId: moduleNs.moduleId, type: moduleNs, loc }; - } - const builtinNs = lowerer.builtinNamespaceModuleOf(expr); - if (builtinNs === "constants") return builtinConstantsObject(lowerer, loc); - if (builtinNs !== null) { - const type: IrType = { kind: "moduleNs", moduleId: `builtin:${builtinNs}` }; - return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc }; - } - const ns = lowerNsIdentifierValue(lowerer, expr); - if (ns) return ns; - } - if (isNavigatorGlobal(lowerer, expr)) { - refuseNavigator( - lowerer, - expr, - "the navigator object as a value", - lowerer.checker.getSymbolAtLocation(expr), - ); - } - // Preflight guarantees no unresolved identifiers; anything else here - // is a blocked declaration's binding (the SC2004 cascade) or a - // binding form we don't model yet. - lowerer.rejectUnresolved( - expr, - `the reference to '${expr.text}' (a binding form with no lowering)`, - ); - } + if (ts.isTypeOfExpression(expr)) return lowerTypeofExpr(lowerer, expr, loc); + if (ts.isIdentifier(expr)) return lowerIdentifierExpr(lowerer, expr, loc); if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) { return lowerer.lowerLambda(expr); } @@ -1995,1014 +674,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isElementAccessExpression(expr)) return lowerer.lowerElementAccess(expr); if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr); - if (ts.isPropertyAccessExpression(expr)) { - if (!expr.questionDotToken) { - const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION); - if (navigator) return navigator; - } - // `require("./x").member` over a program module's export table: the - // module's %init at this position, then the member read exactly like - // a `const m = require("./x")` binding's (cjs-inline-require.ts). - if (!expr.questionDotToken) { - const req = inlineNamespaceRequireOf(lowerer, expr.expression); - if (req !== null && !lowerer.inlineRequireInitDone.has(req.call)) { - return lowerWithInlineRequireInit(lowerer, req, () => lowerExpr(lowerer, expr)); - } - // `module.exports` compared by identity inside its own module, or - // refused by name as any other value (cjs-exports-object.ts). - if (expr.name.text === "exports") { - const exportsValue = lowerCjsExportsObjectValue(lowerer, expr); - if (exportsValue) return exportsValue; - } - // tsc's `x_1.default` over a dependency whose whole export is one - // value: that value (cjs-tsc-interop.ts). - const interop = tscInteropDefaultValueOf(lowerer, expr); - if (interop !== null) { - const value = wholeExportValue(lowerer, interop.dep, expr); - if (value !== null) return value; - } - } - { - const member = lifetimeRegionMemberOf(lowerer.program, expr, lowerer.experimentalFiles); - if (member !== null) - lowerer.noLowering( - `LifetimeRegion.${member} (@scriptc/experimental) as a value`, - expr, - `call region.${member}(${member === "run" ? "fn" : ""}) directly`, - ); - } - { - const table = lowerInternTableProperty(lowerer, expr); - if (table) return table; - } - lowerThreadSlotsProperty(lowerer, expr); - // `EventEmitter.name`, `zlib.Gzip.name`, `C.name` over a builtin - // class alias. - if (expr.name.text === "name" && !expr.questionDotToken) { - const ref = builtinClassRefOf(lowerer, expr.expression); - if (ref !== null) return { kind: "strLit", value: ref.name, type: STRING, loc }; - } - // `C.name` through a runtime-provided class VALUE (a `typeof Readable` - // parameter): the class object's stored name. - if ( - expr.name.text === "name" && - !expr.questionDotToken && - runtimeClassValueOfType(lowerer, lowerer.typeOf(expr.expression)) !== null - ) { - const read = lowerer.lowerFieldRead(expr); - if (read) return read; - } - // Expando statics may still be absent even when JS inference sees - // the eventual assignment's scalar type. Preserve the checked value. - const staticField = lowerStaticFieldRead(lowerer, expr); - if (staticField) return staticField; - const prototypeData = lowerClassPrototypeData(lowerer, expr); - if (prototypeData) return prototypeData; - if ( - ts.isPropertyAccessExpression(expr.expression) && - expr.expression.name.text === "prototype" - ) { - const prototype = lowerClassPrototypeData(lowerer, expr.expression); - if (prototype) - return { - kind: "dynKeyGet", - value: prototype, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, - type: DYN, - loc, - }; - } - // `super.x`: the base chain's GETTER, called directly (super - // dispatch is static in JS — never through the dynamic class). - // super.method() calls are routed at the call site; a bare super - // property read lands here. - if (expr.expression.kind === ts.SyntaxKind.SuperKeyword) { - return lowerer.lowerSuperAccessorRead(expr); - } - // Enum member reads (`E.A`) fold to their compile-time constants — - // claimed FIRST so no receiver-shaped path (records, the - // identifier fence on the enum object) sees them. lower-enums.ts. - { - const en = lowerEnumAccess(lowerer, expr); - if (en) return en; - } - // `import.meta.` — the file-backed ESM metadata that survives - // compilation as a constant. Only direct, identifier-named reads are - // claimed here; the bare meta object, computed access, writes, and - // unsupported members continue through the generic module-loader fence - // below. The source file is the module identity, so imported modules - // receive their own filename/dirname and only the entry receives main. - const importMeta = importMetaProperty(lowerer, expr); - if (importMeta) return importMeta; - // `require.main.filename` / `require.main?.filename` — CommonJS - // entry-module identity: in a compiled binary require.main IS the - // entry module (never undefined in a CJS graph, so the chain's guard - // folds away), and its filename is the ENTRY file's real path — - // Node's exact value when the same tree runs in place, exactly the - // __filename stance. Checked BEFORE the optional-chain gate: the - // checker types the chain `string | undefined`, but the value is a - // compile-time string. - if (isRequireMainFilename(lowerer, expr)) { - return { kind: "strLit", value: lowerer.entry.fileName, type: STRING, loc }; - } - const requireMain = lowerRequireMainProperty(lowerer, expr); - if (requireMain) return requireMain; - const moduleProperty = lowerNodeModuleProperty(lowerer, expr); - if (moduleProperty) return moduleProperty; - if ( - stdlibGlobalNameOf(lowerer, expr.expression) === "Symbol" && - [ - "iterator", - "asyncIterator", - "toStringTag", - "toPrimitive", - "hasInstance", - "isConcatSpreadable", - "match", - "matchAll", - "replace", - "search", - "split", - "species", - "unscopables", - "dispose", - "asyncDispose", - ].includes(expr.name.text) - ) { - return { - kind: "libCall", - fn: "sym.wellKnown", - args: [{ kind: "strLit", value: expr.name.text, type: STRING, loc }], - type: { kind: "symbol" }, - loc, - }; - } - if (lowerer.stdlibGlobalMember(expr, "Object") === "freeze") { - const name = "%builtin.Object.freeze"; - if (!lowerer.liftedFns.some((fn) => fn.name === name)) - lowerer.liftedFns.push({ - name, - params: [{ localId: "value", name: "value", type: DYN }], - locals: [{ id: "value", name: "value", type: DYN, mutable: false }], - returnType: DYN, - body: [ - { - kind: "return", - value: { - kind: "libCall", - fn: "dyn.freeze", - args: [varRef("value", DYN, loc)], - type: DYN, - loc, - }, - loc, - }, - ], - loc, - }); - return { kind: "closure", fnName: name, captures: [], type: funcOf([DYN], DYN), loc }; - } - // An Hmac has no copy() (Hash does): Node answers undefined, which - // `typeof mac.copy` feature checks read. TypeScript rejects the read. - if ( - expr.name.text === "copy" && - lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "cryptoHmac" - ) { - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(expr.expression), loc }], - result: dynUndefinedExpr(loc), - type: DYN, - loc, - }; - } - if (lowerer.stdlibGlobalMember(expr, "globalThis") === "console") { - return { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }; - } - if (lowerer.stdlibGlobalMember(expr, "console") !== null) { - return { - kind: "dynKeyGet", - value: { kind: "libCall", fn: "console.native", args: [], type: DYN, loc }, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, - type: DYN, - loc, - }; - } - // Missing host globals (Bun/Deno capability probes) have no binding - // in a native program. Declared globals retain their own lowering; - // arbitrary global-object mutation remains fenced. Preserve a dyn - // undefined value so stored probes compose with optional calls. - if ( - stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis" && - !lowerer.checker.getPropertyOfType(lowerer.typeOf(expr.expression), expr.name.text) - ) { - return dynUndefinedExpr(loc); - } - // `Uint8Array.prototype.toBase64` and its siblings, the base64/hex - // feature probes libraries such as jose make: the methods are newer - // than Node 24, whose Uint8Array prototype answers undefined for them. - if (isUint8ArrayBase64Probe(lowerer, expr)) return dynUndefinedExpr(loc); - if (expr.questionDotToken && expr.name.text === "electron") { - const processProperty = lowerer.lowerProcessProperty(expr); - if (processProperty) return processProperty; - } - // Optional chaining `a?.b`: the guard lowers here (a tag test around - // the plain property lowering below); the handled marker keeps this - // re-entrant dispatch from looping. - if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) { - return lowerer.lowerOptionalChain(expr); - } - // `a?.b.c` — the tail of a chain whose token sits deeper: the whole - // tail short-circuits with the guard. - if (!expr.questionDotToken && isOptionalChainTail(lowerer, expr)) { - return lowerer.lowerOptionalChain(expr); - } - // Known Web handles retain their declared surface even when their - // storage uses checked values. Apply this before generic reads. - lowerer.fenceStaticAbortControllerMemberRead(expr); - lowerer.fenceStaticResponseMember(expr, "read"); - lowerer.fenceStaticHeadersMember(expr, "read"); - lowerer.fenceStaticReadableStreamMember(expr, "read"); - - // — reading off null/undefined — bridged catchably like everything - // at the boundary). `o?.x` arrives here too, through the chain - // lowering's re-dispatch (a questionDotToken past the gate above is - // always chain-handled; the receiver reads back as the chain's - // bound handle). - - // `m.index` on a for-of-over-matchAll binding reads the companion- - // index array at this iteration's cursor — always a number: every - // drained row matched, so the lib's `.index` is never undefined - // here (lowerForOfMatchAll). - if (expr.name.text === "index" && !expr.questionDotToken && ts.isIdentifier(expr.expression)) { - const sym = lowerer.checker.getSymbolAtLocation(expr.expression); - const companion = sym !== undefined ? lowerer.matchAllIndexBindings.get(sym) : undefined; - if (companion !== undefined) { - return { - kind: "arrayGet", - arr: { kind: "varRef", localId: companion.idxsLocalId, type: arrayOf(F64), loc }, - index: { kind: "varRef", localId: companion.curLocalId, type: F64, loc }, - type: F64, - loc, - }; - } - } - if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) { - const represented = tryLowerExpression(lowerer, expr.expression); - if ( - represented && - isMatchSliceType(lowerer, represented.type) && - (lowerer.isStdlibMember(expr) || isJsSourceFile(expr.getSourceFile())) - ) { - const recv = represented; - const key: IrExpr = { kind: "strLit", value: expr.name.text, type: STRING, loc }; - // A plain slice answers from its match source without a dynamic copy. - const read: IrExpr = typeEquals(recv.type, arrayOf(STRING)) - ? { kind: "libCall", fn: "regex.resultProp", args: [recv, key], type: DYN, loc } - : { kind: "dynKeyGet", value: lowerer.coerceToExpected(recv, DYN), key, type: DYN, loc }; - return lowerer.maybeNarrow(read, expr); - } - } - // `m.groups` on a match result whose regex is statically known: - // the compile-time record projection over the honest slice (or - // Node's undefined when the pattern has no named groups) — see - // lowerMatchGroupsRead. - { - const g = lowerMatchGroupsRead(lowerer, expr); - if (g !== null) return g; - } - // `arguments.length` in a TYPED function whose signature is - // FIXED-ARITY (no optional/default/rest parameters): tsc enforces - // exact arity at every call site and call/apply/bind indirection is - // fenced, so the actual count IS the declared count — a compile-time - // constant. Arrows don't bind `arguments` (the walk skips them, - // exactly JS's scoping); variable-arity signatures keep the fence - // (the count would need a hidden argc). The JS-source variadic - // `arguments` object (dynRest) was claimed at identifier lowering. - if ( - expr.name.text === "length" && - !expr.questionDotToken && - ts.isIdentifier(expr.expression) && - expr.expression.text === "arguments" && - !lowerer.ctx.argumentsLocal && - lowerer.typeOf(expr.expression).getSymbol()?.name === "IArguments" && - lowerer.isStdlibSymbol(lowerer.typeOf(expr.expression).getSymbol()) - ) { - let fn: ts.Node | undefined = expr.parent; - while ( - fn !== undefined && - !ts.isFunctionDeclaration(fn) && - !ts.isFunctionExpression(fn) && - !ts.isMethodDeclaration(fn) && - !ts.isConstructorDeclaration(fn) && - !ts.isGetAccessorDeclaration(fn) && - !ts.isSetAccessorDeclaration(fn) - ) { - fn = ts.isSourceFile(fn) ? undefined : fn.parent; - } - if (fn !== undefined) { - const fixedArity = (fn as ts.FunctionLikeDeclaration).parameters.nodes.every( - (p) => - p.questionToken === undefined && - p.initializer === undefined && - p.dotDotDotToken === undefined, - ); - if (fixedArity) { - return { - kind: "numLit", - value: (fn as ts.FunctionLikeDeclaration).parameters.nodes.length, - type: F64, - loc, - }; - } - lowerer.unsupported( - "SC1090", - expr, - "'arguments.length' in functions with optional, default, or rest parameters (the count varies per call — a fixed-arity signature folds to a constant)", - ); - } - } - // CommonJS namespace binding (`const lib = require("./lib.js")`): - // `lib.member` IS the member — the export table is alias plumbing, - // so the NAME resolves exactly like a bare identifier reference - // (resolveValueSymbol lands on the exporter's declaration and every - // existing path — globals, function values, classes — applies). - if (!expr.questionDotToken && lowerer.cjsLocalModuleBindingOf(expr.expression)) { - // A binding whose dep is a class-expression WHOLE export - // (`module.exports = class {…}`): the member surface is the - // CLASS's — `C.name` folds to NamedEvaluation's answer and own - // statics read their globals (lowerStaticFieldRead resolves the - // binding through the expression's own symbol). The plain - // member-name delegation below would resolve `name` against the - // stdlib var instead — a wrong VALUE, not a fence. Unknown - // members fall through to the delegation (expando statics ride - // their pre-registered export globals). - const viaClass = lowerStaticFieldRead(lowerer, expr); - if (viaClass) return viaClass; - return lowerExpr(lowerer, expr.name); - } - // `module.exports` READ in a CommonJS file whose whole export IS a - // class expression (`module.exports = class …{}`): the read answers - // the class VALUE — `new module.exports()` inside the module is the - // requirer's `new C()` spelled locally. Top-level reads ABOVE the - // assignment fall through to their existing fences (Node still - // answers the original export object there); function bodies - // resolve unconditionally — they run after the module evaluated, - // the same approximation identifier exports already make. Every - // other module.exports read keeps its existing story. - if ( - !expr.questionDotToken && - isModuleExportsAccess(expr) && - isCjsJsFile(expr.getSourceFile(), lowerer.program) - ) { - const whole = cjsClassExprWholeExportOf(expr.getSourceFile()); - if (whole) { - let inBody = false; - for ( - let p: ts.Node | undefined = expr.parent; - p !== undefined && !ts.isSourceFile(p); - p = p.parent - ) { - if (ts.isFunctionLike(p) || ts.isClassDeclaration(p) || ts.isClassExpression(p)) { - inBody = true; - break; - } - } - if (inBody || expr.getStart() > whole.stmt.getEnd()) { - return lowerer.classValueRef(lowerer.lowerClassExpressionInfo(whole.classExpr), expr); - } - } - } - // `module.exports.Sub` / `exports.Sub` READ in the same module: an - // EXPRESSION-valued member rides its pre-registered export global — - // exactly what requirers see through `require('./x').Sub`, and the - // global IS the storage every `exports.Sub =` statement assigns, so - // the read is Node's live member. Identifier-valued members have no - // global (pure alias plumbing) and fall through unchanged; write - // positions belong to the export-assignment machinery. - { - const writePos = - ts.isBinaryExpression(expr.parent) && - expr.parent.left === expr && - expr.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - const g = cjsInFileExportGlobalOf(lowerer, expr); - if (g && !writePos) return { kind: "varRef", localId: g.id, type: g.type, loc }; - } - // Namespace-qualified reads (`N.x`, `A.B.C.f`, import= alias - // chains): the member is a compile-time-known declaration of a - // lowered namespace block — it resolves exactly like a bare - // identifier reference (globals, function values, classes), guarded - // by the namespace source-order fences (lower-namespaces.ts). - if (!expr.questionDotToken) { - const nsMember = nsMemberIdentOf(lowerer, expr); - if (nsMember) { - const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); - if (memberSym) fenceEarlyNsMemberRef(lowerer, expr, memberSym); - return lowerExpr(lowerer, nsMember); - } - // Expando function members (`foo.bar` after `foo.bar = 12`): the - // member's module global (lower-expando.ts), before the ambient- - // namespace fence — a declare-namespace merge over a real function - // (`declare namespace Foo { var baz: number }` + `function Foo`) - // reads its assigned members, not the ambient ReferenceError. - { - const ex = expandoMemberRead(lowerer, expr); - if (ex) return lowerer.maybeNarrow(ex, expr); - } - // An AMBIENT namespace receiver (`M.x` where only `declare - // namespace M` exists): the namespace object never exists at - // runtime — Node's exact catchable ReferenceError at the access, - // the ambient `declare const` stance. - const ambientRoot = ambientNsRootOf(lowerer, expr.expression); - if (ambientRoot !== null) { - const t = ambientUndefReadType(lowerer, expr); - if (t) return nsUndefRead(lowerer, ambientRoot.text, expr, t); - } - } - // `exports.` READS in a module whose export object was - // REPLACED (`module.exports = ...`): `exports` still references the - // ORIGINAL object — Node's aliasing rule — so when nothing ever - // attached this name to it (`exports. =` nowhere in the - // file), the honest answer is undefined. tsc types the read off the - // REPLACEMENT (its CJS model is identity-blind), which is exactly - // the undiagnosed divergence this lowering closes. Reads in modules with - // real `exports. =` attachments keep their fences. - { - const ex = lowerReplacedExportsRead(lowerer, expr); - if (ex) return ex; - } - // `this.` inside a CJS export-table getter: Node binds the - // receiver to module.exports — the read is the sibling getter's - // lifted call. - { - const tm = lowerCjsExportTableThisMember(lowerer, expr); - if (tm) return tm; - } - // Property signatures of class-backed protocols read implementing - // classes' fields directly. - if (!hasOptionalChainGuard(expr.expression)) { - const typed = lowerProtocolPropertyRead(lowerer, expr); - if (typed) return typed; - } - const handled = - lowerer.lowerProcessEnvGet(expr) ?? - lowerer.lowerServerProperty(expr) ?? - // diagnostics_channel Channel receivers — name/hasSubscribers - // over the f64 channel handle. - lowerer.lowerDiagnosticsChannelProperty(expr) ?? - // readline Interface receivers — the history and option members - // over the f64 interface handle. - lowerReadlineProperty(lowerer, expr) ?? - // TracingChannel receivers — the five event channels and - // hasSubscribers over the f64 tracing handle. - lowerer.lowerTracingChannelProperty(expr) ?? - lowerer.lowerTestCtxProperty(expr) ?? - lowerer.lowerProcessProperty(expr) ?? - lowerer.lowerProcessStreamProperty(expr) ?? - lowerer.lowerFsConstantsProperty(expr) ?? - // http2.constants.NGHTTP2_CANCEL (any constants-object spelling): - // the baked-literal table. - lowerer.lowerBuiltinConstantsProperty(expr) ?? - // Builtin namespace imports (`path.sep`, `os.EOL`, - // `fs.constants.R_OK` where the root is `import * as ...`): the - // same constants and per-member fences as named builtin imports. - lowerer.lowerNamespaceBuiltinProperty(expr) ?? - lowerer.lowerJsonProperty(expr) ?? - lowerer.lowerErrorCodeProperty(expr) ?? - lowerer.lowerNumberStaticProperty(expr) ?? - lowerEventStaticProperty(lowerer, expr) ?? - lowerer.lowerMathProperty(expr) ?? - lowerer.lowerIntrinsicProperty(expr) ?? - // The _readableState/_writableState scalar READS (the suite's - // asserts) — checked before the generic stream property surface: - // the inner access maps no type of its own. - lowerStreamStateProperty(lowerer, expr) ?? - lowerStreamObjectProperty(lowerer, expr) ?? - lowerEventObjectProperty(lowerer, expr) ?? - lowerer.lowerUnionProperty(expr) ?? - lowerer.lowerFieldRead(expr); - // A union-typed field read narrows like an identifier when the - // checker has narrowed this use to one arm. - if (handled) { - if ( - handled.type.kind === "union" && - lowerer.armTag(handled.type.unionId, UNDEFINED_T) >= 0 && - handled.kind === "recordGet" && - lowerer.isRuntimeOptionalField(handled.shapeId, handled.field) - ) - return handled; - // A class field assigned a missing read: the checker narrows this - // read to the assigned value's bare type, but the stored undefined - // arm is real (`obj.n = bytes[i]; obj.n` is undefined in Node). - if (runtimeOptionalClassField(lowerer, handled, expr)) return handled; - return iteratorResultValueRead(lowerer, handled, expr) ?? lowerer.maybeNarrow(handled, expr); - } - // `globalThis.` that no lowering above claimed - // (globalThis.crypto, globalThis.SubtleCrypto, globalThis.localStorage - // — the harness's capability-conditional knownGlobals adds): the - // PROPERTY spelling of the identifier chokepoint's JS rule — a stdlib - // global taken as a VALUE in a JavaScript source is the same opaque - // IDENTITY TOKEN the bare spelling answers (`globalThis.crypto` IS - // `crypto` — one global, one token, or identity flows through Sets - // and === would disagree between the two spellings). TypeScript - // sources keep the SC2020 member fence below, like the bare form. - if ( - !expr.questionDotToken && - stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis" && - isJsSourceFile(expr.getSourceFile()) - ) { - const canonical = stdlibGlobalNameOf(lowerer, expr); - if (canonical !== null) { - if (canonical === "globalThis") return lowerGlobalValue(lowerer, expr); - if (canonical === "ArrayBuffer") - return { kind: "libCall", fn: "arrayBuffer.constructor", args: [], type: DYN, loc }; - if (canonical === "fetch") - return { kind: "libCall", fn: "fetch.function", args: [], type: DYN, loc }; - if (Object.values(BYTES_ELEMENT_NAME).includes(canonical)) - return { - kind: "libCall", - fn: "bytes.constructor", - args: [{ kind: "strLit", value: canonical, type: STRING, loc }], - type: DYN, - loc, - }; - return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; - } - } - // A JS receiver whose CHECKER type has no mapping (`mustCallChecks - // .length` where the binding is an evolving any[] living as a - // checked-dynamic global): the stdlib member fence below would blame - // the unmappable checker type (`any[].length`), but the VALUE is a - // dyn node — lower the receiver first and read through the dyn keyed - // read (ARR answers length; everything else JS's own-property - // answer). Mapped receivers keep the fence-first order: their - // members' gaps are real surface gaps ([1,2].entries), not - // representation artifacts. `.length` on a receiver whose checker - // ARRAY type lowers to the checked-dynamic representation — - // `unknown[]`, the collapsed `(string | object)[]`, and the `any[]` - // an Array.isArray guard narrows a collapsed union to — is the checked-dynamic tree - // array's OWN length (a keyed read the ARR kind answers exactly), - // never an `Array.prototype` surface gap; both source languages. - // Prototype-method VALUE reads (`ps.map` unparenthesized) keep the - // fence-first order: a stored-member undefined would mis-answer - // them, and calls dispatch through the dyn method machinery - // instead. - if ( - (isJsSourceFile(expr.getSourceFile()) && - (lowerer.mapTypeOf(lowerer.typeOf(expr.expression)) === null || - (lowerer.typeOf(expr.expression).flags & ts.TypeFlags.Never) !== 0 || - // A never-tainted receiver type maps (never rides as f64) but - // its VALUE lowered checked-dynamic — same dyn read. - neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)))) || - (expr.name.text === "length" && - (lowerer.checkerAnyArray(expr.expression) || - (lowerer.checker.isArrayType(lowerer.typeOf(expr.expression)) && - lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind === "dyn"))) - ) { - const recv = lowerer.lowerExpr(expr.expression); - if (recv.type.kind === "dyn") { - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - const opt = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - key, - ...(opt ? { optional: true as const } : {}), - value: recv, - type: DYN, - loc, - }, - expr, - ); - } - if ( - expr.name.text === "length" && - (recv.type.kind === "array" || recv.type.kind === "string") - ) { - return recv.type.kind === "array" - ? { kind: "arrIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc } - : { kind: "strIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc }; - } - } - if (isJsSourceFile(expr.getSourceFile()) && expr.name.text === "constructor") { - const receiver = tryLowerExpression(lowerer, expr.expression); - if ( - receiver?.type.kind === "dyn" || - (receiver?.type.kind === "object" && - !lowerer.classes.get(receiver.type.className)?.fields.has("constructor")) - ) { - return { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(receiver, DYN), - key: { kind: "strLit", value: "constructor", type: STRING, loc }, - type: DYN, - loc, - }; - } - } - // `f.name` / `f.length` / own properties on a FUNCTION-typed JS - // value (the mustCall wrapper's function-instance members): read - // through the dyn box — the closure's own-property table answers - // first (defineProperties writes land there), then the box's - // best-effort static name and the declared arity; anything else is - // the own-property answer, undefined. Function.prototype METHOD - // names stay fenced (a stored-member undefined would mis-answer - // `f.call` as a value). Static callable values keep the existing TS fence; checked callable - // storage can expose its own name and length in both source languages. - if ( - !["apply", "bind", "call", "toString", "caller", "arguments"].includes(expr.name.text) && - (isJsSourceFile(expr.getSourceFile()) || - expr.name.text === "name" || - expr.name.text === "length" || - expr.name.text === "constructor") - ) { - const probed = tryLowerExpression(lowerer, expr.expression); - // Inferred callable returns can use checked storage even when the - // checker still describes a function. Read its actual dyn value; - // the ambient Function member must not hide its own properties. - const receiverType = lowerer.typeOf(expr.expression); - const receiverSymbol = receiverType.getSymbol(); - const checkedFunction = - lowerer.checker.getCallSignatures(receiverType).length > 0 || - (receiverSymbol?.name === "Function" && lowerer.isStdlibSymbol(receiverSymbol)); - if (probed?.type.kind === "dyn" && checkedFunction) { - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - const opt = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - key, - value: probed, - ...(opt ? { optional: true as const } : {}), - type: DYN, - loc, - }, - expr, - ); - } - if ( - (isJsSourceFile(expr.getSourceFile()) || expr.name.text === "constructor") && - probed?.type.kind === "func" && - canBoxFuncIntoDyn( - probed.type, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) { - const fnName = jsFuncNameOf(expr.expression); - const boxed: IrExpr = { - kind: "dynFrom", - value: probed, - type: DYN, - ...(fnName !== null ? { fnName } : {}), - loc, - }; - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - return lowerer.maybeNarrow({ kind: "dynKeyGet", key, value: boxed, type: DYN, loc }, expr); - } - } - // A NARROWED IteratorResult receiver (`if (!r.done) r.value` — the - // checker narrows to IteratorYieldResult/IteratorReturnResult, whose - // own type cannot map: the shared record shape needs BOTH channels): - // the receiver's LOWERED record still carries the field, so the read - // is an ordinary recordGet — maybeNarrow then bridges the union slot - // to the checker's narrowed arm, exactly like a union field read. - if (expr.name.text === "done" || expr.name.text === "value") { - const receiverType = lowerer.typeOf(expr.expression); - const recvSym = - receiverType.getAliasSymbol()?.name === "IteratorResult" - ? receiverType.getAliasSymbol() - : receiverType.getSymbol(); - if ( - (recvSym?.name === "IteratorResult" || - recvSym?.name === "IteratorYieldResult" || - recvSym?.name === "IteratorReturnResult") && - recvSym.declarations.some( - (d) => - (ts.isInterfaceDeclaration(d) || ts.isTypeAliasDeclaration(d)) && - lowerer.isStdlibFile(d.getSourceFile()), - ) - ) { - const recv = lowerer.lowerExpr(expr.expression); - if (recv.type.kind === "dyn") - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: recv, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, - type: DYN, - loc, - }, - expr, - ); - if (recv.type.kind === "record") { - const shape = lowerer.shapes.get(recv.type.shapeId); - const field = shape?.fields.find((f) => f.name === expr.name.text); - if (field) { - const read: IrExpr = { - kind: "recordGet", - obj: recv, - shapeId: recv.type.shapeId, - field: field.name, - type: field.type, - loc, - }; - return iteratorResultValueRead(lowerer, read, expr) ?? lowerer.maybeNarrow(read, expr); - } - } - } - } - // A chain rooted at an initializer-less ambient `declare const/var` - // whose declared type has NO mapping (mappable roots compose through - // the bare-read undefRead and never reach here): Node throws the - // catchable ReferenceError at the ROOT read before any member - // matters, so the whole access lowers to that throw — typed by the - // use site, or by the context it flows into (the throw never - // returns, so the dummy is never observed). - { - const ambientRoot = ambientUndefVarRootOf(lowerer, expr); - if (ambientRoot !== null) { - const t = ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr); - if (t) return nsUndefRead(lowerer, ambientRoot.text, expr, t); - } - } - // Checked-dynamic stdlib results retain ambient property symbols, - // including parseArgs tokens narrowed to anonymous union arms. Only - // members owned by these implemented families bypass the stdlib fence. - const segmentDataSymbol = lowerer.typeOf(expr.expression).getSymbol(); - const segmentDataProperty = - segmentDataSymbol?.name === "SegmentData" && - lowerer.isStdlibSymbol(segmentDataSymbol) && - ["segment", "index", "input", "isWordLike"].includes(expr.name.text); - // os.constants members vary by platform (the declared groups list - // every platform's names): a number member reads as `number | - // undefined`, like an array element, and a group stays checked. - const osConstants = isOsConstantsType(lowerer.typeOf(expr.expression), lowerer); - if ( - isParseArgsDynProperty(lowerer, expr) || - isStatFsDynProperty(lowerer, expr) || - segmentDataProperty || - osConstants || - isLegacyUrlSymbol(lowerer.checker.getSymbolAtLocation(expr.name), lowerer) - ) { - const recv = lowerer.lowerExpr(expr.expression); - if (recv.type.kind === "dyn") { - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - const opt = hasOptionalChainGuard(expr.expression); - const read: IrExpr = { - kind: "dynKeyGet", - key, - ...(opt ? { optional: true as const } : {}), - value: recv, - type: DYN, - loc, - }; - if (osConstants) { - return lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind === "f64" - ? lowerer.coerceToExpected(read, lowerer.withUndefinedArm(F64)) - : read; - } - return lowerer.maybeNarrow(read, expr); - } - } - // A member read through a NULLISH generic binding (`const i: I = null as any; const _i: I = i.something` — the receiver - // provably holds null/undefined forever): the read throws Node's - // exact TypeError at the access. - if (ts.isIdentifier(expr.expression) && expr.questionDotToken === undefined) { - const unit = nullishGenericBindingUnitOf( - lowerer, - lowerer.resolveValueSymbol(expr.expression), - ); - if (unit !== null) { - const t = - ambientUndefReadType(lowerer, expr) ?? contextualUndefReadType(lowerer, expr) ?? F64; - return nodeThrowExpr( - 1, - "", - `Cannot read properties of ${unit} (reading '${expr.name.text}')`, - t, - loc, - ); - } - } - // The lib fence's PROPERTY chokepoint: a stdlib-declared member that - // no lowering above claimed ([1,2].entries, Math.SQRT2, Promise.all, - // re.exec as a value, ...) reports SC2020 here. - lowerer.stdlibMemberFence(expr); - // The npm chokepoint: a member on a package-typed receiver in a - // static build — attributed to the package, like every other site. - lowerer.npmMemberFence(expr); - // A property read rooted at a BLOCKED binding: the declaration - // carries the real diagnostic — the SC2004 cascade, not a generic - // "property access" rejection. - { - let root: ts.Expression = expr.expression; - while (ts.isPropertyAccessExpression(root)) root = root.expression; - if (ts.isIdentifier(root) && lowerer.isBlockedBinding(lowerer.resolveValueSymbol(root))) { - lowerer.pushDiag(blockedBindingUseDiag(root.text, loc)); - throw new PoisonError(); - } - } - // Nothing claimed the member. Lower the RECEIVER before rejecting: - // when the receiver itself is the blocker (`(await import(x)).y` — - // the dynamic import is the unsupported part), ITS diagnostic is the - // honest one, not a generic recitation about the outer dot. A - // receiver that lowers cleanly means the MEMBER is the gap — name - // the property and the receiver's type instead of "property access". - const recvLowered = lowerer.lowerExpr(expr.expression); - // A dyn receiver — JSON.parse's `any`, or `unknown` the checker - // narrowed to `object` — reads through the dyn keyed read: member - // or undefined (JS's own-property answer), throwing JS's TypeError - // on undefined/null receivers unless an earlier `?.` guards the - // chain. Scalar-narrowed occurrences bridge via maybeNarrow's - // validated dynCheck as usual. - if ( - recvLowered.type.kind === "dyn" || - (isJsSourceFile(expr.getSourceFile()) && - (["f64", "bool", "bigint", "string", "symbol"].includes(recvLowered.type.kind) || - (recvLowered.type.kind === "union" && lowerer.dynConvertible(recvLowered.type)))) - ) { - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - const opt = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - key, - ...(opt ? { optional: true as const } : {}), - value: lowerer.coerceToExpected(recvLowered, DYN), - type: DYN, - loc, - }, - expr, - ); - } - // The lowered receiver is a RECORD the checker spelled wider — - // `s.match(re).groups.key` in a JS file: the checker says - // `{ [key: string]: string } | undefined`, but the groups - // projection already answered the record arm (its null trap - // included). Declared fields read directly; an index-signature - // shape serves undeclared keys through the overflow read, exactly - // the dot-access rule on checker-spelled hybrids. - if (recvLowered.type.kind === "record") { - const recvShape = lowerer.shapes.get(recvLowered.type.shapeId); - const field = recvShape?.fields.find((f) => f.name === expr.name.text); - if (field) { - return lowerer.maybeNarrow( - { - kind: "recordGet", - obj: recvLowered, - shapeId: recvLowered.type.shapeId, - field: field.name, - type: field.type, - loc, - }, - expr, - ); - } - if (recvShape?.indexValue !== undefined && !recvShape.tuple) { - return lowerer.maybeNarrow( - { - kind: "recordKeyGet", - obj: recvLowered, - shapeId: recvLowered.type.shapeId, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - overflowOnly: true, - type: recvShape.indexValue, - loc, - }, - expr, - ); - } - // An inferred package-JS call specializes a parameter to the - // caller's own record shape. Optional option names omitted by that - // caller read undefined; the missing field is not a failed type - // assertion. Known prototype members were handled/fenced above. - if ( - recvShape && - !recvShape.tuple && - lowerer.implicitParamTypes !== null && - isJsSourceFile(expr.getSourceFile()) && - npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null - ) { - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: recvLowered, loc }], - result: dynUndefinedExpr(loc), - type: DYN, - loc, - }; - } - } - // An ABSTRACT property through an abstract-typed receiver: the - // declaration is erased at runtime — Node defines no field for it, - // each concrete subclass declares its OWN (at its own layout - // position), so no shared base slot exists to read. Abstract - // ACCESSORS are the supported spelling: they declare a vtable slot. - if (recvLowered.type.kind === "object" && abstractPropertyDeclOf(lowerer, expr)) { - lowerer.unsupported( - "SC1090", - expr, - `reading the abstract property '${expr.name.text}' through a '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'-typed receiver (abstract property declarations are erased at runtime, so no shared slot exists — type the receiver as the concrete class, or declare an abstract getter instead)`, - ); - } - // Array reads may add an undefined arm even when the checker still - // reports a class. Its property bag must remain reachable, while - // an absent element takes the normal checked-value TypeError. - const nativeReceiver = lowerer.stripUndefinedArm(recvLowered.type); - if (ts.isPrivateIdentifier(expr.name) && recvLowered.type.kind === "object") { - const field = lowerer.classes.get(recvLowered.type.className)?.fields.get(expr.name.text); - if (field) - return { - kind: "fieldGet", - obj: recvLowered, - className: recvLowered.type.className, - field: expr.name.text, - type: field, - loc, - }; - } - if (nativeReceiver.kind === "object" && nativeReceiver.className === "%Error") { - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(recvLowered, DYN), - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, - type: DYN, - loc, - }, - expr, - ); - } - if (isDynTypedRefType(nativeReceiver)) { - const info = lowerer.classes.get(nativeReceiver.className); - if ( - info && - !info.def.runtime && - !info.builtinError && - !info.builtinEmitter && - !info.builtinStream - ) { - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(recvLowered, DYN), - key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, - type: DYN, - loc, - }, - expr, - ); - } - } - // A member a JavaScript module attached to one of its functions - // (`status.message = codes; module.exports = status`), read from - // TypeScript: JavaScript stores such members on the function's own - // property table (`fn.member = v` writes through the function's - // checked-dynamic face), so the read takes the same path and enters - // the static code at the type inference gave the member. - if (recvLowered.type.kind === "func" && jsFunctionExpandoMember(lowerer, expr)) { - const read: IrExpr = { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(recvLowered, DYN), - key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - type: DYN, - loc, - }; - const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); - return mapped && mapped.kind !== "void" ? lowerer.coerceInto(expr, read, mapped) : read; - } - lowerer.unsupported( - "SC1090", - expr, - `reading '${expr.name.text}' from a value of type '${lowerer.checker.typeToString(lowerer.typeOf(expr.expression))}'`, - ); - } + if (ts.isPropertyAccessExpression(expr)) return lowerPropertyAccessExpr(lowerer, expr, loc); // Meta-properties, named: `new.target` reflects HOW a function was // invoked (compiled functions are never constructors of themselves — @@ -3031,988 +703,6 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { lowerer.unsupported("SC1090", expr, `syntax '${ts.syntaxKindName(expr.kind)}'`); } -function moduleFileName(lowerer: Lowerer, sf: ts.SourceFile): string { - return lowerer.targetPlatform === "wasi" - ? (wasiGuestPath(sf.fileName) ?? sf.fileName.replace(/\\/g, "/")) - : sf.fileName; -} - -function importMetaProperty(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - return isImportMeta(expr.expression) ? importMetaField(lowerer, expr, expr.name.text) : null; -} - -/** `c ? a : b`. An explicit destination constructs each branch at that - * layout before joining it. Without one, retain the expression's inferred - * type and the contextual/sibling rules below. */ -export function lowerTernary( - lowerer: Lowerer, - expr: ts.ConditionalExpression, - expected?: IrType, -): IrExpr { - const loc = locOf(expr); - // `Array.isArray(x) ? x : [x]` over a `T | readonly T[]` union: tsc - // narrows the TRUE branch to `any[]` (maybeNarrow's isArray bridge - // rides that) but leaves the FALSE branch wide — a readonly array - // is not assignable to `any[]`, so the arm never subtracts. The - // RUNTIME tag test proves the remaining arm exactly, so the false - // arm's reads of x narrow to the union's one non-array checker - // constituent (the certs configuredTlds shape). Nested functions - // stay out (they run later, when the proof is stale — tsc's own - // invalidation rule). - const falseArmNarrows: ts.Identifier[] = []; - let falseArmNarrowType: ts.Type | null = null; - { - let c: ts.Expression = expr.condition; - while (ts.isParenthesizedExpression(c)) c = c.expression; - if ( - ts.isCallExpression(c) && - ts.isPropertyAccessExpression(c.expression) && - c.arguments.nodes.length === 1 && - ts.isIdentifier(c.arguments.nodes[0]!) && - lowerer.stdlibGlobalMember(c.expression, "Array") === "isArray" - ) { - const argIdent = c.arguments.nodes[0] as ts.Identifier; - const sym = lowerer.resolveValueSymbol(argIdent); - const t = lowerer.checker.getTypeAtLocation(argIdent); - const constituents = t.isUnionType() ? ts.constituentTypes(t) : []; - const nonArray = constituents.filter( - (a) => !lowerer.checker.isArrayType(a) && !lowerer.checker.isTupleType(a), - ); - const mapped = lowerer.mapTypeOf(t); - const armCount = mapped?.kind === "union" ? lowerer.arrayValueTags(mapped.unionId).length : 0; - if (sym && nonArray.length === 1 && armCount === 1) { - falseArmNarrowType = nonArray[0]!; - const collect = (n: ts.Node): void => { - if (ts.isFunctionLike(n)) return; - if ( - ts.isIdentifier(n) && - lowerer.resolveValueSymbol(n) === sym && - !lowerer.chainNarrowedType.has(n) - ) { - falseArmNarrows.push(n); - } - n.forEachChild(collect); - }; - collect(expr.whenFalse); - } - } - } - for (const n of falseArmNarrows) lowerer.chainNarrowedType.set(n, falseArmNarrowType!); - try { - const cond = lowerer.lowerCondition(expr.condition); - // A condition the LOWERING proved constant (typeof-dyn against a - // kind no dyn box can hold — the bigint/symbol/function fold): only - // the taken arm exists at runtime, so only it lowers — which is - // exactly what lets a dual-mode arm with no static lowering (bigint - // literals) sit untaken in compiled JS. A boolLit carries no - // effects, so dropping the condition read loses nothing. - if (cond.kind === "boolLit") { - return lowerer.lowerExprExpecting(cond.value ? expr.whenTrue : expr.whenFalse, expected); - } - if (expected) { - // Construct fresh literals in their actual destination. Joining - // inferred layouts first can erase the literal discriminator or - // choose an empty array's uninhabited element representation. - // Runtime-optional proofs apply only within the selected true arm. - const then = withRuntimeOptionalNarrowed( - lowerer, - runtimeOptionalTrueIds(lowerer, expr.condition), - () => lowerer.lowerExprExpecting(expr.whenTrue, expected), - ); - const else_ = lowerer.lowerExprExpecting(expr.whenFalse, expected); - return { kind: "ternary", cond, then, else_, type: expected, loc }; - } - const ctxTs = lowerer.checker.getContextualType(expr); - const ctxMapped = ctxTs ? lowerer.mapTypeOf(ctxTs) : null; - // Array-literal arms build directly as the slot's array type when - // the ternary sits under an ARRAY context (tsc accepts each arm - // covariantly — `[stdin]` types string[] against an - // (Uint8Array | string)[] slot — but a tagged element representation - // must be BUILT as the slot's element type, the same rule element - // expressions follow inside every array literal). And an EMPTY - // array-literal arm with no usable type of its own (tsc infers - // `never[]`; no mappable contextual type reaches into the arm — the - // conditional-spread idiom `[...(c ? [x] : [])]`) adopts the SIBLING - // arm's array type: the ternary twin of the union-slot rule in - // lowerArrayLiteral, and just as unambiguous — tsc already typed the - // whole ternary by the filled arm alone. Both arms empty stays - // fenced (no element type exists anywhere). - const ctxArray = ctxMapped?.kind === "array" ? ctxMapped : null; - const armLiteral = (e: ts.Expression): ts.ArrayLiteralExpression | null => { - let x = e; - while (ts.isParenthesizedExpression(x)) x = x.expression; - return ts.isArrayLiteralExpression(x) ? x : null; - }; - const emptyUntypedArrayArm = (e: ts.Expression): boolean => { - const lit = armLiteral(e); - if (!lit || lit.elements.nodes.length !== 0) return false; - const t = lowerer.checker.getContextualType(lit) ?? lowerer.typeOf(lit); - if (!lowerer.mapTypeOf(t)) return true; - // never[] MAPS (the f64 representation for the uninhabited) but - // carries no element information — an empty literal typed that way - // still adopts the sibling arm's array type, tsc's own reading. - if (lowerer.checker.isArrayType(t)) { - const elem = lowerer.checker.getTypeArguments(t as ts.TypeReference)[0]; - if (elem !== undefined && elem.flags & ts.TypeFlags.Never) return true; - } - return false; - }; - // Inferred signature tables often join a populated argument array - // with nested empty-array branches. Carry the whole record layout - // through those branches before constructing their fresh fields. - const ownJoin = lowerer.mapTypeOf(lowerer.typeOf(expr)); - const recordJoin = - ctxMapped?.kind === "record" ? ctxMapped : ownJoin?.kind === "record" ? ownJoin : undefined; - const lowerArm = (e: ts.Expression, siblingType?: IrType): IrExpr => { - let fresh = e; - while (ts.isParenthesizedExpression(fresh)) fresh = fresh.expression; - if (recordJoin && ts.isConditionalExpression(fresh)) - return lowerTernary(lowerer, fresh, recordJoin); - if (recordJoin && ts.isObjectLiteralExpression(fresh)) { - return lowerer.lowerObjectLiteral(fresh, recordJoin); - } - const lit = armLiteral(e); - if (lit && ctxArray) return lowerer.lowerArrayLiteral(lit, ctxArray); - if (lit && siblingType?.kind === "array") { - if (emptyUntypedArrayArm(e)) { - return { kind: "arrayLit", elems: [], type: siblingType, loc: locOf(lit) }; - } - // A FILLED literal arm whose own element type is a UNION - // carrying the sibling's element as an arm (`Array.isArray(x) ? - // x : [x]` — the false arm's x checker-types as the whole union - // even though the runtime tag proved the non-array arm): build - // as the sibling's array type — each element coerces into the - // sibling element or fences on its own. - const ownT = lowerer.mapTypeOf(lowerer.typeOf(lit)); - if ( - ownT?.kind === "array" && - ownT.elem.kind === "union" && - siblingType.elem.kind !== "union" && - lowerer.armTag(ownT.elem.unionId, siblingType.elem) >= 0 - ) { - return lowerer.lowerArrayLiteral(lit, siblingType); - } - // A FILLED literal arm whose own array type has NO lift into the - // sibling's (`isWindows ? ['cmd.exe', ['/d']] : ['pwd', []]` — - // the empty nested literal types never[], whose f64-element - // representation re-tags into nothing): the generic path could - // only fence the whole ternary, so build AS the sibling type — - // each element coerces into the sibling's element slot or fences - // on its own, and a nested empty literal adopts the slot's array - // arm (the union-slot rule). tsc already accepted the arm - // covariantly against the join, so a fitting literal is exactly - // the value the checker typed. - if ( - ownT?.kind === "array" && - !typeEquals(ownT, siblingType) && - lowerer.widthLiftPlan(ownT, siblingType) === null - ) { - return lowerer.lowerArrayLiteral(lit, siblingType); - } - } - return lowerer.lowerExpr(e); - }; - // Lower the filled arm first so an empty arm can adopt its type. - // When the checker's OWN join for the ternary maps to an ARRAY, - // literal arms build against it directly (tsc collapses the arms' - // covariant array types into one — `c ? ['sh', []] : ['cmd', ['/d']]` - // joins as (string | string[])[] whichever arm nests the empty - // literal), so neither arm's uninhabited never[] reading decides. - const ownArrayJoin = (() => { - const m = lowerer.mapTypeOf(lowerer.typeOf(expr)); - return m?.kind === "array" ? m : null; - })(); - let thenRaw: IrExpr; - let elseRaw: IrExpr; - const runtimeTrueIds = runtimeOptionalTrueIds(lowerer, expr.condition); - const lowerTrueArm = (): IrExpr => - withRuntimeOptionalNarrowed(lowerer, runtimeTrueIds, () => - lowerArm(expr.whenTrue, ownArrayJoin ?? undefined), - ); - if (!ctxArray && emptyUntypedArrayArm(expr.whenTrue) && !emptyUntypedArrayArm(expr.whenFalse)) { - elseRaw = lowerer.lowerExpr(expr.whenFalse); - thenRaw = withRuntimeOptionalNarrowed(lowerer, runtimeTrueIds, () => - lowerArm( - expr.whenTrue, - elseRaw.type.kind === "dyn" && ownArrayJoin ? ownArrayJoin : elseRaw.type, - ), - ); - } else { - thenRaw = lowerTrueArm(); - // A checked-dynamic producer (Object.entries(...).filter(...)) - // can still have a precise checker array type. Empty literal arms - // must adopt that element layout before the checked array boundary. - elseRaw = lowerArm( - expr.whenFalse, - thenRaw.type.kind === "dyn" && ownArrayJoin ? ownArrayJoin : thenRaw.type, - ); - } - // The ternary's IR type is normally the checker's own: it collapses - // same-kind literal unions ("a" | "b" → string) and forms tagged - // unions for mixed arms that map (`c ? okRec : errRec`); anything - // left unmappable gets the type fence (badType) here. A record/union CONTEXTUAL type - // takes over exactly when the own type can't carry the value — - // unmappable, or a DIFFERENT union (branch literals omitting - // DIFFERENT optional subsets make the own type a union of the - // narrower fresh shapes, and a union-typed slot can sub-union the - // same way; neither has a runtime re-tag, while tsc guarantees each - // branch is assignable to the context). A base-CLASS context absorbs - // the same case: branches producing different subclasses make the - // own type a class union (`c ? new Dog() : new Bird()` against an - // Animal slot), while each branch upcasts into the context fine. A - // collapsed own type must WIN over a wider contextual union - // (`console.log(c ? "yes" : "no")` stays string against the ambient - // string | number | boolean parameter), and an 'unknown'/'any' - // context never absorbs the ternary (`JSON.stringify(c ? 1 : 2)` - // stays f64-typed). Arms that AGREE on an array type decide it - // themselves (a context- or sibling-built literal arm can carry a - // tagged element type the checker's own type doesn't spell). - const own = lowerer.mapTypeOf(lowerer.typeOf(expr)); - const useCtx = - (ctxMapped?.kind === "record" || - ctxMapped?.kind === "union" || - ctxMapped?.kind === "object") && - (own === null || - (own.kind === "union" && - !typeEquals(own, ctxMapped) && - !lowerer.inLogicalLeftPosition(expr))); - // Mixed dyn/unit arms under an unmappable own type (`typeof pkg.name - // === "string" ? pkg.name : null` — the lowering world types the - // unknown-receiver read `any`, which a static build cannot hold): - // dyn represents null and undefined directly, so the ternary stays - // dyn — a unit arm converts to the dyn unit value (the dynFrom form - // the index-signature lowerings already use). A FRESH literal arm - // (`... ? pkg.scripts : {}` — the default-object idiom) converts the - // same way when it is JSON-safe: nothing else aliases a literal, so - // the dyn copy is unobservable. - const dynish = (e: IrExpr): boolean => - e.type.kind === "dyn" || - e.kind === "unitLit" || - ((e.kind === "recordLit" || e.kind === "arrayLit") && lowerer.dynConvertible(e.type)); - const dynJoin = - own === null && - !(useCtx && ctxMapped) && - dynish(thenRaw) && - dynish(elseRaw) && - (thenRaw.type.kind === "dyn" || elseRaw.type.kind === "dyn"); - // A checker-`any` ternary whose lowered arms are STATIC (`rawName ? - // rawName.replace(...) : null` — the dyn-receiver string machinery - // answers a static string): the checker's `any` carries no shape, - // but the arms do — equal arms take their shared type, a unit arm - // joins the other arm as its null/undefined-armed union. Gated on - // genuine `any` so every other unmappable keeps its own diagnostic. - let anyJoin: IrType | null = null; - if ( - own === null && - !dynJoin && - !(useCtx && ctxMapped) && - (lowerer.typeOf(expr).flags & ts.TypeFlags.Any) !== 0 - ) { - const a = thenRaw.type; - const b = elseRaw.type; - const staticArm = (t: IrType): boolean => - t.kind !== "dyn" && t.kind !== "caught" && t.kind !== "void"; - if (staticArm(a) && staticArm(b)) { - if (typeEquals(a, b)) { - anyJoin = a; - } else if (isUnitType(a) !== isUnitType(b)) { - const unit = isUnitType(a) ? a : b; - const val = isUnitType(a) ? b : a; - const arms = - val.kind === "union" ? (lowerer.unions.get(val.unionId)?.arms ?? null) : [val]; - if (arms && !arms.some((x) => typeEquals(x, unit))) { - anyJoin = { kind: "union", unionId: lowerer.unions.intern([...arms, unit]) }; - } else if (arms) { - anyJoin = val; // the unit is already an arm - } - } - } - } - // JavaScript function selection preserves the chosen closure's full - // call ABI. A checker join can erase a rest/arguments pack even though - // a later call supplies additional observable arguments. - const callableJoin = - isJsSourceFile(expr.getSourceFile()) && - thenRaw.type.kind === "func" && - elseRaw.type.kind === "func" && - !typeEquals(thenRaw.type, elseRaw.type) && - lowerer.dynConvertible(thenRaw.type) && - lowerer.dynConvertible(elseRaw.type); - // JavaScript inference can discard an initially-undefined field from - // a conditional's type even after that field acquired other values. - // A checked arm must retain its actual representation at the join. - const checkedJsJoin = - isJsSourceFile(expr.getSourceFile()) && - (thenRaw.type.kind === "dyn" || elseRaw.type.kind === "dyn") && - lowerer.dynConvertible(thenRaw.type) && - lowerer.dynConvertible(elseRaw.type); - const type = - callableJoin || checkedJsJoin - ? DYN - : thenRaw.type.kind === "array" && typeEquals(thenRaw.type, elseRaw.type) - ? thenRaw.type - : dynJoin - ? DYN - : (anyJoin ?? (useCtx && ctxMapped ? ctxMapped : lowerer.irTypeOf(expr))); - // Each arm flows into the ternary's type through the slot-coercion - // path: union arms wrap, dyn slots reject the non-dyn arm with - // SC1101, mismatched record shapes get SC2002; coerceInto is - // inert when the types already agree. - const intoDyn = (e: IrExpr): IrExpr => - e.type.kind === "dyn" ? e : { kind: "dynFrom", value: e, type: DYN, loc: e.loc }; - const then = dynJoin ? intoDyn(thenRaw) : lowerer.coerceInto(expr.whenTrue, thenRaw, type); - const else_ = dynJoin ? intoDyn(elseRaw) : lowerer.coerceInto(expr.whenFalse, elseRaw, type); - if (then.type.kind !== type.kind || else_.type.kind !== type.kind) { - lowerer.badType(expr, lowerer.typeOf(expr)); - } - return { kind: "ternary", cond, then, else_, type, loc }; - } finally { - for (const n of falseArmNarrows) lowerer.chainNarrowedType.delete(n); - } -} - -/** Checker-driven union narrowing. tsc's control-flow analysis narrows a - * union-typed reference at use sites (`if (r.kind === "ok") { ...r... }` - * types `r` as the ok-arm inside the branch); the IR value is still the - * tagged union, so the read is bridged with a `unionNarrow` extracting - * the arm's payload. The extraction relies on tsc having proven the tag; - * backends verify it once and trap on a mismatch, so a wrong proof stops - * the program instead of reading another arm. A checker type that maps to - * the same union (unnarrowed use), to a SUB-union (partial narrowing — - * unrepresentable without a re-tag), or to nothing (`never` in an - * exhaustive default) leaves the expression union-typed. */ -export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrExpr { - if ( - (expr.type.kind === "union" || expr.type.kind === "dyn") && - isJsSourceFile(node.getSourceFile()) && - ts.isPropertyAccessExpression(node) - ) { - const symbol = lowerer.checker.getSymbolAtLocation(node.name); - if ( - symbol && - symbol.declarations.some( - (decl) => - ts.isPropertyDeclaration(decl) && - !decl.initializer && - !decl.type && - isJsSourceFile(decl.getSourceFile()), - ) - ) { - // Inference describes later writes, not the initial undefined. - // A use that requires the inferred type must validate the value. - return expr.type.kind === "dyn" - ? expr - : { kind: "dynFrom", value: expr, type: DYN, loc: expr.loc }; - } - } - // A dyn read tsc narrowed to a SCALAR (a typeof test proved the kind): - // bridge with a VALIDATED extraction — dynCheck, the checked-cast - // machinery — rather than a trusted one. After the guard the check - // never fires; a read smuggled past it throws a catchable TypeError - // instead of misreading the payload (the dyn boundary's usual stance). - // Object/array narrowings stay dyn-typed and keep their fences. - if (expr.type.kind === "dyn") { - // An inferred JS member's storage may be deliberately wider than its - // declaration (prototype-selected buffers, null-initialized scratch - // objects). Extract at the consuming operation, not at the read. - if (isJsSourceFile(node.getSourceFile()) && ts.isPropertyAccessExpression(node)) { - const receiver = lowerer.mapTypeOf(lowerer.typeOf(node.expression)); - if (receiver?.kind === "object") { - const info = lowerer.classes.get(receiver.className); - if ( - info?.fields.get(node.name.text)?.kind === "dyn" || - lowerer.findMethodOn(info ?? null, `get:${node.name.text}`)?.sig.ret.kind === "dyn" - ) - return expr; - } - if (receiver?.kind === "classval") { - const info = lowerer.classes.get(receiver.className); - if (info && hasRuntimeStatics(info)) return expr; - } - } - if ( - isJsSourceFile(node.getSourceFile()) && - (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) - ) { - let receiver = node.expression; - while ( - ts.isPropertyAccessExpression(receiver) || - ts.isElementAccessExpression(receiver) || - ts.isParenthesizedExpression(receiver) - ) { - if (ts.isPropertyAccessExpression(receiver)) { - const owner = lowerer.mapTypeOf(lowerer.typeOf(receiver.expression)); - if ( - owner?.kind === "object" && - lowerer.classes.get(owner.className)?.fields.get(receiver.name.text)?.kind === "dyn" - ) - return expr; - } - receiver = receiver.expression; - } - if (receiver.kind === ts.SyntaxKind.ThisKeyword && lowerer.resolveThis()?.type.kind === "dyn") - return expr; - if (ts.isIdentifier(receiver)) { - const stored = lowerer.peekLocal(receiver)?.type ?? lowerer.globalOf(receiver)?.type; - if (stored?.kind === "dyn") return expr; - const symbol = lowerer.resolveValueSymbol(receiver); - const bound = symbol && lowerer.implicitParamTypes?.get(symbol); - if (bound && lowerer.mapTypeOf(bound)?.kind === "dyn") return expr; - } - } - if (isJsSourceFile(node.getSourceFile()) && ts.isIdentifier(node)) { - const symbol = lowerer.resolveValueSymbol(node); - if ( - symbol && - symbol.declarations.some( - (decl) => - ts.isParameter(decl) && - ts.isMethodDeclaration(decl.parent) && - lowerer.checkedVirtualJsMethods.has(decl.parent), - ) - ) - return expr; - if ( - symbol && - symbol.declarations.some( - (decl) => ts.isParameter(decl) && ts.isConstructorDeclaration(decl.parent), - ) - ) - return expr; - if ( - symbol && - symbol.declarations.some( - (decl) => ts.isVariableDeclaration(decl) && jsBindingHasOpenWrites(lowerer, decl), - ) - ) - return expr; - if ( - symbol && - symbol.declarations.some( - (decl) => - ts.isParameter(decl) && - decl.initializer && - !decl.type && - !/@(?:param|type)\b/.test( - decl.getSourceFile().text.slice(decl.parent?.pos ?? decl.pos, decl.getStart()), - ), - ) - ) - return expr; - if ( - symbol && - symbol.declarations.some((decl) => { - if (!ts.isParameter(decl) || decl.type) return false; - const fn = decl.parent; - if (!ts.isFunctionExpression(fn) && !ts.isArrowFunction(fn)) return false; - const assignment = fn.parent; - return ( - ts.isBinaryExpression(assignment) && - assignment.right === fn && - ts.isPropertyAccessExpression(assignment.left) && - assignment.left.name.text === "write" && - lowerer.isStdlibMember(assignment.left) - ); - }) - ) - return expr; - } - // Node's TTY declarations promise booleans/numbers even when a pipe - // has no such property. Preserve the checked read for JS capability - // probes instead of treating a declaration as a runtime type guard. - if ( - isJsSourceFile(node.getSourceFile()) && - ts.isPropertyAccessExpression(node) && - ["isTTY", "columns", "rows"].includes(node.name.text) && - lowerer.isStdlibMember(node) - ) - return expr; - // A null-initialized JS field can later receive an object even when - // checker flow calls its non-null branch never. The live checked - // value remains authoritative; never is not a numeric guard. - if ( - isJsSourceFile(node.getSourceFile()) && - (lowerer.typeOf(node).flags & ts.TypeFlags.Never) !== 0 - ) - return expr; - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); - if ( - narrowed && - (narrowed.kind === "f64" || - narrowed.kind === "bool" || - narrowed.kind === "string" || - narrowed.kind === "bigint") - ) { - return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; - } - // An instanceof narrow retains the native view after validating its - // exact element kind. Buffer also matches Uint8Array. - if (narrowed?.kind === "bytes" || narrowed?.kind === "url") { - return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; - } - // An `instanceof Error` narrow: the checked-dynamic tree's error encoding rebuilds a - // fresh %Error (name/message/code from the marker object — a COPY, - // the unknown boundary's stance; SEMANTICS.md 67), validated like - // every dyn extraction. - if (narrowed?.kind === "object" && narrowed.className === "%Error") { - return { kind: "dynCheck", value: expr, type: narrowed, loc: expr.loc }; - } - return expr; - } - // instanceof narrowing for classes: tsc types this USE as a subclass of - // the IR value's class (only a dynamic instanceof test narrows a class - // type), so the read bridges with a static downcast that backends verify - // at run time, like the union bridge below. - if (expr.type.kind === "object") { - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); - if (narrowed?.kind === "union") { - const union = narrowClassUnion(lowerer, expr, narrowed); - if (union) return union; - } - if ( - narrowed?.kind === "object" && - narrowed.className !== expr.type.className && - lowerer.isSubclassOf(narrowed.className, expr.type.className) - ) { - const reduced = structuralNarrowing(lowerer, node, expr.type.className, narrowed); - const union = reduced ? narrowClassUnion(lowerer, expr, reduced) : null; - if (union) return union; - if (lowerer.narrowing.untrusted(node, narrowed)) { - const checked = checkedClassAssertion(lowerer, expr, narrowed, expr.loc); - if (checked) return checked; - } - return { kind: "downcast", value: expr, type: narrowed, loc: expr.loc }; - } - return expr; - } - if (expr.type.kind !== "union") return expr; - if (ts.isPropertyAccessExpression(node) && isOptionalProcessStreamProperty(lowerer, node)) - return expr; - - // Represented fields and index values can include undefined even when - // checker inference or lib.d.ts declares strings (RegExp captures). - // Only a declaration that represents the optional arm can provide a - // trustworthy flow narrowing; otherwise consumers must check it. - if ( - (expr.kind === "recordGet" || expr.kind === "recordKeyGet") && - (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) - ) { - // The read itself rules out a null or undefined receiver, which a - // JavaScript receiver such as `m.groups` still declares. - const receiver = lowerer.mapTypeOf( - lowerer.checker.getNonNullableType(lowerer.typeOf(node.expression)), - ); - const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined; - const declared = - expr.kind === "recordGet" - ? shape?.fields.find((field) => field.name === expr.field)?.type - : shape?.indexValue; - if ( - lowerer.armTag(expr.type.unionId, UNDEFINED_T) >= 0 && - declared && - !(declared.kind === "union" && lowerer.armTag(declared.unionId, UNDEFINED_T) >= 0) - ) - return expr; - // The same for a field holding a Date's toJSON() result, whose null - // the declared string does not represent. - if ( - expr.kind === "recordGet" && - lowerer.isRuntimeOptionalField(expr.shapeId, expr.field) && - lowerer.armTag(expr.type.unionId, NULL_T) >= 0 && - declared && - !(declared.kind === "union" && lowerer.armTag(declared.unionId, NULL_T) >= 0) - ) - return expr; - } - if (ts.isIdentifier(node)) { - const symbol = lowerer.resolveValueSymbol(node); - if (symbol && lowerer.unstableSwitchBindings.has(symbol)) return expr; - // A binding initialized by a Date's toJSON() can hold null where the - // checker says string; consumers check it. - if (symbol && lowerer.dateJsonBindings.has(symbol)) return expr; - } - if ( - ts.isPropertyAccessExpression(node) && - getterResultHoldsUndeclaredUndefined(lowerer, node, expr) - ) - return expr; - const narrowedTs = lowerer.typeOf(node); - const narrowed = lowerer.mapTypeOf(narrowedTs); - // `Array.isArray(u)` on a union with one readonly array/tuple arm: the - // lib predicate narrows to `any[]`, possibly intersected with another - // type. That mapping may be a checked value or a synthetic record, but - // the tag test proved the union's one array-valued arm, so bridge to it - // with the same trusted unionNarrow the mapped case below builds. - if (lowerer.checkerAnyArrayType(narrowedTs)) { - const def = lowerer.unions.get(expr.type.unionId); - const arrayTags = lowerer.arrayValueTags(expr.type.unionId); - if (arrayTags.length === 1) { - const arm = def!.arms[arrayTags[0]!]!; - const checked = checkedStaleArm(lowerer, node, expr, arrayTags[0]!, arm); - if (checked) return checked; - return { - kind: "unionNarrow", - unionId: expr.type.unionId, - tag: arrayTags[0]!, - value: expr, - type: arm, - loc: expr.loc, - }; - } - } - // A checker type narrowed to a UNIT arm (the `=== undefined` branch) - // also stays union-typed: a unit arm has no payload to extract, and - // nothing useful reads such a value anyway. (Standalone undefined maps - // to void and standalone null to nothing, so the isUnitType guard is - // defensive.) - if (!narrowed || narrowed.kind === "union" || narrowed.kind === "void" || isUnitType(narrowed)) { - return expr; - } - const tag = lowerer.armTag(expr.type.unionId, narrowed); - if (tag < 0) { - // The checker may refine fields inside a discriminated record too. - // That creates a different mapped shape, but does not change the - // stored payload layout. A required literal discriminator proves - // which original arm to read; retain all of that arm's field types. - const def = lowerer.unions.get(expr.type.unionId); - if (narrowed.kind === "record" && def?.discriminant) { - const property = lowerer.checker.getPropertyOfType(narrowedTs, def.discriminant.field); - if ( - property && - !( - property.flags & - (ts.SymbolFlags.Optional | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor) - ) - ) { - const values = literalValues(lowerer.checker.getTypeOfSymbol(property)); - const arm = values && literalUnionArm(def, values, (id) => lowerer.shapes.get(id)); - if (arm) { - const checked = checkedStaleArm( - lowerer, - node, - expr, - lowerer.armTag(expr.type.unionId, arm), - arm, - ); - if (checked) return checked; - return { - kind: "unionNarrow", - unionId: expr.type.unionId, - tag: lowerer.armTag(expr.type.unionId, arm), - value: expr, - type: arm, - loc: expr.loc, - }; - } - } - } - // A base-class arm can narrow to a subclass after instanceof. The - // payload retains its base-class tag even when the union also has - // records or other classes. Require one unambiguous containing arm. - const arms = lowerer.unions.get(expr.type.unionId)?.arms ?? []; - const valueTag = - narrowed.kind === "object" - ? arms.findIndex( - (arm) => - arm.kind === "object" && lowerer.isSubclassOf(narrowed.className, arm.className), - ) - : -1; - const valueType = arms[valueTag]; - if ( - narrowed.kind === "object" && - valueType?.kind === "object" && - arms.every( - (arm, i) => - i === valueTag || - arm.kind !== "object" || - !lowerer.isSubclassOf(narrowed.className, arm.className), - ) && - lowerer.isSubclassOf(narrowed.className, valueType.className) - ) { - const checked = checkedStaleArm(lowerer, node, expr, valueTag, valueType, narrowed); - if (checked) { - return checkedClassAssertion(lowerer, checked, narrowed, expr.loc) ?? checked; - } - return { - kind: "downcast", - value: { - kind: "unionNarrow", - unionId: expr.type.unionId, - tag: valueTag, - value: expr, - type: valueType, - loc: expr.loc, - }, - type: narrowed, - loc: expr.loc, - }; - } - return expr; - } - const checked = checkedStaleArm(lowerer, node, expr, tag, narrowed); - if (checked) return checked; - return { - kind: "unionNarrow", - unionId: expr.type.unionId, - tag, - value: expr, - type: narrowed, - loc: expr.loc, - }; -} - -/** A getter read whose runtime result can be undefined (an unchecked - * element read in its body, or in another member of its override family) - * although its declared type cannot. The checker's type for the read is - * then no narrowing proof, so the read keeps the getter's result. */ -function getterResultHoldsUndeclaredUndefined( - lowerer: Lowerer, - node: ts.PropertyAccessExpression, - expr: IrExpr, -): boolean { - if (expr.type.kind !== "union" || lowerer.armTag(expr.type.unionId, UNDEFINED_T) < 0) - return false; - const getter = - (expr.kind === "virtualCall" && expr.method.startsWith("get:")) || - (expr.kind === "call" && expr.callee.includes(".get:")); - if (!getter) return false; - const symbol = lowerer.checker.getSymbolAtLocation(node.name); - if (!symbol || (symbol.flags & ts.SymbolFlags.GetAccessor) === 0) return false; - const declared = lowerer.checker.getTypeOfSymbol(symbol); - const holdsUndefined = (type: ts.Type): boolean => - (type.flags & - (ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== - 0; - return !( - holdsUndefined(declared) || - (declared.isUnionType() && ts.constituentTypes(declared).some(holdsUndefined)) - ); -} - -/** The checked form of maybeNarrow's arm extraction for a narrowing the - * checker may hold past code that replaced the value (a call clearing the - * property; see NarrowingStability). Every other arm throws the TypeError - * Node raises for the operation consuming the read (`Cannot read - * properties of undefined (reading 'x')`, `o.f is not a function`), or the - * stranded-value TypeError where Node would carry the value on. Null when - * the narrowing is provably current. `narrowed` is the checker's type when - * it refines the arm (a subclass view). */ -function checkedStaleArm( - lowerer: Lowerer, - node: ts.Node, - value: IrExpr, - tag: number, - arm: IrType, - narrowed: IrType = arm, -): IrExpr | null { - if (value.type.kind !== "union" || tag < 0) return null; - if (!lowerer.narrowing.untrusted(node, narrowed)) return null; - const inPlace = checkedArmExtraction(lowerer, value, tag, arm, (found) => - staleReadMessage(node, found), - ); - if (inPlace) return inPlace; - const helper = lowerer.narrowedArmHelper(value.type.unionId, arm, value.loc); - return helper ? lowerer.checkedNarrowCall(helper, value, arm, value.loc) : null; -} - -/** V8's spelling of a callee in "is not a function": a string-literal - * element key reads as a property name (`o["a"]` is `o.a`), other keys - * keep their brackets. */ -export function v8CalleeText(node: ts.Node): string { - if (ts.isElementAccessExpression(node)) { - const key = node.argumentExpression; - const object = v8CalleeText(node.expression); - return ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key) - ? `${object}.${key.text}` - : `${object}[${key.getText()}]`; - } - if (ts.isPropertyAccessExpression(node) && !node.questionDotToken) - return `${v8CalleeText(node.expression)}.${node.name.text}`; - return node.getText(); -} - -/** Node's TypeError for the operation consuming `node` when it holds a - * value of type `found`, or null where Node would not throw. */ -function staleReadMessage(node: ts.Node, found: IrType): string | null { - let child: ts.Node = node; - let parent = node.parent; - while (ts.isParenthesizedExpression(parent)) { - child = parent; - parent = parent.parent; - } - if (ts.isCallExpression(parent) && parent.expression === child && !parent.questionDotToken) { - return found.kind === "func" ? null : `${v8CalleeText(node)} is not a function`; - } - const unit = found.kind === "undefinedT" ? "undefined" : found.kind === "nullT" ? "null" : null; - if (unit === null) return null; - if ( - !(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) || - parent.expression !== child || - parent.questionDotToken - ) - return null; - let key: string | null = null; - if (ts.isPropertyAccessExpression(parent)) key = parent.name.text; - else if (ts.isStringLiteral(parent.argumentExpression)) key = parent.argumentExpression.text; - else if (ts.isNumericLiteral(parent.argumentExpression)) - key = String(Number(parent.argumentExpression.text)); - if (key === null) return null; - const store = parent.parent; - return ts.isBinaryExpression(store) && - store.left === parent && - store.operatorToken.kind === ts.SyntaxKind.EqualsToken - ? `Cannot set properties of ${unit} (setting '${key}')` - : `Cannot read properties of ${unit} (reading '${key}')`; -} - -/** A runtime-optional slot can be assigned while a truthy guard is in - * force. TypeScript then types a direct member receiver as the plain arm, - * even though an OOB-safe assignment may have restored undefined. Check - * that hidden arm and throw the member-read TypeError Node would produce. */ -function runtimeOptionalReceiverRead( - lowerer: Lowerer, - expr: ts.Identifier, - local: IrLocal, - property: string | null, - loc: SrcLoc, -): IrExpr { - if (local.type.kind !== "union") { - lowerer.runtimeOptionalLocals.delete(local); - lowerer.runtimeOptionalStorageLocals.delete(local); - return varRef(local.id, local.type, loc); - } - // A non-null/type assertion around the receiver carries the useful - // checker narrowing on the wrapper rather than on the identifier token - // itself (`gg!.day`). Follow only erased wrappers; the member/call node - // still receives the checked extraction built below. - let narrowedNode: ts.Expression = expr; - while ( - (ts.isParenthesizedExpression(narrowedNode.parent) || - ts.isAssertionExpression(narrowedNode.parent) || - ts.isSatisfiesExpression(narrowedNode.parent) || - ts.isNonNullExpression(narrowedNode.parent)) && - narrowedNode.parent.expression === narrowedNode - ) { - narrowedNode = narrowedNode.parent; - } - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(narrowedNode)); - if (!narrowed || isUnitType(narrowed)) { - lowerer.unsupported( - "SC1090", - expr, - "a runtime-optional capture whose narrowed value type is not representable", - ); - } - if (narrowed.kind === "union") { - const classView = narrowStoredClassValue(lowerer, varRef(local.id, local.type, loc), narrowed); - if (classView) return classView; - // The checker may keep the receiver at the storage union unchanged - // (`string | undefined` on a JS `.match()` call). There is no - // sub-union to re-tag: preserve the value for the downstream - // nullable-receiver lowering, which performs the member-specific - // checked extraction. Refusing here deferred the whole statement to - // a runtime fence before that lowering could run. - if (typeEquals(local.type, narrowed)) { - return varRef(local.id, local.type, loc); - } - // A captured array element can itself be a union of records (for - // example Circle | Square). The optional storage union has one extra - // undefined arm; re-tag it to the checker-narrowed value union with a - // checked trap for that arm, then let union property lowering inspect - // the surviving record arms. - const helper = lowerer.narrowedRetagHelper( - narrowedNode, - local.type.unionId, - narrowed.unionId, - loc, - ); - if (!helper) { - lowerer.unsupported( - "SC1090", - expr, - "a runtime-optional capture whose narrowed union is not representable", - ); - } - return { - kind: "call", - callee: helper, - args: [varRef(local.id, local.type, loc)], - type: narrowed, - loc, - }; - } - if (narrowed.kind === "object") { - const view = checkedClassAssertion(lowerer, varRef(local.id, local.type, loc), narrowed, loc); - // Preserve the member-specific missing-value error on the two-arm - // path below; mixed storage also needs to validate its concrete class. - if (view && (lowerer.unions.get(local.type.unionId)?.arms.length ?? 0) > 2) return view; - } - const undefTag = lowerer.armTag(local.type.unionId, UNDEFINED_T); - const def = lowerer.unions.get(local.type.unionId); - if (!def || undefTag < 0) - throw new InternalCompilerError("runtime-optional local is missing its undefined arm"); - if (def.arms.length !== 2) { - // The checker may narrow a runtime-optional capture to one arm of a - // value union (`Circle | Square | undefined` -> `Circle`). The - // two-arm fast path below cannot extract that arm safely: a later - // array mutation can leave any of the stored tags in the slot. Use - // the checked helper so every other arm, including undefined, remains - // a catchable TypeError rather than an unchecked payload read. - const helper = lowerer.narrowedArmHelper(local.type.unionId, narrowed, loc); - if (!helper) { - lowerer.unsupported( - "SC1090", - expr, - "a direct read from a runtime-optional capture with multiple value arms", - ); - } - return lowerer.checkedNarrowCall(helper, varRef(local.id, local.type, loc), narrowed, loc); - } - // A predicate can strengthen a record's fields or narrow a base class - // to a subclass without changing the slot's stored value arm. Extract - // that actual arm first: record reads need its original field layout, - // and class reads apply the ordinary downcast bridge afterward. - const valueTag = undefTag === 0 ? 1 : 0; - const valueType = def.arms[valueTag]; - if (!valueType || isUnitType(valueType)) { - lowerer.unsupported( - "SC1090", - expr, - "a runtime-optional receiver without a representable value arm", - ); - } - const message = - property === null - ? `${expr.text} is not a function` - : `Cannot read properties of undefined (reading '${property}')`; - return lowerer.maybeNarrow( - { - kind: "ternary", - cond: { - kind: "unionIsTag", - unionId: local.type.unionId, - tag: undefTag, - negated: false, - value: varRef(local.id, local.type, loc), - type: BOOL, - loc, - }, - then: nodeThrowExpr(1, "", message, valueType, loc), - else_: { - kind: "unionNarrow", - unionId: local.type.unionId, - tag: valueTag, - value: varRef(local.id, local.type, loc), - type: valueType, - loc, - }, - type: valueType, - loc, - }, - narrowedNode, - ); -} - type RuntimeOptionalUse = | { kind: "property"; @@ -4036,7 +726,7 @@ type RuntimeOptionalUse = comma: boolean; }; -function runtimeOptionalUseOf(expr: ts.Expression): RuntimeOptionalUse | null { +export function runtimeOptionalUseOf(expr: ts.Expression): RuntimeOptionalUse | null { let receiver = expr; let complex = false; let comma = false; @@ -4109,36 +799,7 @@ function runtimeOptionalUseOf(expr: ts.Expression): RuntimeOptionalUse | null { return null; } -/** Object.keys/Object.entries require an object receiver, but their first - * argument is not syntactically a member receiver. Recognize that one - * standard-library position so a runtime-optional loop element can be - * checked against the checker-narrowed record arm before builtin lowering. */ -function runtimeOptionalObjectWalkerArg(lowerer: Lowerer, expr: ts.Expression): boolean { - let arg = expr; - while ( - (ts.isParenthesizedExpression(arg.parent) || - ts.isAssertionExpression(arg.parent) || - ts.isSatisfiesExpression(arg.parent) || - ts.isNonNullExpression(arg.parent)) && - arg.parent.expression === arg - ) { - arg = arg.parent; - } - const call = arg.parent; - if ( - !ts.isCallExpression(call) || - call.arguments.nodes[0] !== arg || - !ts.isPropertyAccessExpression(call.expression) - ) - return false; - const access = call.expression; - return ( - (access.name.text === "keys" || access.name.text === "entries") && - lowerer.isStdlibMember(access) - ); -} - -function runtimeOptionalElementKey(expr: ts.Expression): string | null { +export function runtimeOptionalElementKey(expr: ts.Expression): string | null { if (ts.isStringLiteral(expr) || ts.isNumericLiteral(expr)) return expr.text; if (ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; return null; @@ -4163,7 +824,10 @@ function isEqualityOperand(node: ts.Expression): boolean { * argument, an element assignment — arrays record the undefined state), * an equality operand, or a slot whose contextual type admits undefined. * TypeScript's `!` has no runtime effect, so such reads stay optional. */ -function assertedElementKeepsUndefined(lowerer: Lowerer, node: ts.NonNullExpression): boolean { +export function assertedElementKeepsUndefined( + lowerer: Lowerer, + node: ts.NonNullExpression, +): boolean { let outer: ts.Expression = node; while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; const parent = outer.parent; @@ -4205,7 +869,7 @@ function assertedElementKeepsUndefined(lowerer: Lowerer, node: ts.NonNullExpress ); } -function runtimeOptionalAssertionErases( +export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, inner: IrExpr, @@ -4224,223 +888,6 @@ function runtimeOptionalAssertionErases( ); } -/** `v === undefined` / `v !== null` — the narrowing tests for unit-armed - * unions. A union operand compared with a unit literal lowers to a - * runtime TAG test (unionIsTag); afterwards tsc's control-flow narrowing - * types the branches and reads bridge through maybeNarrow as usual. When - * the checker already narrowed the non-literal side PAST the union - * (`w = 5; if (w !== null)`), the comparison is statically decided and - * folds to a bool literal — that drops only a side-effect-free read - * (flow narrowing applies to references, never to calls), and soundness - * rests on the same narrowing proof unionNarrow relies on. Null when - * neither side is a unit literal (not a unit comparison). */ -export function lowerUnitComparison( - lowerer: Lowerer, - left: IrExpr, - right: IrExpr, - negated: boolean, - loc: SrcLoc, -): IrExpr | null { - const unit = left.kind === "unitLit" ? left : right.kind === "unitLit" ? right : null; - if (!unit) return null; - const other = unit === left ? right : left; - if (other.kind === "unitLit") { - // Two unit literals (`undefined === undefined`): statically decided. - return { kind: "boolLit", value: (other.unit === unit.unit) !== negated, type: BOOL, loc }; - } - const slot = lowerer.deferredFieldSlot(other); - if (slot) return lowerUnitComparison(lowerer, slot, unit, negated, loc); - // A binding the checker narrowed past the unit can still hold it at - // runtime when its value came from an unassigned deferred-init field; - // the stored tag answers either way. - if ( - other.kind === "unionNarrow" && - other.value.kind === "varRef" && - other.value.type.kind === "union" && - lowerer.armTag(other.value.type.unionId, unit.type) >= 0 - ) - return lowerUnitComparison(lowerer, other.value, unit, negated, loc); - if (other.type.kind === "union") { - const tag = lowerer.armTag(other.type.unionId, unit.type); - if (tag < 0) { - // A union WITHOUT that unit arm: legal TS (`miss === null` on a - // `number | undefined` — null/undefined guard comparisons are - // permitted against nullable-adjacent types), and === never - // coerces, so the answer is the constant `negated`. The literal - // must NOT flow into the union representation (the unionEq - // fallback would coerce it through the stranded-arm trap and - // throw where Node answers false). JS still evaluates the - // operand, so a non-droppable one (`xs.find(f) === null` — the - // callback's effects are observable) rides one throwaway tag - // test; droppable reads fold to the bare literal. - const answer: IrExpr = { kind: "boolLit", value: negated, type: BOOL, loc }; - if (isSafeToDiscard(other)) return answer; - const evalOnce: IrExpr = { - kind: "unionIsTag", - unionId: other.type.unionId, - tag: 0, - negated: false, - value: other, - type: BOOL, - loc, - }; - return { - kind: "logical", - op: negated ? "||" : "&&", - left: evalOnce, - right: answer, - type: BOOL, - loc, - }; - } - return { - kind: "unionIsTag", - unionId: other.type.unionId, - tag, - negated, - value: other, - type: BOOL, - loc, - }; - } - // A process.env read tsc narrowed past its union (an earlier write to - // the same key): the environment is VOLATILE — `delete process.env.K` - // undoes the write the narrowing rode on — so folding would bake a - // stale answer. Compare the fresh read's union tag instead. - const envRead = volatileEnvRead(other); - if (envRead && envRead.type.kind === "union") { - const tag = lowerer.armTag(envRead.type.unionId, unit.type); - if (tag >= 0) { - return { - kind: "unionIsTag", - unionId: envRead.type.unionId, - tag, - negated, - value: envRead, - type: BOOL, - loc, - }; - } - } - // A VOID-typed operand (`foo() === undefined` where foo's declared - // return is undefined/void — the mapping folds both to void): JS - // yields undefined from such a call, so the compare is TRUE-when-equal - // — the opposite of the never-holds-units fold below — and the IR has - // no value (nor a sequence form to keep the call's effects). Fall - // through to the caller's comparison fence instead of folding a lie. - if (other.type.kind === "void") return null; - // Non-union operand: it can never hold undefined/null at runtime (the - // checker narrowed it to a concrete arm), so `=== unit` is false and - // `!== unit` is true. - return { kind: "boolLit", value: negated, type: BOOL, loc }; -} - -/** The union-typed process.envGet read inside a checker-narrowed operand, - * or null — the volatility escape above (and lowerLooseNullCompare's). */ -function volatileEnvRead(e: IrExpr): IrExpr | null { - if (e.kind === "libCall" && e.fn === "process.envGet") return e; - if (e.kind === "unionNarrow" && e.value.kind === "libCall" && e.value.fn === "process.envGet") { - return e.value; - } - return null; -} - -function lowerLooseNullCompare( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken; - const unwrap = (e: ts.Expression): ts.Expression => - ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e; - const left = unwrap(expr.left); - const right = unwrap(expr.right); - const leftIsNull = left.kind === ts.SyntaxKind.NullKeyword; - if (!leftIsNull && right.kind !== ts.SyntaxKind.NullKeyword) return null; - const otherNode = leftIsNull ? right : left; - const other = lowerPresenceOperand(lowerer, otherNode); - if (isUnitType(other.type)) { - // `null == null`, `undefined == null`: units are mutually loose-equal. - return { kind: "boolLit", value: !negated, type: BOOL, loc }; - } - if (other.type.kind === "dyn") { - // `v != null` on unknown: one dyn kind test covers both units. - return { - kind: "dynTest", - test: "nullish", - ...(negated ? { negated: true as const } : {}), - value: other, - type: BOOL, - loc, - }; - } - if (other.type.kind === "union") { - const ut = other.type; - const def = lowerer.unions.get(ut.unionId); - if (!def) lowerer.badType(otherNode, lowerer.typeOf(otherNode)); - const tags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); - if (tags.length === 0) { - // No unit arms: never null-ish (defensive — the fold below). - return { kind: "boolLit", value: negated, type: BOOL, loc }; - } - const isTag = (tag: number): IrExpr => ({ - kind: "unionIsTag", - unionId: ut.unionId, - tag, - negated, - value: other, - type: BOOL, - loc, - }); - if (tags.length === 1) return isTag(tags[0]!); - // Both null AND undefined arms: tag-in-set as a short-circuit pair - // (De Morgan for `!=`). The operand IrExpr rides both tests, so the - // backend emits it per test — restricted to re-emittable pure reads; - // anything effectful gets its own actionable fence (the generic - // SC1040 hint would wrongly suggest `== null` as the fix). - if (!isSafeToRepeat(other)) { - lowerer.unsupported( - "SC1090", - expr, - `'== null' / '!= null' on a '${lowerer.fmt(ut)}' value that isn't a plain read ` + - `(both unit arms need the operand twice — bind it to a const first)`, - ); - } - return { - kind: "logical", - op: negated ? "&&" : "||", - left: isTag(tags[0]!), - right: isTag(tags[1]!), - type: BOOL, - loc, - }; - } - if (other.type.kind === "void") { - return null; // no static tag to test — keep the fence - } - // The env-volatility escape (see lowerUnitComparison): a narrowed - // process.env read compares its FRESH union tag instead of folding. - const envRead = volatileEnvRead(other); - if (envRead && envRead.type.kind === "union") { - const def = lowerer.unions.get(envRead.type.unionId); - const tag = def ? def.arms.findIndex((a) => isUnitType(a)) : -1; - if (tag >= 0) { - return { - kind: "unionIsTag", - unionId: envRead.type.unionId, - tag, - negated, - value: envRead, - type: BOOL, - loc, - }; - } - } - // A non-nullable operand (tsc allows the comparison as a guard): - // `== null` is statically false, `!= null` statically true. - return { kind: "boolLit", value: negated, type: BOOL, loc }; -} - /** `a ?? b` — JS-exact nullish coalescing: ONLY null/undefined take the * default (0, "", and false do not), and the right side evaluates lazily. * On a unit-armed union left this is the `nullish` node (a runtime tag @@ -4503,274 +950,17 @@ function assertedPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr return checkedClassUnionAssertion(lowerer, probe, target, locOf(outer)) ?? probe; } -/** Read-modify-write updates read the member first: an unassigned - * deferred-init receiver throws Node's member-read TypeError. */ -function checkDeferredMemberReceiver( - lowerer: Lowerer, - target: FieldTarget, - access: ts.Expression, -): void { - if ( - target.container !== "class" || - !ts.isPropertyAccessExpression(access) || - target.obj.type.kind !== "object" || - lowerer.deferredFieldSlot(target.obj) === null - ) - return; - target.obj = - lowerer.runtimeOptionalPropertyReceiver( - access.expression, - target.obj, - target.obj.type, - target.field, - ) ?? target.obj; -} - -/** Whether evaluating `expr` cannot store anything or run other code: - * reads, arithmetic, tests and checked extractions (which at most throw). */ -function writeFree(expr: IrExpr): boolean { - switch (expr.kind) { - case "numLit": - case "strLit": - case "boolLit": - case "unitLit": - case "varRef": - return true; - case "fieldGet": - case "recordGet": - return writeFree(expr.obj); - case "bin": - case "logical": - return writeFree(expr.left) && writeFree(expr.right); - case "unary": - case "toBool": - return writeFree(expr.operand); - case "ternary": - return writeFree(expr.cond) && writeFree(expr.then) && writeFree(expr.else_); - case "unionIsTag": - case "unionNarrow": - case "unionWrap": - return writeFree(expr.value); - case "libCall": - return expr.fn === "error.nodeThrow"; - default: - return false; - } -} - -/** The TypeError of a deferred-init field read that finds the field - * unassigned where its declared type cannot hold undefined. */ -export const DEFERRED_READ_MESSAGE = - "undefined is not representable in the target union (a value narrowed or asserted past it still held it)"; - /** The undefined-armed slot behind a checked deferred-init field read, or * the value itself (Lowerer.deferredFieldSlot). */ -function deferredSlotOr(lowerer: Lowerer, value: IrExpr): IrExpr { +export function deferredSlotOr(lowerer: Lowerer, value: IrExpr): IrExpr { return lowerer.deferredFieldSlot(value) ?? value; } -/** A plain value read that may be runtime-optional: an unchecked element - * read, or a binding, member or call that carries one. Lowering these - * without a destination type is the same as lowering them into one. */ -function isPlainValueRead(node: ts.Expression): boolean { - while (ts.isParenthesizedExpression(node)) node = node.expression; - return ( - ts.isElementAccessExpression(node) || - ts.isIdentifier(node) || - ts.isPropertyAccessExpression(node) || - ts.isCallExpression(node) - ); -} - -/** A member access or call receiver needs the present value itself (JS - * throws a TypeError reading a member of undefined). */ -function isMemberReceiver(node: ts.Expression): boolean { - let child: ts.Node = node; - let parent = node.parent; - while (ts.isParenthesizedExpression(parent)) { - child = parent; - parent = parent.parent; - } - return ( - ((ts.isPropertyAccessExpression(parent) || - ts.isElementAccessExpression(parent) || - ts.isCallExpression(parent)) && - parent.expression === child) || - (ts.isTaggedTemplateExpression(parent) && parent.tag === child) - ); -} - -function lowerNullishPair( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, - left: IrExpr, -): IrExpr { - if (left.type.kind === "dyn") { - // `a ?? b` on a CHECKED-DYNAMIC left: the deciding test is the - // runtime kind (scr_dyn_is_nullish — UNDEF/NULL take the default). - // Both sides live in the checked-dynamic tree — the right converts through - // the usual boundary and evaluates lazily in its branch; a default - // with no dyn representation keeps the fence. - const right = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); - if (right.type.kind !== "dyn") { - lowerer.unsupported( - "SC1100", - expr, - "nullish coalescing on 'unknown' values against defaults with no dynamic representation", - ); - } - return { kind: "nullish", left, right, type: DYN, loc }; - } - - if (left.type.kind !== "union") return left; - const def = lowerer.unions.get(left.type.unionId); - if (!def) lowerer.badType(expr.left, lowerer.typeOf(expr.left)); - if (!def.arms.some(isUnitType)) return left; - const rest = def.arms.filter((a) => !isUnitType(a)); - // `optionalMap ?? new Map()` carries its element contract on the left; - // the empty fallback may use it without constructing a Map - // arm that has no first-class union representation. - const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null; - if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc }; - let type = lowerer.irTypeOf(expr); - // A runtime-optional read can reach the default even when declarations - // call the left side present. Preserve a differently typed primitive - // fallback rather than checking it against that incomplete result type. - const rightType = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); - const primitive = (t: IrType): boolean => - t.kind === "f64" || t.kind === "string" || t.kind === "bool"; - if ( - rightType && - primitive(type) && - primitive(rightType) && - !typeEquals(type, rightType) && - rest.every(primitive) - ) { - const arms = [...rest]; - if (!arms.some((arm) => typeEquals(arm, rightType))) arms.push(rightType); - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - type = { kind: "union", unionId: lowerer.unions.intern(arms) }; - lowerer.runtimeOptionalArithmeticTypes.set(expr, type); - } - if (type.kind === "dyn" && lowerer.dynConvertible(left.type)) { - return { - kind: "nullish", - left: lowerer.coerceToExpected(left, DYN), - right: lowerer.lowerExprExpecting(expr.right, DYN), - type: DYN, - loc, - }; - } - // A runtime-optional default (`name ?? names[0]`): the checker types an - // unchecked element read as the bare element type, but an out-of-range - // read is undefined at runtime. When the default shares the left's - // optional union, that union is the result, so the undefined state - // reaches the consumer (an optional return or parameter takes it as its - // undefined arm; a plain slot still extracts the element with its - // checked narrow). - if ( - rest.length === 1 && - typeEquals(type, rest[0]!) && - isPlainValueRead(expr.right) && - !isMemberReceiver(expr) - ) { - const raw = lowerer.lowerExpr(expr.right); - if (typeEquals(raw.type, left.type)) - return { kind: "nullish", left, right: raw, type: left.type, loc }; - // `names[0] ?? undefined`: an explicit unit default stays the left's arm. - if (isUnitType(raw.type) && lowerer.armTag(left.type.unionId, raw.type) >= 0) { - const right = lowerer.coerceInto(expr.right, raw, left.type); - return { kind: "nullish", left, right, type: left.type, loc }; - } - return { kind: "nullish", left, right: lowerer.coerceInto(expr.right, raw, type), type, loc }; - } - if (typeEquals(type, left.type) || (rest.length === 1 && typeEquals(type, rest[0]!))) { - const right = lowerer.lowerExprExpecting(expr.right, type); - return { kind: "nullish", left, right, type, loc }; - } - if (type.kind !== "union" && rest.length > 1 && rest.some((arm) => typeEquals(arm, type))) { - const helper = lowerer.narrowedArmHelper(left.type.unionId, type, loc); - if (helper !== null) { - return nullishBranches( - lowerer, - left, - left.type, - def.arms, - type, - loc, - () => lowerer.lowerExprExpecting(expr.right, type), - (stable) => ({ kind: "call", callee: helper, args: [stable], type, loc }), - ); - } - } - // A subclass value defaulted to a base-class value (`circle ?? shape`): - // the present arm upcasts after the original nullish test, and the - // default lowers lazily into the base-class result. - const present = rest.length === 1 ? rest[0]! : null; - if ( - present?.kind === "object" && - type.kind === "object" && - present.className !== type.className && - lowerer.isSubclassOf(present.className, type.className) - ) { - const helper = lowerer.narrowedArmHelper(left.type.unionId, present, loc); - if (helper !== null) { - return nullishBranches( - lowerer, - left, - left.type, - def.arms, - type, - loc, - () => lowerer.lowerExprExpecting(expr.right, type), - (stable) => ({ - kind: "upcast", - value: { kind: "call", callee: helper, args: [stable], type: present, loc }, - type, - loc, - }), - ); - } - } - // A default may change the union or widen its records. Test the - // original tag before coercing: converting the whole left first can - // lose nullish arms, and evaluating the default eagerly loses laziness. - if (type.kind === "union") { - const retag = - lowerer.unionRetagHelper(left.type.unionId, type.unionId, loc) ?? - lowerer.narrowedRetagHelper(expr.left, left.type.unionId, type.unionId, loc); - if (retag !== null) { - // Narrowing may exclude a record/class arm while storage retains - // it. The checked retag traps that impossible arm, but only AFTER - // the original nullish test: null/undefined must reach the lazy - // default even when neither is representable in the result. - return nullishBranches( - lowerer, - left, - left.type, - def.arms, - type, - loc, - () => lowerer.lowerExprExpecting(expr.right, type), - (stable) => ({ kind: "call", callee: retag, args: [stable], type, loc }), - ); - } - } - lowerer.unsupported( - "SC1090", - expr, - rest.length !== 1 - ? `'??' on '${lowerer.fmt(left.type)}' (the non-nullish result is a sub-union; check a discriminant field first)` - : `'??' where the default changes the result type (left is '${lowerer.fmt(left.type)}' but the whole expression is '${lowerer.fmt(type)}' — give both sides one type)`, - ); -} - /** Test the original storage before projecting its present value. Both * branches are lazy, and an effectful receiver is evaluated exactly once. * Keeping the original tags matters when the checker has removed an arm * or the default changes the union's layout. */ -function nullishBranches( +export function nullishBranches( lowerer: Lowerer, left: IrExpr, leftType: IrType & { kind: "union" }, @@ -4917,7 +1107,7 @@ function maybePromiseThenPresence( }; } -function lowerPromiseThenPresence( +export function lowerPromiseThenPresence( lowerer: Lowerer, access: ts.PropertyAccessExpression, result: IrExpr, @@ -5020,7 +1210,7 @@ function lowerPromiseThenPresence( }; } -function runtimeOptionalLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null { +export function runtimeOptionalLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | null { let expr = node; while (ts.isParenthesizedExpression(expr)) expr = expr.expression; if (!ts.isIdentifier(expr)) return null; @@ -5030,133 +1220,6 @@ function runtimeOptionalLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal : null; } -export function runtimeOptionalTrueIds(lowerer: Lowerer, node: ts.Expression): IrLocal[] { - return runtimeOptionalGuardIds(lowerer, node, true); -} - -/** Presence proved by one outcome of a boolean guard. Negation flips - * the outcome; all operands of a true AND or false OR have that outcome. - * Assignments invalidate a syntactic proof even when checker types hide - * the undefined produced by an out-of-bounds read. */ -export function runtimeOptionalGuardIds( - lowerer: Lowerer, - node: ts.Expression, - truthy: boolean | null, -): IrLocal[] { - const candidates: IrLocal[] = []; - const collect = (expr: ts.Expression, outcome: boolean | null): void => { - if (ts.isParenthesizedExpression(expr)) return collect(expr.expression, outcome); - if (ts.isPrefixUnaryExpression(expr) && expr.operator === ts.SyntaxKind.ExclamationToken) { - return collect(expr.operand, outcome === null ? null : !outcome); - } - if ( - ts.isBinaryExpression(expr) && - ((outcome === true && expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) || - (outcome === false && expr.operatorToken.kind === ts.SyntaxKind.BarBarToken)) - ) { - collect(expr.left, outcome); - collect(expr.right, outcome); - } else if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { - // A completed ordinary member read checked its receiver even if - // the property's value was falsy. Optional chains make no such - // promise, and calls/captures retain their separate checked ABI. - const local = runtimeOptionalLocalOf(lowerer, expr.expression); - if (local !== null) candidates.push(local); - else if (ts.isPropertyAccessExpression(expr.expression)) collect(expr.expression, outcome); - } else if ( - ts.isBinaryExpression(expr) && - expr.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken && - expr.operatorToken.kind !== ts.SyntaxKind.BarBarToken && - expr.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken - ) { - // Both operands of a comparison/arithmetic operator are evaluated. - // Only member reads prove presence; bare identifiers need truth. - const member = (operand: ts.Expression): void => { - while (ts.isParenthesizedExpression(operand)) operand = operand.expression; - if (ts.isPropertyAccessExpression(operand)) collect(operand, outcome); - }; - member(expr.left); - member(expr.right); - const op = expr.operatorToken.kind; - const equal = - op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsToken; - const unequal = - op === ts.SyntaxKind.ExclamationEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsToken; - if ((equal && outcome === false) || (unequal && outcome === true)) { - const present = (value: ts.Expression, unit: ts.Expression): void => { - while (ts.isParenthesizedExpression(unit)) unit = unit.expression; - const excludesUndefined = - (ts.isIdentifier(unit) && - unit.text === "undefined" && - (lowerer.typeOf(unit).flags & ts.TypeFlags.Undefined) !== 0) || - (unit.kind === ts.SyntaxKind.NullKeyword && - (op === ts.SyntaxKind.EqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsToken)); - if (excludesUndefined) { - const local = runtimeOptionalLocalOf(lowerer, value); - if (local !== null) candidates.push(local); - } - }; - present(expr.left, expr.right); - present(expr.right, expr.left); - } - } else if (outcome) { - const local = runtimeOptionalLocalOf(lowerer, expr); - if (local !== null) candidates.push(local); - } - }; - collect(node, truthy); - if (candidates.length === 0) return candidates; - const assigned = new Set(); - const visit = (part: ts.Node): void => { - if (ts.isFunctionLike(part)) return; - let target: ts.Node | null = null; - if ( - ts.isBinaryExpression(part) && - part.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - part.operatorToken.kind <= ts.SyntaxKind.LastAssignment - ) - target = part.left; - if ( - (ts.isPrefixUnaryExpression(part) || ts.isPostfixUnaryExpression(part)) && - (part.operator === ts.SyntaxKind.PlusPlusToken || - part.operator === ts.SyntaxKind.MinusMinusToken) - ) - target = part.operand; - if (target !== null) { - // Destructuring assignments can contain several identifiers. - const mark = (name: ts.Node): void => { - if (ts.isIdentifier(name)) { - const local = lowerer.resolveLocal(name); - if (local) assigned.add(lowerer.runtimeOptionalRootOf(local)); - } - ts.forEachChild(name, mark); - }; - mark(target); - } - ts.forEachChild(part, visit); - }; - visit(node); - return candidates.filter((local) => !assigned.has(lowerer.runtimeOptionalRootOf(local))); -} - -export function withRuntimeOptionalNarrowed( - lowerer: Lowerer, - locals: readonly IrLocal[], - fn: () => T, -): T { - if (locals.length === 0) return fn(); - for (const local of locals) - lowerer.runtimeOptionalLocals.delete(lowerer.runtimeOptionalRootOf(local)); - try { - return fn(); - } finally { - for (const local of locals) - lowerer.runtimeOptionalLocals.add(lowerer.runtimeOptionalRootOf(local)); - } -} - /** JS ToBoolean: bool passes through; f64/string get a `toBool` wrapper * (falsy: 0, -0, NaN, ""); unions get the same wrapper answered by a * per-union interned helper (unit arms falsy; scalar/string arms by @@ -5210,1357 +1273,6 @@ export function eqComparableUnion(lowerer: Lowerer, unionId: string): boolean { return !!def && def.arms.every((a) => a.kind !== "dyn" && a.kind !== "caught"); } -/** Property access on a string or array receiver: `.length` lowers to the - * matching intrinsic; a bare method reference (`const f = s.slice` — a - * function value) is rejected with a specific message. Returns null for - * other receivers (the generic property-access rejection applies). Both - * the receiver type AND the ambient-file provenance of the member are - * verified — the name alone proves nothing. */ -export function lowerIntrinsicProperty( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): IrExpr | null { - for (const name of ["String", "Number", "Boolean", "BigInt"] as ( - | "String" - | "Number" - | "Boolean" - | "BigInt" - )[]) { - if (!lowerer.chainBlocked(expr) && lowerer.isStdlibGlobal(expr, name)) { - return primitiveCtorClosure(lowerer, name, locOf(expr), isJsSourceFile(expr.getSourceFile())); - } - } - if (lowerer.stdlibGlobalMember(expr, "Reflect") === "ownKeys") { - const value = lowerer.lowerNativeCallableValue( - { - fn: "dyn.ownKeys", - params: [DYN], - result: DYN, - valueParams: [{ mode: "required", type: DYN }], - }, - "Reflect.ownKeys", - locOf(expr), - )!; - return { kind: "dynFrom", value, fnName: "ownKeys", type: DYN, loc: locOf(expr) }; - } - if (lowerer.chainBlocked(expr)) return null; - const builtinPrototype = builtinPrototypeMethod(lowerer, expr); - if (builtinPrototype) - return { - kind: "libCall", - fn: "dyn.builtinMethod", - args: [ - { kind: "strLit", value: builtinPrototype, type: STRING, loc: locOf(expr) }, - { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - ], - type: DYN, - loc: locOf(expr), - }; - if ( - ts.isPropertyAccessExpression(expr.expression) && - lowerer.stdlibGlobalMember(expr.expression, "Array") === "prototype" - ) { - return { - kind: "dynKeyGet", - value: { - kind: "libCall", - fn: "dyn.arrayPrototype", - args: [], - type: DYN, - loc: locOf(expr.expression), - }, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - type: DYN, - loc: locOf(expr), - }; - } - if ( - expr.name.text === "apply" && - ts.isPropertyAccessExpression(expr.expression) && - lowerer.stdlibGlobalMember(expr.expression, "Function") === "prototype" - ) - return { kind: "libCall", fn: "dyn.functionApply", args: [], type: DYN, loc: locOf(expr) }; - if (lowerer.stdlibGlobalMember(expr, "Array") === "prototype") - return { kind: "libCall", fn: "dyn.arrayPrototype", args: [], type: DYN, loc: locOf(expr) }; - if (lowerer.stdlibGlobalMember(expr, "Object") === "prototype") - return { kind: "libCall", fn: "dyn.objectPrototype", args: [], type: DYN, loc: locOf(expr) }; - // Function.prototype as a VALUE (a prototype-chain link: - // `Object.setPrototypeOf(F.prototype, Function.prototype)`): one - // anonymous function answering undefined, length 0 — a zero-capture - // closure of a compiler helper, so every read is the same function - // (closure identity) and shares its property table. Reads through it - // find the function members the checked runtime models for every - // function (apply, constructor, name, length). Member forms keep their - // own lowerings. - if (lowerer.stdlibGlobalMember(expr, "Function") === "prototype") { - const loc = locOf(expr); - const name = "%dyn.functionPrototype"; - if (!lowerer.liftedFns.some((fn) => fn.name === name)) - lowerer.liftedFns.push({ - name, - params: [], - locals: [], - returnType: DYN, - body: [{ kind: "return", value: dynUndefinedExpr(loc), loc }], - loc, - }); - return { - kind: "dynFrom", - value: { kind: "closure", fnName: name, captures: [], type: funcOf([], DYN), loc }, - fnName: "", - type: DYN, - loc, - }; - } - if (lowerer.stdlibGlobalMember(expr, "Array") === "isArray") - return lowerArrayIsArrayValue(lowerer, locOf(expr)); - if (lowerer.stdlibGlobalMember(expr, "Array") === "from") - return lowerArrayFromValue(lowerer, locOf(expr)); - const stringMember = lowerer.stdlibGlobalMember(expr, "String"); - if (stringMember === "fromCharCode" || stringMember === "fromCodePoint") - return lowerStringCodesValue(lowerer, stringMember, locOf(expr)); - const numberMember = lowerer.stdlibGlobalMember(expr, "Number"); - if (numberMember === "parseInt" || numberMember === "parseFloat") - return lowerNumberParserValue(lowerer, numberMember, locOf(expr)); - if (lowerer.stdlibGlobalMember(expr, "Buffer") === "poolSize") - return { kind: "libCall", fn: "buffer.poolSize", args: [], type: F64, loc: locOf(expr) }; - const netAddressStatic = lowerNetAddressStaticRead(lowerer, expr); - if (netAddressStatic) return netAddressStatic; - if (lowerer.stdlibGlobalMember(expr, "Buffer") === "isBuffer") - return lowerCheckedPredicateValue(lowerer, "Buffer.isBuffer", "buffer", locOf(expr)); - if (lowerer.stdlibGlobalMember(expr, "Date") === "now") - return lowerer.lowerNativeCallableValue( - { - fn: "date.now", - params: [], - result: F64, - valueParams: [], - }, - "Date.now", - locOf(expr), - ); - if (stdlibGlobalNameOf(lowerer, expr.expression) === "Object") { - if (expr.name.text === "assign") return lowerObjectAssignValue(lowerer, locOf(expr)); - const fn = own(OBJECT_CALLABLE_VALUES, expr.name.text); - if (fn) return lowerer.lowerNativeCallableValue(fn, `Object.${expr.name.text}`, locOf(expr)); - } - // A never-tainted JS receiver type lowered checked-dynamic - // (neverTaintedJsType — `cmd.length` on `const cmd = ['pwd', []]`): - // stand down so the dyn keyed read below the chain answers, instead - // of an array libCall over a dyn receiver hitting the boundary fence. - let kind = neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)) - ? undefined - : lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind; - // A checker-`any[]` receiver (the readonly-array Array.isArray quirk) - // whose VALUE lowers to a real static array (maybeNarrow's isArray - // bridge): `.length` and friends ride the array path on the lowered - // value (re-lowering is pure IR construction). - if (kind === undefined && lowerer.checkerAnyArray(expr.expression)) { - const probe = lowerer.lowerExpr(expr.expression); - if (probe.type.kind === "array") kind = "array"; - } - if ( - kind !== "string" && - kind !== "array" && - kind !== "map" && - kind !== "set" && - kind !== "f64" && - kind !== "date" && - kind !== "regex" && - kind !== "url" && - kind !== "searchParams" && - kind !== "stats" && - kind !== "spawnRes" && - kind !== "child" && - kind !== "bytes" && - kind !== "symbol" - ) { - return null; - } - // child receivers also admit the user's own child-shaped interface - // members (the NgrokChildProcess duck rule — see isChildSurfaceMember). - // The EMPTY tuple `[]` rides the array REPRESENTATION (type-mapper.ts), but - // its `length` member is the tuple's own synthesized property, not - // Array.prototype's — provenance alone would refuse it. It reads the - // runtime length (always 0) through the array intrinsic like any other - // array; non-empty tuples never reach here (they map to records and - // fold their arity constant on the record path). - const tupleLengthOnArray = - kind === "array" && - expr.name.text === "length" && - lowerer.checker.isTupleType( - lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(expr.expression)), - ); - if ( - kind === "child" - ? !isChildSurfaceMember(lowerer, expr) - : !tupleLengthOnArray && !lowerer.isStdlibMember(expr) - ) - return null; - const name = expr.name.text; - if (kind === "array" && name === "constructor" && isJsSourceFile(expr.getSourceFile())) { - const receiver = lowerer.lowerExpr(expr.expression); - if (receiver.type.kind === "dyn") - return { - kind: "dynKeyGet", - value: receiver, - key: { kind: "strLit", value: name, type: STRING, loc: locOf(expr) }, - type: DYN, - loc: locOf(expr), - }; - const result: IrExpr = { - kind: "libCall", - fn: "dyn.arrayConstructor", - args: [], - type: DYN, - loc: locOf(expr), - }; - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: receiver, loc: locOf(expr) }], - result, - type: DYN, - loc: locOf(expr), - }; - } - const strictReceiver = (expected: IrType): IrExpr => { - const value = lowerer.lowerExpr(expr.expression); - if (value.type.kind === "dyn") { - if (isJsSourceFile(expr.getSourceFile()) && name === "length") return value; - return lowerer.coerceInto(expr.expression, value, expected); - } - return lowerer.runtimeOptionalPropertyReceiver(expr.expression, value, expected, name) ?? value; - }; - if (kind === "child") { - const loc = locOf(expr); - // The lifecycle reads, Node's exact shapes (SEMANTICS.md pins the - // matrix): pid is `number | undefined` (undefined = spawn failure), - // exitCode `number | null` (null while running and after a signal - // death; -errno once a spawn failure settled), killed the - // sent-a-signal flag. The unions build type-directedly in the - // backend (the spawnRes.status pattern); a checker-narrowed read - // extracts through maybeNarrow like any union member. - if (name === "pid") { - const receiver = lowerer.lowerExpr(expr.expression); - const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, UNDEFINED_T]) }; - return { kind: "libCall", fn: "child.pid", args: [receiver], type, loc }; - } - if (name === "exitCode") { - const receiver = lowerer.lowerExpr(expr.expression); - const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, NULL_T]) }; - return { kind: "libCall", fn: "child.exitCode", args: [receiver], type, loc }; - } - if (name === "killed") { - const receiver = lowerer.lowerExpr(expr.expression); - return { kind: "libCall", fn: "child.killed", args: [receiver], type: BOOL, loc }; - } - if (name === "on" || name === "once" || name === "kill" || name === "unref") { - lowerer.unsupported("SC1090", expr, `child methods as values (call '${name}' directly)`); - } - lowerer.noLowering( - `ChildProcess.${name}`, - expr, - 'on/once("exit" | "close" | "error", cb), pid, exitCode, killed, kill(signal?), and unref() are the supported ChildProcess members', - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "spawnRes") { - // The spawnSync-result reads. status is the interned `number | null` - // union (null = signal death or spawn failure); stdout/stderr are - // the captured utf8 strings — under @types/node WITHOUT the - // {encoding: "utf8"} options argument the checker types them Buffer, - // which is re-fenced here (the call site already said so). - const loc = locOf(expr); - if (name === "status") { - // Always the interned `number | null` union; a checker-NARROWED - // read (`missing.status === null ? ... : ${missing.status}`) - // bridges through maybeNarrow like any union read. - const receiver = lowerer.lowerExpr(expr.expression); - const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, NULL_T]) }; - const read: IrExpr = { kind: "libCall", fn: "spawnRes.status", args: [receiver], type, loc }; - return lowerer.maybeNarrow(read, expr); - } - if (name === "stdout" || name === "stderr") { - if (lowerer.mapTypeOf(lowerer.typeOf(expr))?.kind !== "string") { - lowerer.noLowering( - `spawnSync's ${name} without the "utf8" encoding`, - expr, - 'Buffer captures are not representable — call spawnSync(cmd, args, { encoding: "utf8" }) so the outputs are strings', - ); - } - const receiver = lowerer.lowerExpr(expr.expression); - const fn = name === "stdout" ? "spawnRes.stdout" : "spawnRes.stderr"; - return { kind: "libCall", fn, args: [receiver], type: STRING, loc }; - } - if (name === "error") { - // Node's spawn-failure carrier: `Error | undefined` — a fresh - // %Error ("spawnSync ENOENT", `code` stamped) when the - // spawn itself failed, the undefined arm otherwise. The libCall - // always carries the interned union (the declared `error?: Error`), - // and a checker-NARROWED read (`if (r.error) r.error.message`) - // bridges through maybeNarrow like any union read. - const receiver = lowerer.lowerExpr(expr.expression); - const type = lowerer.withUndefinedArmOf({ kind: "object", className: "%Error" }); - if (!type) lowerer.badType(expr, lowerer.typeOf(expr)); - const read: IrExpr = { kind: "libCall", fn: "spawnRes.error", args: [receiver], type, loc }; - return lowerer.maybeNarrow(read, expr); - } - lowerer.noLowering( - `SpawnSyncReturns.${name}`, - expr, - "status, stdout, stderr, and error are the supported spawnSync-result members", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "stats") { - if (name === "size") { - const receiver = lowerer.lowerExpr(expr.expression); - return { kind: "libCall", fn: "stats.size", args: [receiver], type: F64, loc: locOf(expr) }; - } - if (Object.hasOwn(STATS_TYPE_TESTS, name)) { - lowerer.unsupported("SC1090", expr, `Stats methods as values (call '${name}' directly)`); - } - lowerer.noLowering( - `Stats.${name}`, - expr, - "isFile(), isDirectory(), isSymbolicLink(), isBlockDevice(), isCharacterDevice(), isFIFO(), isSocket(), dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs, atime, mtime, ctime, and birthtime are the supported Stats members", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "date") { - const methods = new Set([ - "getTime", - "valueOf", - "toISOString", - "getFullYear", - "getUTCFullYear", - "getMonth", - "getUTCMonth", - "getDate", - "getUTCDate", - "getDay", - "getUTCDay", - "getHours", - "getUTCHours", - "getMinutes", - "getUTCMinutes", - "getSeconds", - "getUTCSeconds", - "getMilliseconds", - "getUTCMilliseconds", - "getTimezoneOffset", - ]); - if (methods.has(name)) { - lowerer.unsupported("SC1090", expr, `Date methods as values (call '${name}' directly)`); - } - lowerer.noLowering( - `Date.prototype.${name}`, - expr, - "getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "symbol") { - const loc = locOf(expr); - // `.description`: the checker's `string | undefined` — undefined - // exactly for the description-less Symbol()/Symbol(undefined) forms - // (Symbol("") answers the EMPTY STRING arm, like Node). The interned - // union builds in the backend from the runtime's +1-or-NULL answer - // (the child.stdout pattern); a checker-narrowed read extracts - // through maybeNarrow like any union member. - if (name === "description") { - const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), SYMBOL_T); - const type: IrType = { kind: "union", unionId: lowerer.unions.intern([STRING, UNDEFINED_T]) }; - return { kind: "libCall", fn: "sym.desc", args: [receiver], type, loc }; - } - if (name === "toString" || name === "valueOf") { - lowerer.unsupported("SC1090", expr, `symbol methods as values (call '${name}' directly)`); - } - lowerer.noLowering( - `Symbol.prototype.${name}`, - expr, - "description and toString() are the supported symbol members", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "url") { - // The supported URL getters. Everything else the lib declares - // (searchParams, setters-as-reads, ...) fences with the - // member-qualified name and the supported list. `host` is the WHATWG - // serialization: lowercased hostname, `:port` appended exactly when - // a non-default port is present (scr_url_host — Node-exact, - // opaque-path URLs answer ""); `hostname` is the stored port-less - // host field verbatim, including IPv6 brackets. - if ( - name === "protocol" || - name === "origin" || - name === "username" || - name === "password" || - name === "pathname" || - name === "href" || - name === "host" || - name === "hostname" || - name === "port" || - name === "search" || - name === "hash" - ) { - const receiver = lowerer.lowerExpr(expr.expression); - const fn = - name === "protocol" - ? "url.protocol" - : name === "origin" - ? "url.origin" - : name === "username" - ? "url.username" - : name === "password" - ? "url.password" - : name === "pathname" - ? "url.pathname" - : name === "host" - ? "url.host" - : name === "hostname" - ? "url.hostname" - : name === "port" - ? "url.port" - : name === "search" - ? "url.search" - : name === "hash" - ? "url.hash" - : "url.href"; - return { kind: "libCall", fn, args: [receiver], type: STRING, loc: locOf(expr) }; - } - // `u.searchParams`: the LIVE cached view (one identity per URL — - // mutations through it re-serialize into the URL's query, so href - // reflects immediately; Node's binding exactly). - if (name === "searchParams") { - const receiver = lowerer.lowerExpr(expr.expression); - return { - kind: "libCall", - fn: "url.searchParams", - args: [receiver], - type: SEARCH_PARAMS_T, - loc: locOf(expr), - }; - } - if (name === "toString" || name === "toJSON") { - lowerer.unsupported("SC1090", expr, `URL methods as values (call '${name}' directly)`); - } - lowerer.noLowering( - `URL.${name}`, - expr, - "protocol, origin, username, password, pathname, href, host, hostname, port, search, hash, searchParams, and toString() are the supported URL members", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "searchParams") { - const SEARCH_PARAMS_METHODS = new Set([ - "get", - "getAll", - "set", - "append", - "delete", - "has", - "sort", - "toString", - "forEach", - "keys", - "values", - "entries", - ]); - // The one data property; every method lowers at its CALL - // (lowerSearchParamsMethodCall) — bare method references fence. - if (name === "size") { - const receiver = lowerer.lowerExpr(expr.expression); - return { kind: "libCall", fn: "sp.size", args: [receiver], type: F64, loc: locOf(expr) }; - } - if (SEARCH_PARAMS_METHODS.has(name)) { - lowerer.unsupported( - "SC1090", - expr, - `URLSearchParams methods as values (call '${name}' directly)`, - ); - } - lowerer.noLowering( - `URLSearchParams.${name}`, - expr, - "get, getAll, set, append, delete, has, sort, size, toString(), forEach, and for-of iteration are the supported URLSearchParams members", - lowerer.checker.getSymbolAtLocation(expr.name), - ); - } - if (kind === "regex") { - if (name === "lastIndex") { - return { - kind: "regexIntrinsic", - method: "lastIndex", - receiver: strictReceiver({ kind: "regex" }), - args: [], - type: F64, - loc: locOf(expr), - }; - } - if (name === "source" || name === "flags") { - const receiver = strictReceiver({ kind: "regex" }); - return { - kind: "regexIntrinsic", - method: name, - receiver, - args: [], - type: STRING, - loc: locOf(expr), - }; - } - // The flag getters answer whether `flags` holds the flag's letter. - const flag = REGEX_FLAG_GETTERS.get(name); - if (flag !== undefined) { - const loc = locOf(expr); - const flags: IrExpr = { - kind: "regexIntrinsic", - method: "flags", - receiver: strictReceiver({ kind: "regex" }), - args: [], - type: STRING, - loc, - }; - return { - kind: "strIntrinsic", - method: "includes", - receiver: flags, - args: [{ kind: "strLit", value: flag, type: STRING, loc }], - type: BOOL, - loc, - }; - } - if (name === "test") { - lowerer.unsupported("SC1090", expr, "regex methods as values (call 'test' directly)"); - } - return null; - } - if (kind === "f64") { - if (own(STDLIB_SURFACE.number, name) !== undefined) { - lowerer.unsupported("SC1090", expr, `number methods as values (call '${name}' directly)`); - } - return null; - } - if (kind === "bytes") { - const loc = locOf(expr); - if (name === "constructor") { - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const receiver = - expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - return { - kind: "dynKeyGet", - value: lowerer.coerceInto(expr.expression, receiver, DYN), - key: { kind: "strLit", value: name, type: STRING, loc }, - type: DYN, - loc, - }; - } - if (name === "length" || name === "byteLength") { - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - let receiver = - expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - if (receiver.type.kind === "dyn") - return { - kind: "dynCheck", - value: { - kind: "dynKeyGet", - value: receiver, - key: { kind: "strLit", value: name, type: STRING, loc: locOf(expr) }, - type: DYN, - loc: locOf(expr), - }, - type: F64, - loc: locOf(expr), - }; - if ( - receiver.type.kind === "union" && - lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0 - ) { - const present = lowerer.stripUndefinedArm(receiver.type); - const helper = - present.kind === "bytes" - ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(expr.expression)) - : null; - receiver = helper - ? lowerer.checkedNarrowCall(helper, receiver, present, locOf(expr.expression)) - : lowerer.maybeNarrow(receiver, expr.expression); - } - - // property read, exiting at the declared number type (the array - - return { kind: "bytesIntrinsic", method: name, receiver, args: [], type: F64, loc }; - } - if (name === "byteOffset") { - // 0 for owners (scriptc typed arrays own their whole storage — - // SEMANTICS.md notes the divergence from Node's Buffer pooling), - // the view's real offset for a DataView. A runtime read, so the - // receiver's evaluation is never discarded. - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const receiver = - expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - return { kind: "bytesIntrinsic", method: "byteOffset", receiver, args: [], type: F64, loc }; - } - if (name === "buffer") { - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const receiver = - expected?.kind === "bytes" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - return { kind: "bytesIntrinsic", method: "buffer", receiver, args: [], type: DYN, loc }; - } - if (name === "slice" || name === "subarray" || name === "set" || name === "toString") { - lowerer.unsupported( - "SC1090", - expr, - `typed-array methods as values (call '${name}' directly)`, - ); - } - return null; // fill, reverse, ... → the SC2020 member fence - } - if (kind === "map") { - if (name === "size") { - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const receiver = - expected?.kind === "map" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - return { - kind: "mapIntrinsic", - method: "size", - receiver, - args: [], - type: F64, - loc: locOf(expr), - }; - } - if (MAP_METHODS.has(name) || name === "forEach") { - lowerer.unsupported("SC1090", expr, `Map methods as values (call '${name}' directly)`); - } - return null; - } - if (kind === "set") { - if (name === "size") { - const expected = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const receiver = - expected?.kind === "set" ? strictReceiver(expected) : lowerer.lowerExpr(expr.expression); - return { - kind: "setIntrinsic", - method: "size", - receiver, - args: [], - type: F64, - loc: locOf(expr), - }; - } - if (SET_METHODS.has(name)) { - lowerer.unsupported("SC1090", expr, `Set methods as values (call '${name}' directly)`); - } - return null; - } - if (name === "length") { - const expected = - kind === "string" ? STRING : lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - let receiver = lowerer.lowerExpr(expr.expression); - if ( - receiver.type.kind !== "dyn" && - expected && - (expected.kind === "string" || expected.kind === "array") - ) { - receiver = - lowerer.runtimeOptionalPropertyReceiver(expr.expression, receiver, expected, name) ?? - receiver; - } - if ( - receiver.type.kind === "union" && - lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0 && - kind === "array" - ) { - const present = lowerer.stripUndefinedArm(receiver.type); - const helper = - present.kind === "array" - ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(expr.expression)) - : null; - receiver = helper - ? lowerer.checkedNarrowCall(helper, receiver, present, locOf(expr.expression)) - : lowerer.maybeNarrow(receiver, expr.expression); - } - - // A CHECKED-DYNAMIC value behind an array-typed checker spelling - // (`Object.keys(u).length` — the dyn walk answers a dyn array while - // tsc spells string[]): the runtime-world dispatch — a dyn keyed - // read validated into the declared number (a lying length throws - // the catchable TypeError, the dynCheck stance). - if (receiver.type.kind === "dyn") { - const read: IrExpr = { - kind: "dynKeyGet", - key: { kind: "strLit", value: "length", type: STRING, loc: locOf(expr) }, - ...(expr.questionDotToken ? { optional: true as const } : {}), - value: receiver, - type: DYN, - loc: locOf(expr), - }; - if (expr.questionDotToken) return read; - return { kind: "dynCheck", value: read, type: F64, loc: locOf(expr) }; - } - return kind === "string" - ? { kind: "strIntrinsic", method: "length", receiver, args: [], type: F64, loc: locOf(expr) } - : { kind: "arrIntrinsic", method: "length", receiver, args: [], type: F64, loc: locOf(expr) }; - } - if ( - kind === "string" - ? own(STR_METHODS, name) !== undefined || - STRING_INDEX_METHODS.has(name) || - own(STDLIB_SURFACE.string, name) !== undefined - : ARRAY_METHODS.has(name) - ) { - lowerer.unsupported("SC1090", expr, `${kind} methods as values (call '${name}' directly)`); - } - return null; -} - -/** JavaScript: whether `expr` is or holds an existing checked-dynamic - * object — a binding stored as a dyn value whose checker type is an - * object, or an array or object literal with such an element. Building - * the enclosing literal with a static element type would copy it. */ -export function jsHoldsDynObject(lowerer: Lowerer, expr: ts.Expression): boolean { - while (ts.isParenthesizedExpression(expr)) expr = expr.expression; - if (ts.isIdentifier(expr)) { - const stored = lowerer.peekLocal(expr)?.type ?? lowerer.globalOf(expr)?.type; - return stored?.kind === "dyn" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Object) !== 0; - } - if (ts.isArrayLiteralExpression(expr)) - return expr.elements.nodes.some( - (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), - ); - if (ts.isObjectLiteralExpression(expr)) - return expr.properties.nodes.some( - (property) => - (ts.isPropertyAssignment(property) && jsHoldsDynObject(lowerer, property.initializer)) || - (ts.isShorthandPropertyAssignment(property) && - ts.isIdentifier(property.name) && - jsHoldsDynObject(lowerer, property.name)), - ); - return false; -} - -/** `[a, b, c]`. The element type comes from the contextual type when tsc - * has one (`const a: number[] = []`, arguments, nested literals) and from - * the literal's own inferred type otherwise. A bare `[]` with no context - * is `never[]` — unmappable, rejected with the component fence (SC2009). `expected` overrides - * the contextual lookup where the caller knows the slot's array type and - * tsc's API doesn't surface it (ternary arms under an array context — - * tsc accepts the arm covariantly, but a tagged element representation - * must be BUILT as the slot's element type). */ -export function lowerArrayLiteral( - lowerer: Lowerer, - expr: ts.ArrayLiteralExpression, - expected?: (IrType & { kind: "array" }) | (IrType & { kind: "record" }), -): IrExpr { - const loc = locOf(expr); - const ctxType = lowerer.checker.getContextualType(expr); - // `as const satisfies readonly T[]` checks against an array but keeps - // the literal's inferred tuple type. Build that shape; an actual array - // destination can use the ordinary tuple-to-array coercion afterward. - const tsType = underConstAssertion(expr) - ? lowerer.typeOf(expr) - : (ctxType ?? lowerer.typeOf(expr)); - let mapped = expected ?? lowerer.mapTypeOf(tsType); - // Inferred JavaScript arrays can contain unrelated constructors with - // structurally equal checker types. Their native class ABIs still differ. - if ( - !expected && - isJsSourceFile(expr.getSourceFile()) && - mapped?.kind === "array" && - mapped.elem.kind === "classval" - ) - mapped = DYN; - // An inferred destructuring pattern supplies a contextual [any, ...] - // tuple. Its positions do not erase the literal's inferred element - // types; explicit destinations already arrive through expected. - if ( - expected === undefined && - ctxType && - lowerer.checker.isTupleType(ctxType) && - lowerer.checker - .getTypeArguments(ctxType as ts.TypeReference) - .some((type) => (type.flags & ts.TypeFlags.Any) !== 0) - ) { - const inferred = lowerer.mapTypeOf(lowerer.typeOf(expr)); - if ( - inferred?.kind === "array" || - (inferred?.kind === "record" && lowerer.shapes.get(inferred.shapeId)?.tuple) - ) - mapped = inferred; - } - // A JS literal whose OWN inferred type is never-tainted - // (neverTaintedJsType — the evolving `const gb = []`, the mixed - // command tuple `['pwd', []]`) carries no element information: route - // it to the checked-dynamic tree fallback below rather than letting never's f64 - // representation build a static number array (a later dyn push would - // throw "expected number at $, got string"; the tuple's union arm - // would re-tag as number[] and fence). A REAL slot still wins: - // `expected`, a contextual array/tuple, or a contextual union's - // single array arm all provide element information first. - // (The contextual lookup can answer the binding's own tainted - // inference back — `const cmd = ['pwd', []]` — so the slot test is a - // taint test on whichever type mapped, not a presence test.) - const neverTaintedOwnJs = - expected === undefined && neverTaintedJsType(lowerer, expr, lowerer.typeOf(expr)); - if (expected === undefined && neverTaintedJsType(lowerer, expr, tsType)) mapped = null; - // A union-typed slot (`const x: string[] | number = [..]`) contextually - // types the literal as the union; build the literal as its OWN type and - // let the slot's coercion wrap it into the union. An unknown-typed slot - // (`JSON.stringify([1, 2])`) likewise: build the literal's own type. An - // UNMAPPABLE context falls back the same way — a destructuring pattern - // without annotations contextually types its initializer `[any]`, while - // the literal's own inferred type is the real tuple. - if (!mapped || mapped.kind === "union" || mapped.kind === "dyn") { - const ctxUnion = mapped?.kind === "union" ? mapped : null; - mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); - // The never-tainted own type carries no element information here - // either (the JS story above) — only a contextual union's single - // array arm below may still supply a static home. - if (neverTaintedOwnJs) mapped = null; - // An EMPTY literal in a union slot (`const t: string[] | undefined = - // []`, the `[]` default argument against `string[] | undefined`) has - // no useful own type — tsc infers `never[]`, whose f64-element - // mapping is a representation for the uninhabited, not the slot's - // arm. When the contextual union has exactly ONE array arm there is - // no ambiguity: build the literal as that arm and let the slot's - // coercion wrap it. Two array arms would need tsc's own best-fit - // choice — none arises in practice; the fence stands. - if (ctxUnion && expr.elements.nodes.length === 0) { - const def = lowerer.unions.get(ctxUnion.unionId); - const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length === 1) mapped = arrayArms[0]!; - } - // A NON-EMPTY literal whose own type has no static home (the JS dyn - // fallback — a null/dyn mapping, or an inference that degraded to a - // unit-only-element array over non-unit elements) under a union - // with exactly ONE array-family arm — an array, or an - // arity-matching tuple (the option-table `default: [{ value: [] }]` - // shape): build AS that arm, exactly the empty-literal rule above; - // the slot's coercion wraps it. - if (ctxUnion && expr.elements.nodes.length > 0) { - // A unit-only-element array (`(null | undefined)[]`) cannot hold - // non-unit elements — neither as the literal's own mapping nor as - // a competing union arm. - const nonUnitElems = expr.elements.nodes.some( - (el) => - !ts.isOmittedExpression(el) && - el.kind !== ts.SyntaxKind.NullKeyword && - !(ts.isIdentifier(el) && el.text === "undefined"), - ); - const ownUnhelpful = - mapped === null || - mapped.kind === "dyn" || - // The checker may echo the context union rather than infer this literal. - mapped.kind === "union" || - (mapped.kind === "array" && nonUnitElems && lowerer.unitOnlyElem(mapped.elem)); - if (ownUnhelpful) { - const def = lowerer.unions.get(ctxUnion.unionId); - const arms = (def?.arms ?? []).filter( - (a) => - (a.kind === "array" && !(nonUnitElems && lowerer.unitOnlyElem(a.elem))) || - (a.kind === "record" && - !!lowerer.shapes.get(a.shapeId)?.tuple && - lowerer.shapes.get(a.shapeId)!.fields.length === expr.elements.nodes.length), - ); - if (arms.length === 1) mapped = arms[0]!; - } - } - } - // A Date's toJSON() element keeps its null in a literal with no declared - // destination: the lib types say string. - if ( - !expected && - mapped?.kind === "array" && - mapped.elem.kind === "string" && - expr.elements.nodes.some((element) => isDateJsonSource(lowerer, element)) - ) - mapped = { kind: "array", elem: dateJsonType(lowerer) }; - // An array literal inside an npm-static implicit-any instance can - // inherit the checker's poisoned any[] context even after the - // active binding gives every element one concrete lowering. Recover a - // static array only when every spelled element lowers to the same - // supported type. An explicit checked-dynamic destination still owns - // the later slot coercion; heterogeneous literals, holes, and spreads - // keep the checked-dynamic fallback below. - if ( - lowerer.implicitParamTypes !== null && - npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null && - (mapped === null || - mapped.kind === "dyn" || - (mapped.kind === "array" && mapped.elem.kind === "dyn")) && - expr.elements.nodes.length > 0 && - expr.elements.nodes.every( - (element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element), - ) - ) { - const elements = expr.elements.nodes.map((element) => tryLowerExpression(lowerer, element)); - const first = elements[0]; - if ( - first !== undefined && - first !== null && - isSupportedArrayElem(first.type) && - elements.every((element) => element !== null && typeEquals(element.type, first.type)) - ) { - mapped = arrayOf(first.type); - } - } - if (!expected && isJsSourceFile(expr.getSourceFile()) && mapped?.kind === "array") { - const elem = mapped.elem; - const arms = elem.kind === "union" ? lowerer.unions.get(elem.unionId)?.arms : [elem]; - if (arms?.length && arms.every((arm) => arm.kind === "classval")) mapped = DYN; - // Inferred JavaScript literals mixing promises and plain values - // (`[Promise.resolve(1), 2]`) keep the checked-dynamic array: their - // consumers (Readable.from, Promise.all over iterators) inspect each - // element at run time. - if (maybePromiseUnionOf(lowerer, elem)) mapped = DYN; - } - if ( - !expected && - isJsSourceFile(expr.getSourceFile()) && - expr.elements.nodes.some( - (element) => - ts.isSpreadElement(element) && - tryLowerExpression(lowerer, element.expression)?.type.kind === "dyn", - ) - ) - mapped = DYN; - // An element that is an existing checked-dynamic object (a file-scope - // object literal binding, or a literal holding one) keeps its identity - // only in a checked-dynamic array: a static element type would copy it. - if ( - !expected && - isJsSourceFile(expr.getSourceFile()) && - mapped?.kind !== "dyn" && - expr.elements.nodes.some( - (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), - ) - ) - mapped = DYN; - // An EMPTY literal under a CONST ASSERTION whose type queries panicked - // (tsgo's readonly-[] TupleType conversion) uses a unit-element array, - // mapType's own `[] as const` rule. - if ( - mapped === null && - expr.elements.nodes.length === 0 && - (tsType.flags & ts.TypeFlags.Any) !== 0 && - underConstAssertion(expr) - ) { - mapped = arrayOf(unitOnlyUnion(lowerer.unions)); - } - // Heterogeneous JavaScript object arrays must retain each literal's - // real keys. A structural union can otherwise drop descriptor fields. - if ( - !expected && - isJsSourceFile(expr.getSourceFile()) && - mapped?.kind === "array" && - mapped.elem.kind === "union" && - expr.elements.nodes.some(ts.isObjectLiteralExpression) - ) - mapped = DYN; - - // A TUPLE-typed slot (`const t: [string, number] = ["a", 1]`): the - // literal constructs the tuple's record shape — one positional field - // per element, source order (which IS index order, so evaluation order - // is JS-exact). tsc has already checked the arity; the recount below - // backstops `as` smuggling. Fixed tuple spreads have known positions. - if (mapped?.kind === "record") { - const shape = lowerer.shapes.get(mapped.shapeId); - if (shape?.tuple) { - const spread = expr.elements.nodes.find(ts.isSpreadElement); - if (spread) { - return lowerTupleSpreadLiteral(lowerer, expr, mapped, shape); - } - if (expr.elements.nodes.length !== shape.fields.length) { - // tsc padded an UNDER-LENGTH literal against an optional-element - // tuple context (`options || []` with `options?: [string?, - // number?]` — the instantiated type spells every position, the - // literal spells fewer): no fixed shape holds it. - lowerer.badType(expr, tsType); - } - const byName = new Map(shape.fields.map((f) => [f.name, f.type])); - const fields = expr.elements.nodes.map((el, i) => { - const fieldType = byName.get(String(i)); - if (!fieldType) lowerer.badType(el, lowerer.typeOf(el)); - return { name: String(i), value: lowerer.lowerExprExpecting(el, fieldType) }; - }); - return { kind: "recordLit", fields, type: mapped, loc }; - } - } - if (!mapped || mapped.kind !== "array") { - // The JS declaration fallback, literal-side: an element type with no - // static home (a string | string[] mixed command tuple, an evolving - // []) builds as a dyn ARRAY — one dyn value whose elements each - // convert through the usual boundary (dynFrom's JSON-safe domain); - // an element that cannot convert fences per element. length/index - // reads and dynamic consumers ride the keyed-dyn paths. TS literals - // take the same build when the slot ITSELF is checked-dynamic — an - // `unknown[]` annotation or a collapsed `(string | object)[]` maps - // to DYN wholesale now (mapType's dyn-element array rule), so the - // literal IS the dyn array. - if ( - isJsSourceFile(expr.getSourceFile()) || - mapped?.kind === "dyn" || - lowerer.checkerAnyArray(expr) - ) { - if (expr.elements.nodes.some(ts.isSpreadElement)) { - // Native collection drains return vectors of retained dyn - // elements. Reuse the ordinary spread builder, then expose the - // resulting array through the established unknown[] representation. - const vector = lowerArrayLiteral(lowerer, expr, arrayOf(DYN) as IrType & { kind: "array" }); - return lowerer.coerceInto(expr, vector, DYN); - } - const elems = expr.elements.nodes.map((el): IrExpr => { - if (ts.isSpreadElement(el)) { - lowerer.unsupported( - "SC1090", - el, - "spread elements in a dynamic (unknown[]) array literal", - ); - } - const v = lowerer.coerceToExpected(lowerer.lowerExpr(el), DYN); - if (v.type.kind !== "dyn") { - lowerer.unsupported( - "SC1101", - el, - `holding '${lowerer.fmt(v.type)}' values in a dynamic (unknown[]) array literal`, - ); - } - return v; - }); - return { kind: "dynArrLit", elems, type: DYN, loc }; - } - lowerer.badType(expr, tsType); - } - let type = mapped as IrType & { kind: "array" }; - // An inferred array of records takes the layout of an element literal - // whose fields widened for missing-element reads; the other elements - // build that layout directly. An explicit destination keeps its own. - if (!expected && type.elem.kind === "record") { - let variant: IrType | null = null; - for (const candidate of expr.elements.nodes) { - let x: ts.Expression = candidate; - while (ts.isParenthesizedExpression(x)) x = x.expression; - if (!ts.isObjectLiteralExpression(x)) continue; - const probe = tryLowerExpression(lowerer, x); - if (!probe || !lowerer.isRuntimeOptionalRecordVariant(probe.type, type.elem)) continue; - if (variant !== null && !typeEquals(variant, probe.type)) { - variant = null; - break; - } - variant = probe.type; - } - if (variant !== null) type = arrayOf(variant) as IrType & { kind: "array" }; - } - // Keep the array payload type fixed. Optional reads are stored through - // arrayValueStore, which records UNDEFINED in the state byte while the - // payload remains number/string/etc.; widening the array element here - // would force boxed union storage and change aliases. - const spreads: number[] = []; - const holes = new Set(); - const runtimeOptionalElements = new Set(); - let needsStateBuild = false; - for (let i = 0; i < expr.elements.nodes.length; i++) { - const candidate = expr.elements.nodes[i]!; - if (ts.isSpreadElement(candidate) || ts.isOmittedExpression(candidate)) continue; - const probe = tryLowerExpression(lowerer, candidate); - if (probe && lowerer.runtimeOptionalWidening(probe.type, type.elem) !== null) { - runtimeOptionalElements.add(i); - needsStateBuild = true; - } - } - const elems: IrExpr[] = expr.elements.nodes.map((el, i): IrExpr => { - if (ts.isSpreadElement(el)) { - // `[...xs, b]`: xs must be an array of the literal's own element - // type — its elements copy in at construction (a fresh array, - // JS-exact). Iterables that aren't arrays (strings, Sets, Maps) - // and mismatched element types stay fenced. A TERNARY source gets - // the literal's own type as its expected type (the conditional- - // spread idiom — tsc surfaces no contextual type through spreads, - // and the arm literals must BUILD as this element type). - let srcNode: ts.Expression = el.expression; - while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; - lowerer.fenceStaticHeadersIteration(el.expression); - let src = ts.isConditionalExpression(srcNode) - ? lowerTernary(lowerer, srcNode, type) - : lowerer.lowerExpr(el.expression); - src = lowerCollectionSpread(lowerer, src, el.expression) ?? src; - // `[...new SymbolIterator]`: a CLASS ITERABLE drains through its - // own protocol into a fresh element array (classIteratorDrainCall - // — an infinite iterator loops forever, exactly Node), and the - // spread machinery copies like any array source. - if (src.type.kind === "object") { - const drained = lowerer.classIteratorDrainCall(src, locOf(el), type.elem); - if (drained) src = drained; - } - // `[...s]` on a STRING spreads its code-point characters (the - // string iterator's walk — astral chars whole) through the same - // interned helper as Array.from(s); the result rides the array - // machinery below like any string[] source. - if (src.type.kind === "string") { - src = strCharsCall(lowerer, src, locOf(el)); - } - // `[...typedArray]`: represented typed arrays are dense numeric - // iterables, so drain their elements into the fresh number[] that - // the surrounding array literal will copy. In particular this is - // the Uint8Array-to-Array bridge (`[...u8]`), with element values - // read rather than backing bytes for the wider typed-array kinds. - if (src.type.kind === "bytes") { - src = { - kind: "bytesIntrinsic", - method: "toArray", - receiver: src, - args: [], - type: arrayOf(F64), - loc: locOf(el), - }; - } - // `[...it]` over a generator (or an iterator adapted into one): - // drain it once, in order, into a fresh element array. - if (src.type.kind === "generator") { - const drained = generatorDrain(lowerer, src, type.elem, locOf(el)); - if (drained) src = drained; - } - // `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its - // checked-dynamic view iterates the live pair list, and the drained - // [name, value] pairs convert to the literal's tuple elements. - const searchParamsSource = isSearchParamsIterable(lowerer, el.expression); - if (src.type.kind === "searchParams") src = lowerer.coerceInto(el.expression, src, DYN); - // A native checked iterable can supply scalar elements even when - // the checker inferred a typed array from its producer. Drain once - // and validate the elements before the ordinary spread copy. - if ( - src.type.kind === "dyn" && - (type.elem.kind === "dyn" || - type.elem.kind === "f64" || - type.elem.kind === "string" || - type.elem.kind === "bool" || - searchParamsSource) - ) { - src = lowerer.coerceInto(el.expression, checkedIteratorPack(lowerer, src, locOf(el)), type); - } - // A same-family array whose ELEMENT lifts (string[] into a - // (string | symbol)[] literal — per-element wrap/width copy): - // the interned width helper reshapes before the spread copies. - if (src.type.kind === "array" && !typeEquals(src.type, type)) { - const w = lowerer.widthCoerce(src, type); - if (w) src = w; - } - if (!typeEquals(src.type, type)) { - lowerer.unsupported( - "SC1090", - el, - `spreading '${lowerer.fmt(src.type)}' into a '${lowerer.fmt(type)}' literal (only a same-element-type array spreads)`, - ); - } - spreads.push(i); - return src; - } - // A HOLE (`[,]` — an elision) remains absent. Ordinary reads still - // answer undefined, while presence-sensitive methods and concat keep - // the hole. The state-building path below advances length without - // writing a value into this slot. - if (ts.isOmittedExpression(el)) { - needsStateBuild = true; - holes.add(i); - return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(el) }; - } - // An ARRAY-LITERAL element under a UNION element slot whose own type - // has no lift into the slot builds against the union's single array - // arm instead (`['pwd', []]` as (string | string[])[] — tsc pushes no - // contextual type through the unannotated chain, so the empty nested - // literal types never[], whose f64-element representation re-tags - // into nothing). One array arm is unambiguous — the union-slot rule's - // stance; several keep the fence. - if (type.elem.kind === "union" || type.elem.kind === "array") { - let x: ts.Expression = el; - while (ts.isParenthesizedExpression(x)) x = x.expression; - if (ts.isArrayLiteralExpression(x)) { - const ownT = lowerer.mapTypeOf(lowerer.checker.getContextualType(x) ?? lowerer.typeOf(x)); - if (ownT === null || lowerer.widthLiftPlan(ownT, type.elem) === null) { - // An ARRAY-typed slot takes the literal directly; a union slot - // routes through its single array arm and wraps. - if (type.elem.kind === "array") { - return lowerer.lowerArrayLiteral(x, type.elem); - } - const def = lowerer.unions.get(type.elem.unionId); - const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length === 1) { - const built = lowerer.lowerArrayLiteral(x, arrayArms[0] as IrType & { kind: "array" }); - return lowerer.coerceInto(el, built, type.elem); - } - } - } - } - // Elements flow into the element slot like an assignment: union - // elements wrap plain arm values (`[1, "a"]` as (number | string)[]), - // holes reject inside lowerExpr. - let lowered: IrExpr; - const directOptionalCandidate = - runtimeOptionalElements.has(i) || - ts.isElementAccessExpression(el) || - (ts.isCallExpression(el) && - ts.isPropertyAccessExpression(el.expression) && - (el.expression.name.text === "pop" || el.expression.name.text === "shift")); - if (directOptionalCandidate) { - const raw = lowerer.lowerExpr(el); - if (!typeEquals(raw.type, type.elem)) { - needsStateBuild = true; - return raw; - } - lowered = raw; - } else { - lowered = lowerer.lowerExprExpecting(el, type.elem); - } - if (!typeEquals(lowered.type, type.elem)) lowerer.badType(el, lowerer.typeOf(el)); - return lowered; - }); - if (!needsStateBuild) { - return { kind: "arrayLit", elems, ...(spreads.length > 0 ? { spreads } : {}), type, loc }; - } - const out = lowerer.declareHiddenLocal("%arrayLit", type); - const outRef = varRef(out.id, type, loc); - // Dense capacity for the elements the statements below store. - const capacity = Math.min(elems.length - spreads.length, 1024); - const body: IrStmt[] = [ - { - kind: "varDecl", - localId: out.id, - init: { - kind: "arrayLit", - elems: [], - ...(capacity > 0 ? { capacity } : {}), - type, - loc, - }, - loc, - }, - ]; - const spreadSet = new Set(spreads); - const currentLength = (): IrExpr => ({ - kind: "arrIntrinsic", - method: "length", - receiver: outRef, - args: [], - type: F64, - loc, - }); - const nextLength = (): IrExpr => ({ - kind: "bin", - op: "+", - left: currentLength(), - right: { kind: "numLit", value: 1, type: F64, loc }, - type: F64, - loc, - }); - for (let i = 0; i < elems.length; i++) { - const el = elems[i]!; - if (spreadSet.has(i)) { - body.push({ - kind: "exprStmt", - expr: { - kind: "arrIntrinsic", - method: "pushSpread", - receiver: outRef, - args: [el], - type: F64, - loc, - }, - loc, - }); - continue; - } - if (holes.has(i)) { - body.push({ kind: "arraySetLength", arr: outRef, length: nextLength(), loc }); - continue; - } - let value = el; - if (!typeEquals(value.type, type.elem)) { - const temp = lowerer.declareHiddenLocal("%arrayValue", value.type); - body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); - value = varRef(temp.id, temp.type, loc); - } - body.push(arrayValueStore(lowerer, outRef, currentLength(), value, type.elem, loc)); - } - return { - kind: "seqExpr", - stmts: body, - result: outRef, - type, - loc, - }; -} - -/** Build a fixed tuple from positional elements and fixed tuple spreads. - * Capture each value in source order: deferring the reads until recordLit - * would observe a later element's mutations of the spread source. */ -function lowerTupleSpreadLiteral( - lowerer: Lowerer, - expr: ts.ArrayLiteralExpression, - type: IrType & { kind: "record" }, - shape: IrRecordShape, -): IrExpr { - const loc = locOf(expr); - const byName = new Map(shape.fields.map((field) => [field.name, field.type])); - const stmts: IrStmt[] = []; - const fields: { name: string; value: IrExpr }[] = []; - const append = (node: ts.Expression, value: IrExpr): void => { - const name = String(fields.length); - const expected = byName.get(name); - if (!expected) lowerer.badType(expr, lowerer.typeOf(expr)); - const coerced = lowerer.coerceInto(node, value, expected); - const temp = lowerer.declareHiddenLocal("%tupleElement", expected); - const at = locOf(node); - stmts.push({ kind: "varDecl", localId: temp.id, init: coerced, loc: at }); - fields.push({ name, value: varRef(temp.id, expected, at) }); - }; - for (const element of expr.elements.nodes) { - if (!ts.isSpreadElement(element)) { - const expected = byName.get(String(fields.length)); - if (!expected) lowerer.badType(element, lowerer.typeOf(element)); - append(element, lowerer.lowerExprExpecting(element, expected)); - continue; - } - const source = lowerer.lowerExpr(element.expression); - const sourceShape = - source.type.kind === "record" ? lowerer.shapes.get(source.type.shapeId) : undefined; - if (!sourceShape?.tuple) { - // Empty tuples use a zero-length array representation. Preserve a - // producing call's effects even though there are no positions to copy. - const sourceTs = lowerer.typeOf(element.expression); - if ( - source.type.kind === "array" && - lowerer.checker.isTupleType(sourceTs) && - lowerer.checker.getTypeArguments(sourceTs as ts.TypeReference).length === 0 - ) { - stmts.push({ kind: "exprStmt", expr: source, loc: locOf(element) }); - continue; - } - lowerer.unsupported( - "SC1090", - element, - "spreading a variable-length value into a fixed tuple literal", - ); - } - const temp = lowerer.declareHiddenLocal("%tupleSpread", source.type); - const at = locOf(element); - stmts.push({ kind: "varDecl", localId: temp.id, init: source, loc: at }); - const receiver = varRef(temp.id, source.type, at); - const positions = [...sourceShape.fields].sort((a, b) => Number(a.name) - Number(b.name)); - for (const field of positions) { - append(element.expression, { - kind: "recordGet", - obj: receiver, - shapeId: sourceShape.id, - field: field.name, - type: field.type, - loc: at, - }); - } - } - if (fields.length !== shape.fields.length) lowerer.badType(expr, lowerer.typeOf(expr)); - return { kind: "seqExpr", stmts, result: { kind: "recordLit", fields, type, loc }, type, loc }; -} - /** Convert an undefined-armed numeric value at an arithmetic use. Ordinary * reads preserve their tagged value; JavaScript's ToNumber(undefined) is NaN, * so compounds and binary arithmetic use this checked one-time extraction. */ @@ -6650,1303 +1362,6 @@ export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): Ir return lowerer.coerceInto(node, value, F64); } -/** `a[i]` reads. Only f64 indices into array receivers are modeled; JS - * string-key element access (`a["length"]`) and string indexing (`s[0]` - * typechecks against the lib's index signature; use .charAt) stay out. */ -export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpression): IrExpr { - if (isImportMeta(expr.expression)) { - const name = lowerer.foldedStringKeyOf(expr.argumentExpression); - if (name === null) - lowerer.unsupported( - "SC1090", - expr, - "import.meta with a runtime-computed key (use a statically-known metadata field)", - ); - return importMetaField(lowerer, expr, name); - } - - // `a?.[i]`: the guard lowers as an optional-chain step around the - // plain element read below. - if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) { - return lowerer.lowerOptionalChain(expr); - } - // `a?.b[i]` — the tail of a chain whose token sits deeper: the whole - // tail short-circuits with the guard. - if (!expr.questionDotToken && isOptionalChainTail(lowerer, expr)) { - return lowerer.lowerOptionalChain(expr); - } - // Enum accesses in element clothing — `E["A"]` forward reads and - // `E[0]` reverse-mapping reads — fold to constants (lower-enums.ts), - // claimed before any receiver-kind dispatch can see the enum object. - { - const en = lowerEnumAccess(lowerer, expr); - if (en) return en; - } - const cachedModule = lowerRequireCacheElement(lowerer, expr); - if (cachedModule) return cachedModule; - const namespaceElement = lowerModuleNamespaceElement(lowerer, expr); - if (namespaceElement) return namespaceElement; - // Native Web handles use the same supported surface for bracket and dot - // spellings. Fence a statically-known unsupported key before the generic - // checked-dynamic element-read path can turn it into a runtime missing - // member. - lowerer.fenceStaticAbortControllerMemberRead(expr); - lowerer.fenceStaticResponseMember(expr, "read"); - lowerer.fenceStaticHeadersMember(expr, "read"); - lowerer.fenceStaticReadableStreamMember(expr, "read"); - // Computed global reads share the stored global object's custom - // properties. Symbol keys retain their identity-indexed store. - if (!expr.questionDotToken && stdlibGlobalNameOf(lowerer, expr.expression) === "globalThis") { - const symbol = globalSymbolKey(lowerer, expr.expression, expr.argumentExpression); - if (symbol) - return { - kind: "libCall", - fn: "dyn.globalSymbolGet", - args: [symbol], - type: DYN, - loc: locOf(expr), - }; - const key = lowerer.lowerExpr(expr.argumentExpression); - const name: IrExpr = - key.type.kind === "string" - ? key - : { - kind: "libCall", - fn: "dyn.toStringCoerce", - args: [lowerer.coerceInto(expr.argumentExpression, key, DYN)], - type: STRING, - loc: locOf(expr), - }; - return { - kind: "dynKeyGet", - value: lowerGlobalValue(lowerer, expr.expression), - key: name, - type: DYN, - loc: locOf(expr), - }; - } - // `req.headers["x-name"]` — the computed twin of `req.headers.host` - // (the server spoke owns both; the envGet precedent). - { - const header = lowerer.lowerHttpHeadersElement(expr); - if (header) return lowerer.maybeNarrow(header, expr); - } - // `process.env[expr]` — the computed twin of `process.env.NAME`; both - // lower to the ONE process.envGet intrinsic. The read narrows like any - // union-typed expression when the checker narrowed this occurrence. - if (lowerer.isProcessEnv(expr.expression)) { - const key = lowerEnvironmentKey(lowerer, expr.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - expr.argumentExpression, - "indexing process.env with non-string keys", - ); - } - const get: IrExpr = { - kind: "libCall", - fn: "process.envGet", - args: [key], - type: lowerer.envValueType(), - loc: locOf(expr), - }; - return lowerer.maybeNarrow(get, expr); - } - // Expando function members in element clothing (`foo[strMem]`, - // `foo[_private]` where foo is a module-level function/callable const - // and the key folds or is a unique-symbol const): the member's module - // global — the dotted read's twin (lower-expando.ts). - { - const ex = expandoMemberRead(lowerer, expr); - if (ex) return lowerer.maybeNarrow(ex, expr); - } - // Symbol-keyed property READS (`this[kLimit]`): when the key is a - // statically-resolvable symbol declared as a field of the receiver's - // class (classSymbolKeyOf — the countdown.js/OpenTUI idioms), the - // read IS an ordinary field read of the hidden slot. Every other - // symbol key stays fenced: record shapes, runtime-identity keys - // (symbol parameters, reassigned bindings), and keys no class declares - // — the layouts are compile-time field lists. - if (lowerer.mapTypeOf(lowerer.typeOf(expr.argumentExpression))?.kind === "symbol") { - const target = symbolFieldTarget(lowerer, expr); - if (target) return lowerer.maybeNarrow(lowerer.fieldGetExpr(target, locOf(expr), expr), expr); - let receiver = lowerer.lowerExpr(expr.expression); - const classInfo = - receiver.type.kind === "classval" ? lowerer.classes.get(receiver.type.className) : undefined; - if ( - receiver.type.kind === "func" || - receiver.type.kind === "generator" || - ((receiver.type.kind === "array" || - receiver.type.kind === "string" || - receiver.type.kind === "bytes") && - lowerer.dynConvertible(receiver.type)) || - (receiver.type.kind === "object" && lowerer.dynConvertible(receiver.type)) || - isDynTypedRefType(receiver.type) || - (classInfo && (classInfo.callableBase || hasRuntimeStatics(classInfo))) || - ((receiver.type.kind === "map" || receiver.type.kind === "set") && - lowerer.dynConvertible(receiver.type)) - ) { - const fnName = jsFuncNameOf(expr.expression); - receiver = { - kind: "dynFrom", - value: receiver, - type: DYN, - loc: locOf(expr), - ...(fnName !== null ? { fnName } : {}), - }; - } - if (receiver.type.kind === "dyn") { - const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); - return lowerer.maybeNarrow( - { kind: "dynKeyGet", value: receiver, key, type: DYN, loc: locOf(expr) }, - expr, - ); - } - lowerer.unsupported( - "SC1090", - expr, - "symbol-keyed property access outside class fields keyed by a stable module-level literal Symbol()/Symbol.for() (other static shapes have no symbol-keyed storage)", - ); - } - // A never-tainted JS receiver type (neverTaintedJsType — `cmd[1]` on - // `const cmd = ['pwd', []]`, whose binding lowered checked-dynamic) - // dispatches as unmapped so the dyn-receiver branch below reads - // through the checked-dynamic tree instead of dynChecking into never's f64 residue. - let receiverIr = neverTaintedJsType(lowerer, expr.expression, lowerer.typeOf(expr.expression)) - ? null - : lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (isJsSourceFile(expr.getSourceFile())) { - const receiver = tryLowerExpression(lowerer, expr.expression); - if (receiver?.type.kind === "dyn") { - const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); - const optional = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: receiver, - key, - ...(optional ? { optional: true as const } : {}), - type: DYN, - loc: locOf(expr), - }, - expr, - ); - } - if (receiver?.type.kind === "func" && lowerer.dynConvertible(receiver.type)) { - const loc = locOf(expr); - const fnName = jsFuncNameOf(expr.expression); - const boxed: IrExpr = { - kind: "dynFrom", - value: receiver, - type: DYN, - loc, - ...(fnName !== null ? { fnName } : {}), - }; - const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); - return lowerer.maybeNarrow({ kind: "dynKeyGet", key, value: boxed, type: DYN, loc }, expr); - } - if (receiver?.type.kind === "array" || receiver?.type.kind === "bytes") - receiverIr = receiver.type; - if (receiver?.type.kind === "union") { - const present = lowerer.stripUndefinedArm(receiver.type); - if (present.kind === "array" || present.kind === "bytes") receiverIr = present; - } - } - // A readonly tuple union under Array.isArray is checker-typed as an - // intersection with any[], whose structural mapping is not the tuple - // shape. maybeNarrow on the receiver uses the runtime tag proof to - // extract the one array-valued arm; dispatch element access from that - // lowered representation, like the existing any[] array-method path. - const checkerArray = lowerer.checkerArrayValue(expr.expression); - if (checkerArray) receiverIr = checkerArray.type; - if (receiverIr?.kind === "object") { - const key = - recordKeyLiteralText(expr.argumentExpression) ?? - recordKeyTypeLiteralText(lowerer, expr.argumentExpression); - if (key !== null) { - const target = classFieldTarget(lowerer, expr.expression, receiverIr, key); - if (target) return lowerer.maybeNarrow(lowerer.fieldGetExpr(target, locOf(expr), expr), expr); - } - if (receiverIr.className === "%Error" || isDynTypedRefType(receiverIr)) { - const receiver = lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN); - const key = lowerer.lowerExprExpecting(expr.argumentExpression, DYN); - return lowerer.maybeNarrow( - { kind: "dynKeyGet", value: receiver, key, type: DYN, loc: locOf(expr) }, - expr, - ); - } - } - - // Typed-array element read `b[i]` — like arrayGet with a scalar - // result; any invalid index traps (the array discipline; JS would - // read undefined — divergence 4's policy). - if (receiverIr?.kind === "bytes") { - let recv = lowerer.lowerExpr(expr.expression); - if (recv.type.kind === "dyn") recv = lowerer.coerceInto(expr.expression, recv, receiverIr); - if (recv.type.kind === "union" && lowerer.armTag(recv.type.unionId, UNDEFINED_T) >= 0) { - const present = lowerer.stripUndefinedArm(recv.type); - const helper = - present.kind === "bytes" - ? lowerer.narrowedArmHelper(recv.type.unionId, present, locOf(expr.expression)) - : null; - recv = helper - ? lowerer.checkedNarrowCall(helper, recv, present, locOf(expr.expression)) - : lowerer.maybeNarrow(recv, expr.expression); - } - - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - locOf(expr.argumentExpression), - expr.argumentExpression, - ); - if (isJsSourceFile(expr.getSourceFile()) && recv.type.kind === "bytes") { - return { - kind: "dynKeyGet", - value: lowerer.coerceInto(expr.expression, recv, DYN), - key: - index.type.kind === "f64" - ? { kind: "toString", operand: index, type: STRING, loc: locOf(expr) } - : index, - type: DYN, - loc: locOf(expr), - }; - } - if (index.type.kind === "dyn" && recv.type.kind === "bytes") { - return { - kind: "dynKeyGet", - value: lowerer.coerceInto(expr.expression, recv, DYN), - key: index, - type: DYN, - loc: locOf(expr), - }; - } - if (index.type.kind !== "f64") { - lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); - } - // `t[i]!` asserts presence: the numeric element read (NaN when - // invalid). Every other read answers undefined for an invalid index, - // like Node. - // A read whose every consumer treats undefined like NaN (see - // nan-coded-reads.ts) is the same plain numeric read. - if ( - (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || - lowerer.nanCodedReads?.isNumericRead(expr) === true - ) { - return { - kind: "bytesIntrinsic", - method: "get", - receiver: recv, - args: [index], - type: F64, - loc: locOf(expr), - invalidNaN: true, - }; - } - return lowerSafeBytesRead(lowerer, recv, index, locOf(expr)); - } - // Tuple element read `t[0]`: a positional-field read of the tuple's - // record shape — LITERAL indices only (the checker's per-index types - // are what make the read honest; a dynamic index over a heterogeneous - // shape has no single element type). Narrows like any field read. - if (receiverIr?.kind === "record") { - const shape = lowerer.shapes.get(receiverIr.shapeId); - if (shape?.tuple) { - let obj = lowerer.lowerExpr(expr.expression); - - if (obj.type.kind === "dyn") { - // Object.entries over a checked value stores each pair in the - // dynamic tree even when the checker names a tuple type. - const key = lowerRecordPropertyKey( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - expr.argumentExpression, - ); - const optional = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: obj, - key, - ...(optional ? { optional: true as const } : {}), - type: DYN, - loc: locOf(expr), - }, - expr, - ); - } - if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { - // The checker sees the outer `arrays[i]` as a tuple when - // noUncheckedIndexedAccess is disabled, but the lowered read is - // still tuple | undefined. A nested tuple field must validate the - // outer tag before recordGet; an unchecked unionNarrow would let a - // missing outer element reach the record backend as a bad pointer. - const present = lowerer.stripUndefinedArm(obj.type); - const helper = - present.kind === "record" - ? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(expr.expression)) - : null; - obj = helper - ? lowerer.checkedNarrowCall(helper, obj, present, locOf(expr.expression)) - : lowerer.maybeNarrow(obj, expr.expression); - } - const idx = tupleLiteralIndex(expr.argumentExpression); - if (idx === null) { - lowerer.unsupported( - "SC1090", - expr.argumentExpression, - "tuple indexing with a non-literal index (tuples are fixed-shape — use t[0], t[1], ...)", - ); - } - const shapeId = obj.type.kind === "record" ? obj.type.shapeId : receiverIr.shapeId; - const actualShape = lowerer.shapes.get(shapeId) ?? shape; - const fieldType = actualShape.fields.find((f) => f.name === String(idx))?.type; - if (fieldType === undefined) lowerer.badType(expr, lowerer.typeOf(expr)); // OOB smuggled past tsc - const get: IrExpr = { - kind: "recordGet", - obj, - shapeId, - field: String(idx), - type: fieldType, - loc: locOf(expr), - }; - return lowerer.isRuntimeOptionalField(shapeId, String(idx)) - ? get - : lowerer.maybeNarrow(get, expr); - } - if (shape && !shape.tuple) { - return lowerer.lowerRecordKeyRead(expr, receiverIr.shapeId, shape); - } - } - // `pkg["k"]` / `scripts[name]` / `scopeMatch[1]` on a dyn receiver (a - // JSON.parse result): the dyn keyed read, exactly the dot form. - // NUMBER-typed indices convert through ToString first — JS property - // keys are strings, and the canonical number text answers array - // indices in the helper (fractions, negatives, and NaN read as - // absent keys, exactly JS). An UNMAPPABLE checker type takes the - // same path when the receiver LOWERS dyn - // (`pkg.workspaces.packages["0"]` — the lowering world types the - // unknown-rooted chain `any`); a non-dyn lowering falls through to - // the fences below (re-lowering is pure IR construction). - if (receiverIr?.kind === "dyn" || receiverIr?.kind === "union" || receiverIr === null) { - const obj = lowerer.lowerExpr(expr.expression); - // A generic mapped type can stay unresolved at this body use even - // though its instantiated parameter has a concrete record ABI. - // Dispatch from that stored shape, as the array fallback below does. - if (receiverIr === null && obj.type.kind === "record") { - const shape = lowerer.shapes.get(obj.type.shapeId); - const index = tupleLiteralIndex(expr.argumentExpression); - const field = - shape?.tuple && index !== null - ? shape.fields.find((field) => field.name === String(index)) - : null; - if (field) - return lowerer.maybeNarrow( - { - kind: "recordGet", - obj, - shapeId: obj.type.shapeId, - field: field.name, - type: field.type, - loc: locOf(expr), - }, - expr, - ); - if (shape && !shape.tuple) { - return lowerer.lowerRecordKeyRead(expr, obj.type.shapeId, shape); - } - } - if ( - obj.type.kind === "dyn" || - (obj.type.kind === "object" && obj.type.className === "%Error") || - isDynTypedRefType(obj.type) - ) { - const rawKey = lowerer.lowerExpr(expr.argumentExpression); - // Number, bool, and DYN keys stringify (ToPropertyKey) — the - // dyn-keyed read `catchWarning[warning.name]` where the property - // chain itself lowered dyn. - const key = - rawKey.type.kind === "dyn" || - rawKey.type.kind === "symbol" || - (rawKey.type.kind === "union" && lowerer.dynConvertible(rawKey.type)) - ? lowerer.coerceToExpected(rawKey, DYN) - : lowerRecordPropertyKey(lowerer, rawKey, expr.argumentExpression); - if (key.type.kind === "string" || key.type.kind === "dyn") { - const opt = hasOptionalChainGuard(expr.expression); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - key, - ...(opt ? { optional: true as const } : {}), - value: lowerer.coerceToExpected(obj, DYN), - type: DYN, - loc: locOf(expr), - }, - expr, - ); - } - } - // A checker-`any` receiver that LOWERS to a static ARRAY - // (`pm.split("@")[0]` — the dyn-receiver string machinery answers - // string[]): the element read rides the ordinary array path on the - // lowered value (invalid indices trap, divergence 4's policy). - if (obj.type.kind === "array") { - const index = lowerer.lowerExpr(expr.argumentExpression); - if (index.type.kind === "f64") { - const safe = lowerSafeIndexRead(lowerer, obj, index, locOf(expr)); - if (safe) return safe; - return lowerer.maybeNarrow( - { kind: "arrayGet", arr: obj, index, type: obj.type.elem, loc: locOf(expr) }, - expr, - ); - } - } - } - // `env[key]` on a UNION of record shapes (`ProcessEnv | Record` — the env-bag parameter pattern): the per-arm keyed read, - // joined exactly like the dot form (lowerUnionProperty's keyed path). - if (receiverIr?.kind === "union") { - const value = lowerer.lowerExpr(expr.expression); - if (value.type.kind === "union") { - const key = lowerer.lowerExpr(expr.argumentExpression); - if (key.type.kind === "f64") { - const def = lowerer.unions.get(value.type.unionId); - const presentArms = def?.arms.filter((arm) => !isUnitType(arm)) ?? []; - if (presentArms.length === 1 && presentArms[0]?.kind === "string") { - const property = runtimeOptionalElementKey(expr.argumentExpression); - const receiver = - property === null - ? null - : lowerer.runtimeOptionalPropertyReceiver(expr.expression, value, STRING, property); - if (receiver !== null) { - return { - kind: "strIntrinsic", - method: "charAt", - receiver, - args: [key], - type: STRING, - loc: locOf(expr), - }; - } - } - } - if (key.type.kind === "f64") { - const safe = lowerOptionalArrayIndexRead(lowerer, expr, value, key); - if (safe) return safe; - } - if (key.type.kind === "string" || key.type.kind === "f64") { - const lit = ts.isStringLiteral(expr.argumentExpression) - ? expr.argumentExpression.text - : null; - const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, lit); - if (keyed) return lowerer.maybeNarrow(keyed, expr); - } - } - } - if (receiverIr?.kind !== "array") { - { - const receiver = tryLowerExpression(lowerer, expr.expression); - if ( - receiver?.type.kind === "dyn" || - (receiver && - isJsSourceFile(expr.getSourceFile()) && - (receiver.type.kind === "union" || receiver.type.kind === "bytes") && - lowerer.dynConvertible(receiver.type)) - ) { - const key = lowerRecordPropertyKey( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - expr.argumentExpression, - ); - return lowerer.maybeNarrow( - { - kind: "dynKeyGet", - value: lowerer.coerceInto(expr.expression, receiver, DYN), - key, - type: DYN, - loc: locOf(expr), - }, - expr, - ); - } - if ( - receiver?.type.kind === "func" && - canBoxFuncIntoDyn( - receiver.type, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) { - const key = lowerRecordPropertyKey( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - expr.argumentExpression, - ); - if (key.type.kind === "string") { - const boxed: IrExpr = { - kind: "dynFrom", - value: receiver, - type: DYN, - loc: locOf(expr.expression), - }; - return lowerer.maybeNarrow( - { kind: "dynKeyGet", key, value: boxed, type: DYN, loc: locOf(expr) }, - expr, - ); - } - } - } - if (receiverIr?.kind === "string") { - // An indexed string property is absent for non-integer and missing - // indexes. charAt has different coercion and out-of-range semantics. - const recv = lowerer.lowerExpr(expr.expression); - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - locOf(expr.argumentExpression), - expr.argumentExpression, - ); - if (index.type.kind === "f64" && recv.type.kind === "string") { - // A proven in-bounds integer index always names a code unit, where - // charAt and the property read agree. - if (lowerer.stringIndexBounds.inBounds(expr)) - return { - kind: "strIntrinsic", - method: "charAt", - receiver: recv, - args: [index], - type: STRING, - loc: locOf(expr), - }; - return lowerOptionalStringIndex( - lowerer, - recv, - index, - lowerer.withUndefinedArm(STRING), - locOf(expr), - ); - } - // The checker narrowed the receiver to string, but its stored value - // is still the union (an array-derived binding, a typeof guard). - if (index.type.kind === "f64") { - const read = lowerUnionStringIndex( - lowerer, - recv, - index, - expr.argumentExpression, - lowerer.withUndefinedArm(STRING), - locOf(expr), - ); - if (read) return read; - } - lowerer.unsupported( - "SC1090", - expr, - "string indexing with this index/result shape (expected a number index and a string or string | undefined result)", - ); - } - lowerer.unsupported("SC1090", expr, "element access on non-array values"); - } - let arr = lowerer.lowerExpr(expr.expression); - const runtimeOptionalSource = lowerer.runtimeOptionalSourceValue(expr.expression, arr); - if ( - runtimeOptionalSource?.kind === "varRef" && - runtimeOptionalSource.type.kind === "union" && - arr.type.kind === "array" - ) { - // lowerer.lowerElementAccess has already recognized a captured - // array | undefined receiver and supplied its checked extraction as - // the expression override. Rebuild that check here so a missing - // receiver throws the member-access error Node exposes, rather than - // the generic target-union narrowing error. The stored receiver is a - // varRef, so both the tag test and extraction read the same value - // without repeating an effect. - const stored = runtimeOptionalSource; - if (stored.type.kind !== "union") - throw new InternalCompilerError("runtime-optional array source lost its union storage"); - const unionId = stored.type.unionId; - const undefTag = lowerer.armTag(unionId, UNDEFINED_T); - const valueTag = lowerer.armTag(unionId, arr.type); - const property = runtimeOptionalElementKey(expr.argumentExpression); - if (undefTag >= 0 && valueTag >= 0 && property !== null) { - arr = { - kind: "ternary", - cond: { - kind: "unionIsTag", - unionId, - tag: undefTag, - negated: false, - value: stored, - type: BOOL, - loc: locOf(expr.expression), - }, - then: nodeThrowExpr( - 1, - "", - `Cannot read properties of undefined (reading '${property}')`, - arr.type, - locOf(expr.expression), - ), - else_: { - kind: "unionNarrow", - unionId, - tag: valueTag, - value: stored, - type: arr.type, - loc: locOf(expr.expression), - }, - type: arr.type, - loc: locOf(expr.expression), - }; - } - } - if ( - arr.type.kind === "union" && - lowerer.armTag(arr.type.unionId, UNDEFINED_T) >= 0 && - receiverIr.kind === "array" - ) { - // As with tuple receivers above, an unchecked outer read can be - // undefined even when the checker reports an array. Extract the - // present array arm with a runtime tag check before arrayGet or the - // safe indexed-read helper runs. - const present = lowerer.stripUndefinedArm(arr.type); - const helper = - present.kind === "array" - ? lowerer.narrowedArmHelper(arr.type.unionId, present, locOf(expr.expression)) - : null; - arr = helper - ? lowerer.checkedNarrowCall(helper, arr, present, locOf(expr.expression)) - : lowerer.maybeNarrow(arr, expr.expression); - } - - // The checker sees an array but the VALUE lowered checked-dynamic (a - // jsdoc-typed default export from a .js module — signature 14): the - // read rides the dynCheck boundary when the array shape is one the checked-dynamic tree - // can validate, and fences by name when it is not — an arrayGet over a - // dyn receiver is never emitted (the validator ICE). - if (arr.type.kind === "dyn") { - if ( - canDynCheckTo( - receiverIr, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) { - arr = { kind: "dynCheck", value: arr, type: receiverIr, loc: locOf(expr) }; - } else { - lowerer.unsupported( - "SC1090", - expr, - `element access on checked-dynamic values with '${lowerer.fmt(receiverIr)}' elements`, - ); - } - } - // Array reads can themselves supply an index. A missing numeric value - // is undefined, whose property lookup misses just like NaN; keep that - // value instead of rejecting a checker-number index with optional IR. - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(expr.argumentExpression), - locOf(expr.argumentExpression), - expr.argumentExpression, - ); - if ( - arr.type.kind === "array" && - (index.type.kind === "dyn" || - (isJsSourceFile(expr.getSourceFile()) && - index.type.kind !== "f64" && - lowerer.dynConvertible(index.type))) - ) { - return { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(arr, DYN), - key: index, - type: DYN, - loc: locOf(expr), - }; - } - if (index.type.kind !== "f64") { - lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); - } - // The LOWERED receiver's element type wins over the checker's when - // both are arrays: a spoke result can be more precise than the - // declared surface (emitter.listeners() answers the event's tuple - // signature where the .d.ts says Function[], which would flatten the - // element to a zero-parameter closure). - const elemT = arr.type.kind === "array" ? arr.type.elem : receiverIr.elem; - // Every ordinary array read is a JavaScript property read: an invalid - // index answers undefined, even when the checker was built without - // noUncheckedIndexedAccess. Keep the explicit optional union in the IR - // so it can flow through templates, locals, calls, returns, operators, - // and narrowing. The helper performs one length/proof check and then - // uses the dense getter for the proven-present path, so this does not - // change the storage representation or the low-level getter itself. - if (arr.type.kind === "array") { - // A non-null assertion is the explicit proven-present form. Preserve - // the dense getter's established bounds trap where the asserted value - // must be the element type (library ABI checks and the catchable - // RangeError contract for an out-of-bounds assert). Where undefined is - // representable, the assertion keeps JavaScript's unchecked read. - const provenPresent = - ts.isNonNullExpression(expr.parent) && - expr.parent.expression === expr && - !assertedElementKeepsUndefined(lowerer, expr.parent); - if (provenPresent) { - return lowerer.maybeNarrow( - { kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, - expr, - ); - } - const safe = lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); - if (safe) return safe; - } - // --npm-static package files retain the original slice-specific route - // for compatibility with package-source inference. - if ( - arr.type.kind === "array" && - arr.kind === "arrIntrinsic" && - arr.method === "slice" && - npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null - ) { - const safe = lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); - if (safe) return safe; - } - // A union-element read narrows like an identifier when the checker has - // narrowed THIS occurrence (`if (a[0] !== undefined) use(a[0])` — tsc - // narrows literal-index element accesses). - return lowerer.maybeNarrow({ kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, expr); -} - -/** A record receiver that an outer keyed read left optional - * (`nested[a][b]`, where `nested[a]` can miss). The receiver evaluates - * once into `stmts`; `present` is its record arm, valid only once the - * access has tested `stable` against the undefined (or, for reads, null) - * arm. */ -interface OptionalRecordReceiver { - stmts: IrStmt[]; - stable: IrExpr; - present: IrExpr; - unionId: string; - undefTag: number; - nullish: "undefined" | "null"; -} - -function optionalRecordReceiver( - lowerer: Lowerer, - value: IrExpr, - loc: SrcLoc, - allowNull = false, -): OptionalRecordReceiver | null { - if (value.type.kind !== "union") return null; - const unionId = value.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms; - if (arms?.length !== 2) return null; - let undefTag = lowerer.armTag(unionId, UNDEFINED_T); - let nullish: OptionalRecordReceiver["nullish"] = "undefined"; - // A null-initialized slot assigned a record later (iconv-lite's - // `encodings = null` table, filled on first use): the checker narrows - // the read to the record, the storage keeps the null arm. - if (undefTag < 0 && allowNull) { - undefTag = lowerer.armTag(unionId, NULL_T); - nullish = "null"; - } - if (undefTag < 0) return null; - const record = arms[1 - undefTag]!; - if (record.kind !== "record") return null; - const stmts: IrStmt[] = []; - const stable = stabilized(lowerer, value, "%recordReceiver", stmts); - const present: IrExpr = { - kind: "unionNarrow", - unionId, - tag: lowerer.armTag(unionId, record), - value: stable, - type: record, - loc, - }; - return { stmts, stable, present, unionId, undefTag, nullish }; -} - -/** `value`, evaluated once at this point of `out`. */ -function stabilized(lowerer: Lowerer, value: IrExpr, name: string, out: IrStmt[]): IrExpr { - if (isSafeToRepeat(value)) return value; - const tmp = lowerer.declareHiddenLocal(name, value.type); - out.push({ kind: "varDecl", localId: tmp.id, init: value, loc: value.loc }); - return varRef(tmp.id, value.type, value.loc); -} - -function undefinedReceiver(receiver: OptionalRecordReceiver, loc: SrcLoc): IrExpr { - return { - kind: "unionIsTag", - unionId: receiver.unionId, - tag: receiver.undefTag, - negated: false, - value: receiver.stable, - type: BOOL, - loc, - }; -} - -/** Node's TypeError for a keyed read or write on an undefined (or null) - * receiver. */ -function undefinedReceiverThrow( - key: IrExpr, - action: "read" | "set", - type: IrType, - loc: SrcLoc, - nullish: OptionalRecordReceiver["nullish"] = "undefined", -): IrExpr { - const head = `Cannot ${action} properties of ${nullish} (${action === "read" ? "reading" : "setting"} '`; - const message: IrExpr = { - kind: "strConcat", - left: { - kind: "strConcat", - left: { kind: "strLit", value: head, type: STRING, loc }, - right: key, - type: STRING, - loc, - }, - right: { kind: "strLit", value: "')", type: STRING, loc }, - type: STRING, - loc, - }; - return { - kind: "libCall", - fn: "error.nodeThrow", - args: [numLit(1, loc), { kind: "strLit", value: "", type: STRING, loc }, message], - type, - loc, - }; -} - -/** The receiver and key of a keyed access whose receiver can be undefined, - * evaluated in order into `stmts`, with the receiver's record arm and the - * key's property-name string. Null when the receiver is not optional. */ -function guardedRecordAccess( - lowerer: Lowerer, - access: ts.ElementAccessExpression, - rawObj: IrExpr, - allowNull = false, -): { receiver: OptionalRecordReceiver; key: IrExpr; name: IrExpr } | null { - const receiver = optionalRecordReceiver(lowerer, rawObj, locOf(access.expression), allowNull); - if (!receiver) return null; - const keyNode = access.argumentExpression; - const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); - const key = - litKey !== null - ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) - : stabilized(lowerer, lowerer.lowerExpr(keyNode), "%recordKey", receiver.stmts); - // A symbol key names no string property; its message spelling is not - // modeled. - const name = - key.type.kind === "symbol" - ? ({ kind: "strLit", value: "Symbol()", type: STRING, loc: locOf(keyNode) } satisfies IrExpr) - : lowerRecordPropertyKey(lowerer, key, keyNode); - return { receiver, key, name }; -} - -/** `nested[a][b] = v` where `nested[a]` can miss: the receiver, the key and - * the value evaluate in order, then an undefined receiver throws Node's - * TypeError and a present one takes the write. Only member and identifier - * receivers are probed, since a probe that finds no optional receiver - * lowers the receiver again. */ -function lowerGuardedRecordWrite( - lowerer: Lowerer, - expr: ts.BinaryExpression, - target: ts.ElementAccessExpression, -): IrStmt | null { - let receiverNode: ts.Expression = target.expression; - while (ts.isParenthesizedExpression(receiverNode)) receiverNode = receiverNode.expression; - if ( - lowerer.chainRecvByNode.has(target.expression) || - !( - ts.isElementAccessExpression(receiverNode) || - ts.isPropertyAccessExpression(receiverNode) || - ts.isIdentifier(receiverNode) - ) - ) - return null; - const guarded = guardedRecordAccess(lowerer, target, lowerer.lowerExpr(target.expression)); - if (!guarded) return null; - const { receiver, key, name } = guarded; - const loc = locOf(expr); - const value = stabilized(lowerer, lowerer.lowerExpr(expr.right), "%recordValue", receiver.stmts); - const write = lowerer.withExpressionOverride(target.expression, receiver.present, () => - lowerer.withExpressionOverride(target.argumentExpression, key, () => - lowerer.withExpressionOverride(expr.right, value, () => lowerElementWrite(lowerer, expr)), - ), - ); - return { - kind: "block", - body: [ - ...receiver.stmts, - { - kind: "if", - cond: undefinedReceiver(receiver, loc), - then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }], - else_: [write], - loc, - }, - ], - loc, - }; -} - -/** `b[i] = v` where the checker's flow type of `b` is a typed array but - * its storage is wider. A hoisted JavaScript `var` (or a field written - * after construction) holds `bytes | undefined`: the receiver, index and - * value evaluate in order, then an undefined receiver throws Node's - * TypeError and a present one takes the typed store. Any other storage (a - * `var` later reassigned a string, a checked-dynamic value) takes the - * checked-dynamic store, which applies whatever value the receiver holds. */ -function lowerWiderBytesWrite( - lowerer: Lowerer, - expr: ts.BinaryExpression, - target: ts.ElementAccessExpression, - recv: IrExpr, -): IrStmt { - const loc = locOf(expr); - if (recv.type.kind === "union") { - const unionId = recv.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms; - const undefTag = lowerer.armTag(unionId, UNDEFINED_T); - const bytes = arms?.length === 2 && undefTag >= 0 ? arms[1 - undefTag]! : null; - const keyType = lowerer.mapTypeOf(lowerer.typeOf(target.argumentExpression)); - const index = - bytes?.kind === "bytes" && keyType?.kind === "f64" - ? lowerer.lowerExpr(target.argumentExpression) - : null; - // An index stored wider than a number (a hoisted `var` counter) keeps - // the checked-dynamic store below, which converts the key exactly. - if (bytes?.kind === "bytes" && index?.type.kind === "f64") { - const stmts: IrStmt[] = []; - const stable = stabilized(lowerer, recv, "%bytesReceiver", stmts); - const key = stabilized(lowerer, index, "%bytesIndex", stmts); - const value = stabilized( - lowerer, - lowerer.lowerExprExpecting(expr.right, F64), - "%bytesValue", - stmts, - ); - const name = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); - const present: IrExpr = { - kind: "unionNarrow", - unionId, - tag: 1 - undefTag, - value: stable, - type: bytes, - loc, - }; - return { - kind: "block", - body: [ - ...stmts, - { - kind: "if", - cond: { - kind: "unionIsTag", - unionId, - tag: undefTag, - negated: false, - value: stable, - type: BOOL, - loc, - }, - then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }], - else_: [{ kind: "bytesSet", arr: present, index: key, value, loc }], - loc, - }, - ], - loc, - }; - } - } - if (!lowerer.dynConvertible(recv.type)) { - lowerer.unsupported( - "SC1090", - target.expression, - `typed-array element writes through '${lowerer.fmt(recv.type)}' storage`, - ); - } - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment(lowerer, expr, lowerer.coerceInto(target.expression, recv, DYN)), - loc, - }; -} - -/** `r[k]` over a (non-tuple) record shape. A LITERAL key naming a declared - * field is the bracket spelling of field access (the ONLY spelling tsc - * permits for signature-declared fields under - * noPropertyAccessFromIndexSignature) — an ordinary recordGet. Everything - * else is a runtime-keyed read (recordKeyGet): declared fields answer - * first via an emitted string-switch, index-signature shapes fall through - * to the overflow map. The result type is the index signature's value type - * armed with undefined (dyn for `unknown`): a missing key reads undefined, - * as in JavaScript, whatever noUncheckedIndexedAccess says. Every declared - * field must be able to SURFACE as that type (equal, an arm of it, or — - * for dyn results — JSON-safe for the dyn conversion); shapes mixing in - * fields outside that stay fenced. Declared-only shapes support reads - * whose key type proves membership (tsc's keyof check): all fields must - * share the result type, and a smuggled miss traps. */ -export function lowerRecordKeyRead( - lowerer: Lowerer, - expr: ts.ElementAccessExpression, - shapeId: string, - shape: IrRecordShape, - includeUndefined = false, -): IrExpr { - const loc = locOf(expr); - const keyNode = expr.argumentExpression; - // Literal declared keys are plain field reads (narrowing included). - // NUMERIC literals are their canonical string spelling — JS object keys - // ARE strings (`r[1]` reads `r["1"]`), so `b[1]` hits a declared field - // named "1" (a mapped type over a numeric enum) exactly like `b["1"]`. - // A key IDENTIFIER whose type proves one literal (a literal-typed - // const, a keyof-constrained type parameter bound to a literal inside - // a generic instance) is the same static read — identifier evaluation - // is pure, so skipping it matches JS exactly. - const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); - // An outer keyed read can leave the receiver undefined: evaluate the - // receiver and the key, then throw Node's TypeError or read the present - // record. - // The receiver lowers FIRST (both branches below read it, and JS - // evaluates the receiver before the key). - const obj = lowerer.lowerExpr(expr.expression); - { - // A null arm is guarded only for string and number keys: other keys - // already take the checked-dynamic read, which handles null itself. - // Only a receiver with a null arm asks about the key. - let allowNull = false; - if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, NULL_T) >= 0) { - const keyType = litKey !== null ? STRING : lowerer.mapTypeOf(lowerer.typeOf(keyNode)); - allowNull = keyType?.kind === "string" || keyType?.kind === "f64"; - } - const guarded = guardedRecordAccess(lowerer, expr, obj, allowNull); - if (guarded) { - const { receiver, key, name } = guarded; - const read = lowerer.withExpressionOverride(expr.expression, receiver.present, () => - litKey !== null - ? lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined) - : lowerer.withExpressionOverride(keyNode, key, () => - lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined), - ), - ); - return { - kind: "seqExpr", - stmts: receiver.stmts, - result: { - kind: "ternary", - cond: undefinedReceiver(receiver, loc), - then: undefinedReceiverThrow(name, "read", read.type, loc, receiver.nullish), - else_: read, - type: read.type, - loc, - }, - type: read.type, - loc, - }; - } - } - // A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a JS - // file-scope object-literal global): the checked-dynamic keyed read — - // dynKeyGet against the runtime keys (a missing key answers the checked-dynamic tree - // undefined, exactly JS); consumers validate (dynCheck) where a - // static type is required, the member-read discipline. - if (obj.type.kind === "dyn") { - let dk = - litKey !== null - ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) - : lowerer.lowerExpr(keyNode); - // Number AND checked-dynamic keys ride the JS-exact formatter — - // property keys ARE strings (o[k] is o[String(k)] in JS), and a dyn - // key (agent.sockets[agent.getName(...)]) stringifies the same way. - if (dk.type.kind === "union" && lowerer.dynConvertible(dk.type)) - dk = lowerer.coerceToExpected(dk, DYN); - if (dk.type.kind !== "symbol" && dk.type.kind !== "dyn") - dk = lowerRecordPropertyKey(lowerer, dk, keyNode); - if (dk.type.kind !== "string" && dk.type.kind !== "symbol" && dk.type.kind !== "dyn") { - lowerer.unsupported("SC1090", keyNode, "indexing records with non-string or non-number keys"); - } - const read: IrExpr = { kind: "dynKeyGet", key: dk, value: obj, type: DYN, loc }; - // Absence probes must keep the checked-dynamic undefined produced by - // a missing key. The ordinary read narrows to the checker's declared - // index value (and therefore validates it); ===/!==, ||, and ?? need - // to observe the missing value instead of throwing during that check. - return includeUndefined ? read : lowerer.maybeNarrow(read, expr); - } - if (litKey !== null) { - const nominal = representedClassFieldTarget(lowerer, expr.expression, litKey, obj); - if (nominal) { - const read = lowerer.fieldGetExpr(nominal, loc, expr); - return includeUndefined ? read : lowerer.maybeNarrow(read, expr); - } - } - if (hasClassPayload(lowerer, obj.type)) { - lowerer.unsupported( - "SC1090", - expr, - "computed property reads through structural views of class instances (use a declared literal key)", - ); - } - // Predicates can strengthen optional fields without changing the - // receiver's stored layout. Use that layout for bracket reads just as - // fieldTarget does for the corresponding dot reads. - if (obj.type.kind === "record") { - const actualShape = lowerer.shapes.get(obj.type.shapeId); - if (!actualShape) - throw new InternalCompilerError("record receiver is missing its stored shape"); - shapeId = obj.type.shapeId; - shape = actualShape; - } - if (litKey !== null) { - const field = shape.fields.find((f) => f.name === litKey); - if (field) { - const get: IrExpr = { - kind: "recordGet", - obj, - shapeId, - field: field.name, - type: field.type, - loc, - }; - return lowerer.maybeNarrow(get, expr); - } - } - let key = - litKey !== null - ? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr) - : lowerer.lowerExpr(keyNode); - // Optional string/number keys need ToPropertyKey, not a boxed copy of - // the receiver. Keep record reads on their native storage so nested - // references retain identity across index-signature reads. - if ( - key.type.kind === "union" && - !lowerer.unions - .get(key.type.unionId) - ?.arms.some((arm) => arm.kind === "symbol" || arm.kind === "dyn") - ) { - key = lowerRecordPropertyKey(lowerer, key, keyNode); - } - if ( - (key.type.kind === "symbol" || key.type.kind === "dyn" || key.type.kind === "union") && - lowerer.dynConvertible(key.type) && - lowerer.dynConvertible(obj.type) - ) { - const read: IrExpr = { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(obj, DYN), - key: lowerer.coerceToExpected(key, DYN), - type: DYN, - loc, - }; - return includeUndefined ? read : lowerer.maybeNarrow(read, expr); - } - // Runtime NUMBER keys ride the JS-exact formatter (o[n] is o[String(n)] - // in JS — the number-keyed-signature access path). - key = lowerRecordPropertyKey(lowerer, key, keyNode); - if (key.type.kind !== "string") { - lowerer.unsupported("SC1090", keyNode, "indexing records with non-string or non-number keys"); - } - if (shape.fields.length === 0 && shape.indexValue === undefined) { - const result = dynUndefinedExpr(loc); - return { - kind: "seqExpr", - stmts: [ - { kind: "exprStmt", expr: obj, loc }, - { kind: "exprStmt", expr: key, loc }, - ], - result, - type: DYN, - loc, - }; - } - // The result type of the access — the index signature's value type - // armed with undefined, or the declared fields' one common type on - // signature-free shapes (tsc's keyof check proved membership; a - // smuggled miss traps). On signature-free shapes the checker may have - // NARROWED this occurrence (assignment CFA on literal keys) — maybeNarrow - // bridges to the narrowed arm exactly like a field read. - let declared: IrType | null = null; - // An index-signature read is a JavaScript property read: a missing key - // answers undefined even without noUncheckedIndexedAccess. The read keeps - // that explicit optional union, as an ordinary array read does. - let optionalRead = false; - // A read whose every consumer treats undefined like NaN (see - // nan-coded-reads.ts) is a plain number that is NaN for a missing key. - const nanCoded = - !includeUndefined && - shape.indexValue?.kind === "f64" && - lowerer.nanCodedReads?.isNumericRead(expr) === true; - if (shape.indexValue) { - declared = shape.indexValue; - if (declared.kind !== "dyn" && !nanCoded) { - declared = lowerer.withUndefinedArmOf(declared); - if (!declared) lowerer.badType(expr, lowerer.typeOf(expr)); - optionalRead = true; - } - } else if ( - shape.fields.length > 0 && - shape.fields.every((f) => typeEquals(f.type, shape.fields[0]!.type)) - ) { - declared = shape.fields[0]!.type; - } - if (!declared && isJsSourceFile(expr.getSourceFile()) && recordKeyResultOk(lowerer, shape, DYN)) - declared = DYN; - if (!declared) { - lowerer.unsupported( - "SC1090", - expr, - `dynamic keyed reads of '${lowerer.fmt({ kind: "record", shapeId })}' (the declared fields have no one common type)`, - ); - } - // A LITERAL key naming no declared field can only hit the overflow: - // the declared-field surfacing constraint doesn't apply. - const overflowOnly = litKey !== null && !!shape.indexValue; - if (!recordKeyResultOk(lowerer, overflowOnly ? { ...shape, fields: [] } : shape, declared)) { - lowerer.unsupported( - "SC1090", - expr, - `dynamic keyed reads of '${lowerer.fmt({ kind: "record", shapeId })}' as '${lowerer.fmt(declared)}' (every declared field must be readable at that type)`, - ); - } - const read: IrExpr = { - kind: "recordKeyGet", - obj, - shapeId, - key, - ...(overflowOnly ? { overflowOnly: true as const } : {}), - ...(nanCoded ? { missNaN: true as const } : {}), - type: declared, - loc, - }; - if (nanCoded) return read; - return includeUndefined || optionalRead ? read : lowerer.maybeNarrow(read, expr); -} - -/** ToPropertyKey for represented record keys. Missing array-derived string - * values remain real undefined values, whose JavaScript property spelling - * is the string "undefined". */ -function lowerRecordPropertyKey(lowerer: Lowerer, key: IrExpr, node: ts.Expression): IrExpr { - if (key.type.kind === "union" && lowerer.dynConvertible(key.type)) { - return { - kind: "libCall", - fn: "dyn.toStringCoerce", - args: [lowerer.coerceInto(node, key, DYN)], - type: STRING, - loc: key.loc, - }; - } - if ( - key.type.kind === "f64" || - key.type.kind === "bool" || - key.type.kind === "dyn" || - lowerer.runtimeOptionalWidening(key.type, STRING) !== null - ) { - return lowerer.ensureString(key, node); - } - return key; -} - /** The stored value of an identifier whose storage holds an undefined arm * that the checker's type at this use has dropped. An assignment from an * unchecked indexed read narrows the declared type even though the value @@ -7994,24 +1409,6 @@ export function runtimeOptionalStorageOperand( return optional.value; } -/** A `T | undefined` class field read that the checker narrowed to `T` - * where the field was assigned a value that may be missing (an unchecked - * indexed read). TypeScript's assignment narrowing is not a runtime fact - * there, so the read keeps the stored union instead of extracting `T`. */ -function runtimeOptionalClassField( - lowerer: Lowerer, - value: IrExpr, - node: ts.PropertyAccessExpression, -): boolean { - if (value.kind !== "fieldGet" || value.type.kind !== "union") return false; - if (lowerer.armTag(value.type.unionId, UNDEFINED_T) < 0) return false; - if ((lowerer.unions.get(value.type.unionId)?.arms.length ?? 0) !== 2) return false; - const symbol = lowerer.checker.getSymbolAtLocation(node.name); - if (!symbol || !lowerer.runtimeOptionalAssignedSymbols.has(symbol)) return false; - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); - return narrowed !== null && typeEquals(narrowed, lowerer.stripUndefinedArm(value.type)); -} - /** A presence-test operand: an absence-aware element or field read, an * identifier's runtime-optional storage, or the ordinary lowering. */ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr { @@ -8119,42 +1516,6 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr return lowerSafeIndexRead(lowerer, arr, index, locOf(expr)); } -/** The compile-time string spelling of a record key literal: a string - * literal's text, or a NON-NEGATIVE numeric literal's canonical JS - * spelling (`1` → "1", `1e21` → "1e+21" — String(Number(text)), which is - * exactly the key JS derives; negative/computed keys stay runtime - * expressions and canonicalize through ensureString instead). Null for - * everything else. */ -function recordKeyLiteralText(node: ts.Expression): string | null { - if (ts.isStringLiteral(node)) return node.text; - if (ts.isNumericLiteral(node)) return String(Number(node.text)); - return null; -} - -/** The compile-time key an IDENTIFIER's TYPE proves: a key variable whose - * (narrowed) type is one string/number literal — `const k: "a" = …; o[k]`, - * and the generic-body form where k's type is a keyof-constrained - * parameter bound to a literal (`pick(o, "a")`'s instance reads `o[k]` - * with K = "a", resolved through typeParamTsBindings). Identifier reads - * are pure, so the static field read may skip evaluating the key exactly - * like a syntactic literal. Null anywhere the type keeps more than one - * key. */ -function recordKeyTypeLiteralText(lowerer: Lowerer, node: ts.Expression): string | null { - if (!ts.isIdentifier(node)) return null; - let t: ts.Type = lowerer.typeOf(node); - if (t.flags & ts.TypeFlags.TypeParameter) { - const bound = lowerer.typeParamTsResolver(t); - if (!bound) return null; - t = bound; - } - if (t.isStringLiteralType()) return t.value; - if (t.isNumberLiteralType()) { - const n = t.value; - return Number.isFinite(n) && n >= 0 ? String(n) : null; - } - return null; -} - /** Can every value a dynamic key can reach (declared fields + the * overflow) surface as the read's result type? dyn results need * dyn-convertible fields (JSON-safe, with the undefined arm allowed at @@ -8162,7 +1523,7 @@ function recordKeyTypeLiteralText(lowerer: Lowerer, node: ts.Expression): string * value, exactly what the missing-key path produces); typed results need * each field to be the type itself or one of its arms, and the overflow * value to be the type or wrappable into it. */ -function recordKeyResultOk(lowerer: Lowerer, shape: IrRecordShape, type: IrType): boolean { +export function recordKeyResultOk(lowerer: Lowerer, shape: IrRecordShape, type: IrType): boolean { const surfaces = (t: IrType): boolean => typeEquals(t, type) || (type.kind === "union" && lowerer.armTag(type.unionId, t) >= 0) || @@ -8176,40 +1537,13 @@ function recordKeyResultOk(lowerer: Lowerer, shape: IrRecordShape, type: IrType) return true; } -function exactUnionWidening(lowerer: Lowerer, from: IrType, to: IrType): boolean { +export function exactUnionWidening(lowerer: Lowerer, from: IrType, to: IrType): boolean { if (from.kind !== "union" || to.kind !== "union") return false; const source = lowerer.unions.get(from.unionId); const target = lowerer.unions.get(to.unionId); return !!source && !!target && unionWideningTags(source.arms, target.arms) !== null; } -/** The literal index of a tuple access, or null when the expression isn't - * a non-negative integer literal (parentheses tolerated — tsc's own - * literal-index typing accepts them). */ -function tupleLiteralIndex(node: ts.Expression): number | null { - let e: ts.Expression = node; - while (ts.isParenthesizedExpression(e)) e = e.expression; - if (!ts.isNumericLiteral(e)) return null; - const n = Number(e.text); - return Number.isInteger(n) && n >= 0 ? n : null; -} - -/** `a[i] op= rhs` over native arrays and byte views. A compound assignment - * evaluates the receiver and index, reads the old element, evaluates rhs, - * then writes. Capture each stage so a call in the index or rhs cannot - * change the target or the value used by the operator. The expression yields - * the computed value before a typed array or Buffer coerces it for storage. */ -export function lowerElementCompound( - lowerer: Lowerer, - expr: ts.BinaryExpression, - op: CompoundOp, -): IrExpr { - const lowered = lowerElementCompoundParts(lowerer, expr, op, false); - if (!lowered.expr) - throw new InternalCompilerError("a compound element value lowered to statements"); - return lowered.expr; -} - /** `a[i] op= rhs` in statement position: a string array append becomes the * canonical `a[i] = a[i] + rhs` store (receiver and index saved first), * which the backend appends in place; other forms keep the value path. */ @@ -8223,229 +1557,9 @@ export function lowerElementCompoundStatement( return { kind: "exprStmt", expr: lowered.expr!, loc: locOf(expr) }; } -function lowerElementCompoundParts( - lowerer: Lowerer, - expr: ts.BinaryExpression, - op: CompoundOp, - statement: boolean, -): { expr: IrExpr | null; stmts: IrStmt[] | null } { - const target = expr.left as ts.ElementAccessExpression; - fenceNodeModuleMutation(lowerer, target, "assignment"); - const loc = locOf(expr); - const checked = lowerer.lowerExpr(target.expression); - if (checked?.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { - const stmts: IrStmt[] = []; - const save = (value: IrExpr, name: string): IrExpr => { - const local = lowerer.declareHiddenLocal(name, value.type); - stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); - return varRef(local.id, value.type, loc); - }; - const receiver = save(checked, "%compoundReceiver"); - const key = save(lowerer.lowerExprExpecting(target.argumentExpression, DYN), "%compoundKey"); - const old = save({ kind: "dynKeyGet", value: receiver, key, type: DYN, loc }, "%compoundOld"); - const rhs = lowerer.lowerExprExpecting(expr.right, DYN); - const numeric = ["-", "*", "/", "%", "**"].includes(op); - const value = save( - { - kind: "libCall", - fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise", - args: op === "+" ? [old, rhs] : [old, rhs, strLit(op, loc)], - type: DYN, - loc, - }, - "%compoundResult", - ); - stmts.push({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySetComputed", - args: [receiver, key, value], - type: VOID, - loc, - }, - loc, - }); - return { expr: { kind: "seqExpr", stmts, result: value, type: DYN, loc }, stmts: null }; - } - const receiverType = - checked.type.kind === "array" || checked.type.kind === "bytes" - ? checked.type - : lowerer.mapTypeOf(lowerer.typeOf(target.expression)); - if (receiverType?.kind !== "array" && receiverType?.kind !== "bytes") { - lowerer.unsupported("SC1090", target, "compound assignment to non-array elements"); - } - let receiver = checked; - if (receiver.type.kind === "union" && lowerer.armTag(receiver.type.unionId, UNDEFINED_T) >= 0) { - const present = lowerer.stripUndefinedArm(receiver.type); - const helper = - present.kind === receiverType.kind - ? lowerer.narrowedArmHelper(receiver.type.unionId, present, locOf(target.expression)) - : null; - if (helper) - receiver = lowerer.checkedNarrowCall(helper, receiver, present, locOf(target.expression)); - } - if (receiver.type.kind !== receiverType.kind) { - lowerer.unsupported( - "SC1090", - target.expression, - "compound assignment through a non-native array or byte view", - ); - } - const index = lowerer.lowerExpr(target.argumentExpression); - if (index.type.kind !== "f64") - lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); - const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); - const indexLocal = lowerer.declareHiddenLocal("%compoundIndex", F64); - const receiverRef = (): IrExpr => varRef(receiverLocal.id, receiver.type, loc); - const indexRef = (): IrExpr => varRef(indexLocal.id, F64, loc); - const oldValue: IrExpr = - receiver.type.kind === "array" - ? receiver.type.elem.kind === "f64" - ? // Every numeric operator applies ToNumber to the old value, so a - // missing element is NaN without materializing `number | undefined`. - arrayNumberRead(receiverRef(), indexRef(), locOf(target)) - : receiver.type.elem.kind === "string" && op === "+" - ? arrayStringOperand(receiverRef(), indexRef(), locOf(target)) - : arrayValueRead(lowerer, receiverRef(), indexRef(), receiver.type.elem, locOf(target)) - : { - // An invalid index reads undefined in Node, which every numeric - // compound operator turns into NaN; the write is then ignored. - kind: "bytesIntrinsic", - method: "get", - receiver: receiverRef(), - args: [indexRef()], - type: F64, - loc: locOf(target), - invalidNaN: true, - }; - const oldLocal = lowerer.declareHiddenLocal("%compoundOld", oldValue.type); - const oldRef = (): IrExpr => varRef(oldLocal.id, oldValue.type, loc); - const rhs = lowerer.lowerExpr(expr.right); - const numericRhs = - rhs.type.kind === "dyn" && - isJsSourceFile(expr.getSourceFile()) && - receiver.type.kind === "bytes" - ? { kind: "libCall" as const, fn: "dyn.toNumberCoerce" as const, args: [rhs], type: F64, loc } - : lowerOptionalNumber(lowerer, rhs, loc); - const elementType = receiver.type.kind === "array" ? receiver.type.elem : F64; - const valueType = - elementType.kind === "union" && lowerer.armTag(elementType.unionId, UNDEFINED_T) >= 0 - ? lowerer.stripUndefinedArm(elementType) - : elementType; - if ( - statement && - op === "+" && - receiver.type.kind === "array" && - receiver.type.elem.kind === "string" - ) { - // Receiver, index, old element (read inside the concat), rhs, write. - const appended: IrExpr = { - kind: "strConcat", - left: oldValue, - right: concatOperand(lowerer, rhs, expr.right), - type: STRING, - loc, - }; - return { - expr: null, - stmts: [ - { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, - { kind: "varDecl", localId: indexLocal.id, init: index, loc }, - { kind: "arraySet", arr: receiverRef(), index: indexRef(), value: appended, loc }, - ], - }; - } - let computed: IrExpr; - if (op === "+" && valueType.kind === "string") { - computed = { - kind: "strConcat", - left: lowerer.ensureString(oldRef(), target), - right: concatOperand(lowerer, rhs, expr.right), - type: STRING, - loc, - }; - } else if (valueType.kind === "f64" && numericRhs.type.kind === "f64") { - computed = { - kind: "bin", - op, - left: lowerOptionalNumber(lowerer, oldRef(), loc), - right: numericRhs, - type: F64, - loc, - }; - } else if (valueType.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { - const left = lowerer.coerceToExpected(oldRef(), DYN), - right = lowerer.coerceToExpected(rhs, DYN); - computed = dynCompoundValue(op, left, right, loc); - } else if ( - rhs.type.kind === "dyn" && - receiver.type.kind === "array" && - lowerer.dynConvertible(oldValue.type) && - lowerer.dynConvertible(valueType) - ) { - // An untyped right operand: the expanded `a[i] = a[i] op rhs`, checked - // back into the element type. - computed = lowerer.coerceInto( - expr, - dynCompoundValue( - op, - lowerer.coerceToExpected(oldRef(), DYN), - lowerer.coerceToExpected(rhs, DYN), - loc, - ), - valueType, - ); - } else { - compoundUnsupported(lowerer, expr, op, oldValue.type, rhs.type); - } - const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type); - const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc); - const write: IrStmt = - receiver.type.kind === "array" - ? arrayValueStore(lowerer, receiverRef(), indexRef(), resultRef(), receiver.type.elem, loc) - : { kind: "bytesSet", arr: receiverRef(), index: indexRef(), value: resultRef(), loc }; - return { - expr: { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, - { kind: "varDecl", localId: indexLocal.id, init: index, loc }, - { kind: "varDecl", localId: oldLocal.id, init: oldValue, loc }, - { kind: "varDecl", localId: resultLocal.id, init: computed, loc }, - write, - ], - result: resultRef(), - type: computed.type, - loc, - }, - stmts: null, - }; -} - -/** ToString of one stabilized `string[]` element read for `+=`: a missing - * element concatenates as "undefined". */ -function arrayStringOperand(arr: IrExpr, index: IrExpr, loc: SrcLoc): IrExpr { - return { - kind: "ternary", - cond: { - kind: "bin", - op: "===", - left: { kind: "arrayState", arr, index, type: F64, loc }, - right: { kind: "numLit", value: 1, type: F64, loc }, - type: BOOL, - loc, - }, - then: { kind: "arrayGet", arr, index, type: STRING, loc }, - else_: strLit("undefined", loc), - type: STRING, - loc, - }; -} - /** Checked member assignment evaluates the reference before its RHS and * yields the original assigned value, including in a chained assignment. */ -function lowerDynMemberAssignment( +export function lowerDynMemberAssignment( lowerer: Lowerer, expr: ts.BinaryExpression, recv: IrExpr, @@ -8499,627 +1613,12 @@ function lowerDynMemberAssignment( }; } -/** JavaScript callable members live on the closure, so aliases and chained - * writes share the same storage as Object.defineProperties. */ -export function lowerNativeFunctionAssignment( - lowerer: Lowerer, - expr: ts.BinaryExpression, -): IrExpr | null { - const target = expr.left; - if ( - !isJsSourceFile(expr.getSourceFile()) || - (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target)) || - target.questionDotToken - ) - return null; - // Checked constructors share the callable's live property descriptors. - const classType = storedClassValueType(lowerer, target.expression); - const staticClass = - classType?.kind === "classval" ? lowerer.classes.get(classType.className) : undefined; - if (staticClass && hasRuntimeStatics(staticClass)) { - return lowerDynMemberAssignment( - lowerer, - expr, - lowerer.lowerExprExpecting(target.expression, DYN), - ); - } - // Other class statics and prototype tables have their own paths. - // Probing them as ordinary functions could materialize unused methods. - if ( - lowerer.exactClassOfReceiver(target.expression) || - (ts.isPropertyAccessExpression(target.expression) && - target.expression.name.text === "prototype" && - lowerer.exactClassOfReceiver(target.expression.expression)) - ) - return null; - const receiver = tryLowerExpression(lowerer, target.expression); - if ( - receiver?.type.kind !== "func" || - !canBoxFuncIntoDyn( - receiver.type, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) - return null; - fenceNodeModuleMutation(lowerer, target, "assignment"); - const fnName = jsFuncNameOf(target.expression); - const boxed: IrExpr = { - kind: "dynFrom", - value: receiver, - type: DYN, - loc: locOf(target.expression), - ...(fnName !== null ? { fnName } : {}), - }; - return lowerDynMemberAssignment(lowerer, expr, boxed); -} - -export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): IrStmt { - const prototype = lowerClassPrototypeComputedAssignment(lowerer, expr); - if (prototype) return { kind: "exprStmt", expr: prototype, loc: locOf(expr) }; - const target = expr.left as ts.ElementAccessExpression; - fenceNodeModuleMutation(lowerer, target, "assignment"); - const globalKey = globalSymbolKey(lowerer, target.expression, target.argumentExpression); - if (globalKey) { - const value = lowerer.lowerExprExpecting(expr.right, DYN); - const loc = locOf(expr); - return { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.globalSymbolSet", - args: [globalKey, value], - type: VOID, - loc, - }, - loc, - }; - } - // `process.env[key] = v` — the computed twin of the dotted env write: - // setenv(3), string keys and string values only. - if (lowerer.isProcessEnv(target.expression) && !target.questionDotToken) { - const loc = locOf(expr); - const key = lowerEnvironmentKey(lowerer, target.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing process.env with non-string keys", - ); - } - const value = lowerer.lowerExpr(expr.right); - if (value.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - expr.right, - `assigning '${lowerer.fmt(value.type)}' values to process.env (env values are strings — convert first: \`\${v}\`)`, - ); - } - return { - kind: "exprStmt", - expr: { kind: "libCall", fn: "process.envSet", args: [key, value], type: VOID, loc }, - loc, - }; - } - // Symbol-keyed property WRITES (`this[kLimit] = v`): the read path's - // twin — a statically-resolvable unique-symbol key declared on the - // receiver's class writes the hidden field slot; everything else keeps - // the fence (static shapes have no symbol-keyed storage). - if (lowerer.mapTypeOf(lowerer.typeOf(target.argumentExpression))?.kind === "symbol") { - const fieldT = symbolFieldTarget(lowerer, target); - if (fieldT) { - const value = lowerer.lowerExprExpecting(expr.right, fieldT.fieldType); - return lowerer.fieldSetStmt(fieldT, value, locOf(expr), target); - } - const receiver = lowerer.lowerExpr(target.expression); - if (receiver.type.kind === "dyn") - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment(lowerer, expr, receiver), - loc: locOf(expr), - }; - if ( - isJsSourceFile(expr.getSourceFile()) && - receiver.type.kind === "object" && - lowerer.dynConvertible(receiver.type) - ) { - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment(lowerer, expr, lowerer.coerceToExpected(receiver, DYN)), - loc: locOf(expr), - }; - } - lowerer.unsupported( - "SC1090", - target, - "symbol-keyed property writes outside class fields keyed by a stable module-level literal Symbol()/Symbol.for() (other static shapes have no symbol-keyed storage)", - ); - } - const checkedReceiver = tryLowerExpression(lowerer, target.expression); - if (checkedReceiver?.type.kind === "dyn") { - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment(lowerer, expr, checkedReceiver), - loc: locOf(expr), - }; - } - if (checkedReceiver?.type.kind === "object" && isJsSourceFile(expr.getSourceFile())) { - const info = lowerer.classes.get(checkedReceiver.type.className); - const key = - recordKeyLiteralText(target.argumentExpression) ?? - recordKeyTypeLiteralText(lowerer, target.argumentExpression); - if (info && key !== null && isClassCallback(lowerer, info, key)) { - const receiver = lowerer.coerceInto(target.expression, checkedReceiver, DYN); - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment(lowerer, expr, receiver), - loc: locOf(expr), - }; - } - } - let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(target.expression)); - if (isJsSourceFile(expr.getSourceFile()) && checkedReceiver) { - const represented = - checkedReceiver.type.kind === "union" - ? lowerer.stripUndefinedArm(checkedReceiver.type) - : checkedReceiver.type; - if (represented.kind === "array" || represented.kind === "bytes") receiverIr = represented; - } - if (receiverIr === null || receiverIr.kind === "dyn") { - const receiver = tryLowerExpression(lowerer, target.expression); - if (receiver?.type.kind === "array") receiverIr = receiver.type; - } - - // A checked-dynamic receiver TYPE (`unknown[]` and the collapsed - // `(string | object)[]` map to DYN wholesale now): the dyn keyed - // write — dyn.keySet. Number keys canonicalize through the JS-exact - // formatter; ARR receivers take canonical index keys as element - // set/extend (undefined-hole padding, JS's length growth exactly), - // OBJ receivers set the member, and the runtime throws Node's - // TypeErrors on every other kind. Values convert into the checked-dynamic tree; - // unconvertible ones fence per site (the dynFrom stance). - if (receiverIr?.kind === "dyn") { - const obj = lowerer.lowerExpr(target.expression); - if (obj.type.kind === "dyn") { - const loc = locOf(expr); - const key = lowerRecordPropertyKey( - lowerer, - lowerer.lowerExpr(target.argumentExpression), - target.argumentExpression, - ); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing with non-string or non-number keys", - ); - } - const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); - if (value.type.kind !== "dyn") { - lowerer.unsupported( - "SC1101", - expr.right, - `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic array (the value cannot convert into the checked-dynamic tree)`, - ); - } - return { - kind: "exprStmt", - expr: { kind: "libCall", fn: "dyn.keySet", args: [obj, key, value], type: VOID, loc }, - loc, - }; - } - } - if (receiverIr?.kind === "object" || receiverIr?.kind === "record") { - const key = - recordKeyLiteralText(target.argumentExpression) ?? - recordKeyTypeLiteralText(lowerer, target.argumentExpression); - if (key !== null) { - const receiver = lowerer.lowerExpr(target.expression); - const field = representedClassFieldTarget(lowerer, target.expression, key, receiver); - if (field) { - const value = lowerer.lowerExprExpecting(expr.right, field.fieldType); - return lowerer.fieldSetStmt(field, value, locOf(expr), target); - } - if (hasClassPayload(lowerer, receiver.type)) { - if (receiver.type.kind === "object" && receiver.type.className === "%Error") { - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment( - lowerer, - expr, - lowerer.coerceInto(target.expression, receiver, DYN), - ), - loc: locOf(expr), - }; - } - lowerer.unsupported( - "SC1090", - target, - "writing undeclared properties through structural views of class instances", - ); - } - } - } - // Typed-array element write `b[i] = v` — bytesSet: the value is an f64 - // the runtime coerces JS-exactly; invalid indices are ignored. - if (receiverIr?.kind === "bytes") { - const recv = lowerer.lowerExpr(target.expression); - // The checker's flow type is a typed array, but the storage is wider. - if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); - const index = lowerer.lowerExpr(target.argumentExpression); - if (index.type.kind === "dyn" && recv.type.kind === "bytes") { - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment( - lowerer, - expr, - lowerer.coerceInto(target.expression, recv, DYN), - ), - loc: locOf(expr), - }; - } - if (index.type.kind !== "f64") { - lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); - } - const value = lowerer.lowerExprExpecting(expr.right, F64); - return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; - } - // Tuple element write `t[0] = v`: a positional-field write (recordSet) - // — literal indices only, like the read. - if (receiverIr?.kind === "record") { - const shape = lowerer.shapes.get(receiverIr.shapeId); - - if (shape) { - const obj = lowerer.lowerExpr(target.expression); - if (hasClassPayload(lowerer, obj.type)) { - lowerer.unsupported( - "SC1090", - target, - "computed property writes through structural views of class instances (use a declared literal key)", - ); - } - - // A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a - // JS file-scope object-literal global): the checked-dynamic keyed - // write — dyn.keySet (later writes win, insertion order; Node's - // TypeErrors on non-object receivers), the value converting into - // the checked-dynamic tree. Literal and runtime keys alike: the checked-dynamic tree's key set is - // open, so no declared-field collision analysis applies. - if (obj.type.kind === "dyn") { - const loc = locOf(expr); - const litKey = recordKeyLiteralText(target.argumentExpression); - let key: IrExpr = - litKey !== null - ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } - : lowerer.lowerExpr(target.argumentExpression); - key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing records with non-string or non-number keys", - ); - } - const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); - if (value.type.kind !== "dyn") { - lowerer.unsupported( - "SC1101", - expr.right, - `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, - ); - } - return { - kind: "exprStmt", - expr: { kind: "libCall", fn: "dyn.keySet", args: [obj, key, value], type: VOID, loc }, - loc, - }; - } - } - if (shape?.tuple) { - const idx = tupleLiteralIndex(target.argumentExpression); - if (idx === null) { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "tuple indexing with a non-literal index (tuples are fixed-shape — use t[0], t[1], ...)", - ); - } - const fieldType = shape.fields.find((f) => f.name === String(idx))?.type; - if (fieldType === undefined) lowerer.badType(target, lowerer.typeOf(target)); - const obj = lowerer.lowerExpr(target.expression); - const value = lowerer.lowerExprExpecting(expr.right, fieldType); - return { - kind: "recordSet", - obj, - shapeId: receiverIr.shapeId, - field: String(idx), - value, - loc: locOf(expr), - }; - } - // Dynamic keyed write `r[k] = v` — index-signature shapes only (a - // declared-only shape's writable key set is closed; spell the field). - // A LITERAL declared key is the bracket spelling of a field write. - // Values flow into the index-value slot (dyn slots convert, typed - // slots coerce); declared-key collisions at runtime validate against - // the field's type through the emitted helper (a mismatch throws the - // catchable TypeError — SEMANTICS.md). - if (shape && !shape.tuple) { - const guarded = lowerGuardedRecordWrite(lowerer, expr, target); - if (guarded) return guarded; - // Literal keys — string literals AND numeric literals in their - // canonical string spelling (`b[1] = v` writes field/key "1", JS's - // own key derivation) — name declared fields directly. A key - // IDENTIFIER whose TYPE proves one literal (a literal-typed const, - // a keyof-constrained type parameter bound to a literal inside a - // generic instance — `set(o, "a", v)`'s body writing `o[k] = v`) - // is the same static field write. - const litKey = - recordKeyLiteralText(target.argumentExpression) ?? - recordKeyTypeLiteralText(lowerer, target.argumentExpression); - if (litKey !== null) { - const field = shape.fields.find((f) => f.name === litKey); - if (field) { - const obj = lowerer.lowerExpr(target.expression); - const value = lowerer.lowerExprExpecting(expr.right, field.type); - return { - kind: "recordSet", - obj, - shapeId: receiverIr.shapeId, - field: litKey, - value, - loc: locOf(expr), - }; - } - } - if (!shape.indexValue) { - // A SIGNATURE-FREE shape whose declared fields share ONE type - // writes through the same per-shape dispatch (the mockable-clock - // module shape: `mocked[functionality] = implementation` over a - // table of same-signature closures). A key naming no declared - // field throws the catchable TypeError — JS would ADD the - // property, which a monomorphic struct cannot (the SEMANTICS.md - // keyed-write-miss divergence). Mixed-type and accessor-carrying - // shapes keep the fence. - const common = - shape.fields.length > 0 && - !shapeHasAccessorSlots(shape) && - shape.fields.every((f) => typeEquals(f.type, shape.fields[0]!.type)) - ? shape.fields[0]!.type - : null; - if (!common) { - lowerer.unsupported( - "SC1090", - target, - "dynamic keyed writes to records without an index signature (declared fields of ONE shared type dispatch by key — spell the field name otherwise)", - ); - } - const obj = lowerer.lowerExpr(target.expression); - let key: IrExpr = - litKey !== null - ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } - : lowerer.lowerExpr(target.argumentExpression); - // Runtime number/boolean/dyn keys stringify — ToPropertyKey, - // exactly the index-signature path's rule. - key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing records with non-string or non-number keys", - ); - } - const value = lowerer.coerceInto(expr.right, lowerer.lowerExpr(expr.right), common); - if (!typeEquals(value.type, common)) - lowerer.badType(expr.right, lowerer.typeOf(expr.right)); - return { - kind: "recordKeySet", - obj, - shapeId: receiverIr.shapeId, - key, - value, - loc: locOf(expr), - }; - } - // A LITERAL key naming no declared field is a pure overflow insert - // — no collision to validate. Runtime keys must be able to collide - // with every declared field: dyn slots validate through the - // dynCheck walker (fields must be dyn-convertible — the same - // convertibility the read needs); typed slots write through - // directly, so every field must BE the index-value type. - const overflowOnly = litKey !== null; - const writable = - overflowOnly || - (shape.indexValue.kind === "dyn" - ? shape.fields.every((f) => lowerer.dynConvertible(f.type)) - : shape.fields.every((f) => typeEquals(f.type, shape.indexValue!))); - if (!writable) { - lowerer.unsupported( - "SC1090", - target, - `dynamic keyed writes to '${lowerer.fmt(receiverIr)}' (a declared field's type cannot take the index signature's value at runtime)`, - ); - } - const obj = lowerer.lowerExpr(target.expression); - let key = - litKey !== null - ? ({ - kind: "strLit", - value: litKey, - type: STRING, - loc: locOf(target.argumentExpression), - } satisfies IrExpr) - : lowerer.lowerExpr(target.argumentExpression); - // Runtime NUMBER keys canonicalize through the JS-exact formatter - // (o[n] = v writes o[String(n)] — the number-keyed-signature path). - key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing records with non-string or non-number keys", - ); - } - const value = lowerer.intoIndexValueSlot( - lowerer.lowerExpr(expr.right), - shape.indexValue, - expr.right, - ); - return { - kind: "recordKeySet", - obj, - shapeId: receiverIr.shapeId, - key, - value, - ...(overflowOnly ? { overflowOnly: true as const } : {}), - loc: locOf(expr), - }; - } - } - // Keyed write `d[k] = v` on a CHECKED-DYNAMIC receiver (a JS `any` - // dictionary — the Object.create(null) memo-table idiom writes - // `result[key] = value`): dyn.keySet, exactly the record-mapped dyn - // arm above — later writes win in insertion order, number keys - // canonicalize through the JS-exact formatter (ToPropertyKey's string - // side), index keys on dyn arrays set/extend elements, and non-object - // receivers throw Node's TypeErrors at runtime. An unmapped checker type - // probes the receiver's lowered representation: a dyn value takes the same - // write, anything else falls through to the fences. - if (receiverIr?.kind === "dyn" || receiverIr?.kind === "object" || receiverIr === null) { - const obj = - receiverIr !== null - ? lowerer.lowerExpr(target.expression) - : tryLowerExpression(lowerer, target.expression); - if ( - obj !== null && - (obj.type.kind === "dyn" || - (obj.type.kind === "object" && obj.type.className === "%Error") || - isDynTypedRefType(obj.type)) - ) { - const loc = locOf(expr); - const litKey = recordKeyLiteralText(target.argumentExpression); - let key: IrExpr = - litKey !== null - ? { kind: "strLit", value: litKey, type: STRING, loc: locOf(target.argumentExpression) } - : lowerer.lowerExpr(target.argumentExpression); - key = lowerRecordPropertyKey(lowerer, key, target.argumentExpression); - if (key.type.kind !== "string") { - lowerer.unsupported( - "SC1090", - target.argumentExpression, - "indexing checked-dynamic values with non-string or non-number keys", - ); - } - const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN); - if (value.type.kind !== "dyn") { - lowerer.unsupported( - "SC1101", - expr.right, - `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, - ); - } - return { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySet", - args: [lowerer.coerceToExpected(obj, DYN), key, value], - type: VOID, - loc, - }, - loc, - }; - } - } - if (receiverIr?.kind !== "array") { - lowerer.unsupported("SC1090", target, "assignment to non-array elements"); - } - let arr = lowerer.lowerExpr(target.expression); - - if ( - arr.type.kind === "union" && - lowerer.armTag(arr.type.unionId, UNDEFINED_T) >= 0 && - receiverIr.kind === "array" - ) { - const present = lowerer.stripUndefinedArm(arr.type); - const helper = - present.kind === "array" - ? lowerer.narrowedArmHelper(arr.type.unionId, present, locOf(target.expression)) - : null; - if (helper) { - arr = lowerer.checkedNarrowCall(helper, arr, present, locOf(target.expression)); - } - } - if (arr.type.kind !== "array") { - lowerer.unsupported( - "SC1090", - target.expression, - "assignment through a possibly missing nested array receiver", - ); - } - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(target.argumentExpression), - locOf(target.argumentExpression), - target.argumentExpression, - ); - if (index.type.kind === "dyn" && lowerer.dynConvertible(arr.type)) { - return { - kind: "exprStmt", - expr: lowerDynMemberAssignment( - lowerer, - expr, - lowerer.coerceInto(target.expression, arr, DYN), - ), - loc: locOf(expr), - }; - } - if (index.type.kind !== "f64") { - lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); - } - // Optional array reads carry the source array's payload type plus an - // undefined arm. arrayValueStore turns that arm into the runtime's - // UNDEFINED state while keeping number[]/string[] payload storage scalar. - let literal = expr.right; - while (ts.isParenthesizedExpression(literal) || ts.isSatisfiesExpression(literal)) - literal = literal.expression; - const value = - ts.isObjectLiteralExpression(literal) || ts.isArrayLiteralExpression(literal) - ? lowerer.lowerExprExpecting(literal, arr.type.elem) - : lowerer.lowerExpr(expr.right); - // Validate the payload before emitting a native store. A dormant JS - // method can contradict its JSDoc element type; retain its call-time - // refusal instead of leaving invalid IR in a newly reachable method. - const expected = - value.type.kind === "undefinedT" || - (value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0) - ? arrayValueType(lowerer, arr.type.elem) - : arr.type.elem; - return arrayValueStore( - lowerer, - arr, - index, - lowerer.coerceInto(expr.right, value, expected), - arr.type.elem, - locOf(expr), - ); -} - -/** Environment property names use ToPrimitive with the string hint, so - * checked objects must execute their own conversion hooks. */ -export function lowerEnvironmentKey(lowerer: Lowerer, node: ts.Expression): IrExpr { - const key = lowerer.lowerExpr(node); - if (key.type.kind === "dyn") - return { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc }; - return lowerRecordPropertyKey(lowerer, key, node); -} - /** `typeof` of a Node 24 Web-platform constructor global or one of its * static methods: "function", decided by name. The read has no effects. * Static methods answer by name because after a `typeof * globalThis.AbortSignal === "function" &&` guard the checker narrows a * fallback-typed receiver to never, hiding the member's declaration. */ -function constructorGlobalTypeof(lowerer: Lowerer, tested: ts.Expression): string | null { +export function constructorGlobalTypeof(lowerer: Lowerer, tested: ts.Expression): string | null { while (ts.isParenthesizedExpression(tested)) tested = tested.expression; const globalName = stdlibGlobalNameOf(lowerer, tested); if (globalName !== null && NODE_CONSTRUCTOR_GLOBALS.has(globalName)) return "function"; @@ -9132,23 +1631,6 @@ function constructorGlobalTypeof(lowerer: Lowerer, tested: ts.Expression): strin : null; } -/** `typeof globalThis.AbortSignal.any === "function"`: the comparison - * folds with constructorGlobalTypeof's answer. */ -function lowerConstructorGlobalTypeofTest( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; - for (const [a, b] of operandPairs(expr)) { - if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; - const answer = constructorGlobalTypeof(lowerer, a.expression); - if (answer !== null) - return { kind: "boolLit", value: (answer === b.text) !== negated, type: BOOL, loc }; - } - return null; -} - /** Static methods of Node 24's Web-platform constructor globals. */ const NODE_CONSTRUCTOR_STATICS: Readonly> = { AbortSignal: ["abort", "any", "timeout"], @@ -9186,7 +1668,7 @@ const NODE_CONSTRUCTOR_GLOBALS: ReadonlySet = new Set([ /** An operand of `+` with a string: ToString of ToPrimitive with the * default hint, which tries a class instance's valueOf before toString. */ -function concatOperand(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { +export function concatOperand(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { return ( classToStringConversion(lowerer, e, "default", node) ?? classUnionToString(lowerer, e, "default", node) ?? @@ -9194,238 +1676,6 @@ function concatOperand(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { ); } -export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr { - if (e.type.kind === "string") return e; - if (e.type.kind === "url") - return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc }; - // String(date) / `${date}` / "" + date: Date's ToPrimitive prefers - // toString(). - if (e.type.kind === "date") - return { kind: "libCall", fn: "date.toString", args: [e], type: STRING, loc: e.loc }; - if (e.type.kind === "regex") { - return { - kind: "regexIntrinsic", - method: "toString", - receiver: e, - args: [], - type: STRING, - loc: e.loc, - }; - } - if (e.type.kind === "bigint") { - return { - kind: "libCall", - fn: "bigint.toString", - args: [e, { kind: "numLit", value: 10, type: F64, loc: e.loc }], - type: STRING, - loc: e.loc, - }; - } - if (e.type.kind === "dyn") { - // String(u) / `${u}`: a runtime dispatch over the dyn kind — Node's - // String() exactly (undefined/null texts, JS number formatting, - // strings verbatim, arrays via join, objects as "[object Object]"). - return { kind: "toString", operand: e, type: STRING, loc: e.loc }; - } - if (e.type.kind === "bytes") { - // Preserve the runtime brand: typed arrays join numeric elements, - // while a Buffer stored in a Uint8Array slot decodes its bytes. - const boxed: IrExpr = { kind: "dynFrom", value: e, type: DYN, loc: e.loc }; - return { kind: "toString", operand: boxed, type: STRING, loc: e.loc }; - } - - if (e.type.kind === "object") { - // String(err) / `${err}` uses the same dispatch as err.toString(). - for (let c = lowerer.classes.get(e.type.className) ?? null; c; c = c.base) { - if (c.builtinError) { - return errorToStringCall(lowerer, e); - } - } - // A program class instance converts through its own methods. - const converted = classToStringConversion(lowerer, e, "string", node); - if (converted) return converted; - } - if (e.type.kind === "union") { - // `${u}` — the arm's ToString via a per-union interned helper - // (sc_us_*), Node-exact per arm kind: unit arms are the known texts - // "undefined"/"null", string arms pass through, f64/bool arms use the - // JS-exact formatters. Ref arms (records, arrays, ...) stay fenced: - // JS would print "[object Object]" and friends — narrow first. - const def = lowerer.unions.get(e.type.unionId); - const stringable = def?.arms.every( - (a) => - a.kind === "undefinedT" || - a.kind === "nullT" || - a.kind === "string" || - a.kind === "f64" || - a.kind === "bigint" || - a.kind === "bool", - ); - if (stringable) { - return { kind: "toString", operand: e, type: STRING, loc: e.loc }; - } - // Runtime-optional locals can retain their stored union after the - // checker has narrowed this use to a primitive arm. Validate that - // arm before converting it; an unguarded object arm stays fenced. - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); - if ( - narrowed && - (narrowed.kind === "string" || - narrowed.kind === "f64" || - narrowed.kind === "bool" || - narrowed.kind === "bigint") - ) { - const helper = lowerer.narrowedArmHelper(e.type.unionId, narrowed, e.loc); - if (helper) { - return ensureString(lowerer, lowerer.checkedNarrowCall(helper, e, narrowed, e.loc), node); - } - } - if ( - narrowed?.kind === "union" && - !typeEquals(narrowed, e.type) && - lowerer.unions - .get(narrowed.unionId) - ?.arms.every( - (arm) => - isUnitType(arm) || - arm.kind === "string" || - arm.kind === "f64" || - arm.kind === "bool" || - arm.kind === "bigint", - ) - ) { - const helper = lowerer.narrowedRetagHelper(node, e.type.unionId, narrowed.unionId, e.loc); - if (helper) - return ensureString( - lowerer, - { kind: "call", callee: helper, args: [e], type: narrowed, loc: e.loc }, - node, - ); - } - const viaClasses = classUnionToString(lowerer, e, "string", node); - if (viaClasses) return viaClasses; - lowerer.unsupported( - "SC1090", - node, - `string conversions of unions with object arms (${NARROW_FIRST})`, - ); - } - if (e.type.kind === "symbol") { - // JS splits here: `${sym}` and concatenation THROW a TypeError, - // String(sym) answers "Symbol(desc)". One shared lowering cannot - // honor both — fence with the sanctioned spelling instead of - // guessing the context. - lowerer.unsupported( - "SC1090", - node, - "string conversions of symbol values (template literals throw in JS — call .toString() explicitly)", - ); - } - if (isUnitType(e.type)) { - if (e.kind === "unitLit") { - return { kind: "strLit", value: e.unit, type: STRING, loc: e.loc }; - } - // Keep sequencing and branch effects even though the final unit's - // spelling is constant. Never turn an unknown producer into a literal. - if (e.kind === "seqExpr") { - return { ...e, result: lowerer.ensureString(e.result, node), type: STRING }; - } - if (e.kind === "ternary") { - return { - ...e, - then: lowerer.ensureString(e.then, node), - else_: lowerer.ensureString(e.else_, node), - type: STRING, - }; - } - lowerer.unsupported( - "SC1090", - node, - `string conversions of '${e.type.kind === "undefinedT" ? "undefined" : "null"}' values`, - ); - } - if (e.type.kind === "array") { - // `${[1,2,3]}` / String(arr): Array.prototype.toString IS join(",") - // — the SAME intrinsic the .join() lowering emits, fenced to the - // same element kinds (f64/string/bool, unions of those with unit - // arms printing empty). Nested arrays would need JS's recursive - // dispatch — the join fence already tells that story. - const elem = e.type.elem; - const joinableUnion = - elem.kind === "union" && - (lowerer.unions - .get(elem.unionId) - ?.arms.every( - (a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a), - ) ?? - false); - if (elem.kind === "f64" || elem.kind === "string" || elem.kind === "bool" || joinableUnion) { - const sep: IrExpr = { kind: "strLit", value: ",", type: STRING, loc: e.loc }; - return { - kind: "arrIntrinsic", - method: "join", - receiver: e, - args: [sep], - type: STRING, - loc: e.loc, - }; - } - } - if (e.type.kind === "record") { - // `${obj}` / String(obj): a plain data record has no toString of its - // own (a func-typed `toString` FIELD would shadow the prototype's — - // JS would call it, so that shape keeps the fence), and a TUPLE - // prints its elements like an array (not lowered — fenced) — every - // other record is Object.prototype.toString's constant. - const shape = lowerer.shapes.get(e.type.shapeId); - if (isPerfEntryType(lowerer, e.type)) return perfEntryTag(lowerer, e, e.type, e.loc); - if (shape && !shape.tuple && !shape.fields.some((f) => f.name === "toString")) { - // A null-prototype record (Object.groupBy) has no toString or - // valueOf: ToPrimitive throws Node's TypeError. - const nullProtoT = nullProtoRecordShape(lowerer, e.type); - if (nullProtoT) return nullProtoToString(lowerer, e, nullProtoT); - return { kind: "toString", operand: e, type: STRING, loc: e.loc }; - } - } - if (e.type.kind !== "f64" && e.type.kind !== "bool") lowerer.badType(node, lowerer.typeOf(node)); - return { kind: "toString", operand: e, type: STRING, loc: e.loc }; -} - -/** String(r) over a record that may have a null prototype: an interned - * helper that throws Node's TypeError for a null-prototype record and - * answers "[object Object]" otherwise. */ -function nullProtoToString(lowerer: Lowerer, e: IrExpr, t: IrType & { kind: "record" }): IrExpr { - const loc = e.loc; - const key = `nullProtoToString:${t.shapeId}`; - let helper = lowerer.valueHelpers.get(key); - if (!helper) { - helper = `%rec.tostr.${lowerer.valueHelpers.size}`; - lowerer.valueHelpers.set(key, helper); - const r = varRef("r.0", t, loc); - lowerer.liftedFns.push({ - name: helper, - params: [{ localId: "r.0", name: "r", type: t }], - returnType: STRING, - locals: [{ id: "r.0", name: "r", type: t, mutable: false }], - body: [ - { - kind: "return", - value: guardNullProto( - r, - t.shapeId, - "Cannot convert object to primitive value", - { kind: "toString", operand: r, type: STRING, loc }, - loc, - ), - loc, - }, - ], - loc, - }); - } - return { kind: "call", callee: helper, args: [e], type: STRING, loc }; -} - /** `new String(x)` (stdlib provenance, ≤1 argument) in a ToString * position: the wrapper's only distinguishers are typeof and identity — * neither is observable where the value immediately stringifies — so the @@ -9479,706 +1729,6 @@ export function lowerTemplate(lowerer: Lowerer, expr: ts.TemplateExpression): Ir return pieces.reduce((acc, p) => ({ kind: "strConcat", left: acc, right: p, type: STRING, loc })); } -/** `x as T`. Non-dyn inner values ordinarily erase; a record assertion - * changing monomorphic shape rebuilds through the established width-copy - * path so its represented value agrees with its asserted type. - * A dyn ('unknown') inner value makes this THE dynamic boundary: - * - `as unknown` (dyn → dyn) stays erasure; - * - dyn → a JSON-representable target type T compiles to `dynCheck`, a - * runtime validation that builds the typed value or THROWS a catchable - * TypeError-flavored error (JS `as` never checks — the headline - * documented divergence: a lying cast throws instead of corrupting - * memory); - * - dyn → anything else (closures, class instances, void) is rejected: - * those types cannot be found inside a JSON dyn. */ -/** `e as T` and the old-style assertion `e` — one node shape (both - * carry `.type` and `.expression`), one lowering. */ -export function lowerAsExpression( - lowerer: Lowerer, - expr: ts.AsExpression | ts.TypeAssertion, -): IrExpr { - // `[] as const` — tsgo panics computing the expression's `readonly []` - // type (the facade's fence answers `any`), but the syntax pins the - // value exactly: the empty tuple, ridden as the unit-element array - // (mapType's empty-tuple rule). Lower it directly; enclosing slots - // coerce like any other empty-array source. - if ( - ts.isAsExpression(expr) && - isConstAssertionTypeNode(expr.type) && - ts.isArrayLiteralExpression(expr.expression) && - expr.expression.elements.nodes.length === 0 - ) { - return { - kind: "arrayLit", - elems: [], - type: arrayOf(unitOnlyUnion(lowerer.unions)), - loc: locOf(expr), - }; - } - // `e as C` on a CATCH BINDING (`(err as Error).message`): the checked - // extraction — an instanceof match extracts the payload, anything else - // throws the catchable TypeError (dynCheck's trust-but-verify stance, - // extended to exception payloads). Intercepts BEFORE lowerExpr — the - // raw read would hit caughtRead's narrowness fence. - const caughtLocal = lowerer.caughtLocalOf(expr.expression); - if (caughtLocal) { - const loc = locOf(expr); - const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); - const target = lowerer.mapTypeOf(targetTs); - if (target?.kind === "object") { - const info = lowerer.classes.get(target.className); - if (info && lowerer.inHierarchy(info)) { - return { - kind: "caughtCheck", - value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc }, - className: target.className, - type: target, - loc, - }; - } - } - // Structural assertions use the ordinary checked dynamic bridge. It - // retains primitive values and native class identity before validating - // the requested shape; an assertion does not bypass runtime checks. - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - const bridged = - narrowed && - (narrowed.kind === "f64" || - narrowed.kind === "bool" || - narrowed.kind === "string" || - narrowed.kind === "object" || - narrowed.kind === "dyn"); - if (!bridged) lowerer.unsupported("SC1063", expr); - } - const inner = lowerer.lowerExpr(expr.expression); - const use = runtimeOptionalUseOf(expr); - if (inner.type.kind === "union" && runtimeOptionalAssertionErases(lowerer, expr, inner, use)) - return inner; - if (inner.type.kind !== "dyn") { - const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type); - const target = lowerer.mapTypeOf(targetTs0); - if (target?.kind === "object") { - const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr)); - if (asserted) return asserted; - } - // Unknown-only record views use the checked-dynamic representation. - // A native record asserted to such a view still denotes the original - // object. Box a live reference instead of erasing the assertion and - // handing native storage to a checked-dynamic property operation. - if (target?.kind === "dyn" && (targetTs0.flags & ts.TypeFlags.Object) !== 0) { - // A class instance asserted to a class-backed protocol view (a type - // class instances flow into): the same identity-keeping box an - // implicit flow into the view uses. - if (inner.type.kind === "object" && lowerer.classImplementedProtocol(targetTs0)) - return lowerer.coerceInto(expr, inner, DYN); - const sourceShape = - inner.type.kind === "record" ? lowerer.shapes.get(inner.type.shapeId) : undefined; - if ( - hasClassPayload(lowerer, inner.type) || - (sourceShape && shapeHasAccessorSlots(sourceShape)) - ) { - lowerer.unsupported( - "SC1090", - expr, - "opaque structural views of classes or accessor records (name the property's concrete type to retain native accessor dispatch)", - ); - } - const boxed = lowerer.coerceInto(expr, inner, DYN); - // Only references box live; a scalar or unit source (`value as - // unknown as View`) is an ordinary snapshot. - const seen = new Set(); - const live = (type: IrType): boolean => { - if ( - type.kind === "record" || - type.kind === "array" || - type.kind === "bytes" || - isDynTypedRefType(type) - ) - return true; - if (type.kind !== "union" || seen.has(type.unionId)) return false; - seen.add(type.unionId); - return lowerer.unions.get(type.unionId)?.arms.some(live) ?? false; - }; - return boxed.kind === "dynFrom" && live(boxed.value.type) - ? { ...boxed, liveRef: true } - : boxed; - } - // Collection assertions are interface views only: their backing - // value must already have the same native key/element/value layout. - // Erasing a mock or a different instantiation here would let member - // lowering interpret a record (or another ABI) as a native handle. - if (target?.kind === "map" || target?.kind === "set") { - return lowerer.coerceInto(expr, inner, target); - } - // A class-derived interface still names the class's native layout. - // An assertion must not let a structural mock acquire that layout; - // unions retain their checked extraction below, and real instances - // keep the ordinary class narrowing path. - if (target?.kind === "object" && inner.type.kind !== "object" && inner.type.kind !== "union") { - return lowerer.coerceInto(expr, inner, target); - } - if ( - target?.kind === "object" && - inner.type.kind === "object" && - !typeEquals(target, inner.type) && - !lowerer.isSubclassOf(inner.type.className, target.className) && - !lowerer.isSubclassOf(target.className, inner.type.className) - ) { - return lowerer.coerceInto(expr, inner, target); - } - // Static assertions normally erase, but record layouts are - // monomorphic: a consumer selected from the asserted shape must see - // that shape physically. Reuse the ordinary slot coercion so plain - // widths and index-signature captures share their established copy - // semantics (and unsupported pairs report SC2002 at this assertion). - if ( - inner.type.kind === "record" && - target?.kind === "record" && - inner.type.shapeId !== target.shapeId - ) { - return lowerer.coerceInto(expr, inner, target); - } - // `u as Arm` on a UNION value is `u!`'s spelling with a named arm - // (`req.headers[h] as string`): the CHECKED single-arm extraction — - // the asserted arm's payload comes out, any other arm throws the - // catchable TypeError (divergence 38's lying-assertion stance; an - // erasure would just move the failure to the next typed slot as an - // opaque union-mismatch fence). Sub-union targets and same-type - // casts keep the historic erasure. - if (inner.type.kind === "union") { - if (target && target.kind !== "union" && !typeEquals(target, inner.type)) { - const helper = lowerer.narrowedArmHelper(inner.type.unionId, target, locOf(expr)); - if (helper) { - return lowerer.checkedNarrowCall(helper, inner, target, locOf(expr)); - } - } - } - // `node.parent as Sub | undefined`: a union target whose class arm is - // narrower than the stored class gets the same checked class view as - // `node.parent as Sub`; the other arms keep their identical tags. - if ( - target?.kind === "union" && - (inner.type.kind === "union" || inner.type.kind === "object") && - !typeEquals(target, inner.type) - ) { - const asserted = checkedClassUnionAssertion(lowerer, inner, target, locOf(expr)); - if (asserted) return asserted; - } - return inner; // erasure, unchanged - } - - const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); - const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs); - if (!target) lowerer.badType(expr.type, targetTs); - if (target.kind === "dyn") return inner; // `as unknown`: erasure - // An all-`unknown`-fields record target (`err as { code?: unknown }` - // — the errno-probing idiom): there is nothing to validate (every - // field is unknown, exactly what the dyn value already answers) and - // nothing to build — the cast is pure typing, so it ERASES and the - // reads ride the dyn keyed read. - if (target.kind === "record") { - const shape = lowerer.shapes.get(target.shapeId); - if ( - shape && - !shape.tuple && - shape.fields.every((f) => f.type.kind === "dyn") && - (!shape.indexValue || shape.indexValue.kind === "dyn") - ) { - return inner; - } - } - // The global object: the checked extraction would build a separate - // value, so the assertion keeps the object and its member operations - // reach it (assertion-identity.ts). - if (extractionCopiesReference(lowerer, target) && assertsGlobalObject(lowerer, expr)) - return inner; - if ( - target.kind === "void" || - !canDynCheckTo( - target, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) { - // Bare undefined-armed targets pass when every OTHER arm is - // JSON-safe: the checked-dynamic tree holds a first-class undefined value now - // (index-signature overflow reads produce it for missing keys), and - // the undefined arm matches exactly it — `p[key] as string | - // undefined` is the missing-key idiom. Parsed JSON still never - // contains undefined, so casts over parse results keep failing on - // non-string values with the usual path-annotated TypeError. - if (target.kind === "union" && lowerer.dynConvertible(target)) { - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; - } - // Uint8Array targets: the checked-dynamic tree carries a bytes kind now (converted - // stdin chunks) — the extraction validates the kind and copies out. - if (target.kind === "bytes" && target.elem === "u8") { - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; - } - // Class instances round-trip only through compiler-owned typed - // references. Plain JSON objects fail the runtime class-brand check; - // exact capsules unwrap the original object by identity. - if (isDynTypedRefType(target)) { - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; - } - // ADAPTABLE function targets (`u as (x: number) => number` — the - // checked-dynamic function boundary): kind check, then exact unwrap - // or the per-target adapter shim. Non-adaptable signatures keep the - // fence below. - if ( - target.kind === "func" && - canAdaptDynFuncTo( - target, - (id) => lowerer.shapes.get(id), - (id) => lowerer.unions.get(id), - ) - ) { - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; - } - // Runtime HANDLE targets (`u as IncomingMessage` — a boxed handle - // coming back out of an untyped wrapper): a tag-checked reference - // unwrap, identity preserved (DYN_HANDLE_KINDS). - if (DYN_HANDLE_KINDS.has(target.kind)) { - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; - } - lowerer.unsupported( - "SC1090", - expr, - `a checked cast of 'unknown' to '${target.kind === "void" ? "void" : lowerer.fmt(target)}' ` + - `(a dynamic value can only be validated against JSON-representable types: ` + - `number, string, boolean, records, arrays, and unions of those)`, - ); - } - return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; -} - -export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpression): IrExpr { - const loc = locOf(expr); - switch (expr.operator) { - case ts.SyntaxKind.MinusToken: { - const raw = lowerer.lowerExpr(expr.operand); - - if (raw.type.kind === "bigint") { - return { kind: "libCall", fn: "bigint.neg", args: [raw], type: BIGINT_T, loc }; - } - // Match the checked numeric boundary used by JS binary arithmetic. - const operand = - raw.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) - ? { kind: "dynCheck" as const, value: raw, type: F64, loc } - : lowerOptionalNumber(lowerer, raw, loc, expr.operand); - if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "-", [raw.type]); - if (operand.kind === "numLit") return { ...operand, value: -operand.value, loc }; - return { kind: "unary", op: "-", operand, type: F64, loc }; - } - case ts.SyntaxKind.PlusToken: { - const raw = lowerer.lowerExpr(expr.operand); - if (raw.type.kind === "dyn") { - return { kind: "libCall", fn: "dyn.toNumberCoerce", args: [raw], type: F64, loc }; - } - // Unary + is ToNumber; on an already-number operand it's identity, - // and a STRING operand runs the runtime's ECMA-exact StringToNumber - // (num.fromString — Number(aString)'s lowering, scr_string.c). - - if (raw.type.kind === "string") { - return { kind: "libCall", fn: "num.fromString", args: [raw], type: F64, loc }; - } - const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); - if (classNumber) return classNumber; - const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); - if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]); - return operand; - } - case ts.SyntaxKind.ExclamationToken: { - // `!x` is ToBoolean-then-negate: f64/string operands go through toBool. - const operand = lowerer.ensureBool( - deferredSlotOr( - lowerer, - runtimeOptionalStorageOperand(lowerer, expr.operand) ?? lowerer.lowerExpr(expr.operand), - ), - expr.operand, - ); - return { kind: "unary", op: "!", operand, type: BOOL, loc }; - } - case ts.SyntaxKind.TildeToken: { - // `~x`: ToInt32, complement, back to f64 (JS-exact, incl. NaN → -1). - const raw = lowerer.lowerExpr(expr.operand); - if (raw.type.kind === "bigint") { - return { kind: "libCall", fn: "bigint.not", args: [raw], type: BIGINT_T, loc }; - } - if (raw.type.kind === "dyn") - return { - kind: "libCall", - fn: "dyn.bitwise", - args: [raw, dynUndefinedExpr(loc), { kind: "strLit", value: "~", type: STRING, loc }], - type: DYN, - loc, - }; - // A checked-dynamic value retains enough JavaScript structure to - // run exact ToNumber, including the object valueOf/toString - // protocol. This is the static `any`/JS-lane answer for idioms - // such as `while (~index)`, where inference may have widened an - // otherwise numeric slot to the checked-dynamic tree. - const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); - if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "~", [raw.type]); - return { kind: "unary", op: "~", operand, type: F64, loc }; - } - case ts.SyntaxKind.PlusPlusToken: - case ts.SyntaxKind.MinusMinusToken: - // `++x` / `--x` in expression position: yields the NEW value. - return lowerIncDec(lowerer, expr, true); - } - lowerer.unsupported("SC1090", expr, `syntax '${ts.syntaxKindName(expr.kind)}'`); -} - -/** Member updates evaluate the reference once, convert its old value with - * ToNumeric, store the new value, then yield the old or new numeric value. */ -export function lowerIncDec( - lowerer: Lowerer, - expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression, - prefix: boolean, -): IrExpr { - const loc = locOf(expr); - const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; - if (!ts.isIdentifier(expr.operand)) { - const access = expr.operand; - if ( - (!ts.isPropertyAccessExpression(access) && !ts.isElementAccessExpression(access)) || - access.questionDotToken - ) { - lowerer.unsupported("SC1045", expr, "increment/decrement of this target"); - } - fenceNodeModuleMutation(lowerer, access, "assignment"); - if (ts.isPropertyAccessExpression(access)) { - const target = - staticFieldWriteTarget(lowerer, access) ?? - expandoWritableTarget(lowerer, access) ?? - nsWritableTarget(lowerer, access); - if (target) { - return lowerIncDecTarget(lowerer, expr, target, prefix); - } - } - const stmts: IrStmt[] = []; - const save = (value: IrExpr, name: string): IrExpr => { - const local = lowerer.declareHiddenLocal(name, value.type); - stmts.push({ kind: "varDecl", localId: local.id, init: value, loc }); - return varRef(local.id, value.type, loc); - }; - const finish = (read: IrExpr, write: (value: IrExpr) => IrStmt): IrExpr => { - if (read.type.kind === "bigint") { - const old = save(read, "%incrementOld"); - const next = save(bigintStep(op, old, loc), "%incrementNext"); - stmts.push(write(next)); - const result = prefix ? next : old; - return { kind: "seqExpr", stmts, result, type: result.type, loc }; - } - const old = save( - read.type.kind === "f64" - ? read - : { - kind: "libCall", - fn: "dyn.toNumeric", - args: [lowerer.coerceInto(access, read, DYN)], - type: DYN, - loc, - }, - "%incrementOld", - ); - const next = save( - old.type.kind === "f64" - ? { - kind: "bin", - op, - left: old, - right: { kind: "numLit", value: 1, type: F64, loc }, - type: F64, - loc, - } - : { - kind: "libCall", - fn: "dyn.increment", - args: [old, { kind: "boolLit", value: op === "+", type: BOOL, loc }], - type: DYN, - loc, - }, - "%incrementNext", - ); - stmts.push(write(next)); - const result = prefix ? next : old; - return { kind: "seqExpr", stmts, result, type: result.type, loc }; - }; - const raw = lowerer.lowerExpr(access.expression); - // Keep native array/byte storage: boxing a native array can create a - // checked view, which is not a substitute for updating its slot. - if ( - ts.isElementAccessExpression(access) && - (raw.type.kind === "array" || raw.type.kind === "bytes") - ) { - const receiver = save(raw, "%incrementArray"); - const rawIndex = lowerer.lowerExpr(access.argumentExpression); - const index = save( - rawIndex.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()) - ? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc } - : rawIndex, - "%incrementIndex", - ); - if (index.type.kind !== "f64") - lowerer.unsupported("SC1090", access, "incrementing non-number array keys"); - const type = raw.type; - const read: IrExpr = - type.kind === "array" - ? type.elem.kind === "f64" - ? arrayNumberRead(receiver, index, loc) - : arrayValueRead(lowerer, receiver, index, type.elem, loc) - : { - kind: "bytesIntrinsic", - method: "get", - receiver, - args: [index], - type: F64, - loc, - invalidNaN: true, - }; - return finish(read, (value) => - type.kind === "array" - ? arrayValueStore( - lowerer, - receiver, - index, - lowerer.coerceInto(access, value, type.elem), - type.elem, - loc, - ) - : { - kind: "bytesSet", - arr: receiver, - index, - value: lowerer.coerceInto(access, value, F64), - loc, - }, - ); - } - // A TS member statically typed number on a checked-dynamic receiver (a - // class-backed protocol view): the expanded form `o.v = o.v + 1`, its - // read checked into number like any other read of the member. - const checkedNumber = - !isJsSourceFile(expr.getSourceFile()) && - raw.type.kind === "dyn" && - lowerer.mapTypeOf(lowerer.typeOf(access))?.kind === "f64"; - if ((isJsSourceFile(expr.getSourceFile()) || checkedNumber) && raw.type.kind === "dyn") { - const receiver = save(raw, "%incrementReceiver"); - const key = save( - ts.isElementAccessExpression(access) - ? lowerer.lowerExprExpecting(access.argumentExpression, DYN) - : lowerer.coerceInto( - access, - { kind: "strLit", value: access.name.text, type: STRING, loc }, - DYN, - ), - "%incrementKey", - ); - const read: IrExpr = { kind: "dynKeyGet", value: receiver, key, type: DYN, loc }; - return finish(checkedNumber ? lowerer.coerceInto(access, read, F64) : read, (value) => ({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySetComputed", - args: [receiver, key, lowerer.coerceInto(access, value, DYN)], - type: VOID, - loc, - }, - loc, - })); - } - const target = ts.isPropertyAccessExpression(access) - ? lowerer.fieldTarget(access) - : symbolFieldTarget(lowerer, access); - if (target && lowerer.dynConvertible(target.fieldType)) { - checkDeferredMemberReceiver(lowerer, target, access); - target.obj = save(target.obj, "%incrementReceiver"); - return finish(lowerer.fieldGetExpr(target, loc, access), (value) => - lowerer.fieldSetStmt( - target, - lowerer.coerceInto(access, value, target.fieldType), - loc, - access, - ), - ); - } - if (isJsSourceFile(expr.getSourceFile()) && lowerer.dynConvertible(raw.type)) { - const receiver = save(lowerer.coerceInto(access.expression, raw, DYN), "%incrementReceiver"); - const key = save( - ts.isElementAccessExpression(access) - ? lowerer.lowerExprExpecting(access.argumentExpression, DYN) - : lowerer.coerceInto( - access, - { kind: "strLit", value: access.name.text, type: STRING, loc }, - DYN, - ), - "%incrementKey", - ); - return finish({ kind: "dynKeyGet", value: receiver, key, type: DYN, loc }, (value) => ({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySetComputed", - args: [receiver, key, lowerer.coerceInto(access, value, DYN)], - type: VOID, - loc, - }, - loc, - })); - } - lowerer.unsupported("SC1045", expr, "increment/decrement of this member target"); - } - const target = lowerer.resolveWritable(expr.operand); - if (!target) { - lowerer.rejectUnresolved( - expr.operand, - `increment/decrement of '${expr.operand.text}' (not a writable local or module global)`, - ); - } - return lowerIncDecTarget(lowerer, expr, target, prefix); -} - -/** Numeric updates read the represented slot once and write the computed - * value back in that same representation. In particular, an unchecked - * indexed initializer can leave undefined in an otherwise numeric binding. */ -export function lowerIncDecTarget( - lowerer: Lowerer, - expr: ts.PrefixUnaryExpression | ts.PostfixUnaryExpression, - target: { id: string; type: IrType }, - prefix: boolean, -): IrExpr { - const loc = locOf(expr); - const op = expr.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-"; - if (target.type.kind === "dyn") { - const old = lowerer.declareHiddenLocal("%numericOld", DYN); - const next = lowerer.declareHiddenLocal("%numericNext", DYN); - const read = varRef(target.id, target.type, loc); - return { - kind: "seqExpr", - stmts: [ - { - kind: "varDecl", - localId: old.id, - init: { kind: "libCall", fn: "dyn.toNumeric", args: [read], type: DYN, loc }, - loc, - }, - { - kind: "varDecl", - localId: next.id, - init: { - kind: "libCall", - fn: "dyn.increment", - args: [ - varRef(old.id, DYN, loc), - { kind: "boolLit", value: op === "+", type: BOOL, loc }, - ], - type: DYN, - loc, - }, - loc, - }, - { kind: "assign", localId: target.id, value: varRef(next.id, DYN, loc), loc }, - ], - result: varRef(prefix ? next.id : old.id, DYN, loc), - type: DYN, - loc, - }; - } - if (target.type.kind === "union") { - const read = lowerOptionalNumber( - lowerer, - varRef(target.id, target.type, loc), - loc, - expr.operand, - ); - if (read.type.kind === "f64") { - const old = lowerer.declareHiddenLocal("%numericOld", F64); - const next = lowerer.declareHiddenLocal("%numericNext", F64); - const result = varRef(prefix ? next.id : old.id, F64, loc); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: old.id, init: read, loc }, - { - kind: "varDecl", - localId: next.id, - init: { - kind: "bin", - op, - left: varRef(old.id, F64, loc), - right: numLit(1, loc), - type: F64, - loc, - }, - loc, - }, - { - kind: "assign", - localId: target.id, - value: lowerer.coerceInto(expr.operand, varRef(next.id, F64, loc), target.type), - loc, - }, - ], - result, - type: F64, - loc, - }; - } - } - if (target.type.kind === "bigint") { - const old = lowerer.declareHiddenLocal("%bigintOld", BIGINT_T); - const next = lowerer.declareHiddenLocal("%bigintNext", BIGINT_T); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: old.id, init: varRef(target.id, BIGINT_T, loc), loc }, - { - kind: "varDecl", - localId: next.id, - init: bigintStep(op, varRef(old.id, BIGINT_T, loc), loc), - loc, - }, - { kind: "assign", localId: target.id, value: varRef(next.id, BIGINT_T, loc), loc }, - ], - result: varRef(prefix ? next.id : old.id, BIGINT_T, loc), - type: BIGINT_T, - loc, - }; - } - if (target.type.kind !== "f64") - operatorUnsupported(lowerer, expr, op === "+" ? "++" : "--", [target.type]); - return { kind: "incDec", op, prefix, localId: target.id, type: F64, loc }; -} - -/** Whether a lowered statement can live inside a seqExpr. These statements - * do not leave the expression through a jump; the native emitters can place - * their writes and local state branches before evaluating the final result. - * Blocks check their bodies recursively. */ -function seqExprSafeStmt(s: IrStmt): boolean { - switch (s.kind) { - case "varDecl": - case "assign": - case "exprStmt": - case "fieldSet": - case "recordSet": - case "recordKeySet": - case "arraySet": - case "arraySetLength": - case "bytesSet": - case "arraySetUndefined": - case "arrayDelete": - return true; - case "if": - return s.then.every(seqExprSafeStmt) && (s.else_?.every(seqExprSafeStmt) ?? true); - case "block": - return s.body.every(seqExprSafeStmt); - default: - return false; - } -} - const BIGINT_COMPOUND = { "+": "bigint.add", "-": "bigint.sub", @@ -10195,7 +1745,7 @@ const BIGINT_COMPOUND = { /** `old op= rhs` over two bigints: the binary operator's libCall. Null * for other operand types (and `>>>`, which BigInt does not have). */ -function bigintCompoundValue( +export function bigintCompoundValue( op: CompoundOp, left: IrExpr, right: IrExpr, @@ -10205,88 +1755,9 @@ function bigintCompoundValue( return { kind: "libCall", fn: BIGINT_COMPOUND[op], args: [left, right], type: BIGINT_T, loc }; } -/** `++`/`--` on a bigint value: old ± 1n. */ -function bigintStep(op: "+" | "-", old: IrExpr, loc: SrcLoc): IrExpr { - const one: IrExpr = { - kind: "libCall", - fn: "bigint.parse", - args: [{ kind: "strLit", value: "1", type: STRING, loc }], - type: BIGINT_T, - loc, - }; - return { - kind: "libCall", - fn: op === "+" ? "bigint.add" : "bigint.sub", - args: [old, one], - type: BIGINT_T, - loc, - }; -} - -export function lowerCompoundValueToTarget( - lowerer: Lowerer, - expr: ts.BinaryExpression, - compound: CompoundOp, - target: { id: string; type: IrType }, -): IrExpr { - const loc = locOf(expr); - const read: IrExpr = { - kind: "varRef", - localId: target.id, - type: target.type, - loc: locOf(expr.left), - }; - const rhs = lowerer.lowerExpr(expr.right); - const numericRead = lowerOptionalNumber(lowerer, read, loc); - const numericRhs = lowerOptionalNumber(lowerer, rhs, loc); - - if ( - compound === "+" && - (target.type.kind === "string" || - (target.type.kind === "union" && - lowerer.stripUndefinedArm(target.type).kind === "string" && - rhs.type.kind === "string")) - ) { - return lowerer.coerceInto( - expr, - { - kind: "strConcat", - left: lowerer.ensureString(read, expr.left), - right: concatOperand(lowerer, rhs, expr.right), - type: STRING, - loc, - }, - target.type, - ); - } - if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { - return lowerer.coerceInto( - expr, - { kind: "bin", op: compound, left: numericRead, right: numericRhs, type: F64, loc }, - target.type, - ); - } - // An untyped operand: exactly the expanded `x = x op rhs` — the runtime - // operator on both values, then the checked conversion into the - // target's declared type (a string result for a number target throws the - // catchable TypeError, as the expanded assignment does). - if ( - (target.type.kind === "dyn" || rhs.type.kind === "dyn") && - (isJsSourceFile(expr.getSourceFile()) || - (lowerer.dynConvertible(read.type) && lowerer.dynConvertible(rhs.type))) - ) { - const left = lowerer.coerceInto(expr.left, read, DYN); - const right = lowerer.coerceInto(expr.right, rhs, DYN); - return lowerer.coerceInto(expr, dynCompoundValue(compound, left, right, loc), target.type); - } - const bigint = bigintCompoundValue(compound, read, rhs, loc); - if (bigint) return bigint; - compoundUnsupported(lowerer, expr, compound, read.type, rhs.type); -} - /** The runtime operator of a compound assignment over two checked-dynamic * operands (Node's ToPrimitive/ToNumeric order lives in the runtime). */ -function dynCompoundValue(op: CompoundOp, left: IrExpr, right: IrExpr, loc: SrcLoc): IrExpr { +export function dynCompoundValue(op: CompoundOp, left: IrExpr, right: IrExpr, loc: SrcLoc): IrExpr { const numeric = ["-", "*", "/", "%", "**"].includes(op); return { kind: "libCall", @@ -10305,7 +1776,7 @@ function operandText(lowerer: Lowerer, t: IrType): string { /** SC1043 for a compound assignment whose operand types have no lowering, * naming the operator and both types rather than the comparison wording. */ -function compoundUnsupported( +export function compoundUnsupported( lowerer: Lowerer, node: ts.Node, op: CompoundOp, @@ -10321,7 +1792,7 @@ function compoundUnsupported( /** SC1043 for an arithmetic, unary or update operator whose operand types * have no lowering (comparisons keep the registry wording). */ -function operatorUnsupported( +export function operatorUnsupported( lowerer: Lowerer, node: ts.Node, op: string, @@ -10337,1808 +1808,6 @@ function operatorUnsupported( ); } -/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may - * stand for undefined and never holds a genuine NaN: Node's undefined equals - * undefined, so two NaNs compare equal here. Operands evaluate once, in - * order. */ -function lowerNanCodedEquality(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { - const loc = locOf(expr); - const left = lowerer.lowerExpr(expr.left); - const right = lowerer.lowerExpr(expr.right); - if (left.type.kind !== "f64" || right.type.kind !== "f64") { - throw new InternalCompilerError("NaN-coded equality operand is not a plain number"); - } - const stmts: IrStmt[] = []; - const stable = (value: IrExpr, name: string): IrExpr => { - if (value.kind === "varRef" || value.kind === "numLit") return value; - const tmp = lowerer.declareHiddenLocal(name, F64); - stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); - return varRef(tmp.id, F64, loc); - }; - const a = stable(left, "%eqLeft"); - const b = stable(right, "%eqRight"); - const isNaN = (value: IrExpr): IrExpr => ({ - kind: "libCall", - fn: "num.isNaN", - args: [value], - type: BOOL, - loc, - }); - const bothUndefined: IrExpr = { - kind: "logical", - op: "&&", - left: isNaN(a), - right: isNaN(b), - type: BOOL, - loc, - }; - const op = expr.operatorToken.kind; - const negated = - op === ts.SyntaxKind.ExclamationEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsToken; - const result: IrExpr = negated - ? { - kind: "logical", - op: "&&", - left: { kind: "bin", op: "!==", left: a, right: b, type: BOOL, loc }, - right: { kind: "unary", op: "!", operand: bothUndefined, type: BOOL, loc }, - type: BOOL, - loc, - } - : { - kind: "logical", - op: "||", - left: { kind: "bin", op: "===", left: a, right: b, type: BOOL, loc }, - right: bothUndefined, - type: BOOL, - loc, - }; - return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc }; -} - -export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { - const loc = locOf(expr); - const op = expr.operatorToken.kind; - - const cacheHas = lowerRequireCacheHas(lowerer, expr); - if (cacheHas) return cacheHas; - - if (lowerer.nanCodedReads?.needsUndefinedEquality(expr)) { - const undefinedEquality = lowerNanCodedEquality(lowerer, expr); - if (undefinedEquality) return undefinedEquality; - } - - if ( - op === ts.SyntaxKind.EqualsToken || - (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) - ) { - const urlWrite = lowerUrlAssignment(lowerer, expr); - if (urlWrite) return urlWrite; - if (op === ts.SyntaxKind.EqualsToken) { - const prototypeWrite = lowerClassPrototypeComputedAssignment(lowerer, expr); - if (prototypeWrite) return prototypeWrite; - const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); - if (callableWrite) return callableWrite; - } - if ( - op === ts.SyntaxKind.QuestionQuestionEqualsToken || - op === ts.SyntaxKind.AmpersandAmpersandEqualsToken || - op === ts.SyntaxKind.BarBarEqualsToken - ) { - return lowerShortCircuitAssignment(lowerer, expr); - } - if (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) { - fenceNodeModuleMutation(lowerer, expr.left, "assignment"); - } - // The writable Event accessors on Event subclass instances, in value - // position (`const r = (event.returnValue = false)`). - if (op === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(expr.left)) { - const eventWrite = lowerEventObjectPropertyWrite(lowerer, expr.left, expr.right); - if (eventWrite) return eventWrite; - } - // `x = e` in EXPRESSION position (`while ((idx = s.indexOf("\n")) !== -1)`, - // `f(x = v)`): evaluate e once, write the binding, yield the assigned - // value — JS evaluation order. Variable targets only (locals and module - // globals, captured/boxed included); the RHS coerces into the binding's - // type exactly like statement position, and the expression's value is - // the coerced binding-typed value (representation change only — never - // observably different from JS's raw-RHS yield). Indexed compounds - // use a sequenced read/modify/write; other member targets and - // destructuring targets stay fenced. - if (op === ts.SyntaxKind.EqualsToken && ts.isIdentifier(expr.left)) { - const target = lowerer.resolveWritable(expr.left); - if (!target) { - lowerer.rejectUnresolved( - expr.left, - `assignment to '${expr.left.text}' (not a writable local or module global)`, - ); - } - const value = lowerer.lowerExprExpecting(expr.right, target.type); - const runtimeOptionalRoot = lowerer.runtimeOptionalRootOf(target); - if (lowerer.runtimeOptionalStorageLocals.has(runtimeOptionalRoot)) - lowerer.runtimeOptionalLocals.add(runtimeOptionalRoot); - return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; - } - const indexedCompound = COMPOUND_ASSIGN_OPS[op]; - if (indexedCompound !== undefined && ts.isElementAccessExpression(expr.left)) { - if (symbolFieldInfo(lowerer, expr.left)) - return lowerFieldCompoundValue(lowerer, expr.left, indexedCompound, expr.right, loc); - return lowerElementCompound(lowerer, expr, indexedCompound); - } - if (indexedCompound !== undefined && ts.isIdentifier(expr.left)) { - const target = lowerer.resolveWritable(expr.left); - if (!target) { - lowerer.rejectUnresolved(expr.left, "assignment to an unresolved variable"); - } - const value = lowerCompoundValueToTarget(lowerer, expr, indexedCompound, target); - return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; - } - if ( - indexedCompound !== undefined && - (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) && - !(ts.isPropertyAccessExpression(expr.left) && expr.left.questionDotToken) - ) { - const target = - expandoWritableTarget(lowerer, expr.left) ?? - (ts.isPropertyAccessExpression(expr.left) ? nsWritableTarget(lowerer, expr.left) : null); - if (target) { - const value = lowerCompoundValueToTarget(lowerer, expr, indexedCompound, target); - return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; - } - return lowerFieldCompoundValue(lowerer, expr.left, indexedCompound, expr.right, loc); - } - // `events.defaultMaxListeners = v` — the module-property write - // Node validates (validateNumber(n, 'defaultMaxListeners', 0)): - // the value crosses into the checked-dynamic tree and the runtime ladder throws - // ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE with Node's exact slot - // name; valid numbers apply. The expression's value is the RHS - // (JS's assignment yield). - if ( - op === ts.SyntaxKind.EqualsToken && - ts.isPropertyAccessExpression(expr.left) && - !expr.left.questionDotToken && - expr.left.name.text === "defaultMaxListeners" - ) { - const bi = lowerer.builtinMemberOf(expr.left); - if (bi && bi.module === "events" && bi.member === "defaultMaxListeners") { - const rhs = lowerer.lowerExpr(expr.right); - if (rhs.type.kind === "dyn" || rhs.kind === "unitLit" || lowerer.dynConvertible(rhs.type)) { - const valTmp = lowerer.declareHiddenLocal("%setVal", rhs.type); - const valRef = (): IrExpr => ({ - kind: "varRef", - localId: valTmp.id, - type: rhs.type, - loc, - }); - const dynVal: IrExpr = - rhs.type.kind === "dyn" - ? valRef() - : { kind: "dynFrom", value: valRef(), type: DYN, loc }; - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: valTmp.id, init: rhs, loc }, - { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "emitter.setDefaultMaxChk", - args: [ - dynVal, - { kind: "strLit", value: "defaultMaxListeners", type: STRING, loc }, - ], - type: VOID, - loc, - }, - loc, - }, - ], - result: valRef(), - type: rhs.type, - loc, - }; - } - } - } - // Member targets whose storage IS a module global — namespace - // members (`N.x`) and expando function members (`Foo.baz`): the - // same assignExpr, since the "member" is a variable. - if ( - op === ts.SyntaxKind.EqualsToken && - (ts.isPropertyAccessExpression(expr.left) || ts.isElementAccessExpression(expr.left)) && - !(ts.isPropertyAccessExpression(expr.left) && expr.left.questionDotToken) - ) { - const target = - expandoWritableTarget(lowerer, expr.left) ?? - (ts.isPropertyAccessExpression(expr.left) ? nsWritableTarget(lowerer, expr.left) : null); - if (target) { - const value = lowerer.lowerExprExpecting(expr.right, target.type); - return { kind: "assignExpr", localId: target.id, value, type: target.type, loc }; - } - // Bundled numeric enums use e[e["name"] = value] = "name". - // Capture the receiver and key before the RHS. PutValue coerces the - // key afterward, then yields the RHS without rereading the property. - if (ts.isElementAccessExpression(expr.left) && !expr.left.questionDotToken) { - const recv = tryLowerExpression(lowerer, expr.left.expression); - if ( - recv && - (recv.type.kind === "dyn" || - (recv.type.kind === "array" && - isJsSourceFile(expr.getSourceFile()) && - lowerer.dynConvertible(recv.type))) - ) { - return lowerDynMemberAssignment( - lowerer, - expr, - lowerer.coerceInto(expr.left.expression, recv, DYN), - ); - } - if (recv?.type.kind === "array" || recv?.type.kind === "bytes") { - const receiver = lowerer.declareHiddenLocal("%setArray", recv.type); - const index = lowerer.lowerExpr(expr.left.argumentExpression); - if (index.type.kind !== "f64") - lowerer.unsupported( - "SC1090", - expr.left.argumentExpression, - "indexing with non-number keys", - ); - const key = lowerer.declareHiddenLocal("%setIndex", F64); - const raw = lowerer.lowerExpr(expr.right); - const value = lowerer.declareHiddenLocal("%setValue", raw.type); - const result = varRef(value.id, raw.type, loc); - const arr = varRef(receiver.id, recv.type, loc); - const idx = varRef(key.id, F64, loc); - const write: IrStmt = - recv.type.kind === "array" - ? arrayValueStore(lowerer, arr, idx, result, recv.type.elem, loc) - : { - kind: "bytesSet", - arr, - index: idx, - value: lowerer.coerceInto(expr.right, result, F64), - loc, - }; - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: receiver.id, init: recv, loc }, - { kind: "varDecl", localId: key.id, init: index, loc }, - { kind: "varDecl", localId: value.id, init: raw, loc }, - write, - ], - result, - type: result.type, - loc, - }; - } - } - - // `h.k = v` on a CHECKED-DYNAMIC receiver in VALUE position - // (`var _ = module.exports = foo`): receiver first, then RHS - // (JS's reference-before-value order), the keyed write, and the - // RHS value is the expression's value — its OWN type, so the - // consumer sees exactly what the checker typed. - if (ts.isPropertyAccessExpression(expr.left) && !expr.left.questionDotToken) { - // `const src = exports.src = []`: an export member assigned in - // value position writes its export global and yields the value. - { - let inFunction = false; - for (let p: ts.Node | undefined = expr.parent; p !== undefined; p = p.parent) { - if (ts.isFunctionLike(p) || ts.isClassDeclaration(p) || ts.isClassExpression(p)) { - inFunction = true; - break; - } - } - const g = inFunction - ? cjsExportMemberWriteGlobalOf(lowerer, expr.left) - : cjsInFileExportGlobalOf(lowerer, expr.left); - if (g) { - const value = lowerer.lowerExprExpecting(expr.right, g.type); - const tmp = lowerer.declareHiddenLocal("%exportValue", value.type); - const result = varRef(tmp.id, value.type, loc); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: tmp.id, init: value, loc }, - { kind: "assign", localId: g.id, value: result, loc }, - ], - result, - type: value.type, - loc, - }; - } - } - const recv = tryLowerExpression(lowerer, expr.left.expression); - if (recv && recv.type.kind === "dyn") return lowerDynMemberAssignment(lowerer, expr, recv); - const unionWrite = lowerUnionFieldWrite(lowerer, expr.left, expr.right); - if (unionWrite) return unionWrite; - const field = lowerer.fieldTarget(expr.left); - if (field) { - const recvTmp = lowerer.declareHiddenLocal("%setReceiver", field.obj.type); - const receiver = field.obj; - field.obj = varRef(recvTmp.id, receiver.type, loc); - const value = lowerer.lowerExprExpecting(expr.right, field.fieldType); - const valTmp = lowerer.declareHiddenLocal("%setValue", value.type); - const result = varRef(valTmp.id, value.type, loc); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: recvTmp.id, init: receiver, loc }, - { kind: "varDecl", localId: valTmp.id, init: value, loc }, - lowerer.fieldSetStmt(field, result, loc, expr.left), - ], - result, - type: result.type, - loc, - }; - } - } - } - // Destructuring assignment in VALUE position (`(() => [i] = [i+1])()`, - // `({} = {x} = a)`, `var d = ([] = src)`): the statement machinery's - // parts under a seqExpr — the expression's value is the RHS value - // (JS's GetValue of the right reference), which is exactly the - // parts' hidden temp. - if ( - op === ts.SyntaxKind.EqualsToken && - (ts.isObjectLiteralExpression(expr.left) || ts.isArrayLiteralExpression(expr.left)) - ) { - const parts = lowerer.lowerDestructuringAssignParts(expr.left, expr.right, loc); - return { - kind: "seqExpr", - stmts: parts.stmts, - result: parts.value, - type: parts.value.type, - loc, - }; - } - lowerer.unsupported( - "SC1090", - expr, - op === ts.SyntaxKind.EqualsToken - ? "assignment to non-variables as an expression (only `x = e` over a variable yields a value; write property/destructuring assignments as statements)" - : "compound assignment as an expression (write `x op= e` as a statement, or spell out `x = x op e`)", - ); - } - if (op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken) { - const nullTest = lowerLooseNullCompare(lowerer, expr, loc); - if (nullTest) return nullTest; - const loose = lowerAbstractEquality( - lowerer, - deferredSlotOr(lowerer, lowerer.lowerExpr(expr.left)), - deferredSlotOr(lowerer, lowerer.lowerExpr(expr.right)), - op === ts.SyntaxKind.ExclamationEqualsToken, - loc, - ); - if (loose) return loose; - lowerer.unsupported( - "SC1040", - expr, - undefined, - "object-to-primitive loose equality can call user-defined valueOf/toString methods — compare explicit primitive conversions instead", - ); - } - if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) { - return lowerLogicalChain(lowerer, expr); - } - if (op === ts.SyntaxKind.QuestionQuestionToken) { - return lowerer.lowerNullishCoalesce(expr, loc); - } - if (op === ts.SyntaxKind.CommaToken) { - // Comma expression in VALUE position (`r = ({} = a, [] = a)` — the - // conformance corpus's paired-destructuring idiom): the left operand - // runs for EFFECT exactly as its statement lowering (JS discards its - // value), the right operand is the expression's value — a seqExpr. - // The validator restricts seqExpr statements to straight-line writes - // (the lowering point is mid-expression), so a left operand whose - // statement lowering needs real control flow keeps a pointed fence - // instead of tripping the validator downstream. - const effect = lowerer.lowerExprStatement(expr.left); - if (!seqExprSafeStmt(effect)) { - lowerer.unsupported( - "SC1090", - expr.left, - "comma expressions whose left operand needs control flow (run it as its own statement first)", - ); - } - const result = lowerer.lowerExpr(expr.right); - return { kind: "seqExpr", stmts: [effect], result, type: result.type, loc }; - } - if (op === ts.SyntaxKind.InstanceOfKeyword) return lowerer.lowerInstanceOf(expr, loc); - if (op === ts.SyntaxKind.InKeyword) { - return lowerInExpression(lowerer, expr, loc); - } - // `typeof e === "string"` on a catch binding or an `unknown` value: a - // runtime kind test — intercepted BEFORE the operands lower (a raw - // read of the binding is the narrowness fence, and bare `typeof e` - // has no lowering). - if ( - op === ts.SyntaxKind.EqualsEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsEqualsToken - ) { - const test = - lowerConstructorGlobalTypeofTest(lowerer, expr, loc) ?? - lowerWebAssemblyTypeofTest(lowerer, expr, loc) ?? - lowerErrorCodeTypeofTest(lowerer, expr, loc) ?? - lowerer.lowerCaughtTypeofTest(expr, loc) ?? - lowerDynTypeofTest(lowerer, expr, loc) ?? - lowerUnionTypeofTest(lowerer, expr, loc); - if (test) return test; - } - // `C === EventEmitter` between two builtin class references: the - // classes are compile-time values, so the answer folds. - if ( - op === ts.SyntaxKind.EqualsEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsEqualsToken - ) { - const leftClass = builtinClassRefOf(lowerer, expr.left); - const rightClass = leftClass === null ? null : builtinClassRefOf(lowerer, expr.right); - if (leftClass !== null && rightClass !== null) { - const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - const same = sameBuiltinClass(leftClass, rightClass); - return { kind: "boolLit", value: same !== negated, type: BOOL, loc }; - } - } - - const strictEquality = - op === ts.SyntaxKind.EqualsEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - // An identifier whose storage is runtime-optional is compared by its - // stored value: the checker's narrowing to the present arm is not a - // runtime proof. Identifier reads are pure, so taking the stored value - // after both operands lowered keeps the evaluation order. A union on - // the other side keeps the narrowed read (union-to-union comparison - // has no lowering). - let left = strictEquality - ? (lowerAbsenceProbe(lowerer, expr.left) ?? lowerer.lowerExpr(expr.left)) - : lowerer.lowerExpr(expr.left); - let right = strictEquality - ? (lowerAbsenceProbe(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right)) - : lowerer.lowerExpr(expr.right); - if (strictEquality) { - const storedLeft = runtimeOptionalStorageOperand(lowerer, expr.left); - const storedRight = runtimeOptionalStorageOperand(lowerer, expr.right); - if (storedLeft !== null && right.type.kind !== "union") left = storedLeft; - if (storedRight !== null && left.type.kind !== "union") right = storedRight; - // A typed-array read compared with a plain number needs no - // `number | undefined` box. - const fusedLeft = tryLowerBytesReadNumberEquality(lowerer, left, right, expr.right); - if (fusedLeft) left = fusedLeft; - else { - const fusedRight = tryLowerBytesReadNumberEquality(lowerer, right, left, expr.left); - if (fusedRight) right = fusedRight; - } - } - if (strictEquality && (left.type.kind === "caught" || right.type.kind === "caught")) { - if (left.type.kind === "caught") - left = { kind: "caughtToDyn", value: left, type: DYN, loc: left.loc }; - if (right.type.kind === "caught") - right = { kind: "caughtToDyn", value: right, type: DYN, loc: right.loc }; - } - if (left.type.kind === "dyn" || right.type.kind === "dyn") { - // The narrowing unit comparisons ARE answerable on unknown: `v === - // undefined` / `v !== null` test the dyn node's kind directly, and - // tsc's control flow narrows the branches (reads then bridge through - // maybeNarrow's validated extraction). - if ( - op === ts.SyntaxKind.EqualsEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsEqualsToken - ) { - const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - const unit = left.kind === "unitLit" ? left : right.kind === "unitLit" ? right : null; - const other = left.kind === "unitLit" ? right : left; - if (unit && other.type.kind === "dyn") { - return { - kind: "dynTest", - test: unit.unit, - ...(negated ? { negated: true as const } : {}), - value: other, - type: BOOL, - loc, - }; - } - // dyn vs SCALAR strict equality (`value !== ""`, `u === 5` — the - // normalizePricing filter): a guarded kind test + payload compare, - // JS-exact (strict equality never coerces; a non-matching dyn kind - // answers false). dyn vs dyn keeps the fence below. - const dynSide = left.type.kind === "dyn" ? left : right; - const scalarSide = dynSide === left ? right : left; - const reference = scalarSide.type; - if ( - (reference.kind === "object" || - reference.kind === "record" || - reference.kind === "array" || - reference.kind === "bytes" || - reference.kind === "func" || - reference.kind === "classval" || - reference.kind === "regex" || - reference.kind === "url" || - reference.kind === "bigint" || - reference.kind === "symbol" || - reference.kind === "map" || - reference.kind === "set" || - reference.kind === "union" || - DYN_HANDLE_KINDS.has(reference.kind)) && - lowerer.dynConvertible(reference) - ) { - // Records, arrays and byte views compare by the capsule identity - // their dyn reads produce, including when they arrive through a - // union (an optional array field read from a typed record). - const liveArm = (type: IrType): boolean => { - if (type.kind === "array" || type.kind === "bytes") return true; - const shape = type.kind === "record" ? lowerer.shapes.get(type.shapeId) : undefined; - return !!shape && (shape.fields.length > 0 || shape.indexValue !== undefined); - }; - const boxed: IrExpr = { - kind: "dynFrom", - value: scalarSide, - type: DYN, - loc: scalarSide.loc, - ...(reference.kind === "record" || - liveArm(reference) || - (reference.kind === "union" && - (lowerer.unions.get(reference.unionId)?.arms.some(liveArm) ?? false)) - ? { liveRef: true as const } - : {}), - }; - return { - kind: "dynScalarEq", - left: scalarSide === left ? boxed : left, - right: scalarSide === right ? boxed : right, - ...(negated ? { negated: true as const } : {}), - type: BOOL, - loc, - }; - } - if ( - dynSide.type.kind === "dyn" && - (scalarSide.type.kind === "f64" || - scalarSide.type.kind === "string" || - scalarSide.type.kind === "bool" || - // dyn vs dyn (`context.actual !== context.exact` — - // test/common's exit accounting): the runtime's whole-dyn - // strict equality — scalars by value, units by kind, - // reference kinds by identity (scr_dyn_strict_eq). - scalarSide.type.kind === "dyn") - ) { - return { - kind: "dynScalarEq", - left, - right, - ...(negated ? { negated: true as const } : {}), - type: BOOL, - loc, - }; - } - } - const bitwise: Partial> = { - [ts.SyntaxKind.AmpersandToken]: "&", - [ts.SyntaxKind.BarToken]: "|", - [ts.SyntaxKind.CaretToken]: "^", - [ts.SyntaxKind.LessThanLessThanToken]: "<<", - [ts.SyntaxKind.GreaterThanGreaterThanToken]: ">>", - [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: ">>>", - }; - if (bitwise[op]) { - const l = lowerer.coerceToExpected(left, DYN); - const r = lowerer.coerceToExpected(right, DYN); - if (l.type.kind === "dyn" && r.type.kind === "dyn") { - const value: IrExpr = { - kind: "libCall", - fn: "dyn.bitwise", - args: [l, r, { kind: "strLit", value: bitwise[op]!, type: STRING, loc }], - type: DYN, - loc, - }; - return left.type.kind === "f64" || right.type.kind === "f64" - ? { kind: "dynCheck", value, type: F64, loc } - : value; - } - } - // Numeric operators dispatch on ToNumeric's actual Number/BigInt - // result, including values flowing through native callable properties. - if ( - isJsSourceFile(expr.getSourceFile()) || - op === ts.SyntaxKind.PlusToken || - [ - ts.SyntaxKind.LessThanToken, - ts.SyntaxKind.LessThanEqualsToken, - ts.SyntaxKind.GreaterThanToken, - ts.SyntaxKind.GreaterThanEqualsToken, - ].includes(op) || - [ - ts.SyntaxKind.MinusToken, - ts.SyntaxKind.AsteriskToken, - ts.SyntaxKind.SlashToken, - ts.SyntaxKind.PercentToken, - ts.SyntaxKind.AsteriskAsteriskToken, - ].includes(op) - ) { - const other = left.type.kind === "dyn" ? right : left; - const NUM_BIN: Partial> = { - [ts.SyntaxKind.MinusToken]: "-", - [ts.SyntaxKind.AsteriskToken]: "*", - [ts.SyntaxKind.SlashToken]: "/", - [ts.SyntaxKind.PercentToken]: "%", - [ts.SyntaxKind.AsteriskAsteriskToken]: "**", - }; - const NUM_CMP: Partial" | ">=">> = { - [ts.SyntaxKind.LessThanToken]: "<", - [ts.SyntaxKind.LessThanEqualsToken]: "<=", - [ts.SyntaxKind.GreaterThanToken]: ">", - [ts.SyntaxKind.GreaterThanEqualsToken]: ">=", - }; - const arith = NUM_BIN[op]; - const cmp = NUM_CMP[op]; - if (arith) { - const l = lowerer.coerceToExpected(left, DYN); - const r = lowerer.coerceToExpected(right, DYN); - if (l.type.kind === "dyn" && r.type.kind === "dyn") { - const value: IrExpr = { - kind: "libCall", - fn: "dyn.arithmetic", - args: [l, r, { kind: "strLit", value: arith, type: STRING, loc }], - type: DYN, - loc, - }; - const numeric = - left.type.kind === "bigint" || right.type.kind === "bigint" - ? BIGINT_T - : left.type.kind === "f64" || right.type.kind === "f64" - ? F64 - : null; - return numeric ? { kind: "dynCheck", value, type: numeric, loc } : value; - } - } - if (cmp && lowerer.dynConvertible(left.type) && lowerer.dynConvertible(right.type)) { - return { - kind: "libCall", - fn: "dyn.compare", - args: [ - lowerer.coerceToExpected(left, DYN), - lowerer.coerceToExpected(right, DYN), - { kind: "strLit", value: cmp, type: STRING, loc }, - ], - type: BOOL, - loc, - }; - } - // Untyped addition chooses concatenation or numeric addition only - // after both operands have undergone ToPrimitive at runtime. - if (op === ts.SyntaxKind.PlusToken) { - const l = lowerer.coerceToExpected(left, DYN); - const r = lowerer.coerceToExpected(right, DYN); - if (l.type.kind === "dyn" && r.type.kind === "dyn") { - const value: IrExpr = { kind: "libCall", fn: "dyn.add", args: [l, r], type: DYN, loc }; - // A statically known string operand guarantees a string result. - return other.type.kind === "string" - ? { kind: "dynCheck", value, type: STRING, loc } - : value; - } - } - } - // Unsupported `any`-origin operations get their specific diagnostic; - // unknown retains the narrowing diagnostic below. - if ( - (left.type.kind === "dyn" && lowerer.anyOrigin(expr.left)) || - (right.type.kind === "dyn" && lowerer.anyOrigin(expr.right)) - ) { - // `JSON.parse` and other checked-dynamic producers deliberately - // remain dyn when assigned to an `any` local: eagerly converting - // the binding would deep-copy its data and sever aliases. At an - // unsupported operator, require an explicit represented type. - lowerer.anyOpFence(`the '${ts.tokenToString(op) ?? ts.syntaxKindName(op)}' operator`, expr); - } - // tsc allows ===/!== on unknown (arithmetic/comparisons it rejects - // itself); a dynamic equality would need a dyn walk — validate first. - lowerer.unsupported("SC1100", expr, "operators on 'unknown' values"); - } - - if (left.type.kind === "bigint" && right.type.kind === "bigint") { - const binary: Partial< - Record< - ts.SyntaxKind, - | "bigint.add" - | "bigint.sub" - | "bigint.mul" - | "bigint.div" - | "bigint.mod" - | "bigint.pow" - | "bigint.and" - | "bigint.or" - | "bigint.xor" - | "bigint.shl" - | "bigint.shr" - > - > = { - [ts.SyntaxKind.PlusToken]: "bigint.add", - [ts.SyntaxKind.MinusToken]: "bigint.sub", - [ts.SyntaxKind.AsteriskToken]: "bigint.mul", - [ts.SyntaxKind.SlashToken]: "bigint.div", - [ts.SyntaxKind.PercentToken]: "bigint.mod", - [ts.SyntaxKind.AsteriskAsteriskToken]: "bigint.pow", - [ts.SyntaxKind.AmpersandToken]: "bigint.and", - [ts.SyntaxKind.BarToken]: "bigint.or", - [ts.SyntaxKind.CaretToken]: "bigint.xor", - [ts.SyntaxKind.LessThanLessThanToken]: "bigint.shl", - [ts.SyntaxKind.GreaterThanGreaterThanToken]: "bigint.shr", - }; - const fn = binary[op]; - if (fn) return { kind: "libCall", fn, args: [left, right], type: BIGINT_T, loc }; - if ( - op === ts.SyntaxKind.EqualsEqualsEqualsToken || - op === ts.SyntaxKind.ExclamationEqualsEqualsToken - ) { - const equal: IrExpr = { - kind: "libCall", - fn: "bigint.eq", - args: [left, right], - type: BOOL, - loc, - }; - return op === ts.SyntaxKind.ExclamationEqualsEqualsToken - ? { kind: "unary", op: "!", operand: equal, type: BOOL, loc } - : equal; - } - if ( - op === ts.SyntaxKind.LessThanToken || - op === ts.SyntaxKind.LessThanEqualsToken || - op === ts.SyntaxKind.GreaterThanToken || - op === ts.SyntaxKind.GreaterThanEqualsToken - ) { - const cmp: IrExpr = { - kind: "libCall", - fn: "bigint.cmp", - args: [left, right], - type: F64, - loc, - }; - const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; - const cmpOp = - op === ts.SyntaxKind.LessThanToken - ? "<" - : op === ts.SyntaxKind.LessThanEqualsToken - ? "<=" - : op === ts.SyntaxKind.GreaterThanToken - ? ">" - : ">="; - return { kind: "bin", op: cmpOp, left: cmp, right: zero, type: BOOL, loc }; - } - } - if ( - (left.type.kind === "bigint" && right.type.kind === "f64") || - (left.type.kind === "f64" && right.type.kind === "bigint") - ) { - if ( - op === ts.SyntaxKind.LessThanToken || - op === ts.SyntaxKind.LessThanEqualsToken || - op === ts.SyntaxKind.GreaterThanToken || - op === ts.SyntaxKind.GreaterThanEqualsToken - ) { - const big = left.type.kind === "bigint" ? left : right; - const num = left.type.kind === "f64" ? left : right; - const cmp: IrExpr = { - kind: "libCall", - fn: "bigint.cmpNumber", - args: [big, num], - type: F64, - loc, - }; - const cmpLocal = lowerer.declareHiddenLocal("%bigcmp", F64); - const cmpRef: IrExpr = { kind: "varRef", localId: cmpLocal.id, type: F64, loc }; - const unordered: IrExpr = { - kind: "bin", - op: "===", - left: cmpRef, - right: { kind: "numLit", value: 2, type: F64, loc }, - type: BOOL, - loc, - }; - const zero: IrExpr = { kind: "numLit", value: 0, type: F64, loc }; - const leftBig = left.type.kind === "bigint"; - const cmpOp = leftBig - ? op === ts.SyntaxKind.LessThanToken - ? "<" - : op === ts.SyntaxKind.LessThanEqualsToken - ? "<=" - : op === ts.SyntaxKind.GreaterThanToken - ? ">" - : ">=" - : op === ts.SyntaxKind.LessThanToken - ? ">" - : op === ts.SyntaxKind.LessThanEqualsToken - ? ">=" - : op === ts.SyntaxKind.GreaterThanToken - ? "<" - : "<="; - const result: IrExpr = { - kind: "logical", - op: "&&", - left: { kind: "unary", op: "!", operand: unordered, type: BOOL, loc }, - right: { kind: "bin", op: cmpOp, left: cmpRef, right: zero, type: BOOL, loc }, - type: BOOL, - loc, - }; - return { - kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: cmpLocal.id, init: cmp, loc }], - result, - type: BOOL, - loc, - }; - } - } - // An unchecked array read keeps its undefined arm in the IR even when - // the checker typed the binding as `string`. An optional string is not - // itself a string operand for `+`: `undefined + 1` and - // `undefined + undefined` are NaN, while a present string concatenates. - // Keep this predicate exact so a nullable string does not take the - // string path either. - const optionalStringType = (type: IrType): boolean => { - if (type.kind !== "union") return false; - const arms = lowerer.unions.get(type.unionId)?.arms; - return ( - !!arms && - arms.length === 2 && - arms.some((arm) => arm.kind === "string") && - arms.some((arm) => arm.kind === "undefinedT") - ); - }; - const arithmeticNumber = (operand: IrExpr, node: ts.Expression): IrExpr => - lowerOptionalNumber(lowerer, operand, loc, node); - const arithmeticLeft = strictEquality ? left : arithmeticNumber(left, expr.left); - const arithmeticRight = strictEquality ? right : arithmeticNumber(right, expr.right); - const bothNum = arithmeticLeft.type.kind === "f64" && arithmeticRight.type.kind === "f64"; - const bothStr = left.type.kind === "string" && right.type.kind === "string"; - - const lowerOptionalStringPlus = (): IrExpr | null => { - const leftOptional = optionalStringType(left.type); - const rightOptional = optionalStringType(right.type); - if (!leftOptional && !rightOptional) return null; - const allowed = (type: IrType): boolean => - type.kind === "f64" || type.kind === "string" || optionalStringType(type); - if (!allowed(left.type) || !allowed(right.type)) return null; - - // The tag tests and the chosen concatenation branch must see the same - // operand values. This matters for `take()[0] + 1` and for two reads - // with observable getters/calls. - const prefix: IrStmt[] = []; - const stable = (operand: IrExpr): IrExpr => { - if (isSafeToRepeat(operand) || isSafeToDiscard(operand)) return operand; - const tmp = lowerer.declareHiddenLocal("%plus", operand.type); - prefix.push({ kind: "varDecl", localId: tmp.id, init: operand, loc }); - return varRef(tmp.id, operand.type, loc); - }; - const stableLeft = stable(left); - const stableRight = stable(right); - const leftString = lowerer.ensureString(stableLeft, expr.left); - const rightString = lowerer.ensureString(stableRight, expr.right); - const concat: IrExpr = { - kind: "strConcat", - left: leftString, - right: rightString, - type: STRING, - loc, - }; - const nan: IrExpr = { - kind: "bin", - op: "/", - left: numLit(0, loc), - right: numLit(0, loc), - type: F64, - loc, - }; - const resultT: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, STRING]) }; - lowerer.runtimeOptionalArithmeticTypes.set(expr, resultT); - const numberTag = lowerer.armTag(resultT.unionId, F64); - const stringTag = lowerer.armTag(resultT.unionId, STRING); - if (numberTag < 0 || stringTag < 0) - throw new InternalCompilerError("lowerer bug: optional string plus result union"); - const number = (): IrExpr => ({ - kind: "unionWrap", - unionId: resultT.unionId, - tag: numberTag, - value: nan, - type: resultT, - loc, - }); - const string = (): IrExpr => ({ - kind: "unionWrap", - unionId: resultT.unionId, - tag: stringTag, - value: concat, - type: resultT, - loc, - }); - const undefinedTest = (operand: IrExpr): IrExpr => ({ - kind: "unionIsTag", - unionId: operand.type.kind === "union" ? operand.type.unionId : "", - tag: operand.type.kind === "union" ? lowerer.armTag(operand.type.unionId, UNDEFINED_T) : -1, - negated: false, - value: operand, - type: BOOL, - loc, - }); - let result: IrExpr; - if (leftOptional && rightOptional) { - const rightMissing = undefinedTest(stableRight); - const bothMissing: IrExpr = { - kind: "ternary", - cond: rightMissing, - then: number(), - else_: string(), - type: resultT, - loc, - }; - result = { - kind: "ternary", - cond: undefinedTest(stableLeft), - then: bothMissing, - else_: string(), - type: resultT, - loc, - }; - } else if (leftOptional) { - result = { - kind: "ternary", - cond: undefinedTest(stableLeft), - then: number(), - else_: string(), - type: resultT, - loc, - }; - } else { - result = { - kind: "ternary", - cond: undefinedTest(stableRight), - then: number(), - else_: string(), - type: resultT, - loc, - }; - } - return prefix.length === 0 - ? result - : { kind: "seqExpr", stmts: prefix, result, type: resultT, loc }; - }; - - switch (op) { - case ts.SyntaxKind.PlusToken: - if (bothNum) - return { - kind: "bin", - op: "+", - left: arithmeticLeft, - right: arithmeticRight, - type: F64, - loc, - }; - if (left.type.kind === "string" || right.type.kind === "string") { - return { - kind: "strConcat", - left: concatOperand(lowerer, left, expr.left), - right: concatOperand(lowerer, right, expr.right), - type: STRING, - loc, - }; - } - const optionalStringPlus = lowerOptionalStringPlus(); - if (optionalStringPlus) return optionalStringPlus; - { - const primitive = (type: IrType): boolean => - type.kind === "f64" || - type.kind === "string" || - type.kind === "bool" || - isUnitType(type) || - (type.kind === "union" && - (lowerer.unions.get(type.unionId)?.arms.every(primitive) ?? false)); - if ( - (left.type.kind === "union" || right.type.kind === "union") && - primitive(left.type) && - primitive(right.type) - ) { - lowerer.runtimeOptionalArithmeticTypes.set(expr, DYN); - return { - kind: "libCall", - fn: "dyn.add", - args: [lowerer.coerceToExpected(left, DYN), lowerer.coerceToExpected(right, DYN)], - type: DYN, - loc, - }; - } - } - operatorUnsupported(lowerer, expr, "+", [left.type, right.type]); - break; - case ts.SyntaxKind.MinusToken: - case ts.SyntaxKind.AsteriskToken: - case ts.SyntaxKind.SlashToken: - case ts.SyntaxKind.PercentToken: - case ts.SyntaxKind.AsteriskAsteriskToken: { - if (!bothNum) - operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]); - const binOp = - op === ts.SyntaxKind.MinusToken - ? "-" - : op === ts.SyntaxKind.AsteriskToken - ? "*" - : op === ts.SyntaxKind.SlashToken - ? "/" - : op === ts.SyntaxKind.PercentToken - ? "%" - : "**"; - return { - kind: "bin", - op: binOp, - left: arithmeticLeft, - right: arithmeticRight, - type: F64, - loc, - }; - } - // The bitwise six, JS-exact: operands through ToInt32/ToUint32 - // (NaN/±Infinity → 0, truncate, wrap mod 2^32), the operation in - // 32-bit space (shift counts masked to 5 bits), the result back to - // f64 — `>>>` as Uint32, the rest as Int32 (runtime scr_bit_*). - case ts.SyntaxKind.AmpersandToken: - case ts.SyntaxKind.BarToken: - case ts.SyntaxKind.CaretToken: - case ts.SyntaxKind.LessThanLessThanToken: - case ts.SyntaxKind.GreaterThanGreaterThanToken: - case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: { - if (!bothNum) - operatorUnsupported(lowerer, expr, ts.tokenToString(op) ?? "?", [left.type, right.type]); - const bitOp = - op === ts.SyntaxKind.AmpersandToken - ? "&" - : op === ts.SyntaxKind.BarToken - ? "|" - : op === ts.SyntaxKind.CaretToken - ? "^" - : op === ts.SyntaxKind.LessThanLessThanToken - ? "<<" - : op === ts.SyntaxKind.GreaterThanGreaterThanToken - ? ">>" - : ">>>"; - return { - kind: "bin", - op: bitOp, - left: arithmeticLeft, - right: arithmeticRight, - type: F64, - loc, - }; - } - case ts.SyntaxKind.EqualsEqualsEqualsToken: - case ts.SyntaxKind.ExclamationEqualsEqualsToken: - return lowerStrictEquality( - lowerer, - expr, - expr.left, - expr.right, - left, - right, - op === ts.SyntaxKind.ExclamationEqualsEqualsToken, - loc, - ); - case ts.SyntaxKind.LessThanToken: - case ts.SyntaxKind.LessThanEqualsToken: - case ts.SyntaxKind.GreaterThanToken: - case ts.SyntaxKind.GreaterThanEqualsToken: { - const cmpOp = - op === ts.SyntaxKind.LessThanToken - ? "<" - : op === ts.SyntaxKind.LessThanEqualsToken - ? "<=" - : op === ts.SyntaxKind.GreaterThanToken - ? ">" - : ">="; - if (bothNum) - return { - kind: "bin", - op: cmpOp, - left: arithmeticLeft, - right: arithmeticRight, - type: BOOL, - loc, - }; - if (bothStr) return { kind: "strCmp", op: cmpOp, left, right, type: BOOL, loc }; - // A Date converts to a new checked-dynamic Date on each crossing, so - // comparisons keep their Date fence instead of comparing copies. - if ( - left.type.kind !== "date" && - right.type.kind !== "date" && - lowerer.dynConvertible(left.type) && - lowerer.dynConvertible(right.type) - ) { - return { - kind: "libCall", - fn: "dyn.compare", - args: [ - lowerer.coerceToExpected(left, DYN), - lowerer.coerceToExpected(right, DYN), - { kind: "strLit", value: cmpOp, type: STRING, loc }, - ], - type: BOOL, - loc, - }; - } - lowerer.unsupported("SC1043", expr); - break; - } - default: - break; - } - lowerer.unsupported( - "SC1090", - expr, - `operator '${ts.tokenToString(op) ?? ts.syntaxKindName(op)}'`, - ); -} - -/** `left === right` (`!==` when negated) over lowered operands: JS strict - * equality for every pair of static kinds the program can compare. - * `node` anchors diagnostics, and `leftNode`/`rightNode` the coercions. */ -export function lowerStrictEquality( - lowerer: Lowerer, - node: ts.Node, - leftNode: ts.Node, - rightNode: ts.Node, - left: IrExpr, - right: IrExpr, - negated: boolean, - loc: SrcLoc, -): IrExpr { - const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; - const bothStr = left.type.kind === "string" && right.type.kind === "string"; - const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); - if (indexed) return indexed; - if (plainBothNum) - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - if (bothStr) { - if (left.kind === "strLit" && right.kind === "strLit") - return { - kind: "boolLit", - value: (left.value === right.value) !== negated, - type: BOOL, - loc, - }; - return { kind: "strEq", negated, left, right, type: BOOL, loc }; - } - // bool === bool: a plain value compare (the config-drift checks' - // `desired.lanMode !== actual.lanMode` shape). - if (left.type.kind === "bool" && right.type.kind === "bool") { - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - } - const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); - if (unitTest) return unitTest; - if (left.type.kind === "union" || right.type.kind === "union") { - const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); - if (compared) return compared; - const aliasRefusal = (type: IrType): void => { - if (tagEqualityMayMissAlias(lowerer, type)) - lowerer.unsupported( - "SC1090", - node, - `comparisons of related class values beside structural union members (${NARROW_FIRST})`, - ); - }; - - // JS strict equality of the ARM values, per union tag (a - // per-union helper): equal tags compare payloads (f64 ==, string - // bytes, bool ==, ref-arm POINTER identity), different tags are - // never equal. Two shapes lower: same union on both sides, and - // union vs plain arm value (`u === "text"` where the checker - // didn't narrow) — the plain side wraps into the union, which - // preserves payload identity. Different unions (tsc admits - // partially-overlapping ones) still need narrowing first. - 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); - const plainFunc = left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; - const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; - const funcArm = unionArms.find((arm) => arm.kind === "func"); - // `listeners()` reports original prefix callbacks, but the - // checker views an indexed result as function | undefined. For - // identity comparison, retain the original closure pointer rather - // than adapting it to the tuple signature. - if ( - !bothUnion && - plainFunc !== null && - funcArm?.kind === "func" && - unionArms.filter((arm) => arm.kind === "func").length === 1 && - unionArms.some(isUnitType) - ) { - return { - kind: "unionFuncEq", - unionId: ut.unionId, - tag: lowerer.armTag(ut.unionId, funcArm), - union: left.type.kind === "union" ? left : right, - func: plainFunc, - negated, - type: BOOL, - loc, - }; - } - if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { - aliasRefusal(ut); - return { - kind: "unionEq", - unionId: ut.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(leftNode, left, ut), - right: lowerer.coerceInto(rightNode, right, ut), - type: BOOL, - loc, - }; - } - if (left.type.kind === "union" && right.type.kind === "union") { - const leftDef = lowerer.unions.get(left.type.unionId); - const rightDef = lowerer.unions.get(right.type.unionId); - if (leftDef && rightDef) { - const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); - const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); - const sameValueArms = - leftValues.length === rightValues.length && - leftValues.every((arm) => rightValues.some((candidate) => typeEquals(candidate, arm))); - if (sameValueArms) { - const arms = [...leftDef.arms]; - for (const arm of rightDef.arms) { - if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); - } - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - const common: IrType & { kind: "union" } = { - kind: "union", - unionId: lowerer.unions.intern(arms), - }; - aliasRefusal(common); - return { - kind: "unionEq", - unionId: common.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(leftNode, left, common), - right: lowerer.coerceInto(rightNode, right, common), - type: BOOL, - loc, - }; - } - } - } - lowerer.unsupported("SC1090", node, `comparisons of union-typed values (${NARROW_FIRST})`); - } - // Arrays, maps, functions, class instances and records compare by - // reference identity (pointer compare) — JS-exact object equality. - // Hierarchy-related classes compare after widening the derived side - // (same pointer either way — prefix layout). - let idLeft = left; - let idRight = right; - if (left.type.kind === "object" && right.type.kind === "object") { - if (lowerer.isSubclassOf(left.type.className, right.type.className)) { - idLeft = lowerer.upcastTo(left, right.type.className); - } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { - idRight = lowerer.upcastTo(right, left.type.className); - } - } - // Two function values compare by identity whatever their STATIC - // signatures (tsc admits the comparison when one side is - // assignable to the other — a tuple-typed listeners() element - // against a prefix-declared handler): the pointer answers it. - if (idLeft.type.kind === "func" && idRight.type.kind === "func") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Two CLASS values compare by identity whatever their static - // classes — one immortal object per class, one pointer compare - // (the function-identity rule verbatim; `X === D` through a - // base-typed slot answers exactly JS). - if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if ( - (idLeft.type.kind === "array" || - idLeft.type.kind === "map" || - idLeft.type.kind === "set" || - idLeft.type.kind === "regex" || - idLeft.type.kind === "url" || - idLeft.type.kind === "object" || - idLeft.type.kind === "record" || - // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, - // a symbol equals exactly itself, and Symbol.for's interned - // values compare equal across call sites — all one pointer - // compare, JS's spec without approximation. - idLeft.type.kind === "symbol" || - // Typed arrays / Buffers ARE objects to ===: pointer identity - // (buf === buf.swap16() — the in-place mutators return this). - idLeft.type.kind === "bytes" || - idLeft.type.kind === "promise") && - typeEquals(idLeft.type, idRight.type) - ) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Runtime HANDLES are objects to === too: one handle per socket/ - // request/response, so pointer identity IS JS's object equality - // (`c.pause() === c` — Node's chaining assertions). */ - if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // A Date converts to a new checked-dynamic Date on each crossing; its - // identity has no representation, so equality keeps the fence. - if ( - idLeft.type.kind !== "date" && - idRight.type.kind !== "date" && - lowerer.dynConvertible(idLeft.type) && - lowerer.dynConvertible(idRight.type) - ) { - return { - kind: "dynScalarEq", - left: lowerer.coerceToExpected(idLeft, DYN), - right: lowerer.coerceToExpected(idRight, DYN), - ...(negated ? { negated: true as const } : {}), - type: BOOL, - loc, - }; - } - return lowerer.unsupported("SC1043", node); -} - -function lowerLogicalChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { - // Logical chains have a left-nested AST. Keep the native stack bounded - // while retaining each pair's checker type and lazy operand evaluation. - const parents: ts.BinaryExpression[] = []; - let current = expr; - let result: IrExpr; - while (true) { - if (current.operatorToken.kind === ts.SyntaxKind.BarBarToken) { - const fallback = lowerAbsenceAwareOrChain(lowerer, current); - if (fallback !== null) { - result = fallback; - break; - } - } - const left = current.left; - if ( - ts.isBinaryExpression(left) && - (left.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || - left.operatorToken.kind === ts.SyntaxKind.BarBarToken) && - !lowerer.chainRecvByNode.has(left) - ) { - parents.push(current); - current = left; - continue; - } - result = lowerLogicalPair( - lowerer, - current, - current.operatorToken.kind === ts.SyntaxKind.BarBarToken - ? lowerPresenceOperand(lowerer, left) - : (lowerAbsenceProbe(lowerer, left) ?? lowerer.lowerExpr(left)), - ); - break; - } - for (let i = parents.length - 1; i >= 0; i--) { - result = lowerLogicalPair(lowerer, parents[i]!, result); - } - return result; -} - -function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrExpr): IrExpr { - const op = expr.operatorToken.kind; - const loc = locOf(expr); - // Preserve short-circuiting during lowering too: an unreachable member - // on a specialized value must not poison the surrounding JS statement. - if (left.kind === "boolLit") { - const takeRight = op === ts.SyntaxKind.AmpersandAmpersandToken ? left.value : !left.value; - return takeRight ? lowerer.lowerExpr(expr.right) : left; - } - if (left.kind === "unitLit" && op === ts.SyntaxKind.AmpersandAmpersandToken) return left; - if (op === ts.SyntaxKind.BarBarToken && REF_TRUTHY_KINDS.has(left.type.kind)) return left; - const right = lowerer.lowerExpr(expr.right); - // A LITERAL-unit left operand (a compile-time undefined/null — the - // capability-probe members: `process.features.inspector || - // !flag.startsWith('--inspect')` reads undefined in a compiled - // binary): statically falsy, so the operator folds JS-exactly — - // `unit || X` IS X, `unit && X` IS the unit (X's side effects never - // run in JS either; the lowered right is simply dropped). Literal - // units only — computed unit-typed values keep the fences below. - if (left.kind === "unitLit") { - return op === ts.SyntaxKind.BarBarToken ? right : left; - } - // JS objects and symbols are always truthy. In `ref || fallback` - // the fallback is therefore unreachable and the result is the - // already-evaluated left value — the browser-fallback idiom used by - // packages for `process.argv || []` and `process.env || {}`. The - // operand itself remains in the IR, preserving reads/calls that - // produce the reference; only the unreachable right lowering drops. - if (op === ts.SyntaxKind.BarBarToken && REF_TRUTHY_KINDS.has(left.type.kind)) { - return left; - } - if (left.type.kind === "dyn" || right.type.kind === "dyn") { - // A checked-dynamic operand (`fn.name || ''` — - // test/common's _mustCallInner): both sides live in the checked-dynamic tree and - // the deciding test is ToBoolean over the dyn kind - // (scr_dyn_truthy) — JS value semantics exactly, result dyn. The - // non-dyn side converts through the usual boundary; a value with - // no dyn representation keeps the fence. - const l = lowerer.coerceToExpected(left, DYN); - const r = lowerer.coerceToExpected(right, DYN); - if (l.type.kind !== "dyn" || r.type.kind !== "dyn") { - lowerer.unsupported("SC1100", expr, "logical operators on 'unknown' values"); - } - return { - kind: "logical", - op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", - left: l, - right: r, - type: DYN, - loc, - }; - } - - if ( - isJsSourceFile(expr.getSourceFile()) && - op === ts.SyntaxKind.AmpersandAmpersandToken && - left.type.kind === "union" && - lowerer.unions.get(left.type.unionId)?.arms.some((arm) => arm.kind === "bool") && - !typeEquals(left.type, right.type) && - lowerer.dynConvertible(left.type) && - lowerer.dynConvertible(right.type) - ) { - return { - kind: "logical", - op: "&&", - left: lowerer.coerceToExpected(left, DYN), - right: lowerer.coerceToExpected(right, DYN), - type: DYN, - loc, - }; - } - if (left.type.kind === "union" || right.type.kind === "union") { - // Test the deciding operand in its own representation. The result - // contains only the values that can survive short-circuit evaluation. - let target = lowerer.mapTypeOf(lowerer.typeOf(expr)); - // Index reads can be absent without noUncheckedIndexedAccess. Their - // explicit nullish fallback still runs, even if the checker discarded it. - if (target !== null) { - const returned = [ - right.type, - ...(op === ts.SyntaxKind.AmpersandAmpersandToken ? [left.type] : []), - ]; - for (const type of returned) { - const arms = - type.kind === "union" ? (lowerer.unions.get(type.unionId)?.arms ?? []) : [type]; - for (const arm of arms) { - if (arm.kind === "undefinedT" && !isUnitType(target)) - target = lowerer.runtimeOptionalType(target); - if (arm.kind === "nullT") - target = withUnitArm(target, arm.kind, lowerer.unions) ?? target; - } - } - } - // A broad-JSDoc npm-static body can leave the CHECKER result `any` - // even after declaration-backed specialization has recovered both - // operands (`helpOption && args.find(...)` in commander). When the - // left consists only of always-truthy references and falsy units, - // the static result is exactly the right operand plus those units. - if ( - op === ts.SyntaxKind.AmpersandAmpersandToken && - target === null && - lowerer.implicitParamTypes !== null && - npmStaticPackageOfPath(expr.getSourceFile().fileName) !== null && - (lowerer.typeOf(expr).flags & ts.TypeFlags.Any) !== 0 && - left.type.kind === "union" - ) { - const leftArms = lowerer.unions.get(left.type.unionId)?.arms; - const rightArms = - right.type.kind === "union" ? lowerer.unions.get(right.type.unionId)?.arms : [right.type]; - const rightIsStatic = - rightArms?.every( - (arm) => arm.kind !== "dyn" && arm.kind !== "caught" && arm.kind !== "void", - ) ?? false; - if ( - leftArms && - rightArms && - rightIsStatic && - leftArms.some(isUnitType) && - leftArms.every((arm) => isUnitType(arm) || REF_TRUTHY_KINDS.has(arm.kind)) - ) { - const byKey = new Map(); - for (const arm of [...rightArms, ...leftArms.filter(isUnitType)]) { - byKey.set(typeKey(arm), arm); - } - const arms = [...byKey.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - if (arms.length > 1) { - const source = - right.type.kind === "union" ? lowerer.unions.get(right.type.unionId) : undefined; - target = { - kind: "union", - unionId: source ? lowerer.unions.transform(source, arms) : lowerer.unions.intern(arms), - }; - } - } - } - // `&&` over an always-truthy plain left operand (an array, object or - // function value) whose result union has no arm for it: every JS - // object is truthy, so `list && list[0]` evaluates the left for its - // effects and answers the right. Coercing the left into that union - // would trap at run time where Node answers the right operand. - if ( - op === ts.SyntaxKind.AmpersandAmpersandToken && - target?.kind === "union" && - REF_TRUTHY_KINDS.has(left.type.kind) && - lowerer.armTag(target.unionId, left.type) < 0 - ) { - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: left, loc }], - result: lowerer.coerceInto(expr.right, right, target), - type: target, - loc, - }; - } - if (target?.kind === "union") { - // `&&` whose left does NOT fit the result union: the checker - // built that result by DROPPING left arms which are always - // truthy (`Option | undefined && string | undefined` answers - // `string | undefined`, not `Option | string | undefined`). - // Evaluate the left once, test it in its own union, and retag it - // only on the falsy path. Any stranded reference arms are - // unreachable there because every JS object is truthy. - if ( - op === ts.SyntaxKind.AmpersandAmpersandToken && - left.type.kind === "union" && - !typeEquals(left.type, target) - ) { - const def = lowerer.unions.get(left.type.unionId); - const trappable = new Set(); - def?.arms.forEach((arm, tag) => { - if (lowerer.armTag(target.unionId, arm) < 0 && REF_TRUTHY_KINDS.has(arm.kind)) { - trappable.add(tag); - } - }); - const retag = - trappable.size === 0 - ? null - : lowerer.unionRetagHelper(left.type.unionId, target.unionId, loc, trappable); - if (retag) { - lowerer.requireTruthyUnion(left.type.unionId, expr); - const stmts: IrStmt[] = []; - let stable: IrExpr = left; - if (!isSafeToRepeat(left)) { - const local = lowerer.declareHiddenLocal("%and", left.type); - stmts.push({ kind: "varDecl", localId: local.id, init: left, loc }); - stable = varRef(local.id, left.type, loc); - } - const result: IrExpr = { - kind: "ternary", - cond: lowerer.ensureBool(stable, expr.left), - then: lowerer.coerceInto(expr.right, right, target), - else_: { kind: "call", callee: retag, args: [stable], type: target, loc }, - type: target, - loc, - }; - return stmts.length === 0 - ? result - : { kind: "seqExpr", stmts, result, type: target, loc }; - } - } - // `||` whose left does NOT fit the result union: the checker built - // that result by DROPPING the left's falsy arms (`process.env.X || - // null` is `string | null`, `|| 3000` is `string | number` — the - // `undefined` is gone from both), so coercing the left eagerly, as - // the shared shape below does, retags an arm the test is about to - // rule out and throws where Node yields the default. Single-eval - // instead: test the left in its OWN union and retag only on the - // truthy side, where the dropped arms are unreachable. - if ( - op === ts.SyntaxKind.BarBarToken && - left.type.kind === "union" && - !typeEquals(left.type, target) - ) { - const retag = lowerer.unionRetagHelper(left.type.unionId, target.unionId, loc); - if (retag) { - lowerer.requireTruthyUnion(left.type.unionId, expr); - return { - kind: "orDefault", - left, - right: lowerer.coerceInto(expr.right, right, target), - retag, - type: target, - loc, - }; - } - } - lowerer.requireTruthyUnion(target.unionId, expr); - return { - kind: "logical", - op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", - left: lowerer.coerceInto(expr.left, left, target), - right: lowerer.coerceInto(expr.right, right, target), - type: target, - loc, - }; - } - // `u || d` NARROWED by the default: the checker types the result - // as u's single non-unit arm (`marker() || "default"`) — evaluate - // u once, truthy extracts the arm, falsy takes d lazily (the - // orDefault node, nullish's truthiness sibling). An UNMAPPABLE - // checker result takes the same rule (`options.runner || - // defaultRunner` — tsc's union of two structurally-compatible - // function types has no representation, while the left's one - // non-unit arm is the only representable answer): the default - // must coerce into the arm or fence on its own. - if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") { - const def = lowerer.unions.get(left.type.unionId); - const rest = def ? def.arms.filter((a) => !isUnitType(a)) : []; - const funcArmDefault = - rest[0]?.kind === "func" && (target === null || target.kind === "func"); - if ( - rest.length === 1 && - ((target !== null && typeEquals(target, rest[0]!)) || funcArmDefault) - ) { - lowerer.requireTruthyUnion(left.type.unionId, expr); - const dflt = lowerer.lowerExprExpecting(expr.right, rest[0]!); - return { kind: "orDefault", left, right: dflt, type: rest[0]!, loc }; - } - } - // JavaScript defaults can join differently inferred object layouts. - // When both operands have native checked representations, preserve - // the deciding value and short-circuit evaluation in that domain. - if ( - isJsSourceFile(expr.getSourceFile()) && - lowerer.dynConvertible(left.type) && - lowerer.dynConvertible(right.type) - ) { - return { - kind: "logical", - op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", - left: lowerer.coerceInto(expr.left, left, DYN), - right: lowerer.coerceInto(expr.right, right, DYN), - type: DYN, - loc, - }; - } - lowerer.unsupported( - "SC1090", - expr, - `logical operators on union-typed values outside conditions (${NARROW_FIRST})`, - ); - } - const kind = left.type.kind; - if (kind !== right.type.kind || (kind !== "f64" && kind !== "string" && kind !== "bool")) { - // Mixed PLAIN operands (`value || null`, `flag || undefined`, - // `s || 0`): JS value semantics still compile when the checker - // types the RESULT as one union both operands coerce into — the - // same lift the union-operand path above takes, arriving here - // with two plain arm values instead. - const target = lowerer.mapTypeOf(lowerer.typeOf(expr)); - // An always-truthy left operand (a record, object, array or function - // value: every JS object is truthy): `o && x` evaluates `o` for its - // effects and answers `x`; `o || x` answers `o` and never evaluates - // `x`. supports-color's `stream && stream.isTTY` is the shape. Ahead - // of the union lift below exactly where that lift would store the - // object in a result union with no arm for it; a union holding the - // operand keeps the lift. - if ( - REF_TRUTHY_KINDS.has(kind) && - (target?.kind !== "union" || - (op === ts.SyntaxKind.AmpersandAmpersandToken && - lowerer.armTag(target.unionId, left.type) < 0)) - ) { - const and = op === ts.SyntaxKind.AmpersandAmpersandToken; - const answer = and ? right : left; - const answerT = target ?? answer.type; - if (answerT.kind !== "void") { - const value = lowerer.coerceInto(and ? expr.right : expr.left, answer, answerT); - if (!and) return value; - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: left, loc }], - result: value, - type: answerT, - loc, - }; - } - } - if (target?.kind === "union") { - lowerer.requireTruthyUnion(target.unionId, expr); - return { - kind: "logical", - op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", - left: lowerer.coerceInto(expr.left, left, target), - right: lowerer.coerceInto(expr.right, right, target), - type: target, - loc, - }; - } - lowerer.unsupported("SC1042", expr); - } - return { - kind: "logical", - op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||", - left, - right, - type: left.type, - loc, - }; -} - -function lowerAbsenceAwareOrChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null { - const nodes: ts.Expression[] = []; - const pending: ts.Expression[] = [expr]; - while (pending.length !== 0) { - const node = pending.pop()!; - if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) { - pending.push(node.right, node.left); - } else { - nodes.push(node); - } - } - if (nodes.length < 3) return null; - const lowered = nodes.map((node) => lowerAbsenceProbe(lowerer, node) ?? lowerer.lowerExpr(node)); - if ( - !lowered.some( - (value) => - value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0, - ) - ) { - return null; - } - let result = lowered[lowered.length - 1]!; - for (let i = lowered.length - 2; i >= 0; i--) { - const left = lowered[i]!; - const loc = locOf(nodes[i]!); - if (left.type.kind === "union") { - const def = lowerer.unions.get(left.type.unionId); - const nonUnit = def?.arms.filter((arm) => !isUnitType(arm)) ?? []; - if (nonUnit.length !== 1 || !typeEquals(nonUnit[0]!, result.type)) return null; - lowerer.requireTruthyUnion(left.type.unionId, expr); - result = { kind: "orDefault", left, right: result, type: result.type, loc }; - continue; - } - if (!typeEquals(left.type, result.type)) return null; - result = { kind: "logical", op: "||", left, right: result, type: result.type, loc }; - } - return result; -} - /** `typeof e === "lit"` / `typeof e !== "lit"` where e is a catch * binding: the runtime kind test over the snapshot's tag. Only the * primitive literals narrow ("string"/"number"/"boolean" — exactly what @@ -12152,7 +1821,7 @@ function lowerAbsenceAwareOrChain(lowerer: Lowerer, expr: ts.BinaryExpression): * what `err instanceof Error && "code" in err` narrows a catch binding * to; the refinement parts are type-level decoration, the VALUE is the * error object). */ -function errorRootedObjectOf(lowerer: Lowerer, t: ts.Type): IrType | null { +export function errorRootedObjectOf(lowerer: Lowerer, t: ts.Type): IrType | null { const rooted = (m: IrType | null): m is IrType & { kind: "object" } => { if (m?.kind !== "object") return false; let info = lowerer.classes.get(m.className) ?? null; @@ -12170,100 +1839,6 @@ function errorRootedObjectOf(lowerer: Lowerer, t: ts.Type): IrType | null { return null; } -/** `typeof WebAssembly === "object"` and `typeof WebAssembly. === - * "function"`: the operand needs no value, so the test folds as bare typeof - * does (builtins/webassembly.ts) before any dyn-typed operand lowers. */ -function lowerWebAssemblyTypeofTest( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; - for (const [a, b] of operandPairs(expr)) { - if (!ts.isTypeOfExpression(a) || !ts.isStringLiteral(b)) continue; - const folded = lowerWebAssemblyTypeof(lowerer, a.expression); - if (folded?.kind !== "strLit") continue; - return { kind: "boolLit", value: (folded.value === b.text) !== negated, type: BOOL, loc }; - } - return null; -} - -/** `typeof e.code === "string"` / `"undefined"` (and the `!==` forms) - * where e — through parens and as-casts — is a catch binding or a value - * of %Error-rooted type: the runtime error's code slot is a string - * exactly when present, so the typeof test IS the presence test (the - * isErrnoException predicate's third conjunct, - * `typeof (err as Record).code === "string"`). The - * as-cast changes the expression's TYPE, never the value — peeled like - * lowerProcessStreamProperty's receiver match. Null for every other - * shape, so the sibling typeof lowerings keep trying. */ -function lowerErrorCodeTypeofTest( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; - for (const [a, b] of operandPairs(expr)) { - if (!ts.isTypeOfExpression(a)) continue; - let prop: ts.Expression = a.expression; - while (ts.isParenthesizedExpression(prop)) prop = prop.expression; - if (!ts.isPropertyAccessExpression(prop) || prop.questionDotToken) continue; - if (prop.name.text !== "code") continue; - let recv: ts.Expression = prop.expression; - while ( - ts.isParenthesizedExpression(recv) || - ts.isAsExpression(recv) || - ts.isTypeAssertion(recv) - ) - recv = recv.expression; - const errT = errorRootedObjectOf(lowerer, lowerer.typeOf(recv)); - if (!errT) continue; - if (isExecExceptionType(lowerer, lowerer.typeOf(recv))) refuseExecExceptionCode(lowerer, prop); - const caught = lowerer.caughtLocalOf(recv); - let receiver: IrExpr; - if (caught) { - // The checker proved the Error narrowing (errT above), so the - // trusted extraction is sound — caughtRead itself would fence on - // the intersection spelling. - receiver = { - kind: "caughtNarrow", - value: { kind: "varRef", localId: caught.id, type: CAUGHT, loc }, - type: errT, - loc, - }; - } else { - receiver = lowerer.lowerExpr(recv); - if (receiver.type.kind !== "object") continue; - } - if (!ts.isStringLiteral(b)) continue; - if (b.text !== "string" && b.text !== "undefined") continue; - const codeType = lowerer.envValueType(); - if (codeType.kind !== "union") - throw new InternalCompilerError("lowerer bug: error code type is not a union"); - const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T); - const read: IrExpr = { - kind: "libCall", - fn: "error.code", - args: [receiver], - type: codeType, - loc, - }; - // typeof === "string" ⇔ the code slot is present (NOT the undefined - // arm); === "undefined" ⇔ absent. `!==` flips either. - const isNotUndef = b.text === "string" ? !negated : negated; - return { - kind: "unionIsTag", - unionId: codeType.unionId, - tag: undefTag, - ...(isNotUndef ? { negated: true as const } : {}), - value: read, - type: BOOL, - loc, - }; - } - return null; -} - export function lowerCaughtTypeofTest( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -12300,7 +1875,7 @@ export function lowerCaughtTypeofTest( return null; } -function typeofAnswer(arm: IrType): string | null { +export function typeofAnswer(arm: IrType): string | null { switch (arm.kind) { case "f64": return "number"; @@ -12405,7 +1980,7 @@ export function aliasTypeofNarrows( } /** Visit both operand orders while keeping the sequence an array of pairs. */ -function operandPairs(expr: ts.BinaryExpression): [ts.Expression, ts.Expression][] { +export function operandPairs(expr: ts.BinaryExpression): [ts.Expression, ts.Expression][] { return [ [expr.left, expr.right], [expr.right, expr.left], @@ -12467,268 +2042,8 @@ function aliasTypeofArmOf( return null; } -/** `typeof v === "lit"` / `typeof v !== "lit"` where v is UNION-typed: the - * arms whose static typeof answer equals the literal form a tag set, and - * the test is a runtime tag test — one `unionIsTag` for a single matching - * arm (the operand embeds once, so even effectful operands compose), a - * short-circuit chain for several (pure reads only — the operand rides - * every test, exactly the `== null` composition rule). When every arm - * answers the same way the comparison is statically decided and folds to - * a bool literal — dropping only a side-effect-free read, the same - * trust-the-checker bet as lowerUnitComparison. tsc's control-flow - * narrowing then types the branches, and reads bridge through - * maybeNarrow's unionNarrow as usual. Null when neither side is a typeof - * over a union-typed operand or the literal side isn't a literal (the - * bare-typeof value form then composes with strEq for pure operands). */ -function lowerUnionTypeofTest( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; - for (const [a, b] of operandPairs(expr)) { - if (!ts.isTypeOfExpression(a)) continue; - const stored = runtimeOptionalStorageOperand(lowerer, a.expression); - if (stored === null && lowerer.mapTypeOf(lowerer.typeOf(a.expression))?.kind !== "union") - continue; - if (!ts.isStringLiteral(b)) continue; // bare-typeof + strEq handles pure operands - const value = stored ?? lowerer.lowerExpr(a.expression); - if (value.type.kind !== "union") continue; // defensive: an already-narrowed use - const unionId = value.type.unionId; - const def = lowerer.unions.get(unionId); - const answers = def?.arms.map(typeofAnswer); - if (!def || !answers || !answers.every((s): s is string => s !== null)) continue; - const tags = answers.flatMap((s, i) => (s === b.text ? [i] : [])); - if (tags.length === 0 || tags.length === def.arms.length) { - if (!isSafeToRepeat(value)) { - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'typeof' tests over effectful operands (bind the value to a const first)", - ); - } - return { kind: "boolLit", value: (tags.length !== 0) !== negated, type: BOOL, loc }; - } - const isTag = (tag: number): IrExpr => ({ - kind: "unionIsTag", - unionId, - tag, - negated, - value, - type: BOOL, - loc, - }); - if (tags.length === 1) return isTag(tags[0]!); - if (!isSafeToRepeat(value)) { - lowerer.unsupported( - "SC1090", - expr, - "'typeof' tests matching several union arms over operands that aren't plain reads (bind the value to a const first)", - ); - } - // Tag-in-set as a short-circuit chain (De Morgan for `!==`). - let acc = isTag(tags[0]!); - for (const t of tags.slice(1)) { - acc = { - kind: "logical", - op: negated ? "&&" : "||", - left: acc, - right: isTag(t), - type: BOOL, - loc, - }; - } - return acc; - } - return null; -} - -/** `typeof v === "lit"` / `typeof v !== "lit"` where v is `unknown`-typed - * (dyn): the runtime kind test over the dyn node's tag. Only the kinds a - * later read can bridge narrow ("string"/"number"/"boolean") plus - * "undefined" (no payload to read); "object"/"function" tests keep the - * fence — an object-narrowed `unknown` read has no lowering anyway (a - * checked cast `v as T` is the supported extraction). Null when neither - * side is a typeof over a dyn-typed operand (not this pattern). */ -function lowerDynTypeofTest( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - const negated = expr.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken; - for (const [a, b] of operandPairs(expr)) { - if (!ts.isTypeOfExpression(a)) continue; - if (lowerer.isStdlibGlobal(a.expression, "fetch")) continue; - // Non-literal comparisons use the ordinary typeof string value, - // preserving both operands' evaluation order without a narrowing test. - if (!ts.isStringLiteral(b)) continue; - const declared = lowerer.mapTypeOf(lowerer.typeOf(a.expression)); - let value: IrExpr; - if (declared?.kind === "dyn") { - value = lowerer.lowerExpr(a.expression); - if (value.type.kind !== "dyn") continue; // defensive: an already-narrowed use - } else if (lowerer.caughtLocalOf(a.expression)) { - continue; // the caught lowering's territory (it ran first and declined) - } else { - // `typeof (value as T).field === "string"` — the raw-object - // validation idiom (routes.ts's isValidRoute): the `as` is erasure - // in JS, so the READ is the dyn KEYED read of the un-cast value — - // a missing key answers undefined and the guard goes false, - // Node-exact — never the checked cast (validating T here would - // throw on exactly the values the guard exists to reject). - const unparen = (e: ts.Expression): ts.Expression => { - while (ts.isParenthesizedExpression(e)) e = e.expression; - return e; - }; - let keyed: IrExpr | null = null; - const pn = unparen(a.expression); - if (ts.isPropertyAccessExpression(pn) && !pn.questionDotToken && ts.isIdentifier(pn.name)) { - const recv = unparen(pn.expression); - if (ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) { - const inner = unparen(recv.expression); - const obj = tryLowerExpression(lowerer, inner); - if (obj?.type.kind === "dyn") { - keyed = { - kind: "dynKeyGet", - key: { kind: "strLit", value: pn.name.text, type: STRING, loc }, - value: obj, - type: DYN, - loc, - }; - } - } - } - if (keyed) { - value = keyed; - } else { - // A narrowing residue of `unknown`: a `!== undefined` (or truthy) - // guard ahead of the typeof test spells the operand `{}`/`{} | - // null` — no useful static mapping, but the READ is still the dyn - // value (`if (obj.name !== undefined) { if (typeof obj.name !== - // "string") ... }`, the validateConfig idiom). Probe the lowering - // and claim exactly the dyn results; anything else keeps its - // sibling lowerings and fences untouched. - const probed = tryLowerExpression(lowerer, a.expression); - if (probed?.type.kind !== "dyn") continue; - value = probed; - } - } - if ( - b.text === "string" || - b.text === "number" || - b.text === "boolean" || - b.text === "undefined" || - b.text === "bigint" || - b.text === "symbol" - ) { - return { - kind: "dynTest", - test: b.text, - ...(negated ? { negated: true as const } : {}), - value, - type: BOOL, - loc, - }; - } - // `typeof v === "object"`: the dyn answers exactly (object, array, - // bytes, and null kinds — JS's oldest wart preserved). tsc narrows - // the branch to `object | null`; reads past the narrow take the - // checked-cast path per site. - if (b.text === "object") { - return { - kind: "dynTest", - test: "object", - ...(negated ? { negated: true as const } : {}), - value, - type: BOOL, - loc, - }; - } - // `typeof v === "function"`: a REAL runtime test since the checked-dynamic tree's - // function kind exists (boxed closures — the mustCall guard - // `if (typeof fn !== 'function') throw` answers honestly). - if (b.text === "function") { - return { - kind: "dynTest", - test: "function", - ...(negated ? { negated: true as const } : {}), - value, - type: BOOL, - loc, - }; - } - // Native checked values do not yet carry symbols. - if (b.text === "symbol") { - return { kind: "boolLit", value: negated, type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - b, - `'typeof' tests against "${b.text}" on 'unknown' values (only "string"/"number"/` + - `"boolean"/"undefined"/"object" narrow; use a checked cast — 'v as T' — to extract)`, - ); - } - return null; -} - -/** `exports.` in a JS module whose `module.exports =` REPLACED the - * export object, with no `exports. =` attachment anywhere in the - * file: the read observes the ORIGINAL (empty-here) exports object, so - * the honest value is undefined (Node's object identity exactly; tsc's - * CJS model types the read off the replacement — identity-blind). Null - * everywhere else: no replacement, a real attachment of this name, a - * shadowing binding named `exports`, or write position. */ -function lowerReplacedExportsRead( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): IrExpr | null { - if (expr.questionDotToken) return null; - const recv = expr.expression; - if (!ts.isIdentifier(recv) || recv.text !== "exports") return null; - const sf = expr.getSourceFile(); - if (!isJsSourceFile(sf) || isNodeEsmFile(sf, lowerer.program)) return null; - if (lowerer.peekLocal(recv) || lowerer.globalOf(recv)) return null; // a user binding shadows - // Write position is the export-assignment machinery's territory. - if ( - ts.isBinaryExpression(expr.parent) && - expr.parent.left === expr && - expr.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken - ) { - return null; - } - let replaced = false; - for (const stmt of sf.statements.nodes) { - const cjs = cjsExportAssignmentOf(stmt); - if (!cjs) continue; - if (cjs.kind === "table" && cjsExportDiscardReason(stmt) === null) replaced = true; - if (cjs.kind === "member" && ts.isIdentifier(cjs.name) && cjs.name.text === expr.name.text) { - return null; // a real attachment of this name exists somewhere - } - } - if (!replaced) return null; - return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(expr) }; -} - -/** A read that resolves to a CJS export-table ACCESSOR property - * (`module.exports = { get path() {...}, set path(v) {...} }`): the - * getter lifts as a module-level function (module globals need no - * captures) — interned per declaration so every read site calls ONE - * function — and the read IS the call, Node's per-read evaluation - * exactly. Null when the identifier doesn't resolve to such a property - * (or it has no getter: a setter-only read answers undefined in Node — - * rare enough to keep fenced). */ -function cjsExportAccessorRead(lowerer: Lowerer, ident: ts.Identifier): IrExpr | null { - const symbol = lowerer.resolveValueSymbol(ident); - const getter = symbol ? symbol.declarations.find(ts.isGetAccessorDeclaration) : undefined; - if (!getter) return null; - if (!ts.isObjectLiteralExpression(getter.parent) || !isCjsExportTableLiteral(getter.parent)) { - return null; - } - return cjsAccessorCall(lowerer, getter, locOf(ident)); -} - /** The interned lifted-getter call both accessor-read paths share. */ -function cjsAccessorCall( +export function cjsAccessorCall( lowerer: Lowerer, getter: ts.GetAccessorDeclaration, loc: SrcLoc, @@ -12753,43 +2068,6 @@ function cjsAccessorCall( return { kind: "callValue", callee: closureVal, args: [], type: fn.type.ret, loc }; } -/** `this.` INSIDE a CJS export-table accessor (test/common's - * `get localhostIPv4() { if (this.inFreeBSDJail) ... }`): Node binds the - * getter's receiver to module.exports, so the read is the SIBLING table - * property — a sibling GETTER's lifted call (per-read evaluation, like - * any accessor read). Only arrows inherit the binding (a nested function - * expression's `this` is its own). Null everywhere else — non-getter - * siblings and absent names keep their per-site fences (none of the - * probed suite shapes read them). */ -function lowerCjsExportTableThisMember( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): IrExpr | null { - if (expr.expression.kind !== ts.SyntaxKind.ThisKeyword || expr.questionDotToken) return null; - if (!isJsSourceFile(expr.getSourceFile())) return null; - // The enclosing accessor, crossing only arrow boundaries (arrows - // inherit `this`; any other function form rebinds it). - let n: ts.Node | undefined = expr.parent; - while (n !== undefined && !ts.isGetAccessorDeclaration(n) && !ts.isSetAccessorDeclaration(n)) { - if (ts.isFunctionLike(n) && !ts.isArrowFunction(n)) return null; - if (ts.isSourceFile(n)) return null; - n = n.parent; - } - if ( - n === undefined || - !ts.isObjectLiteralExpression(n.parent) || - !isCjsExportTableLiteral(n.parent) - ) { - return null; - } - const sibling = n.parent.properties.nodes.find( - (p): p is ts.GetAccessorDeclaration => - ts.isGetAccessorDeclaration(p) && ts.isIdentifier(p.name) && p.name.text === expr.name.text, - ); - if (!sibling) return null; - return cjsAccessorCall(lowerer, sibling, locOf(expr)); -} - /** A read of a catch binding — the caught analog of maybeNarrow. Where * tsc's control-flow narrowing has proven a supported test (`instanceof` * over a hierarchy class, `typeof` over a primitive), the read bridges @@ -12873,1618 +2151,6 @@ export function caughtLocalOf(lowerer: Lowerer, node: ts.Expression): IrLocal | return local?.type.kind === "caught" ? local : null; } -/** `x instanceof C` for a program-declared class C. When x's static - * class and C are both in extends-hierarchies the test is dynamic — the - * O(1) preorder-interval check against the vtable (`instanceOf` node). - * Everything else is decided by the static class graph alone and folds - * to the honest constant: `x instanceof C` is always true when x's - * static class IS C or descends from it, and always false when the - * classes are unrelated (a standalone class has no other relatives). - * Folding is limited to side-effect-free operands — dropping a computed - * operand would skip its effects, so those are rejected instead. */ -export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc): IrExpr { - refuseWebAssemblyUse(lowerer, expr.right); - // `x instanceof net.Socket` over a union with a netSocket arm — the - // h2 compat 'connect' narrowing (lower-server.ts): a union tag test. - const sockTest = lowerSocketInstanceOf(lowerer, expr, loc); - if (sockTest !== null) return sockTest; - const rightClass = builtinClassRefOf(lowerer, expr.right); - if (rightClass?.kind === "zlib") return lowerZlibInstanceOf(lowerer, expr, rightClass.mode, loc); - - if (rightClass?.kind === "collection") { - const collection = rightClass.which === "Map" ? "map" : "set"; - const value = lowerer.lowerExpr(expr.left); - if (value.type.kind === "union") { - const union = lowerer.unions.get(value.type.unionId); - if (union) { - const local = lowerer.declareHiddenLocal("%collectionTest", value.type); - const reference = varRef(local.id, value.type, loc); - let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; - union.arms.forEach((arm, tag) => { - if (arm.kind !== collection) return; - const test: IrExpr = { - kind: "unionIsTag", - unionId: union.id, - value: reference, - tag, - negated: false, - type: BOOL, - loc, - }; - result = - result.kind === "boolLit" - ? test - : { kind: "logical", op: "||", left: result, right: test, type: BOOL, loc }; - }); - return { - kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }], - result, - type: BOOL, - loc, - }; - } - } - if (value.type.kind === "dyn") - return { - kind: "libCall", - fn: collection === "map" ? "dyn.nativeMapIs" : "dyn.nativeSetIs", - args: [value], - type: BOOL, - loc, - }; - if (value.type.kind === collection) - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: value, loc }], - result: { kind: "boolLit", value: true, type: BOOL, loc }, - type: BOOL, - loc, - }; - return { - kind: "libCall", - fn: collection === "map" ? "dyn.nativeMapIs" : "dyn.nativeSetIs", - args: [lowerer.coerceInto(expr.left, value, DYN)], - type: BOOL, - loc, - }; - } - if ( - lowerer.isStdlibGlobal(expr.right, "WeakMap") || - lowerer.isStdlibGlobal(expr.right, "WeakSet") - ) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return { - kind: "libCall", - fn: lowerer.isStdlibGlobal(expr.right, "WeakMap") ? "weakMap.is" : "weakSet.is", - args: [value], - type: BOOL, - loc, - }; - } - const rhsBuiltin = ts.isIdentifier(expr.right) - ? lowerer.builtinImportOf(expr.right) - : ts.isPropertyAccessExpression(expr.right) - ? lowerer.builtinMemberOf(expr.right) - : null; - if ( - lowerer.isStdlibGlobal(expr.right, "URL") || - (rhsBuiltin?.module === "url" && rhsBuiltin.member === "URL") - ) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return { kind: "libCall", fn: "dyn.nativeUrlIs", args: [value], type: BOOL, loc }; - } - { - const stack = lowerDisposableStackInstanceOf(lowerer, expr); - if (stack) return stack; - } - if (lowerer.isStdlibGlobal(expr.right, "DataView")) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return { kind: "libCall", fn: "dyn.dataViewIs", args: [value], type: BOOL, loc }; - } - { - const eventKind = ["Event", "CustomEvent", "EventTarget"].findIndex((name) => - lowerer.isStdlibGlobal(expr.right, name), - ); - if (eventKind >= 0) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - const kind: IrExpr = { kind: "numLit", value: eventKind, type: F64, loc }; - return { kind: "libCall", fn: "event.is", args: [value, kind], type: BOOL, loc }; - } - } - { - const blob = lowerBlobInstanceof(lowerer, expr); - if (blob) return blob; - const keyObject = lowerKeyObjectInstanceof(lowerer, expr); - if (keyObject) return keyObject; - const cryptoKey = lowerCryptoKeyInstanceof(lowerer, expr); - if (cryptoKey) return cryptoKey; - const netAddress = lowerNetAddressInstanceof(lowerer, expr); - if (netAddress) return netAddress; - } - if (lowerer.isStdlibGlobal(expr.right, "SharedArrayBuffer")) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return { kind: "libCall", fn: "sharedArrayBuffer.is", args: [value], type: BOOL, loc }; - } - if (lowerer.isStdlibGlobal(expr.right, "ArrayBuffer")) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return { kind: "libCall", fn: "arrayBuffer.is", args: [value], type: BOOL, loc }; - } - const rhsSymbol = ts.isIdentifier(expr.right) ? lowerer.resolveValueSymbol(expr.right) : null; - // `x instanceof events.EventEmitter` — the namespace-member spelling - // resolves to the same ambient class as the named import. - const rhsMemberSymbol = (() => { - if (!ts.isPropertyAccessExpression(expr.right) || !ts.isIdentifier(expr.right.name)) - return null; - const sym = lowerer.checker.getSymbolAtLocation(expr.right.name); - return sym && sym.flags & ts.SymbolFlags.Alias - ? lowerer.checker.getAliasedSymbol(sym) - : (sym ?? null); - })(); - // `x instanceof N.C` — the USER-namespace-qualified spelling: the - // member resolves to the registered program class, with the - // source-order guard (an init-position test above the class's block - // would read an uninitialized member in Node). - const rhsNsClass = (() => { - if (!ts.isPropertyAccessExpression(expr.right)) return undefined; - const nsMember = nsMemberIdentOf(lowerer, expr.right); - if (!nsMember) return undefined; - const memberSym = lowerer.checker.getSymbolAtLocation(nsMember); - if (memberSym) fenceEarlyNsMemberRef(lowerer, expr.right, memberSym); - const classSym = lowerer.resolveValueSymbol(nsMember); - const nsInfo = classSym ? lowerer.classBySymbol.get(classSym) : undefined; - // Qualified spellings of a rebindable decorated class (import= - // alias chains) must not fold against the declaration — the - // decoration result decides at runtime. - return nsInfo?.classDecorators?.valueGlobalId !== undefined ? undefined : nsInfo; - })(); - // A rebindable decorated name as the RHS: the runtime target is the - // decoration result — fall to the class-VALUE path below, whose - // instanceOfValue reads the interval off the bound class object. - const directRhs = rhsSymbol ? lowerer.classBySymbol.get(rhsSymbol) : undefined; - const target = - (directRhs?.localClass || directRhs?.classDecorators?.valueGlobalId !== undefined - ? undefined - : directRhs) ?? - rhsNsClass ?? - lowerer.builtinErrorInfoOf(rhsSymbol) ?? - lowerer.builtinErrorInfoOf(rhsMemberSymbol) ?? - lowerer.builtinEmitterInfoOf(rhsSymbol) ?? - lowerer.builtinEmitterInfoOf(rhsMemberSymbol) ?? - lowerer.builtinStreamInfoOf(rhsSymbol) ?? - lowerer.builtinStreamInfoOf(rhsMemberSymbol) ?? - undefined; - if (!target) { - const date = lowerer.isStdlibGlobal(expr.right, "Date"); - const url = lowerer.isStdlibGlobal(expr.right, "URL"); - if ((date || url) && !lowerer.caughtLocalOf(expr.left)) { - const value = lowerer.lowerExpr(expr.left); - if (value.type.kind === "dyn") - return { - kind: "libCall", - fn: date ? "dyn.nativeDateIs" : "dyn.nativeUrlIs", - args: [value], - type: BOOL, - loc, - }; - if (value.type.kind === "union") - return { - kind: "libCall", - fn: date ? "dyn.nativeDateIs" : "dyn.nativeUrlIs", - args: [lowerer.coerceInto(expr.left, value, DYN)], - type: BOOL, - loc, - }; - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: value, loc }], - result: { - kind: "boolLit", - value: value.type.kind === (date ? "date" : "url"), - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - const webBrand = ["Request", "Response", "Headers"].find((name) => - lowerer.isStdlibGlobal(expr.right, name), - ); - if (webBrand) - return { - kind: "libCall", - fn: "fetch.webIs", - args: [ - lowerer.lowerExprExpecting(expr.left, DYN), - { kind: "strLit", value: webBrand, type: STRING, loc }, - ], - type: BOOL, - loc, - }; - if (lowerer.isStdlibGlobal(expr.right, "ReadableStream")) { - const value = lowerer.lowerExprExpecting(expr.left, DYN); - return { kind: "libCall", fn: "fetch.streamIs", args: [value], type: BOOL, loc }; - } - // Unknown storage retains each numeric typed array's exact native brand. - if (!lowerer.caughtLocalOf(expr.left)) { - const elem = (Object.keys(BYTES_ELEMENT_NAME) as IrBytesElem[]).find((kind) => - lowerer.isStdlibGlobal(expr.right, BYTES_ELEMENT_NAME[kind]), - ); - if (elem !== undefined) { - const left = lowerer.lowerExpr(expr.left); - if (left.type.kind === "dyn") { - return { kind: "dynTest", test: "bytes", bytesElem: elem, value: left, type: BOOL, loc }; - } - return { - kind: "dynTest", - test: "bytes", - bytesElem: elem, - value: lowerer.coerceInto(expr.left, left, DYN), - type: BOOL, - loc, - }; - } - } - // `x instanceof Promise` over a promise-armed union (the maybe-promise - // `T | Promise` dispatch): a union tag test — the data arms are - // never promises (scriptc promises are all native, and no data arm - // can hold one). A promise-typed operand answers true after its - // evaluation; a checked-dynamic one asks the runtime. - if ( - ts.isIdentifier(expr.right) && - lowerer.isStdlibGlobal(expr.right, "Promise") && - !lowerer.caughtLocalOf(expr.left) - ) { - const left = lowerer.lowerExpr(expr.left); - const tagTest = maybePromiseInstanceOf(lowerer, left, loc); - if (tagTest) return tagTest; - if (left.type.kind === "dyn") - return { kind: "dynTest", test: "promise", value: left, type: BOOL, loc }; - if (left.type.kind === "promise") { - const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; - if (isSafeToDiscard(left)) return yes; - return { - kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: left, loc }], - result: yes, - type: BOOL, - loc, - }; - } - lowerer.unsupported( - "SC1090", - expr, - `'instanceof Promise' on '${lowerer.fmt(left.type)}' values (only promises, promise-armed unions, and checked-dynamic values have a lowering)`, - ); - } - // `x instanceof RegExp` over a union with a regex arm (the - // skip-utility `string | RegExp` dispatch): a union tag test — the - // socket-narrowing shape; the checker types the branches and - // maybeNarrow bridges the reads. A plain regex-typed LHS folds - // true; the fold keeps the operand-purity rule of the class folds - // (identifier reads only). - if ( - ts.isIdentifier(expr.right) && - lowerer.isStdlibGlobal(expr.right, "RegExp") && - !lowerer.caughtLocalOf(expr.left) - ) { - const left = lowerer.lowerExpr(expr.left); - if (left.type.kind === "dyn") - return { kind: "libCall", fn: "dyn.nativeRegexIs", args: [left], type: BOOL, loc }; - if (left.type.kind === "union") { - const def = lowerer.unions.get(left.type.unionId); - const tag = def ? def.arms.findIndex((a) => a.kind === "regex") : -1; - if (tag >= 0) { - return { - kind: "unionIsTag", - unionId: left.type.unionId, - tag, - negated: false, - value: left, - type: BOOL, - loc, - }; - } - } - return { - kind: "libCall", - fn: "dyn.nativeRegexIs", - args: [lowerer.coerceInto(expr.left, left, DYN)], - type: BOOL, - loc, - }; - } - // `x instanceof X` where X is a class VALUE (a classval-typed - // binding): the target is DYNAMIC — a hierarchy target reads its - // interval from the class object at runtime (instanceOfValue); a - // STANDALONE target class has exactly one possible runtime value - // (itself — no descendants can flow into the slot), so the answer - // folds statically exactly like the named-target folds below. - const rhsClassval = storedClassValueType(lowerer, expr.right); - if (rhsClassval?.kind === "classval" && !lowerer.caughtLocalOf(expr.left)) { - const targetInfo = lowerer.classes.get(rhsClassval.className); - if (!targetInfo) { - // The type world names a class the lowering never registered - // (a fenced class expression, a deferred declaration): flush - // its diagnostics and poison the test site, never an ICE. - lowerer.flushDeferredClass(rhsClassval.className); - lowerer.unsupported( - "SC1090", - expr, - "'instanceof' against a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic)", - ); - } - const left = lowerer.lowerExpr(expr.left); - if (left.type.kind === "dyn") { - if (targetInfo.def.runtime) { - lowerer.unsupported( - "SC1090", - expr, - "'instanceof' through a runtime-provided class value on an untyped value (give the operand a class type first)", - ); - } - const classValue = lowerer.lowerExpr(expr.right); - return classInstanceOf(lowerer, left, targetInfo, loc, classValue); - } - // A union of class instances against a runtime class value (`new K()` - // through a widened `[Readable, Writable]` element). - if (left.type.kind === "union" && targetInfo.def.runtime) { - const tested = unionClassValueInstanceOf(lowerer, left, expr.right, targetInfo, loc); - if (tested) return tested; - } - if (left.type.kind !== "object") { - lowerer.unsupported( - "SC1090", - expr, - "'instanceof' on values other than class instances (narrow union-typed values first)", - ); - } - const lhsInfo = lowerer.classes.get(left.type.className); - if (!lhsInfo) - throw new InternalCompilerError(`lowerer bug: unknown class ${left.type.className}`); - if ( - (lowerer.inHierarchy(targetInfo) && lowerer.inHierarchy(lhsInfo)) || - (targetInfo.localClass !== undefined && lhsInfo.localClass !== undefined) - ) { - const classValue = lowerer.lowerExpr(expr.right); - if (classValue.type.kind !== "classval") - lowerer.badType(expr.right, lowerer.typeOf(expr.right)); - const writableTest = writableClassValueTest( - lowerer, - left, - classValue, - targetInfo, - lhsInfo, - loc, - ); - if (writableTest) return writableTest; - return { kind: "instanceOfValue", value: left, classValue, type: BOOL, loc }; - } - // A standalone side: the answer is static (the target slot can - // only hold the named class; a standalone operand's runtime class - // IS its static class). Folding discards the operands' evaluation, - // so only side-effect-free shapes fold — the named-target rule. - const value = - left.type.className === targetInfo.def.name || - lowerer.isSubclassOf(left.type.className, targetInfo.def.name); - if ( - left.kind === "varRef" && - (ts.isIdentifier(expr.right) || expr.right.kind === ts.SyntaxKind.ThisKeyword) - ) { - return readsClassAfter(lowerer, targetInfo, expr.right, [], { - kind: "boolLit", - value, - type: BOOL, - loc, - }); - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'instanceof' on computed operands (bind the values to variables first)", - ); - } - const constructor = tryLowerExpression(lowerer, expr.right); - if (constructor?.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { - const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); - return checkedClassInstanceOf(lowerer, value, constructor, loc); - } - lowerer.unsupported( - "SC1090", - expr.right, - "'instanceof' right-hand sides other than classes declared in the program", - ); - } - // `x instanceof Writable`: Node's Writable[Symbol.hasInstance] also - // answers true for streams with a writable side that do not inherit from - // Writable, which are Duplex and its subclasses (Transform, PassThrough, - // the zlib streams). Subclasses of Writable keep the prototype test. - const duplex = - target.def.name === "%Writable" && target.builtinStream !== undefined - ? lowerer.classes.get("%Duplex") - : undefined; - // The classes whose interval test can answer true for a value of static - // class `className`: Writable and Duplex are disjoint, so a value typed - // as one side needs only that side's test. - const runtimeTests = (className: string): ClassInfo[] => { - if (!duplex) return [target]; - const mayHold = (info: ClassInfo): boolean => - className === info.def.name || - lowerer.isSubclassOf(className, info.def.name) || - lowerer.isSubclassOf(info.def.name, className); - const tests = [target, duplex].filter(mayHold); - return tests.length > 0 ? tests : [target]; - }; - // A catch binding on the left: the runtime test against the class's - // preorder interval (false for non-hierarchy-object payloads). tsc's - // narrowing then types the branches; reads bridge through caughtRead. - const caughtLocal = lowerer.caughtLocalOf(expr.left); - if (caughtLocal) { - const caughtInstanceOf = (info: ClassInfo): IrExpr => { - if (!lowerer.inHierarchy(info)) { - const value = varRef(caughtLocal.id, CAUGHT, loc); - return { - kind: "ternary", - cond: { - kind: "caughtTest", - value, - test: "instanceof", - className: info.def.name, - type: BOOL, - loc, - }, - then: { kind: "boolLit", value: true, type: BOOL, loc }, - else_: classInstanceOf( - lowerer, - { kind: "caughtToDyn", value, type: DYN, loc }, - info, - loc, - ), - type: BOOL, - loc, - }; - } - return { - kind: "caughtTest", - value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc }, - test: "instanceof", - className: info.def.name, - type: BOOL, - loc, - }; - }; - const test = caughtInstanceOf(target); - return duplex - ? { kind: "logical", op: "||", left: test, right: caughtInstanceOf(duplex), type: BOOL, loc } - : test; - } - const left = lowerer.lowerExpr(expr.left); - // `u instanceof Error` on an `unknown` value: the checked-dynamic tree's error encoding - // (the shape caughtToDyn builds for Error payloads — the reserved - // "%error" marker) answers the test, so a caught Error passed through - // an unknown slot narrows like Node (`error instanceof Error ? - // error.message : String(error)` — the LAN-monitor handler). ROOT - // only: the marker cannot honestly answer subclass prototype chains - // (name strings are user-writable), so `u instanceof TypeError` keeps - // the fence. Reads past the narrow bridge through maybeNarrow's - // validated %Error extraction. SEMANTICS.md 67. - if ( - left.type.kind === "dyn" && - !target.def.runtime && - !target.builtinError && - !target.builtinEmitter && - !target.builtinStream && - !target.localClass - ) { - return classInstanceOf(lowerer, left, target, loc); - } - if (left.type.kind === "dyn" && target.def.name === "%Error") { - return { kind: "dynTest", test: "error", value: left, type: BOOL, loc }; - } - // `u instanceof TypeError` (and the other BUILTIN error classes) on a - // dyn value: the from_error cache holds the checked-dynamic tree↔error identity edge, - // so the runtime resolves the encoding back to its error and asks the - // vtable's stamped interval — exact for every error that crossed the - // boundary (a hand-built {%error} literal answers false: subclass - // identity is unknowable there). User subclasses keep the fence. - if (left.type.kind === "dyn" && RUNTIME_ERROR_CLASSES.has(target.def.name)) { - const rec = RUNTIME_ERROR_CLASSES.get(target.def.name)!; - return { - kind: "libCall", - fn: "dyn.errInstanceof", - args: [left, { kind: "numLit", value: rec.kind, type: F64, loc }], - type: BOOL, - loc, - }; - } - if (left.type.kind === "dyn") { - lowerer.unsupported( - "SC1090", - expr, - `'instanceof ${target.def.name.replace(/^%/, "")}' on 'unknown' values (only the Error classes answer — test 'instanceof Error' and read '.name')`, - ); - } - if (left.type.kind === "union") { - const unionId = left.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms; - // Ordinary records, arrays and scalar values do not carry a class - // prototype (no program class extends Array). Dynamic/opaque runtime - // objects keep their existing instanceof fences. - if ( - arms?.every( - (arm) => - arm.kind === "object" || - arm.kind === "record" || - arm.kind === "array" || - arm.kind === "f64" || - arm.kind === "string" || - arm.kind === "bool" || - arm.kind === "bigint" || - arm.kind === "symbol" || - isUnitType(arm), - ) - ) { - const key = `instanceof.union:${unionId}:${target.def.name}`; - let helper = lowerer.valueHelpers.get(key); - if (!helper) { - helper = `%instanceof.union.${lowerer.valueHelpers.size}`; - lowerer.valueHelpers.set(key, helper); - const value: IrExpr = { kind: "varRef", localId: "value.0", type: left.type, loc }; - const body: IrStmt[] = []; - arms.forEach((arm, tag) => { - if (arm.kind !== "object") return; - const lhsInfo = lowerer.classes.get(arm.className); - if (!lhsInfo) - throw new InternalCompilerError(`lowerer bug: unknown class ${arm.className}`); - const armTest = (info: ClassInfo): IrExpr => - lowerer.inHierarchy(lhsInfo) && lowerer.inHierarchy(info) - ? { - kind: "instanceOf", - value: { kind: "unionNarrow", unionId, tag, value, type: arm, loc }, - className: info.def.name, - type: BOOL, - loc, - } - : { - kind: "boolLit", - value: - arm.className === info.def.name || - lowerer.isSubclassOf(arm.className, info.def.name), - type: BOOL, - loc, - }; - const result = runtimeTests(arm.className) - .map(armTest) - .reduce((left: IrExpr, right: IrExpr): IrExpr => ({ - kind: "logical", - op: "||", - left, - right, - type: BOOL, - loc, - })); - body.push({ - kind: "if", - cond: { kind: "unionIsTag", unionId, tag, negated: false, value, type: BOOL, loc }, - then: [{ kind: "return", value: result, loc }], - else_: null, - loc, - }); - }); - body.push({ - kind: "return", - value: { kind: "boolLit", value: false, type: BOOL, loc }, - loc, - }); - lowerer.liftedFns.push({ - name: helper, - params: [{ localId: "value.0", name: "value", type: left.type }], - returnType: BOOL, - locals: [{ id: "value.0", name: "value", type: left.type, mutable: false }], - body, - loc, - }); - } - return { kind: "call", callee: helper, args: [left], type: BOOL, loc }; - } - // A class arm could hold an instance: the test cannot be decided - // statically. - if (arms?.some((arm) => arm.kind === "object")) - lowerer.unsupported( - "SC1090", - expr, - "'instanceof' on a union of class instances and values without a class prototype test", - ); - } - if (left.type.kind !== "object") { - return readsClassAfter(lowerer, target, expr.right, [{ kind: "exprStmt", expr: left, loc }], { - kind: "boolLit", - value: false, - type: BOOL, - loc, - }); - } - const lhsInfo = lowerer.classes.get(left.type.className); - if (!lhsInfo) - throw new InternalCompilerError(`lowerer bug: unknown class ${left.type.className}`); - if (lowerer.inHierarchy(lhsInfo) && lowerer.inHierarchy(target)) { - const tests = runtimeTests(left.type.className); - if (tests.length === 1) { - return { kind: "instanceOf", value: left, className: tests[0]!.def.name, type: BOOL, loc }; - } - const local = lowerer.declareHiddenLocal("%writableTest", left.type); - const ref = varRef(local.id, left.type, loc); - const test = (className: string): IrExpr => ({ - kind: "instanceOf", - value: ref, - className, - type: BOOL, - loc, - }); - return { - kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: local.id, init: left, loc }], - result: { - kind: "logical", - op: "||", - left: test(tests[0]!.def.name), - right: test(tests[1]!.def.name), - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - const leftClass = left.type.className; - const isInstance = (className: string): boolean => - leftClass === className || lowerer.isSubclassOf(leftClass, className); - const value = - isInstance(target.def.name) || (duplex !== undefined && isInstance(duplex.def.name)); - if (left.kind === "varRef") { - return readsClassAfter(lowerer, target, expr.right, [], { - kind: "boolLit", - value, - type: BOOL, - loc, - }); - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'instanceof' on computed operands (bind the value to a variable first)", - ); -} - -/** `x instanceof K` for a union-typed operand whose arms are class - * instances or unit values: each instance arm tests against the class - * value (with the Writable rule), unit arms answer false. Null when an arm - * is anything else. */ -function unionClassValueInstanceOf( - lowerer: Lowerer, - left: IrExpr, - right: ts.Expression, - targetInfo: ClassInfo, - loc: SrcLoc, -): IrExpr | null { - if (left.type.kind !== "union") return null; - const unionId = left.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms; - if ( - !arms?.every((arm) => { - if (arm.kind !== "object") return isUnitType(arm); - const info = lowerer.classes.get(arm.className); - return info !== undefined && lowerer.inHierarchy(info); - }) - ) - return null; - const classValue = lowerer.lowerExpr(right); - if (classValue.type.kind !== "classval") lowerer.badType(right, lowerer.typeOf(right)); - const value = lowerer.declareHiddenLocal("%instanceValue", left.type); - const cls = lowerer.declareHiddenLocal("%instanceClass", classValue.type); - const valueRef = varRef(value.id, left.type, loc); - const clsRef = varRef(cls.id, classValue.type, loc); - let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; - arms.forEach((arm, tag) => { - if (arm.kind !== "object") return; - const narrowed: IrExpr = { kind: "unionNarrow", unionId, tag, value: valueRef, type: arm, loc }; - const lhsInfo = lowerer.classes.get(arm.className)!; - const test: IrExpr = writableClassValueTest( - lowerer, - narrowed, - clsRef, - targetInfo, - lhsInfo, - loc, - ) ?? { kind: "instanceOfValue", value: narrowed, classValue: clsRef, type: BOOL, loc }; - result = { - kind: "ternary", - cond: { kind: "unionIsTag", unionId, tag, negated: false, value: valueRef, type: BOOL, loc }, - then: test, - else_: result, - type: BOOL, - loc, - }; - }); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: value.id, init: left, loc }, - { kind: "varDecl", localId: cls.id, init: classValue, loc }, - ], - result, - type: BOOL, - loc, - }; -} - -/** `x instanceof K` where K is a class value that may hold Writable (a - * `typeof Writable` or `typeof EventEmitter` slot): Node's - * Writable[Symbol.hasInstance] also answers true for streams with a - * writable side that do not inherit from Writable (Duplex and its - * subclasses), so when K is Writable the Duplex interval answers too. - * Null when the slot cannot hold Writable or the operand cannot be a - * Duplex. */ -function writableClassValueTest( - lowerer: Lowerer, - left: IrExpr, - classValue: IrExpr, - targetInfo: ClassInfo, - lhsInfo: ClassInfo, - loc: SrcLoc, -): IrExpr | null { - const writable = lowerer.classes.get("%Writable"); - const duplex = lowerer.classes.get("%Duplex"); - if (!writable || !duplex || classValue.type.kind !== "classval" || left.type.kind !== "object") - return null; - const slotHoldsWritable = - targetInfo === writable || lowerer.isSubclassOf("%Writable", targetInfo.def.name); - const operandMayBeDuplex = - lhsInfo === duplex || - lowerer.isSubclassOf(lhsInfo.def.name, "%Duplex") || - lowerer.isSubclassOf("%Duplex", lhsInfo.def.name); - if (!slotHoldsWritable || !operandMayBeDuplex) return null; - lowerer.defineClassValueConstruct(writable); - const value = lowerer.declareHiddenLocal("%instanceValue", left.type); - const cls = lowerer.declareHiddenLocal("%instanceClass", classValue.type); - const valueRef = varRef(value.id, left.type, loc); - const clsRef = varRef(cls.id, classValue.type, loc); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: value.id, init: left, loc }, - { kind: "varDecl", localId: cls.id, init: classValue, loc }, - ], - result: { - kind: "logical", - op: "||", - left: { kind: "instanceOfValue", value: valueRef, classValue: clsRef, type: BOOL, loc }, - right: { - kind: "logical", - op: "&&", - left: { - kind: "bin", - op: "===", - left: clsRef, - right: { - kind: "classRef", - className: "%Writable", - type: { kind: "classval", className: "%Writable" }, - loc, - }, - type: BOOL, - loc, - }, - right: { kind: "instanceOf", value: valueRef, className: "%Duplex", type: BOOL, loc }, - type: BOOL, - loc, - }, - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; -} - -/** A statically decided `instanceof` still reads its class after the left - * operand: a class with an initialization flag throws there before its - * declaration ran. */ -function readsClassAfter( - lowerer: Lowerer, - target: ClassInfo, - right: ts.Expression, - stmts: IrStmt[], - result: IrExpr, -): IrExpr { - if (!lowerer.classInitFlags.has(target.def.name)) { - if (stmts.length === 0) return result; - return { kind: "seqExpr", stmts, result, type: result.type, loc: result.loc }; - } - const read = lowerer.lowerExpr(right); - return { - kind: "seqExpr", - stmts: [...stmts, { kind: "exprStmt", expr: read, loc: locOf(right) }], - result, - type: result.type, - loc: result.loc, - }; -} - -/** `#name in obj` — the ergonomic brand check. In this closed world a - * brand is held by exactly the instances of the declaring class - * (subclasses included — construction always runs the declaring class's - * own initializers), so the test IS `obj instanceof `: - * statically decided when the receiver's class sits at/below the - * declarer (true) or in a disjoint subtree (false) — both folds under - * the instanceof purity rule — and a runtime interval test when the - * declarer sits strictly BELOW the receiver's static class (the - * narrowing use; tsc types the true branch at the class, and reads - * bridge through maybeNarrow's downcast exactly like instanceof). tsc - * confines the spelling to the declaring class's body and rejects - * primitive/unknown receivers, so Node's in-operator TypeError is - * unreachable in compilable programs. Timing residue: JS installs - * brands DURING construction, so a check reachable from a base - * constructor can observe false mid-construction where this answers - * true (SEMANTICS.md). */ -function lowerPrivateIn( - lowerer: Lowerer, - expr: ts.BinaryExpression, - priv: ts.PrivateIdentifier, - loc: SrcLoc, -): IrExpr { - const pname = priv.text; - // The declaring class: the nearest enclosing class declaring the name - // (JS scoping — an inner class's spelling shadows an outer one's). - let classDecl: ts.ClassLikeDeclaration | null = null; - let staticBrand = false; - for (let n: ts.Node | undefined = priv.parent; n; n = n.parent) { - if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) { - const owner = n.members.nodes.find((m) => { - const name = (m as { name?: ts.PropertyName }).name; - return name !== undefined && ts.isPrivateIdentifier(name) && name.text === pname; - }); - if (owner) { - classDecl = n; - staticBrand = - ts.canHaveModifiers(owner) && - ts.getModifiers(owner)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true; - break; - } - } - } - // tsc rejects the spelling outside a declaring class body; defensive. - if (!classDecl) - lowerer.unsupported("SC1090", expr, `'${pname} in …' outside a class declaring '${pname}'`); - if (staticBrand) { - lowerer.unsupported( - "SC1090", - expr, - `'${pname} in …' brand checks for private STATICS (JS brands the declaring class OBJECT, not instances — compare against the class value directly)`, - ); - } - const info = - lowerer.currentClass?.decl === classDecl - ? lowerer.currentClass - : (() => { - const sym = classDecl.name - ? lowerer.checker.getSymbolAtLocation(classDecl.name) - : undefined; - return sym ? lowerer.classBySymbol.get(sym) : undefined; - })(); - if (!info) { - lowerer.unsupported( - "SC1090", - expr, - `'${pname} in …' where the declaring class has no lowering (see the class declaration's own diagnostic)`, - ); - } - // A GENERIC family: JS has ONE runtime class and ONE brand for every - // instantiation, while these layouts mint one class per instantiation - // — a cross-instantiation check would answer false where Node says - // true, so the family reports SC1090 instead of splitting the brand. - if (info.generic || info.genericInstance) { - lowerer.unsupported( - "SC1090", - expr, - `'${pname} in …' inside a generic class (JS shares one brand across every instantiation; these layouts mint one class per instantiation)`, - ); - } - const recv = lowerer.lowerExpr(expr.right); - const declName = info.def.name; - // A UNION of class instances (`o: Counter | Helper` — the - // discriminating use): every object arm answers membership STATICALLY - // (at/below the declarer → true, disjoint subtree → false), so the - // whole test collapses to runtime TAG tests — the record-shape 'in' - // discrimination, brand form. An arm ABOVE the declarer answers per - // VALUE (its slot can hold branded and unbranded instances), and - // non-object arms have no brand story — both fence. - if (recv.type.kind === "union") { - const unionId = recv.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms ?? []; - const answers: { tag: number; has: boolean }[] = []; - let staticAnswers = arms.length > 0; - for (const arm of arms) { - const tag = lowerer.armTag(unionId, arm); - if (tag < 0 || arm.kind !== "object" || lowerer.isSubclassOf(declName, arm.className)) { - staticAnswers = false; - break; - } - answers.push({ - tag, - has: arm.className === declName || lowerer.isSubclassOf(arm.className, declName), - }); - } - if (staticAnswers) { - const pureRecv = - recv.kind === "varRef" || recv.kind === "recordGet" || recv.kind === "fieldGet"; - const isTag = (tag: number, negated: boolean): IrExpr => ({ - kind: "unionIsTag", - unionId, - tag, - negated, - value: recv, - type: BOOL, - loc, - }); - const trues = answers.filter((a) => a.has); - if (trues.length === answers.length || trues.length === 0) { - const ans = trues.length !== 0; - if (pureRecv) return { kind: "boolLit", value: ans, type: BOOL, loc }; - // Constant either way, receiver still evaluates once: one tag - // test whose branches agree (the record-'in' rule verbatim). - return { - kind: "ternary", - cond: isTag(answers[0]!.tag, false), - then: { kind: "boolLit", value: ans, type: BOOL, loc }, - else_: { kind: "boolLit", value: ans, type: BOOL, loc }, - type: BOOL, - loc, - }; - } - if (trues.length === 1) return isTag(trues[0]!.tag, false); - const falses = answers.filter((a) => !a.has); - if (falses.length === 1) return isTag(falses[0]!.tag, true); - if (pureRecv) { - let out: IrExpr = isTag(trues[0]!.tag, false); - for (const t of trues.slice(1)) { - out = { - kind: "logical", - op: "||", - left: out, - right: isTag(t.tag, false), - type: BOOL, - loc, - }; - } - return out; - } - lowerer.unsupported( - "SC1090", - expr, - `statically-decided '${pname} in …' on computed receivers (bind the value to a variable first)`, - ); - } - } - if (recv.type.kind !== "object") { - lowerer.unsupported( - "SC1090", - expr, - `'${pname} in …' on '${lowerer.fmt(recv.type)}' receivers (only class-instance receivers — and unions of classes below or beside the declarer — have a static brand answer; narrow first)`, - ); - } - const lhsName = recv.type.className; - const lhsInfo = lowerer.classes.get(lhsName); - if (!lhsInfo) throw new InternalCompilerError(`lowerer bug: unknown class ${lhsName}`); - if (lowerer.isSubclassOf(declName, lhsName)) { - // The narrowing direction: the declarer strictly below the - // receiver's static class — a strict subclass relation puts both in - // a hierarchy, so the vtable interval test always exists. - return { kind: "instanceOf", value: recv, className: declName, type: BOOL, loc }; - } - const value = lhsName === declName || lowerer.isSubclassOf(lhsName, declName); - if (recv.kind === "varRef") return { kind: "boolLit", value, type: BOOL, loc }; - lowerer.unsupported( - "SC1090", - expr, - `statically-decided '${pname} in …' on computed operands (bind the value to a variable first)`, - ); -} - -/** The string keys every record inherits from Object.prototype. */ -const OBJECT_PROTOTYPE_KEYS: ReadonlySet = new Set([ - "constructor", - "__defineGetter__", - "__defineSetter__", - "hasOwnProperty", - "__lookupGetter__", - "__lookupSetter__", - "isPrototypeOf", - "propertyIsEnumerable", - "toString", - "valueOf", - "__proto__", - "toLocaleString", -]); - -/** `"key" in v` — the key-presence test. Three lowered receivers: - * process.env (getenv(3) presence — Node-exact, an empty value still - * counts as present), and monomorphic record shapes, where the answer is - * type-directed: a declared non-optional field is a compile-time `true`, - * a missing field (no index signature) a compile-time `false` unless - * Object.prototype has it — both - * folds limited to side-effect-free receivers, the lowerInstanceOf rule — - * and an OPTIONAL field (undefined-armed union) is a runtime presence - * test on the slot: an omitted or deleted property is absent, while a - * property explicitly holding `undefined` is present, as in JS - * (`"a" in {a: undefined}` is true). Union receivers (the `in`-narrowing - * idiom over multiple shapes), index-signature keys, class instances, - * and dyn/unknown stay fenced. Keys are literal strings — a computed key - * over a shape would need the runtime key table. */ -function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc): IrExpr { - if ( - lowerer.isStdlibGlobal(expr.right, "process") && - lowerer.foldedStringKeyOf(expr.left) === "hrtime" - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - const globalKey = globalSymbolKey(lowerer, expr.right, expr.left); - if (globalKey) - return { kind: "libCall", fn: "dyn.globalSymbolHas", args: [globalKey], type: BOOL, loc }; - // `#name in obj` — the ergonomic brand check (ES2022) — resolves - // before any string-key machinery: the left operand is a private - // NAME, not a value. - if (ts.isPrivateIdentifier(expr.left)) { - return lowerPrivateIn(lowerer, expr, expr.left, loc); - } - // Compile-time-known STRING keys fold — literals, and the same - // const/enum-literal and template folding computed property keys get - // (foldedStringKeyOf); runtime-valued keys keep the fence. - // NUMERIC literal keys answer on ARRAY receivers through the shared - // presence query. Arrays retain holes independently from length, and - // noncanonical numeric keys live in their ordinary-property table, so - // a length comparison is not an honest `in` answer. - { - let kNode = expr.left; - while (ts.isParenthesizedExpression(kNode)) kNode = kNode.expression; - if ( - ts.isNumericLiteral(kNode) && - lowerer.mapTypeOf(lowerer.typeOf(expr.right))?.kind === "array" - ) { - const recvArr = lowerer.lowerExpr(expr.right); - if (recvArr.type.kind === "array") { - const n = Number(kNode.text); - return { - kind: "arrayHas", - arr: recvArr, - index: { kind: "numLit", value: n, type: F64, loc }, - type: BOOL, - loc, - }; - } - } - } - // A RUNTIME numeric key over an ARRAY receiver (`if (i in arr)` in a - // loop) asks the same slot/property presence query as the literal fold - // above and `Object.hasOwn`. The checked-dynamic fallback below would - // box the receiver and materialize every element on each evaluation, - // which makes such loops quadratic. A Number key never names an - // inherited property: neither Array.prototype nor Object.prototype has - // index-like or numeric-string keys. JS evaluates the key before the - // receiver, so the key is stabilized in a hidden local first. - if ( - lowerer.mapTypeOf(lowerer.typeOf(expr.left))?.kind === "f64" && - lowerer.mapTypeOf(lowerer.typeOf(expr.right))?.kind === "array" - ) { - const k = lowerer.lowerExpr(expr.left); - const recvArr = lowerer.lowerExpr(expr.right); - if (k.type.kind === "f64" && recvArr.type.kind === "array") { - const keyLocal = lowerer.declareHiddenLocal("%inIndex", F64); - return { - kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: keyLocal.id, init: k, loc }], - result: { - kind: "arrayHas", - arr: recvArr, - index: varRef(keyLocal.id, F64, loc), - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - } - const key = foldedStringKeyOf(lowerer, expr.left); - if (key === null) { - // A RUNTIME string key over an INDEX-SIGNATURE record receiver (the - // `names.filter((k) => k in config)` idiom): the interned key- - // presence helper — declared fields answer statically per name - // (optional slots by their runtime presence), then the overflow - // map's live keys. Other receivers keep the - // fence: fixed shapes want the literal folds above, and there is no - // runtime key table to ask. - const rIn = lowerRuntimeKeyIn(lowerer, expr, loc); - if (rIn) return rIn; - // A runtime key over a CHECKED-DYNAMIC receiver (`name in - // agent.sockets` — both sides computed; the checker may type the - // receiver as a Dict while the VALUE lives in the checked-dynamic tree, so the - // LOWERED type decides): the dyn presence answer, with the key - // stringified like every property key (o[k] is o[String(k)] in JS; - // `in` shares the coercion). - { - const probed = tryLowerExpression(lowerer, expr.right); - if (probed && lowerer.dynConvertible(probed.type)) { - const k = lowerer.lowerExpr(expr.left); // JS order: the key evaluates first - if (lowerer.dynConvertible(k.type)) { - const boxed = lowerer.coerceToExpected(k, DYN); - const keyLocal = lowerer.declareHiddenLocal("%inKey", DYN); - return { - kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: keyLocal.id, init: boxed, loc }], - result: { - kind: "libCall", - fn: "dyn.hasKeyComputed", - args: [ - lowerer.coerceToExpected(lowerer.lowerExpr(expr.right), DYN), - varRef(keyLocal.id, DYN, loc), - ], - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - } - } - lowerer.unsupported( - "SC1090", - expr.left, - "'in' with computed (non-literal) keys (a const whose type is one string literal folds; a string-typed key answers over index-signature record receivers)", - ); - } - // A receiver re-read (the static folds below) is safe for plain reads - // AND for side-effect-free expressions (an object literal of literals - // — the `"a" in { a: true }` shape). - const pureRecvNode = sideEffectFreeOptionValue(expr.right); - if (lowerer.isProcessEnv(expr.right)) { - // `"NO_COLOR" in process.env`: presence via the one envGet intrinsic — - // getenv(3) returning non-NULL is Node's `in` on process.env exactly. - const envType = lowerer.envValueType(); - if (envType.kind !== "union") - throw new InternalCompilerError("lowerer bug: env value type is not a union"); - const undefTag = lowerer.armTag(envType.unionId, UNDEFINED_T); - const read: IrExpr = { - kind: "libCall", - fn: "process.envGet", - args: [{ kind: "strLit", value: key, type: STRING, loc }], - type: envType, - loc, - }; - return { - kind: "unionIsTag", - unionId: envType.unionId, - tag: undefTag, - negated: true, - value: read, - type: BOOL, - loc, - }; - } - if (key === "code" && isExecExceptionType(lowerer, lowerer.typeOf(expr.right))) { - refuseExecExceptionCode(lowerer, expr); - } - let recv = lowerer.lowerExpr(expr.right); - const classInfo = - recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined; - if ( - (classInfo && (classInfo.callableBase || hasRuntimeStatics(classInfo))) || - (isJsSourceFile(expr.getSourceFile()) && - recv.type.kind === "func" && - lowerer.dynConvertible(recv.type)) - ) { - recv = lowerer.coerceToExpected(recv, DYN); - } - const siteType = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); - if ( - recv.type.kind === "union" && - siteType?.kind === "union" && - !typeEquals(recv.type, siteType) - ) { - const helper = lowerer.narrowedRetagHelper( - expr.right, - recv.type.unionId, - siteType.unionId, - loc, - ); - if (helper) recv = { kind: "call", callee: helper, args: [recv], type: siteType, loc }; - } - // Error-rooted receivers (builtin or user subclass — the isErrnoException - // predicate's `err instanceof Error && "code" in err` shape): `code` - // answers from the runtime error's code slot (stamped by fs/system/ - // event/spawn errors, absent on plain constructions — exactly Node's - // own-property answer), and the always-declared members fold true. - // Everything else fences: the runtime object carries no other dynamic - // properties to ask (`stack` would be a lie — Node has one, we don't). - if (recv.type.kind === "object") { - // %DOMException first: `'cause' in e` answers the options form's - // own-property record (the runtime slot), and code/name/message are - // always present. - if (recv.type.className === "%DOMException") { - if (key === "cause") { - return { kind: "libCall", fn: "error.domHasCause", args: [recv], type: BOOL, loc }; - } - if ( - (key === "code" || key === "message" || key === "name") && - (recv.kind === "varRef" || recv.kind === "caughtNarrow") - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - } - let info = lowerer.classes.get(recv.type.className) ?? null; - while (info && info.base) info = info.base; - if (info?.def.name === "%Error") { - if (key === "cause") { - return { kind: "libCall", fn: "error.hasCause", args: [recv], type: BOOL, loc }; - } - if (key === "code") { - const codeType = lowerer.envValueType(); - if (codeType.kind !== "union") - throw new InternalCompilerError("lowerer bug: error code type is not a union"); - const undefTag = lowerer.armTag(codeType.unionId, UNDEFINED_T); - const read: IrExpr = { - kind: "libCall", - fn: "error.code", - args: [recv], - type: codeType, - loc, - }; - return { - kind: "unionIsTag", - unionId: codeType.unionId, - tag: undefTag, - negated: true, - value: read, - type: BOOL, - loc, - }; - } - if ( - (key === "message" || key === "name") && - (recv.kind === "varRef" || recv.kind === "caughtNarrow") - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - expr, - `'in' with the key '${key}' on Error receivers (code answers from the error's code slot; message and name are always present)`, - ); - } - } - // A dyn receiver (`"portless" in pkg` after the `typeof pkg === - // "object"` guard): own-member presence on the checked-dynamic tree — tsc admits `in` - // only on object-typed operands, so unit receivers are unreachable. - if (recv.type.kind === "dyn") { - return { kind: "dynHasKey", key, value: recv, type: BOOL, loc }; - } - // `"k" in u` over a UNION whose arms are FIXED record shapes: every - // arm answers membership STATICALLY (a declared non-optional field is - // always present, an undeclared name never is), so the whole test - // collapses to runtime TAG tests — tsc's own narrowing then types the - // branches (the discriminating-`in` idiom). Optional (undefined-armed) - // fields and tuple/index-signature arms answer per VALUE, not per arm - // — those unions keep the fence below. - if (recv.type.kind === "union") { - const unionId = recv.type.unionId; - const arms = lowerer.unions.get(unionId)?.arms ?? []; - const answers: { tag: number; has: boolean }[] = []; - let staticAnswers = arms.length > 0; - for (const arm of arms) { - const tag = lowerer.armTag(unionId, arm); - const shape = arm.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; - if (tag < 0 || !shape || shape.tuple || shape.indexValue) { - staticAnswers = false; - break; - } - const f = shape.fields.find((x) => x.name === key); - const inherited = OBJECT_PROTOTYPE_KEYS.has(key); - if ( - f && - !inherited && - f.type.kind === "union" && - lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 - ) { - staticAnswers = false; // an optional slot: presence is per-value - break; - } - // Accessor properties are own properties to `in` (Node answers - // true without invoking the getter) — either slot present makes - // the name a member. - const acc = shape.fields.some((x) => x.name === `%get:${key}` || x.name === `%set:${key}`); - answers.push({ tag, has: f !== undefined || acc || inherited }); - } - if (staticAnswers) { - const pureRecv = - recv.kind === "varRef" || - recv.kind === "recordGet" || - recv.kind === "fieldGet" || - pureRecvNode; - const isTag = (tag: number, negated: boolean): IrExpr => ({ - kind: "unionIsTag", - unionId, - tag, - negated, - value: recv, - type: BOOL, - loc, - }); - const trues = answers.filter((a) => a.has); - if (trues.length === answers.length || trues.length === 0) { - // Constant either way — but JS still EVALUATES the operand (it - // may throw: an ambient-const read is a ReferenceError). Pure - // reads fold; anything else rides one tag test whose branches - // agree, evaluating the receiver exactly once. - const ans = trues.length !== 0; - if (pureRecv) return { kind: "boolLit", value: ans, type: BOOL, loc }; - return { - kind: "ternary", - cond: isTag(answers[0]!.tag, false), - then: { kind: "boolLit", value: ans, type: BOOL, loc }, - else_: { kind: "boolLit", value: ans, type: BOOL, loc }, - type: BOOL, - loc, - }; - } - // One deciding arm (either polarity): a single tag test, receiver - // evaluated once — no purity requirement. - if (trues.length === 1) return isTag(trues[0]!.tag, false); - const falses = answers.filter((a) => !a.has); - if (falses.length === 1) return isTag(falses[0]!.tag, true); - // Several arms on each side: the OR chain re-reads the receiver, - // so only side-effect-free reads qualify. - if (pureRecv) { - let out: IrExpr = isTag(trues[0]!.tag, false); - for (const t of trues.slice(1)) { - out = { - kind: "logical", - op: "||", - left: out, - right: isTag(t.tag, false), - type: BOOL, - loc, - }; - } - return out; - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'in' on computed receivers (bind the value to a variable first)", - ); - } - } - if (recv.type.kind !== "record") { - lowerer.unsupported( - "SC1090", - expr, - `'in' on '${lowerer.fmt(recv.type)}' receivers (only process.env, Error instances, record-typed values, and unions of fixed record shapes answer; ${NARROW_FIRST})`, - ); - } - const shape = lowerer.shapes.get(recv.type.shapeId); - if (!shape) throw new InternalCompilerError(`lowerer bug: unknown shape ${recv.type.shapeId}`); - const field = shape.fields.find((f) => f.name === key); - if (field) { - if ( - !OBJECT_PROTOTYPE_KEYS.has(key) && - field.type.kind === "union" && - lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0 - ) { - // Optional slot: the key is present unless the field is absent - // (omitted or deleted); an explicit undefined is present. - return lowerer.recordFieldPresent(recv, recv.type.shapeId, key, loc); - } - // A declared non-optional field always exists on every value of the - // shape — statically true, but folding may only drop a - // side-effect-free receiver read. - if ( - recv.kind === "varRef" || - recv.kind === "recordGet" || - recv.kind === "fieldGet" || - pureRecvNode - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'in' on computed receivers (bind the value to a variable first)", - ); - } - // Accessor properties answer `in` as own members (Node: true, getter - // NOT invoked) — statically true under the same purity discipline as - // declared non-optional fields. - if (shape.fields.some((f) => f.name === `%get:${key}` || f.name === `%set:${key}`)) { - if ( - recv.kind === "varRef" || - recv.kind === "recordGet" || - recv.kind === "fieldGet" || - pureRecvNode - ) { - return { kind: "boolLit", value: true, type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'in' on computed receivers (bind the value to a variable first)", - ); - } - // Records are ordinary objects: Object.prototype's members are in them. - const inherited = OBJECT_PROTOTYPE_KEYS.has(key); - if (shape.indexValue && !inherited) { - lowerer.unsupported( - "SC1090", - expr, - "'in' over index-signature keys (read the key and test '!== undefined' instead)", - ); - } - if ( - recv.kind === "varRef" || - recv.kind === "recordGet" || - recv.kind === "fieldGet" || - pureRecvNode - ) { - // A null-prototype record (Object.groupBy) has no inherited names. - const nullProtoT = inherited ? nullProtoRecordShape(lowerer, recv.type) : null; - if (nullProtoT) { - return { - kind: "unary", - op: "!", - operand: recordNullProtoTest(recv, nullProtoT.shapeId, loc), - type: BOOL, - loc, - }; - } - return { kind: "boolLit", value: inherited, type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'in' on computed receivers (bind the value to a variable first)", - ); -} - -/** The runtime-key `in` (see lowerInExpression): `k in r` where k is a - * runtime string and r an index-signature record — an interned - * `%rec.haskey.(k, r)` walks the declared names (a string-equality - * chain: non-optional fields and accessor slots answer true, optional - * slots answer their per-value tag test) and then probes the overflow - * map's hashed key set. Null when the pair is outside that shape (the - * caller keeps its fence). */ -function lowerRuntimeKeyIn( - lowerer: Lowerer, - expr: ts.BinaryExpression, - loc: SrcLoc, -): IrExpr | null { - if (lowerer.mapTypeOf(lowerer.typeOf(expr.left))?.kind !== "string") return null; - const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.right)); - if (recvT?.kind !== "record") return null; - const shape = lowerer.shapes.get(recvT.shapeId); - if (!shape?.indexValue || shape.tuple) return null; - const keyIr = lowerer.lowerExprExpecting(expr.left, STRING); - const recv = lowerer.lowerExprExpecting(expr.right, recvT); - const hkey = `haskey:${recvT.shapeId}`; - let helper = lowerer.valueHelpers.get(hkey); - if (!helper) { - helper = `%rec.haskey.${lowerer.valueHelpers.size}`; - lowerer.valueHelpers.set(hkey, helper); - const recT: IrType = { kind: "record", shapeId: recvT.shapeId }; - - const k = varRef("k.0", STRING, loc); - const r = varRef("r.0", recT, loc); - const body: IrStmt[] = []; - const ret = (value: IrExpr): IrStmt => ({ kind: "return", value, loc }); - for (const f of shape.fields) { - const accessor = f.name.startsWith("%get:") || f.name.startsWith("%set:"); - if (f.name.startsWith("%") && !accessor) continue; - const name = accessor ? f.name.slice(5) : f.name; - const eq: IrExpr = { - kind: "strEq", - negated: false, - left: k, - right: { kind: "strLit", value: name, type: STRING, loc }, - type: BOOL, - loc, - }; - const answer: IrExpr = accessor - ? { kind: "boolLit", value: true, type: BOOL, loc } - : lowerer.recordFieldPresent(r, recvT.shapeId, f.name, loc); - body.push({ kind: "if", cond: eq, then: [ret(answer)], else_: null, loc }); - } - // Overflow keys answer through the map's hashed presence probe (a - // stored undefined value is still present), not a scan over a fresh - // key snapshot, which made `k in dict` loops quadratic. - const ovfHas: IrExpr = { - kind: "recordOvfHas", - obj: r, - shapeId: recvT.shapeId, - key: k, - type: BOOL, - loc, - }; - // A miss in the record's own keys still finds Object.prototype's, - // unless the record has a null prototype (Object.groupBy). - let protoName: IrExpr | null = null; - for (const name of OBJECT_PROTOTYPE_KEYS) { - if (shape.fields.some((f) => f.name === name)) continue; - const eq: IrExpr = { - kind: "strEq", - negated: false, - left: k, - right: { kind: "strLit", value: name, type: STRING, loc }, - type: BOOL, - loc, - }; - protoName = - protoName === null - ? eq - : { kind: "logical", op: "||", left: protoName, right: eq, type: BOOL, loc }; - } - if (protoName !== null && nullProtoRecordShape(lowerer, recT)) { - protoName = { - kind: "logical", - op: "&&", - left: protoName, - right: { - kind: "unary", - op: "!", - operand: recordNullProtoTest(r, recvT.shapeId, loc), - type: BOOL, - loc, - }, - type: BOOL, - loc, - }; - } - body.push( - ret( - protoName === null - ? ovfHas - : { kind: "logical", op: "||", left: ovfHas, right: protoName, type: BOOL, loc }, - ), - ); - lowerer.liftedFns.push({ - name: helper, - params: [ - { localId: "k.0", name: "k", type: STRING }, - { localId: "r.0", name: "r", type: recT }, - ], - returnType: BOOL, - locals: [ - { id: "k.0", name: "k", type: STRING, mutable: true }, - { id: "r.0", name: "r", type: recT, mutable: true }, - ], - body, - loc, - }); - } - return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; -} - -/** RegExp.prototype's flag getters and the flag each one reads. */ -const REGEX_FLAG_GETTERS: ReadonlyMap = new Map([ - ["hasIndices", "d"], - ["global", "g"], - ["ignoreCase", "i"], - ["multiline", "m"], - ["dotAll", "s"], - ["unicode", "u"], - ["unicodeSets", "v"], - ["sticky", "y"], -]); - /** A regex literal `/ab+c/gi` → regexLit (fresh state per evaluation, * shared bytecode per (pattern, flags) pair). The TS parser has already syntax-checked the literal; * what remains here is the flag-alphabet fence (d is a @@ -14525,7 +2191,9 @@ export function lowerRegexLiteral(lowerer: Lowerer, expr: ts.RegularExpressionLi * only needs to track escapes, character classes, and `(` kinds; tsc * has already syntax-checked the literal, so a malformed pattern here * answers null and the engine's lazy compile reports it. */ -function namedCaptureGroupsOfPattern(pattern: string): { name: string; index: number }[] | null { +export function namedCaptureGroupsOfPattern( + pattern: string, +): { name: string; index: number }[] | null { const groups: { name: string; index: number }[] = []; let captureIndex = 0; let inClass = false; @@ -14577,7 +2245,7 @@ function namedCaptureGroupsOfPattern(pattern: string): { name: string; index: nu * `const regexp = /(?\d+)/` shape), or `new RegExp("...")` over a * string literal (the cooked text IS the pattern). Null when the regex * only exists at runtime — .groups consumers fence there. */ -function staticRegexPatternOf(lowerer: Lowerer, e: ts.Expression): string | null { +export function staticRegexPatternOf(lowerer: Lowerer, e: ts.Expression): string | null { let expr = e; for (;;) { if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { @@ -14613,7 +2281,7 @@ function staticRegexPatternOf(lowerer: Lowerer, e: ts.Expression): string | null * VariableDeclaration under a const list) — the one-hop provenance step * the regex traces walk. Null for let/var (reassignable), params, * destructured bindings, and unresolvable names. */ -function constInitializerOf(lowerer: Lowerer, ident: ts.Identifier): ts.Expression | null { +export function constInitializerOf(lowerer: Lowerer, ident: ts.Identifier): ts.Expression | null { const sym = lowerer.resolveValueSymbol(ident); const decl = sym ? sym.valueDeclaration : undefined; if (decl === undefined || !ts.isVariableDeclaration(decl) || decl.initializer === undefined) @@ -14623,137 +2291,6 @@ function constInitializerOf(lowerer: Lowerer, ident: ts.Identifier): ts.Expressi return decl.initializer; } -/** The named-group table of the regex that PRODUCED a match-result - * expression — the `.groups` desugar's provenance question. Traces - * (through parens, `!`, and const bindings): - * - `re.exec(s)` / `s.match(re)` → re's static pattern - * - a matchAll ROW: `rows[i]`, the for-of binding over a direct - * `s.matchAll(re)` or a stored const drain → re's static pattern - * Answers null when the producing regex isn't statically known (the - * caller keeps its fence), or the groups list (possibly empty — a - * traced regex WITHOUT named groups is the Node-undefined case). */ -export function matchResultNamedGroupsOf( - lowerer: Lowerer, - e: ts.Expression, -): { name: string; index: number }[] | null { - let producer = e; - while (ts.isParenthesizedExpression(producer) || ts.isNonNullExpression(producer)) - producer = producer.expression; - if (ts.isIdentifier(producer)) { - const init = constInitializerOf(lowerer, producer); - if (init !== null) return matchResultNamedGroupsOf(lowerer, init); - } - const reExpr = matchProducerRegexOf(lowerer, e); - if (reExpr === null) return null; - if ( - ts.isCallExpression(producer) && - ts.isPropertyAccessExpression(producer.expression) && - producer.expression.name.text === "match" - ) { - const flags = staticRegexFlagsOf(lowerer, reExpr); - if (flags === null) return null; - // Global String.match returns whole matches without a groups - // property, even when the pattern contains named captures. - if (flags.includes("g")) return []; - } - const pattern = staticRegexPatternOf(lowerer, reExpr); - if (pattern === null) return null; - return namedCaptureGroupsOfPattern(pattern); -} - -function staticRegexFlagsOf(lowerer: Lowerer, node: ts.Expression): string | null { - let expr = node; - while (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) expr = expr.expression; - if (ts.isRegularExpressionLiteral(expr)) return expr.text.slice(expr.text.lastIndexOf("/") + 1); - if (ts.isIdentifier(expr)) { - const init = constInitializerOf(lowerer, expr); - return init !== null ? staticRegexFlagsOf(lowerer, init) : null; - } - if ( - ts.isNewExpression(expr) && - ts.isIdentifier(expr.expression) && - lowerer.isStdlibGlobal(expr.expression, "RegExp") - ) { - const flags = expr.arguments?.nodes[1]; - if (!flags) return ""; - if (ts.isStringLiteral(flags) || ts.isNoSubstitutionTemplateLiteral(flags)) return flags.text; - } - return null; -} - -/** The regex EXPRESSION whose match produced `e` (see - * matchResultNamedGroupsOf for the traced shapes). */ -function matchProducerRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | null { - let expr = e; - for (;;) { - if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { - expr = expr.expression; - continue; - } - break; - } - // The direct producers: re.exec(s) / s.match(re) — the receiver's - // mapped type pins the STDLIB operation (a regex for exec, a string - // or its nullable spelling for match — the claim string-and-regexp - // makes), so a user method that happens to be named `match` with a - // regex argument never traces. - if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression)) { - const name = expr.expression.name.text; - const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression.expression)); - if (name === "exec" && expr.arguments.nodes.length === 1 && recvT?.kind === "regex") { - return expr.expression.expression; - } - if ( - name === "match" && - expr.arguments.nodes.length === 1 && - (recvT?.kind === "string" || - (recvT !== null && recvT !== undefined && nullableStringType(lowerer, recvT))) && - lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "regex" - ) { - return expr.arguments.nodes[0]!; - } - } - // A matchAll ROW by element access: rows[i] with rows tracing to the - // drain call — or the direct s.matchAll(re)[i] spelling. - if (ts.isElementAccessExpression(expr)) { - return matchAllRegexOf(lowerer, expr.expression); - } - // A row picked by `rows.find(cb)`, `rows.findLast(cb)` or `rows.at(i)`. - if ( - ts.isCallExpression(expr) && - ts.isPropertyAccessExpression(expr.expression) && - MATCH_ROW_PICK_METHODS.has(expr.expression.name.text) && - lowerer.isStdlibMember(expr.expression) - ) { - return matchAllRegexOf(lowerer, expr.expression.expression); - } - if (ts.isIdentifier(expr)) { - // The for-of binding over a matchAll drain: `for (const m of - // s.matchAll(re))` (or over a stored const rows). - const sym = lowerer.resolveValueSymbol(expr); - const decl = sym ? sym.valueDeclaration : undefined; - if ( - decl !== undefined && - ts.isVariableDeclaration(decl) && - ts.isVariableDeclarationList(decl.parent) && - ts.isForOfStatement(decl.parent.parent) - ) { - return matchAllRegexOf(lowerer, decl.parent.parent.expression); - } - // A stored const match result: `const m = re.exec(s)`. - const init = constInitializerOf(lowerer, expr); - if (init !== null) return matchProducerRegexOf(lowerer, init); - // The element parameter of an array callback over matchAll rows: - // `[...s.matchAll(re)].map((m) => m.groups)`, and Array.from's - // mapping function over `s.matchAll(re)`. - if (decl !== undefined && ts.isParameter(decl)) return matchAllCallbackRegexOf(lowerer, decl); - } - return null; -} - -/** Array methods that return one row of the receiver. */ -const MATCH_ROW_PICK_METHODS = new Set(["find", "findLast", "at"]); - /** Array methods whose result holds rows of the receiver. */ const MATCH_ROW_SUBSET_METHODS = new Set(["filter", "slice", "toReversed", "toSorted"]); @@ -14772,7 +2309,7 @@ const MATCH_ROW_CALLBACK_METHODS = new Set([ /** The regex behind `param` when it is the first parameter of an inline * callback whose rows come from a matchAll drain. */ -function matchAllCallbackRegexOf( +export function matchAllCallbackRegexOf( lowerer: Lowerer, param: ts.ParameterDeclaration, ): ts.Expression | null { @@ -14817,7 +2354,7 @@ export function matchCallbackParamGroupsOf( } /** The regex argument of a (possibly const-stored) `s.matchAll(re)`. */ -function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | null { +export function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | null { let expr = e; for (;;) { if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { @@ -14856,631 +2393,6 @@ function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | nu return null; } -/** `m.groups` on a match result whose producing regex is statically - * known: the honest slice already HOLDS every named group's value at - * its capture index, so the groups object is a compile-time record - * projection — `{ year: m[1], month: m[2] }` — built by one interned - * helper per (shape, index list, receiver type). Node's exact shape - * decisions: - * - no named groups → `undefined` (the identifier-receiver shapes — - * a call receiver would lose its own null trap, so those keep the - * member fence); - * - a nullable receiver (the JS `s.match(re).groups` idiom) throws - * Node's exact TypeError on the unit arms; - * - every declared name is a key (nonparticipating groups hold undefined, - * exactly the slice elements they project); - * - ES2025 duplicate names (distinct alternatives) project the first - * participating occurrence, including a participating empty string; - * - key order is group declaration order (declaredOrder), Node's own. - * The record has a null prototype, like Node's groups object. A - * JavaScript callback parameter holds the row as a dynamic array; the - * projection reads its elements through dynamic reads. Null when the - * receiver isn't a match slice or the regex isn't traceable — the - * caller's member fence (with the groups hint) names the gap. */ -function lowerMatchGroupsRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - if (expr.name.text !== "groups") return null; - // `m?.groups` re-dispatches through the optional-chain machinery, which - // narrows the receiver to its match arm first. - if (expr.questionDotToken !== undefined && !lowerer.chainHandled.has(expr)) return null; - const loc = locOf(expr); - const strArr = arrayOf(STRING); - const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (!recvT || !isMatchSliceType(lowerer, recvT) || !lowerer.isStdlibMember(expr)) return null; - const groups = matchResultNamedGroupsOf(lowerer, expr.expression); - // An untraced regex: TypeScript reaches the member fence; JavaScript - // would otherwise read the slice's absent expando as undefined. - if (groups === null && isJsSourceFile(expr.getSourceFile())) lowerer.stdlibMemberFence(expr); - if (groups === null) return null; - if (groups.length === 0) { - // Node: a match result of a group-less regex answers undefined — - // the unit literal, exactly the `undefined` identifier's lowering. - // Identifier receivers only (evaluation-free): a CALL receiver's - // exec/match must still run and null-trap, which a folded unit - // cannot carry — those keep the fence. - let r: ts.Expression = expr.expression; - while (ts.isParenthesizedExpression(r) || ts.isNonNullExpression(r)) r = r.expression; - if (ts.isIdentifier(r) && typeEquals(recvT, strArr)) { - return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; - } - return null; - } - const recv = lowerer.lowerExpr(expr.expression); - // A JavaScript callback parameter holds the row as a dynamic array. - const dynRow = recv.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()); - if (!dynRow && !isMatchSliceType(lowerer, recv.type)) return null; - return lowerGroupsProjection(lowerer, recv, groups, loc); -} - -/** True when the IR type is a string-or-units union — the Dict - * member spelling (`process.versions.openssl`), match's nullable - * receiver claim. */ -function nullableStringType(lowerer: Lowerer, t: IrType): boolean { - if (t.kind !== "union") return false; - const arms = lowerer.unions.get(t.unionId)?.arms ?? []; - return ( - arms.some((a) => a.kind === "string") && arms.every((a) => a.kind === "string" || isUnitType(a)) - ); -} - -/** True when the IR type is the honest match slice — `string[]`, or a - * union of it with unit arms only (`string[] | null`). The groups - * projection's receiver gate, shared with the destructure interception. */ -export function isMatchSliceType(lowerer: Lowerer, t: IrType): boolean { - if (typeEquals(t, arrayOf(STRING))) return true; - return t.kind === "union" && matchSliceUnionArms(lowerer, t.unionId) !== null; -} - -/** The union-arm layout of a nullable match slice (`string[] | null`, - * matchAll's checker spellings with undefined arms included): the - * string[] arm's tag plus each unit arm's tag and spelling. Null when - * the union holds anything else. */ -function matchSliceUnionArms( - lowerer: Lowerer, - unionId: string, -): { arrTag: number; units: { tag: number; unit: "null" | "undefined" }[] } | null { - const def = lowerer.unions.get(unionId); - if (!def) return null; - const strArr = arrayOf(STRING); - const arrTag = def.arms.findIndex((a) => typeEquals(a, strArr)); - if (arrTag < 0) return null; - const units: { tag: number; unit: "null" | "undefined" }[] = []; - for (let i = 0; i < def.arms.length; i++) { - if (i === arrTag) continue; - const a = def.arms[i]!; - if (!isUnitType(a)) return null; - units.push({ tag: i, unit: a.kind === "nullT" ? "null" : "undefined" }); - } - return { arrTag, units }; -} - -/** Node's TypeError for `.groups` read off null or undefined. */ -function groupsReadTypeError(unit: "null" | "undefined", loc: SrcLoc): IrStmt { - return { - kind: "throw", - value: { - kind: "libCall", - fn: "error.new", - args: [strLit(`Cannot read properties of ${unit} (reading 'groups')`, loc)], - type: { kind: "object", className: "%TypeError" }, - loc, - }, - loc, - }; -} - -/** The groups-record projection holds string | undefined values, including - * an own overflow entry for every nonparticipating capture. One lifted helper per - * (index list, receiver representation) builds it; see - * lowerMatchGroupsRead for the semantics the body encodes. */ -export function lowerGroupsProjection( - lowerer: Lowerer, - recv: IrExpr, - groups: { name: string; index: number }[], - loc: SrcLoc, -): IrExpr { - // Name → its capture indices, in declaration order (duplicates keep - // every occurrence — the projection picks the participating one). - const order: string[] = []; - const indicesByName = new Map(); - for (const g of groups) { - const got = indicesByName.get(g.name); - if (got) { - got.push(g.index); - } else { - indicesByName.set(g.name, [g.index]); - order.push(g.name); - } - } - const valueType = arrayValueType(lowerer, STRING); - const shapeId = lowerer.shapes.intern([], false, valueType); - const recordT: IrType = { kind: "record", shapeId }; - const recvKey = - recv.type.kind === "union" ? recv.type.unionId : recv.type.kind === "dyn" ? "dyn" : "arr"; - const key = `regexgroups:${shapeId}:${groups.map((g) => `${g.name}=${g.index}`).join(",")}:${recvKey}`; - let helper = lowerer.valueHelpers.get(key); - if (!helper) { - helper = `%regex.groups.${lowerer.valueHelpers.size}`; - lowerer.valueHelpers.set(key, helper); - const body: IrStmt[] = []; - const locals: IrLocal[] = [{ id: "m.0", name: "m", type: recv.type, mutable: true }]; - let mRef: IrExpr = { kind: "varRef", localId: "m.0", type: recv.type, loc }; - if (recv.type.kind === "union") { - // Node's exact TypeError per unit arm, then the checked narrow. - const arms = matchSliceUnionArms(lowerer, recv.type.unionId)!; - for (const u of arms.units) { - body.push({ - kind: "if", - cond: { - kind: "unionIsTag", - unionId: recv.type.unionId, - tag: u.tag, - negated: false, - value: mRef, - type: BOOL, - loc, - }, - then: [groupsReadTypeError(u.unit, loc)], - else_: null, - loc, - }); - } - const narrowed: IrExpr = { - kind: "unionNarrow", - unionId: recv.type.unionId, - tag: arms.arrTag, - value: mRef, - type: arrayOf(STRING), - loc, - }; - locals.push({ id: "arr.0", name: "arr", type: arrayOf(STRING), mutable: false }); - body.push({ kind: "varDecl", localId: "arr.0", init: narrowed, loc }); - mRef = { kind: "varRef", localId: "arr.0", type: arrayOf(STRING), loc }; - } else if (recv.type.kind === "dyn") { - // A JavaScript callback parameter holds the row as a dynamic array: - // Node's TypeError for null and undefined, then dynamic element reads. - for (const unit of ["null", "undefined"] as const) { - body.push({ - kind: "if", - cond: { kind: "dynTest", test: unit, value: mRef, type: BOOL, loc }, - then: [groupsReadTypeError(unit, loc)], - else_: null, - loc, - }); - } - } - - const elem = (index: number): IrExpr => - mRef.type.kind === "dyn" - ? lowerer.coerceToExpected( - { - kind: "dynKeyGet", - value: mRef, - key: strLit(String(index), loc), - type: DYN, - loc, - }, - valueType, - ) - : arrayValueRead(lowerer, mRef, numLit(index, loc), STRING, loc); - const values = new Map(); - order.forEach((name, i) => { - const idxs = indicesByName.get(name)!; - if (idxs.length === 1) { - values.set(name, elem(idxs[0]!)); - return; - } - // Duplicates: the first participating occurrence. Empty strings - // participate; undefined means this alternative did not match. - const id = `g${i}.0`; - locals.push({ id, name: `g${i}`, type: valueType, mutable: true }); - body.push({ kind: "varDecl", localId: id, init: elem(idxs[0]!), loc }); - const gRef: IrExpr = { kind: "varRef", localId: id, type: valueType, loc }; - for (const idx of idxs.slice(1)) { - body.push({ - kind: "if", - cond: { - kind: "unionIsTag", - unionId: valueType.kind === "union" ? valueType.unionId : "", - tag: valueType.kind === "union" ? lowerer.armTag(valueType.unionId, UNDEFINED_T) : -1, - negated: false, - value: gRef, - type: BOOL, - loc, - }, - then: [{ kind: "assign", localId: id, value: elem(idx), loc }], - else_: null, - loc, - }); - } - values.set(name, gRef); - }); - body.push({ - kind: "return", - value: { - kind: "recordLit", - fields: order.map((name) => ({ name, value: values.get(name)!, overflow: true as const })), - nullProto: true, - type: recordT, - loc, - }, - loc, - }); - lowerer.liftedFns.push({ - name: helper, - params: [{ localId: "m.0", name: "m", type: recv.type }], - returnType: recordT, - locals, - body, - loc, - }); - } - return { kind: "call", callee: helper, args: [recv], type: recordT, loc }; -} - -/** Field reads use the shared FieldTarget union. Native method reads select - * an interned unbound callable; invocation supplies its receiver separately. */ -export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { - // A checked receiver already owns its property table. An ambient - // generic method signature must not divert its value into native - // object-literal specialization (Array.prototype.map is one example). - if (ts.isIdentifier(expr.expression)) { - const stored = lowerer.peekLocal(expr.expression) ?? lowerer.globalOf(expr.expression); - if (stored?.type.kind === "dyn") { - const value = lowerer.lowerExpr(expr.expression); - if (value.type.kind === "dyn") - return { - kind: "dynKeyGet", - value, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - ...(expr.questionDotToken || hasOptionalChainGuard(expr.expression) - ? { optional: true as const } - : {}), - type: DYN, - loc: locOf(expr), - }; - } - } - const target = lowerer.fieldTarget(expr); - if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr); - if (expr.questionDotToken) return null; - let receiverIr = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - // The readonly-tuple Array.isArray narrow has no directly mappable - // checker type (`Result & any[]`), but maybeNarrow extracted the tuple - // record behind it. Route tuple properties such as `.length` from that - // lowered value, the element-access bridge's twin. - const checkerArray = lowerer.checkerArrayValue(expr.expression); - if (checkerArray?.type.kind === "record") receiverIr = checkerArray.type; - if ( - receiverIr?.kind === "object" && - (lowerer.findMethodOn(lowerer.classes.get(receiverIr.className) ?? null, expr.name.text) || - findGenericMethodOn( - lowerer, - lowerer.classes.get(receiverIr.className) ?? null, - expr.name.text, - )) - ) { - const info = lowerer.classes.get(receiverIr.className); - if (info) return lowerClassMethodValue(lowerer, expr, info); - } - // An object-literal GENERIC method as a VALUE (`o.m` — the member is - // excluded from the record shape): the pinned-value rule verbatim when - // the receiver is the defining literal's own const binding (the value - // is the pinned instance's closure — no `this` exists); anything else - // fences by name inside the helpers. - { - const propSym = lowerer.checker.getPropertyOfType( - lowerer.typeOf(expr.expression), - expr.name.text, - ); - if ( - receiverIr?.kind !== "object" && - propSym && - isGenericCallableMemberType(lowerer.checker.getTypeOfSymbol(propSym), lowerer.checker) && - // CLASS members keep the class-path fences (a poisoned class's own - // diagnostics, the bound-method fence above). - !propSym.declarations.some( - (d) => - d.parent !== undefined && - (ts.isClassDeclaration(d.parent) || ts.isClassExpression(d.parent)), - ) - ) { - const found = objLitGenericFnNodeOf(lowerer, propSym); - if (!found) { - lowerer.unsupported( - "SC1090", - expr, - `the generic method '${expr.name.text}' as a value with no defining object literal (only methods declared with a body in an object literal compile)`, - ); - } - requireObjLitGenericReceiver(lowerer, expr, expr.expression, found.literal, expr.name.text); - return lowerer.lowerGenericFnValue( - expr, - objLitGenericFnInfoOf(lowerer, expr, expr.name.text, found), - ); - } - } - // Dot access to an UNDECLARED key of an index-signature shape - // (`bag.count` on `Record`): tsc allows it without - // noPropertyAccessFromIndexSignature, but the bracket spelling is the - // canonical index-signature form here — point at it. - if (receiverIr?.kind === "record") { - const shape = lowerer.shapes.get(receiverIr.shapeId); - if (shape?.indexValue && !shape.fields.some((f) => f.name === expr.name.text)) { - const actual = tryLowerExpression(lowerer, expr.expression); - if (actual?.type.kind === "dyn") - return { - kind: "dynKeyGet", - value: actual, - key: { kind: "strLit", value: expr.name.text, type: STRING, loc: locOf(expr.name) }, - type: DYN, - loc: locOf(expr), - }; - if (actual?.type.kind === "record") { - const field = lowerer.shapes - .get(actual.type.shapeId) - ?.fields.find((field) => field.name === expr.name.text); - if (field) - return { - kind: "recordGet", - obj: actual, - shapeId: actual.type.shapeId, - field: field.name, - type: field.type, - loc: locOf(expr), - }; - } - lowerer.unsupported( - "SC1090", - expr, - `dot access to index-signature keys (spell it r["${expr.name.text}"] — brackets are the index-signature form)`, - ); - } - } - // `t.length` on a tuple: the arity CONSTANT (tuples are fixed-shape — - // the checker types it as the literal arity too). Folding discards the - // receiver's evaluation, so only side-effect-free receivers fold; - // anything else (a call result) binds to a const first. - if (receiverIr?.kind === "record" && expr.name.text === "length") { - const shape = lowerer.shapes.get(receiverIr.shapeId); - if (shape?.tuple) { - let root: ts.Expression = expr.expression; - while (ts.isPropertyAccessExpression(root)) root = root.expression; - if (!ts.isIdentifier(root) && root.kind !== ts.SyntaxKind.ThisKeyword) { - lowerer.unsupported( - "SC1090", - expr, - "'.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first)", - ); - } - return { kind: "numLit", value: shape.fields.length, type: F64, loc: locOf(expr) }; - } - } - return null; -} - -/** Shared-field read `r.f` on a UNION receiver: supported exactly when - * every arm is a record/class possessing the field with ONE shared IR - * type — the discriminant pattern (`r.kind`, primitive) and the - * shared-payload pattern (`spec.config` where every ServiceSpec arm - * carries the same record). Lowers to `unionDisc` (the backend switches - * on the runtime tag and reads the field from the concrete arm), which - * composes with existing strEq/bin/switch nodes, so `r.kind === "ok"` - * and `switch (r.kind)` work without dedicated test nodes. Anything else - * on a union receiver is rejected specifically (narrow first). */ -export function lowerUnionProperty( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): IrExpr | null { - if (lowerer.chainBlocked(expr)) return null; - const receiverIr = - structuralReadType(lowerer, expr.expression) ?? - lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (receiverIr?.kind !== "union") return null; - // Lower the receiver first: its runtime value can still carry the full - // binding union even when the checker has removed several variants. - let value = lowerer.lowerExpr(expr.expression); - // A guard can remove several variants without selecting one record. - // Materialize that sub-union before looking for common fields; arms - // ruled out by the guard must not veto a field shared by the survivors. - if (value.type.kind === "union" && !typeEquals(value.type, receiverIr)) { - const helper = lowerer.narrowedRetagHelper( - expr.expression, - value.type.unionId, - receiverIr.unionId, - locOf(expr), - ); - if (helper) - value = { kind: "call", callee: helper, args: [value], type: receiverIr, loc: locOf(expr) }; - // Optional storage of a base class (a runtime-optional binding holding - // `Base | undefined`) that `instanceof` narrowed to several subclasses: - // extract the instance and test its class, as typed slots do. - else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value; - } - // A checker-union receiver whose VALUE lowered to a plain RECORD (the - // merged-signature fiction — `runner(cmd, args)` where runner joined - // a structural runner type with spawnSync's, and the local adopted - // the record arm): read the record field directly, the dyn-receiver - // fallback's discipline. - // A checker-union receiver whose VALUE lowered checked-dynamic (a - // never-tainted JS chain — `cmd[1].length` on `const cmd = ['pwd', - // []]`, where the element read stayed a dyn node): read through the - // dyn keyed read like the unmappable-receiver path below the chain. - if (value.type.kind === "dyn") { - const key: IrExpr = { - kind: "strLit", - value: expr.name.text, - type: STRING, - loc: locOf(expr.name), - }; - return { kind: "dynKeyGet", key, value, type: DYN, loc: locOf(expr) }; - } - if (value.type.kind === "record") { - const shape = lowerer.shapes.get(value.type.shapeId); - const f = shape?.fields.find((x) => x.name === expr.name.text); - if (f) { - return { - kind: "recordGet", - obj: value, - shapeId: value.type.shapeId, - field: f.name, - type: f.type, - loc: locOf(expr), - }; - } - return null; - } - // A class instance the checker sees through a wider union (`(concrete - // as A | B).value`): the value is that class's instance, so read its - // field directly. - if (value.type.kind === "object") { - const className = value.type.className; - const info = lowerer.classes.get(className) ?? null; - const field = expr.name.text; - const fieldType = info?.fields.get(field); - if (fieldType !== undefined) - return lowerer.fieldGetExpr( - { container: "class", obj: value, className, field, fieldType }, - locOf(expr), - expr, - ); - const getter = lowerer.findMethodOn(info, `get:${field}`); - if (getter) - return lowerer.fieldGetExpr( - { container: "accessor", obj: value, className, field, fieldType: getter.sig.ret }, - locOf(expr), - expr, - ); - } - if (value.type.kind !== "union") { - lowerer.unsupported( - "SC1090", - expr, - "shared property access without a represented union receiver", - ); - } - const def = lowerer.unions.get(value.type.unionId); - if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${value.type.unionId}`); - const field = expr.name.text; - if (field === "length") { - const presentArms = def.arms.filter((arm) => !isUnitType(arm)); - const present = presentArms.length === 1 ? presentArms[0]! : null; - if (present?.kind === "string" || present?.kind === "array") { - const receiver = lowerer.runtimeOptionalPropertyReceiver( - expr.expression, - value, - present, - field, - ); - if (receiver !== null) { - return present.kind === "string" - ? { - kind: "strIntrinsic", - method: "length", - receiver, - args: [], - type: F64, - loc: locOf(expr), - } - : { - kind: "arrIntrinsic", - method: "length", - receiver, - args: [], - type: F64, - loc: locOf(expr), - }; - } - } - } - const nonUnitArms = def.arms.filter((arm) => !isUnitType(arm)); - if (nonUnitArms.length === 1 && nonUnitArms[0]?.kind === "object") { - const objectType = nonUnitArms[0]; - const fieldType = lowerer.classes.get(objectType.className)?.fields.get(field); - if (fieldType !== undefined) { - let receiver = lowerer.runtimeOptionalPropertyReceiver( - expr.expression, - value, - objectType, - field, - ); - if (receiver === null) { - const helper = lowerer.narrowedArmHelper( - value.type.unionId, - objectType, - locOf(expr.expression), - ); - if (helper !== null) - receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression)); - } - if (receiver !== null) { - return { - kind: "fieldGet", - obj: receiver, - className: objectType.className, - field, - type: fieldType, - loc: locOf(expr), - }; - } - } - } - let common: IrType | null = null; - for (const arm of def.arms) { - let ft: IrType | undefined; - if (arm.kind === "record") { - ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === field)?.type; - } else if (arm.kind === "object") { - ft = lowerer.classes.get(arm.className)?.fields.get(field); - } - if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { - common = null; - break; - } - common = ft; - } - if (common) { - return { - kind: "unionDisc", - unionId: value.type.unionId, - field, - value, - type: common, - loc: locOf(expr), - }; - } - // The arms answer DIFFERENT types (or through index signatures / unit - // arms): the JOIN path — `env.PORTLESS_PORT` on `ProcessEnv | - // Record`, the tail read of `loaded?.config.script`. - const key: IrExpr = { kind: "strLit", value: field, type: STRING, loc: locOf(expr.name) }; - const keyed = lowerUnionKeyedRead(lowerer, expr, value.type.unionId, value, key, field); - if (keyed) return keyed; - // Class arms declaring the field with compatible but different types - // (`T | undefined` beside `T`): per-arm slot reads widened to one type. - const widened = lowerUnionFieldRead(lowerer, expr, value); - if (widened) return widened; - // JavaScript overloads commonly inspect a class brand on a scalar-or- - // instance argument (e.g. Matrix4.makeTranslation). Preserve primitive - // missing-property semantics and dispatch class data through its native - // capsule instead of demanding a field shared by every union arm. - if (isJsSourceFile(expr.getSourceFile()) && lowerer.dynConvertible(value.type)) { - return { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(value, DYN), - key, - ...(hasOptionalChainGuard(expr.expression) ? { optional: true as const } : {}), - type: DYN, - loc: locOf(expr), - }; - } - lowerer.unsupported( - "SC1090", - expr, - `reading '${field}' on a union-typed value (every arm must be an object/record ` + - `with a same-typed field '${field}'; ` + - `${NARROW_FIRST})`, - ); -} - /** A number-keyed read in JavaScript on an array that may be undefined or * null outside an optional chain (`m[1]` on a `RegExpExecArray | null` * that an earlier statement ruled non-null without narrowing it): the @@ -15491,7 +2403,7 @@ export function lowerUnionProperty( * once, in source order. Null when the union is not exactly one array arm * beside unit arms; optional-chain tails and TypeScript keep the keyed * read. */ -function lowerOptionalArrayIndexRead( +export function lowerOptionalArrayIndexRead( lowerer: Lowerer, expr: ts.ElementAccessExpression, value: IrExpr, @@ -15537,255 +2449,12 @@ function lowerOptionalArrayIndexRead( return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: keyed.type, loc }; } -/** The unionDisc generalization: a keyed read on a union receiver whose - * arms answer DIFFERENT (but joinable) types. Each arm contributes its - * declared answer — an array element's type (number keys), a declared field's type (literal keys), an - * index-signature arm's value type (plus its declared fields' types for - * runtime keys, which reach them through the keyed-read helper), and - * UNDEFINED for unit arms (reachable only through optional-chain tails, - * where JS short-circuits to undefined; a unit arm the checker narrowed - * away is simply unreachable). The result type is the JOIN of those - * answers; every arm's answer must be the join itself, one of its - * arms, or a sub-union whose payload representations are unchanged. - * Array reads retain the ordinary invalid-index trap policy. - * Returns null when any arm cannot answer — the caller owns the fence - * message. The caller (the property/element dispatch) maybeNarrows. */ -function lowerUnionKeyedRead( - lowerer: Lowerer, - expr: ts.Expression, - unionId: string, - value: IrExpr, - key: IrExpr, - literalField: string | null, -): IrExpr | null { - const def = lowerer.unions.get(unionId); - if (!def) return null; - // Pass 1: per-arm declared answers, joined into the result type. - const joinArms: IrType[] = []; - const seen = new Set(); - const push = (t: IrType): void => { - const k = typeKey(t); - if (!seen.has(k)) { - seen.add(k); - joinArms.push(t); - } - }; - const pushAnswer = (t: IrType): boolean => { - if (t.kind === "void") return false; - if (t.kind === "union") { - const inner = lowerer.unions.get(t.unionId); - if (!inner) return false; - for (const a of inner.arms) push(a); - return true; - } - push(t); - return true; - }; - for (const arm of def.arms) { - if (isUnitType(arm)) { - push(UNDEFINED_T); - continue; - } - if (key.type.kind === "f64") { - if (arm.kind !== "array" || !pushAnswer(arm.elem)) return null; - continue; - } - if (arm.kind !== "record") return null; - const shape = lowerer.shapes.get(arm.shapeId); - if (!shape || shape.tuple) return null; - const declared = - literalField !== null ? shape.fields.find((f) => f.name === literalField)?.type : undefined; - if (declared) { - if (!pushAnswer(declared)) return null; - continue; - } - // Runtime keys can reach the declared fields through the keyed-read - // helper's string switch — every one joins. - if (literalField === null) { - for (const f of shape.fields) if (!pushAnswer(f.type)) return null; - } - if (!shape.indexValue) { - if (literalField === null && shape.fields.length > 0) continue; - return null; - } - if (!pushAnswer(shape.indexValue)) return null; - } - if (joinArms.length === 0) return null; - // The join must satisfy native union invariants. The caller owns the - // diagnostic when a container or opaque arm prevents that join. - if ( - joinArms.length > 1 && - (!unionContainerArmsOk(joinArms) || - joinArms.some((a) => a.kind === "dyn" || a.kind === "generator" || a.kind === "caught")) - ) { - return null; - } - joinArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - const type: IrType = - joinArms.length === 1 - ? joinArms[0]! - : { kind: "union", unionId: lowerer.unions.intern(joinArms) }; - // Pass 2: every arm's answer must SURFACE as the join, and index arms - // must pass the single-record keyed-read constraints (the helper is - // shared with recordKeyGet, its missing-key policy included). - const surfaces = (t: IrType): boolean => - typeEquals(t, type) || - (type.kind === "union" && lowerer.armTag(type.unionId, t) >= 0) || - exactUnionWidening(lowerer, t, type); - for (const arm of def.arms) { - if (isUnitType(arm)) { - if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; - continue; - } - if (key.type.kind === "f64") { - if (arm.kind !== "array" || !surfaces(arm.elem)) return null; - continue; - } - if (arm.kind !== "record") return null; - const shape = lowerer.shapes.get(arm.shapeId); - if (!shape) return null; - const declared = - literalField !== null ? shape.fields.find((f) => f.name === literalField)?.type : undefined; - if (declared) { - if (!surfaces(declared)) return null; - continue; - } - const ovfShape: IrRecordShape = - literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; - if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; - } - return { kind: "unionKeyGet", unionId, key, value, type, loc: locOf(expr) }; -} - -/** A structural TypeScript view does not change a nominal object's layout - * or accessor dispatch. Resolve the property from the represented class; - * the optional checker view must not turn its getter into a record read. */ -function classFieldTarget( - lowerer: Lowerer, - receiverNode: ts.Expression, - receiverType: IrType & { kind: "object" }, - fieldName: string, - saved?: IrExpr, -): FieldTarget | null { - const represented = saved ?? lowerer.lowerExpr(receiverNode); - if ( - represented.type.kind === "dyn" && - isJsSourceFile(receiverNode.getSourceFile()) && - !fieldName.startsWith("#") - ) { - return { container: "dynamic", obj: represented, field: fieldName, fieldType: DYN }; - } - const info = lowerer.classes.get(receiverType.className); - if (!info) { - // A receiver typed as a class whose collection deferred: the - // deferred diagnostics are what explains the miss. - lowerer.flushDeferredClass(receiverType.className); - return null; - } - const fieldType = info.fields.get(fieldName); - const lowerObjectReceiver = (): IrExpr => { - let obj = represented; - if (obj.type.kind === "union") { - // A receiver that may hold a missing value (a field assigned an - // unchecked read): Node's member-read TypeError for the unit arm. - const present = lowerer.runtimeOptionalPropertyReceiver( - receiverNode, - obj, - receiverType, - fieldName, - ); - if (present) return present; - const helper = lowerer.narrowedArmHelper(obj.type.unionId, receiverType, locOf(receiverNode)); - obj = helper - ? lowerer.checkedNarrowCall(helper, obj, receiverType, locOf(receiverNode)) - : lowerer.maybeNarrow(obj, receiverNode); - } - return lowerer.coerceInto(receiverNode, obj, receiverType); - }; - if (fieldName === "cause" && receiverType.className !== "%DOMException") { - let root = info; - while (root.base) root = root.base; - if (root.def.name === "%Error") { - return { - container: "errorCause", - obj: lowerObjectReceiver(), - field: "cause", - fieldType: DYN, - }; - } - } - if (fieldType) { - const obj = lowerObjectReceiver(); - if ( - isJsSourceFile(receiverNode.getSourceFile()) && - !fieldName.startsWith("#") && - [...lowerer.classes.values()].some( - (candidate) => - lowerer.isSubclassOf(candidate.def.name, receiverType.className) && - (candidate.methods.has(`get:${fieldName}`) || candidate.methods.has(`set:${fieldName}`)), - ) - ) { - return { - container: "dynamic", - obj: lowerer.coerceToExpected(obj, DYN), - field: fieldName, - fieldType: DYN, - }; - } - return { - container: "class", - obj, - className: receiverType.className, - field: fieldName, - fieldType, - }; - } - // Accessor property: either half declared anywhere on the chain - // makes the name an accessor target (fields and accessors share a - // namespace — tsc rejects mixing them, so the halves agree on kind). - const getF = lowerer.findMethodOn(info, `get:${fieldName}`); - const setF = lowerer.findMethodOn(info, `set:${fieldName}`); - if (getF || setF) { - const obj = lowerObjectReceiver(); - return { - container: "accessor", - obj, - className: receiverType.className, - field: fieldName, - fieldType: getF ? getF.sig.ret : setF!.sig.params[0]!.type, - }; - } - if (info.decl && isJsSourceFile(info.decl.getSourceFile())) { - const property = lowerer.checker.getPropertyOfType( - lowerer.checker.getTypeAtLocation(receiverNode), - fieldName, - ); - if ( - property && - property.declarations.some( - (declaration) => - ts.isPropertyAccessExpression(declaration) || - ts.isBinaryExpression(declaration) || - ts.isElementAccessExpression(declaration), - ) - ) { - return { - container: "dynamic", - obj: lowerer.coerceToExpected(lowerObjectReceiver(), DYN), - field: fieldName, - fieldType: DYN, - }; - } - } - return null; -} - /** A structural assertion can leave the value in its original class * representation, including an unchecked array read's undefined arm. Only * a single nominal payload has an unambiguous property layout. Validate * that payload before dispatching a field or accessor; an absent receiver * must never reach the backend as an object pointer. */ -function hasClassPayload(lowerer: Lowerer, type: IrType): boolean { +export function hasClassPayload(lowerer: Lowerer, type: IrType): boolean { return ( type.kind === "object" || (type.kind === "union" && @@ -15793,7 +2462,7 @@ function hasClassPayload(lowerer: Lowerer, type: IrType): boolean { ); } -function representedClassFieldTarget( +export function representedClassFieldTarget( lowerer: Lowerer, receiverNode: ts.Expression, fieldName: string, @@ -15809,215 +2478,6 @@ function representedClassFieldTarget( : null; } -/** Recognizes `obj.field` as an assignable field target: receiver is a - * known class instance OR a record, and the member is a field or (class - * receivers) a declared accessor property. Returns the pieces of a - * fieldSet/recordSet/accessor-call (minus value/kind) or null. */ -export function fieldTarget( - lowerer: Lowerer, - access: ts.PropertyAccessExpression, -): FieldTarget | null { - if (lowerer.cjsLocalModuleBindingOf(access.expression)) return null; - // `module.exports` / `module.id`: CommonJS module surface, never a field. - if (isCjsModuleGlobal(lowerer, access.expression)) return null; - if (isCjsExportsIdentifier(lowerer, access.expression)) return null; - if (lowerer.chainBlocked(access)) return null; - if ( - access.name.text === "stackTraceLimit" && - lowerer.isStdlibGlobal(access.expression, "Error") - ) { - return { - container: "errorStackLimit", - obj: { kind: "numLit", value: 0, type: F64, loc: locOf(access) }, - field: "stackTraceLimit", - fieldType: F64, - }; - } - const stored = ts.isIdentifier(access.expression) - ? (lowerer.peekLocal(access.expression)?.type ?? lowerer.globalOf(access.expression)?.type) - : undefined; - const narrowed = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); - // Structural record views retain their storage layout, while a nominal - // subclass guard proves a more specific layout of the same object. - const receiverIr = - stored?.kind === "record" - ? stored - : stored?.kind === "object" && - !( - narrowed?.kind === "object" && - lowerer.isSubclassOf(narrowed.className, stored.className) - ) - ? stored - : narrowed; - if (receiverIr?.kind === "object") { - const target = classFieldTarget(lowerer, access.expression, receiverIr, access.name.text); - if (target) return target; - // Abstract properties declare no base storage. Resolve the concrete - // instance's live field or accessor through the existing class bridge; - // inventing a shared field offset would misread subclass layouts. - if (abstractPropertyDeclOf(lowerer, access)) { - return { - container: "dynamic", - obj: lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), DYN), - field: access.name.text, - fieldType: lowerer.irTypeOf(access), - }; - } - return null; - } - if (receiverIr?.kind === "union" && hasClassPayload(lowerer, receiverIr)) { - return representedClassFieldTarget( - lowerer, - access.expression, - access.name.text, - lowerer.lowerExpr(access.expression), - ); - } - if (receiverIr?.kind === "record") { - const shape = lowerer.shapes.get(receiverIr.shapeId); - const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; - if (fieldType) { - let obj = lowerer.lowerExpr(access.expression); - const nominal = representedClassFieldTarget( - lowerer, - access.expression, - access.name.text, - obj, - ); - if (nominal) return nominal; - // A checker-record receiver whose VALUE stayed dyn (the erased - // all-unknown-fields cast — `(err as { code?: unknown }).code`): - // decline, and the dyn keyed-read fallback answers. - if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { - // A nested property read such as `rows[i].value` or - // `table[k].value`: the outer read can miss. Validate the record - // arm before fieldGet/recordGet so a missing outer value raises - // Node's TypeError rather than reaching the backend with a union - // receiver. - const present = lowerer.stripUndefinedArm(obj.type); - const parent = access.parent; - const written = - parent !== undefined && - ts.isBinaryExpression(parent) && - parent.left === access && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken; - const helper = - present.kind === "record" - ? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(access.expression)) - : null; - obj = - (present.kind === "record" - ? lowerer.runtimeOptionalPropertyReceiver( - access.expression, - obj, - present, - access.name.text, - written ? "set" : "read", - ) - : null) ?? - (helper - ? lowerer.checkedNarrowCall(helper, obj, present, locOf(access.expression)) - : lowerer.maybeNarrow(obj, access.expression)); - } - if (obj.type.kind !== "record") return null; - const actualShape = lowerer.shapes.get(obj.type.shapeId); - const actualField = actualShape?.fields.find((f) => f.name === access.name.text); - return { - container: "record", - obj, - shapeId: obj.type.shapeId, - field: access.name.text, - fieldType: actualField?.type ?? fieldType, - }; - } - // A RECORD accessor property: either slot present makes the name an - // accessor target (get/set share the property namespace — tsc - // rejects mixing an accessor with a data property, so the halves - // agree). Reads dispatch the %get: closure, writes the %set: one. - { - const getSlot = shape?.fields.find((f) => f.name === `%get:${access.name.text}`)?.type; - const setSlot = shape?.fields.find((f) => f.name === `%set:${access.name.text}`)?.type; - if (getSlot?.kind === "func" || setSlot?.kind === "func") { - const obj = lowerer.lowerExpr(access.expression); - if (obj.type.kind !== "record") return null; // dyn-valued receiver: the keyed fallback answers - const getType = getSlot?.kind === "func" ? getSlot : undefined; - const setType = setSlot?.kind === "func" ? setSlot : undefined; - return { - container: "recordAccessor", - obj, - shapeId: receiverIr.shapeId, - field: access.name.text, - fieldType: getType ? getType.ret : setType!.params[0]!, - ...(getType ? { getType } : {}), - ...(setType ? { setType } : {}), - }; - } - } - // Dot access to an UNDECLARED key of an index-signature shape: tsc - // types it through the signature — the access resolves to NO property - // symbol (mapped types like Record) or to the signature's - // own `__index` symbol (interface-declared signatures). A real member - // symbol means a lib member like `toString` — not an index access; - // those keep their fences below. It IS the bracket access in dot - // spelling — the same overflow path. - const nameSym = lowerer.checker.getSymbolAtLocation(access.name); - if (shape?.indexValue && !shape.tuple) { - // A member the shape CANONICALIZATION dropped into the overflow - // (the header family: @types declares `host?: string` on - // IncomingHttpHeaders while the canonical shape is a pure index - // record) is the bracket access in dot spelling too: a PROPERTY - // declaration whose own type fits within the index value. METHOD - // members (`toString` — the lib's inherited surface) keep their - // fences: JS finds the prototype function, never an overflow miss. - const canonicalized = (): boolean => { - if (!nameSym) return false; - const nameDecls = nameSym.declarations; - if (!nameDecls.length) return false; - if (!nameDecls.every((d) => ts.isPropertySignature(d))) return false; - const declared = lowerer.mapTypeOf(lowerer.typeOf(access)); - if (!declared) return false; - const iv = shape.indexValue!; - if (typeEquals(declared, iv)) return true; - if (iv.kind !== "union") return false; - const ivArms = lowerer.unions.get(iv.unionId)?.arms; - if (!ivArms) return false; - const declaredArms = - declared.kind === "union" - ? (lowerer.unions.get(declared.unionId)?.arms ?? [declared]) - : [declared]; - return declaredArms.every((a) => ivArms.some((b) => typeEquals(a, b))); - }; - if (!nameSym || nameSym.name === ts.InternalSymbolName.Index || canonicalized()) { - let obj = lowerer.lowerExpr(access.expression); - if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { - const present = lowerer.stripUndefinedArm(obj.type); - obj = - present.kind === "record" - ? (lowerer.runtimeOptionalPropertyReceiver( - access.expression, - obj, - present, - access.name.text, - ) ?? obj) - : lowerer.maybeNarrow(obj, access.expression); - } - if (obj.type.kind !== "record") return null; - const actualShape = lowerer.shapes.get(obj.type.shapeId); - if (!actualShape?.indexValue) return null; - return { - container: "recordOvf", - obj, - shapeId: obj.type.shapeId, - field: access.name.text, - fieldType: actualShape.indexValue, - }; - } - } - return null; - } - return null; -} - /** A STATICALLY-RESOLVABLE unique-symbol key: an identifier whose value * resolves (imports included) to a module-level `const k = Symbol(...)` * with no description or a literal one. tsc types such a const `unique @@ -16097,7 +2557,10 @@ export function symbolFieldInfo( /** `obj[k]` as an assignable field target — the symbol-keyed twin of * fieldTarget's class branch (the receiver lowers here, exactly once). */ -function symbolFieldTarget(lowerer: Lowerer, expr: ts.ElementAccessExpression): FieldTarget | null { +export function symbolFieldTarget( + lowerer: Lowerer, + expr: ts.ElementAccessExpression, +): FieldTarget | null { const info = symbolFieldInfo(lowerer, expr); if (!info) return null; const obj = lowerer.lowerExpr(expr.expression); @@ -16110,472 +2573,6 @@ function symbolFieldTarget(lowerer: Lowerer, expr: ts.ElementAccessExpression): }; } -/** The read expression for a field target (fieldGet / recordGet / - * getter call). `blame` locates the rejection of a setter-only read — - * tsc-clean (the property types as the setter's param), but Node yields - * undefined, which these property types cannot represent. */ -export function fieldGetExpr( - lowerer: Lowerer, - target: FieldTarget, - loc: SrcLoc, - blame: ts.Node, -): IrExpr { - // A member read through an unassigned deferred-init field throws Node's - // member-read TypeError instead of the generic checked extraction. - if ( - target.container === "class" && - ts.isPropertyAccessExpression(blame) && - target.obj.type.kind === "object" && - lowerer.deferredFieldSlot(target.obj) !== null - ) { - const checked = lowerer.runtimeOptionalPropertyReceiver( - blame.expression, - target.obj, - target.obj.type, - target.field, - ); - if (checked) target = { ...target, obj: checked }; - } - if (target.container === "dynamic") - return lowerer.coerceInto( - blame, - { - kind: "dynKeyGet", - value: target.obj, - key: { kind: "strLit", value: target.field, type: STRING, loc }, - type: DYN, - loc, - }, - target.fieldType, - ); - if ( - target.container === "class" && - (target.field === "message" || target.field === "name") && - (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) - ) - return errorPropertyRead(lowerer, target.obj, target.field); - if (target.container === "errorStackLimit") { - return { kind: "libCall", fn: "error.stackLimitGet", args: [], type: F64, loc }; - } - if (target.container === "errorCause") { - return { kind: "libCall", fn: "error.cause", args: [target.obj], type: DYN, loc }; - } - // A record-shaped CHECKER target whose receiver VALUE lives in the checked-dynamic tree - // (a JS file-scope object-literal global): the checked-dynamic keyed - // read — dynKeyGet (a missing key answers the dyn undefined, exactly - // JS); consumers validate (dynCheck) where a static type is required. - if ( - (target.container === "record" || target.container === "recordOvf") && - target.obj.type.kind === "dyn" - ) { - return { - kind: "dynKeyGet", - key: { kind: "strLit", value: target.field, type: STRING, loc }, - value: target.obj, - type: DYN, - loc, - }; - } - if (target.container === "accessor") { - const getF = lowerer.findMethodOn( - lowerer.classes.get(target.className) ?? null, - `get:${target.field}`, - ); - if (!getF) { - lowerer.unsupported( - "SC1090", - blame, - `reading a property that has only a setter ('${target.field}' — Node would yield undefined)`, - ); - } - if ( - !target.field.startsWith("#") && - isJsSourceFile(blame.getSourceFile()) && - lowerer.dynConvertible(getF.sig.ret) - ) { - const value: IrExpr = { - kind: "dynKeyGet", - value: lowerer.coerceToExpected(target.obj, DYN), - key: { kind: "strLit", value: target.field, type: STRING, loc }, - type: DYN, - loc, - }; - return lowerer.coerceToExpected(value, getF.sig.ret); - } - return lowerer.accessorCall( - target.className, - `get:${target.field}`, - target.obj, - [], - getF.sig.ret, - loc, - ); - } - // Record accessor properties: the read IS a call of the %get: closure - // — once per read, side effects and all (JS's evaluation). The - // setter-only read keeps the class-path fence: the property types as - // the setter's param where Node yields undefined. - if (target.container === "recordAccessor") { - if (!target.getType) { - lowerer.unsupported( - "SC1090", - blame, - `reading a property that has only a setter ('${target.field}' — Node would yield undefined)`, - ); - } - const closure: IrExpr = { - kind: "recordGet", - obj: target.obj, - shapeId: target.shapeId, - field: `%get:${target.field}`, - type: target.getType, - loc, - }; - return { kind: "callValue", callee: closure, args: [], type: target.getType.ret, loc }; - } - // Overflow dot reads: exactly the bracket read with a literal key — - // recordKeyGet, overflowOnly (the name declares no field by - // construction), typed as the index value armed with undefined - // (mirroring lowerRecordKeyRead). - if (target.container === "recordOvf") { - let obj = target.obj; - if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) { - const present = lowerer.stripUndefinedArm(obj.type); - if (present.kind === "record") { - obj = - lowerer.runtimeOptionalPropertyReceiver(blame.expression, obj, present, target.field) ?? - obj; - } - } - if (obj.type.kind !== "record") { - lowerer.unsupported( - "SC1090", - blame, - `reading '${target.field}' on a non-record receiver (narrow first)`, - ); - } - const shape = lowerer.shapes.get(obj.type.shapeId); - if (!shape?.indexValue) - lowerer.unsupported( - "SC1090", - blame, - `reading '${target.field}' on a record without an index signature`, - ); - let t: IrType = shape.indexValue; - if (t.kind !== "dyn") { - const armed = lowerer.withUndefinedArmOf(t); - if (!armed) lowerer.badType(blame, lowerer.typeOf(blame)); - t = armed; - } - return { - kind: "recordKeyGet", - obj, - shapeId: obj.type.shapeId, - key: { kind: "strLit", value: target.field, type: STRING, loc }, - overflowOnly: true, - type: t, - loc, - }; - } - if (target.container === "class") { - const read: IrExpr = { - kind: "fieldGet", - obj: target.obj, - className: target.className, - field: target.field, - type: target.fieldType, - loc, - }; - // DEFERRED-INIT fields (`stream!: T` assigned past the constructor's - // top level — ClassInfo.deferredInitFields): the SLOT is the - // undefined-armed union; the read CHECKED-extracts the declared type - // — a genuinely unassigned read throws the catchable TypeError - // where Node reads an undefined the declared type cannot hold. - if ( - lowerer.classes.get(target.className)?.deferredInitFields?.has(target.field) === true && - target.fieldType.kind === "union" - ) { - const inner = lowerer.stripUndefinedArm(target.fieldType); - // Reference arms over a stable receiver check inline: the present - // arm is a plain projection of the slot, so ordinary reads keep their - // borrowed form and only an unassigned slot reaches the throw. - const unionId = target.fieldType.unionId; - const presentTag = lowerer.armTag(unionId, inner); - const undefTag = lowerer.armTag(unionId, UNDEFINED_T); - if ( - inner.kind === "object" && - presentTag >= 0 && - undefTag >= 0 && - lowerer.unions.get(unionId)?.arms.length === 2 && - isSafeToRepeat(read) - ) { - return { - kind: "ternary", - cond: { - kind: "unionIsTag", - unionId, - tag: undefTag, - negated: false, - value: read, - type: BOOL, - loc, - }, - then: nodeThrowExpr(1, "", DEFERRED_READ_MESSAGE, inner, loc), - else_: { kind: "unionNarrow", unionId, tag: presentTag, value: read, type: inner, loc }, - type: inner, - loc, - }; - } - const helper = lowerer.deferredReadHelper(target.fieldType.unionId, inner, loc); - if (helper) return { kind: "call", callee: helper, args: [read], type: inner, loc }; - } - return read; - } - return { - kind: "recordGet", - obj: target.obj, - shapeId: target.shapeId, - field: target.field, - type: target.fieldType, - loc, - }; -} - -/** The write statement for a field target (fieldSet / recordSet / setter - * call). A write to a getter-only property never gets here in a clean - * program (tsc's TS2540 is the fence); the rejection is the backstop. */ -export function fieldSetStmt( - lowerer: Lowerer, - target: FieldTarget, - value: IrExpr, - loc: SrcLoc, - blame: ts.Node, -): IrStmt { - // A member write through an unassigned deferred-init field: the value - // still evaluates first, then the write throws Node's TypeError. - const deferredSlot = - target.container === "class" && - !( - (target.field === "message" || target.field === "name") && - (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) - ) - ? lowerer.deferredFieldSlot(target.obj) - : null; - if (target.container === "class" && deferredSlot?.type.kind === "union") { - const unionId = deferredSlot.type.unionId; - const undefTag = lowerer.armTag(unionId, UNDEFINED_T); - const presentTag = lowerer.armTag(unionId, target.obj.type); - if (undefTag >= 0 && presentTag >= 0) { - // A value that cannot write anything leaves the field unchanged, so - // the field is read after it (no snapshot of the slot). - const late = isSafeToRepeat(deferredSlot) && writeFree(value); - const receiver = late - ? null - : lowerer.declareHiddenLocal("%deferredWriteObject", deferredSlot.type); - const stored = lowerer.declareHiddenLocal("%deferredWriteValue", value.type); - const receiverRef = receiver ? varRef(receiver.id, receiver.type, loc) : deferredSlot; - const result = varRef(stored.id, stored.type, loc); - const write: IrStmt = { - kind: "fieldSet", - obj: { - kind: "unionNarrow", - unionId, - tag: presentTag, - value: receiverRef, - type: target.obj.type, - loc, - }, - className: target.className, - field: target.field, - value: result, - loc, - }; - return { - kind: "exprStmt", - expr: { - kind: "seqExpr", - stmts: [ - ...(receiver - ? [{ kind: "varDecl" as const, localId: receiver.id, init: deferredSlot, loc }] - : []), - { kind: "varDecl", localId: stored.id, init: value, loc }, - ], - result: { - kind: "ternary", - cond: { - kind: "unionIsTag", - unionId, - tag: undefTag, - value: receiverRef, - negated: false, - type: BOOL, - loc, - }, - then: nodeThrowExpr( - 1, - "", - `Cannot set properties of undefined (setting '${target.field}')`, - result.type, - loc, - ), - else_: { kind: "seqExpr", stmts: [write], result, type: result.type, loc }, - type: result.type, - loc, - }, - type: result.type, - loc, - }, - loc, - }; - } - } - if (target.container === "dynamic") - return { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySet", - args: [ - target.obj, - { kind: "strLit", value: target.field, type: STRING, loc }, - lowerer.coerceToExpected(value, DYN), - ], - type: VOID, - loc, - }, - loc, - }; - if ( - target.container === "class" && - (target.field === "message" || target.field === "name") && - (target.className === "%Error" || lowerer.isSubclassOf(target.className, "%Error")) - ) - return errorPropertyWrite(lowerer, target.obj, value, target.field); - if (target.container === "errorStackLimit") { - return { - kind: "exprStmt", - expr: { kind: "libCall", fn: "error.stackLimitSet", args: [value], type: VOID, loc }, - loc, - }; - } - if (target.container === "errorCause") { - return { - kind: "exprStmt", - expr: { kind: "libCall", fn: "error.setCause", args: [target.obj, value], type: VOID, loc }, - loc, - }; - } - // A record-shaped CHECKER target whose receiver VALUE lives in the checked-dynamic tree: - // the checked-dynamic keyed write — dyn.keySet (later writes win, - // insertion order; Node's TypeErrors on non-object receivers), the - // value converting into the checked-dynamic tree. - if ( - (target.container === "record" || target.container === "recordOvf") && - target.obj.type.kind === "dyn" - ) { - const v = lowerer.coerceToExpected(value, DYN); - if (v.type.kind !== "dyn") { - lowerer.unsupported( - "SC1101", - blame, - `storing '${lowerer.fmt(value.type)}' values in a checked-dynamic object (the value cannot convert into the checked-dynamic tree)`, - ); - } - return { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySet", - args: [target.obj, { kind: "strLit", value: target.field, type: STRING, loc }, v], - type: VOID, - loc, - }, - loc, - }; - } - if (target.container === "accessor") { - const setF = lowerer.findMethodOn( - lowerer.classes.get(target.className) ?? null, - `set:${target.field}`, - ); - if (!setF) - lowerer.unsupported( - "SC1090", - blame, - `assignment to the getter-only property '${target.field}'`, - ); - return { - kind: "exprStmt", - expr: lowerer.accessorCall( - target.className, - `set:${target.field}`, - target.obj, - [value], - VOID, - loc, - ), - loc, - }; - } - // Record accessor properties: the write calls the %set: closure with - // the coerced value. A getter-only write never gets here in a clean - // program (tsc's TS2540); the rejection is the backstop. - if (target.container === "recordAccessor") { - if (!target.setType) { - lowerer.unsupported( - "SC1090", - blame, - `assignment to the getter-only property '${target.field}'`, - ); - } - const closure: IrExpr = { - kind: "recordGet", - obj: target.obj, - shapeId: target.shapeId, - field: `%set:${target.field}`, - type: target.setType, - loc, - }; - return { - kind: "exprStmt", - expr: { kind: "callValue", callee: closure, args: [value], type: VOID, loc }, - loc, - }; - } - // Overflow dot writes: the bracket write with a literal key — a pure - // overflow insert (recordKeySet, overflowOnly: the name declares no - // field, so no declared collision exists to validate). The value was - // coerced into the index-value slot type by the caller. - if (target.container === "recordOvf") { - return { - kind: "recordKeySet", - obj: target.obj, - shapeId: target.shapeId, - key: { kind: "strLit", value: target.field, type: STRING, loc }, - value, - overflowOnly: true, - loc, - }; - } - return target.container === "class" - ? { - kind: "fieldSet", - obj: target.obj, - className: target.className, - field: target.field, - value, - loc, - } - : { - kind: "recordSet", - obj: target.obj, - shapeId: target.shapeId, - field: target.field, - value, - loc, - }; -} - /** `Promise.reject(reason)` on THE Promise global: a pre-rejected * promise. The reason is pinned to the Error hierarchy (the executor * reject parameter's contract — rejection payloads share the @@ -16720,233 +2717,6 @@ export function lowerFieldCompound( }; } -function lowerFieldCompoundValue( - lowerer: Lowerer, - access: ts.PropertyAccessExpression | ts.ElementAccessExpression, - op: CompoundOp, - rhsNode: ts.Expression | null, - loc: SrcLoc, -): IrExpr { - if (access.expression.kind === ts.SyntaxKind.SuperKeyword) { - lowerer.unsupported( - "SC1090", - access, - "compound assignment through 'super' (read and write separately)", - ); - } - const body: IrStmt[] = []; - const save = (value: IrExpr, name: string): IrExpr => { - const local = lowerer.declareHiddenLocal(name, value.type); - body.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); - return varRef(local.id, value.type, value.loc); - }; - // A CHECKED-DYNAMIC receiver (`context.actual++` — test/common's call - // accounting; dot spelling only — symbol-keyed element targets are - // static fields): read the member (dynKeyGet), VALIDATE it as a - // number (dynCheck — a non-number member throws the catchable - // TypeError where JS would ToNumber-coerce; it throws instead of - // producing NaN — SEMANTICS.md), combine, write back (dyn.keySet). The - // receiver and member read are saved before evaluating the RHS. - if (ts.isPropertyAccessExpression(access)) { - let probed = tryLowerExpression(lowerer, access.expression); - const staticClass = - probed?.type.kind === "classval" ? lowerer.classes.get(probed.type.className) : undefined; - if (probed && staticClass && hasRuntimeStatics(staticClass)) - probed = lowerer.coerceToExpected(probed, DYN); - if (probed?.type.kind === "dyn") { - const receiver = save(probed, "%compoundReceiver"); - const key: IrExpr = { - kind: "strLit", - value: access.name.text, - type: STRING, - loc: locOf(access.name), - }; - const read = save( - { kind: "dynKeyGet", key, value: receiver, type: DYN, loc }, - "%compoundOld", - ); - const cur: IrExpr = { kind: "dynCheck", value: read, type: F64, loc }; - const rhs = save( - rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc }, - "%compoundRhs", - ); - if (isJsSourceFile(access.getSourceFile()) && lowerer.dynConvertible(rhs.type)) { - const numeric = ["-", "*", "/", "%", "**"].includes(op); - const right = lowerer.coerceToExpected(rhs, DYN); - const value = save( - { - kind: "libCall", - fn: op === "+" ? "dyn.add" : numeric ? "dyn.arithmetic" : "dyn.bitwise", - args: - op === "+" - ? [read, right] - : [read, right, { kind: "strLit", value: op, type: STRING, loc }], - type: DYN, - loc, - }, - "%compoundResult", - ); - body.push({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySet", - args: [receiver, key, value], - type: VOID, - loc, - }, - loc, - }); - return { kind: "seqExpr", stmts: body, result: value, type: DYN, loc }; - } - const numericRhs: IrExpr = - rhs.type.kind === "dyn" - ? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc } - : lowerOptionalNumber(lowerer, rhs, loc); - if (numericRhs.type.kind !== "f64") - compoundUnsupported(lowerer, access, op, F64, numericRhs.type); - const value = save( - { kind: "bin", op, left: cur, right: numericRhs, type: F64, loc }, - "%compoundResult", - ); - const boxed: IrExpr = { kind: "dynFrom", value, type: DYN, loc }; - body.push({ - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.keySet", - args: [receiver, { ...key }, boxed], - type: VOID, - loc, - }, - loc, - }); - return { kind: "seqExpr", stmts: body, result: value, type: value.type, loc }; - } - } - const targetOf = (): FieldTarget | null => - ts.isPropertyAccessExpression(access) - ? lowerer.fieldTarget(access) - : symbolFieldTarget(lowerer, access); - const target = targetOf(); - if (!target) - lowerer.unsupported("SC1090", access, "compound assignment to unsupported field targets"); - checkDeferredMemberReceiver(lowerer, target, access); - target.obj = save(target.obj, "%compoundReceiver"); - // Accessors observe getter, RHS side effects, then setter, all through - // the saved receiver. - const fieldRead = lowerer.fieldGetExpr(target, locOf(access), access); - // `+=` on a stored string field reads it into a mutable local, so the - // concat below can append to that local in place. - const storedString = - op === "+" && - fieldRead.type.kind === "string" && - target.fieldType.kind === "string" && - (target.container === "class" || target.container === "record"); - let read: IrExpr; - if (storedString) { - const old = declareContextLocal( - lowerer.ctx, - "%compoundOld", - STRING, - true, - undefined, - undefined, - ); - body.push({ kind: "varDecl", localId: old.id, init: fieldRead, loc: fieldRead.loc }); - read = varRef(old.id, STRING, fieldRead.loc); - } else { - read = save(fieldRead, "%compoundOld"); - } - const rhs = save( - rhsNode ? lowerer.lowerExpr(rhsNode) : { kind: "numLit", value: 1, type: F64, loc }, - "%compoundRhs", - ); - const numericRhs: IrExpr = - rhs.type.kind === "dyn" && isJsSourceFile(access.getSourceFile()) - ? { kind: "dynCheck", value: rhs, type: F64, loc: rhs.loc } - : lowerOptionalNumber(lowerer, rhs, loc); - let value: IrExpr; - let appended: IrExpr | null = null; - const numericRead = lowerOptionalNumber(lowerer, read, loc, access); - const bigintValue = rhsNode ? bigintCompoundValue(op, read, rhs, loc) : null; - if (op === "+" && read.type.kind === "string") { - appended = concatOperand(lowerer, rhs, rhsNode ?? access); - value = { kind: "strConcat", left: read, right: appended, type: STRING, loc }; - } else if (numericRead.type.kind === "f64" && numericRhs.type.kind === "f64") { - value = { kind: "bin", op, left: numericRead, right: numericRhs, type: F64, loc }; - } else if (bigintValue) { - value = bigintValue; - } else if ( - (target.fieldType.kind === "dyn" && isJsSourceFile(access.getSourceFile())) || - // An untyped right operand: the expanded `o.f = o.f op rhs`; the field - // write below checks the result into the declared field type. - (rhs.type.kind === "dyn" && - lowerer.dynConvertible(read.type) && - lowerer.dynConvertible(target.fieldType)) - ) { - value = dynCompoundValue( - op, - lowerer.coerceToExpected(read, DYN), - lowerer.coerceToExpected(rhs, DYN), - loc, - ); - } else { - compoundUnsupported(lowerer, access, op, read.type, rhs.type); - } - if (storedString && rhs.type.kind === "string" && read.kind === "varRef" && appended !== null) { - // The field gives up its value before the concat (no code runs in - // between: both operands are already strings), and the concat is the - // self-append `old = old + rhs`, which hands the local's reference to - // the runtime: the old string is then uniquely owned unless something - // else holds it, and is appended to in place. Repeated `this.text += s` - // is linear rather than a copy per append. - const oldId = read.localId; - const oldLoc = read.loc; - const oldRef = (): IrExpr => varRef(oldId, STRING, oldLoc); - const concat: IrExpr = { - kind: "strConcat", - left: oldRef(), - right: appended, - type: STRING, - loc, - }; - body.push( - lowerer.fieldSetStmt(target, { kind: "strLit", value: "", type: STRING, loc }, loc, access), - { kind: "assign", localId: oldId, value: concat, loc }, - lowerer.fieldSetStmt(target, oldRef(), loc, access), - ); - return { kind: "seqExpr", stmts: body, result: oldRef(), type: STRING, loc }; - } - const result = save(value, "%compoundResult"); - body.push( - lowerer.fieldSetStmt(target, lowerer.coerceInto(access, result, target.fieldType), loc, access), - ); - return { kind: "seqExpr", stmts: body, result, type: result.type, loc }; -} - -/** Stream-rooted receivers' property surface (readableEnded, destroyed, - * ...): the receiver's mapped class dispatches into lower-stream.ts. - * Null off the stream hierarchy or for names the spoke does not own. */ -function lowerStreamObjectProperty( - lowerer: Lowerer, - expr: ts.PropertyAccessExpression, -): IrExpr | null { - if (expr.questionDotToken && !lowerer.chainHandled.has(expr)) return null; - const recvT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); - if (recvT?.kind !== "object") return null; - const info = lowerer.classes.get(recvT.className); - if (!info || streamSidesOf(lowerer, info) === null) return null; - if (expr.name.text === "bytesWritten" && isZlibStreamMember(lowerer, expr)) { - return lowerZlibStreamBytesWritten(lowerer, expr); - } - if (isFsStreamMember(lowerer, expr)) { - const member = lowerFsStreamProperty(lowerer, expr); - if (member) return member; - } - return lowerStreamProperty(lowerer, expr, info); -} - /** The primitive-constructor closure (`String`/`Number`/`Boolean` as a * VALUE): interns one synthesized module function per constructor/ABI and * returns the zero-capture closure over it. Typed sources use the mapped @@ -16956,7 +2726,7 @@ function lowerStreamObjectProperty( * dyn bodies run exact ToString/ToNumber/ToBoolean, including object hooks. * Interning makes every reference under one ABI the SAME immortal closure, * so `opt.type === String` compares like JS function identity. */ -function primitiveCtorClosure( +export function primitiveCtorClosure( lowerer: Lowerer, name: "String" | "Number" | "Boolean" | "BigInt", loc: SrcLoc, @@ -17006,133 +2776,3 @@ function primitiveCtorClosure( } return { kind: "closure", fnName, captures: [], type: fnT, loc }; } - -/** Drain using checked dispatch so native class and generator iterators keep their methods. */ -function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, loc: SrcLoc): IrExpr { - const name = "%iterator.pack.checked"; - if (!lowerer.liftedFns.some((fn) => fn.name === name)) { - const ref = (id: string): IrExpr => ({ kind: "varRef", localId: id, type: DYN, loc }); - const property = (key: string): IrExpr => ({ - kind: "dynKeyGet", - value: ref("step"), - key: { kind: "strLit", value: key, type: STRING, loc }, - type: DYN, - loc, - }); - lowerer.liftedFns.push({ - name, - params: [{ localId: "source", name: "source", type: DYN }], - returnType: DYN, - locals: ["source", "iterator", "step", "result"].map((id) => ({ - id, - name: id, - type: DYN, - mutable: id !== "source", - })), - body: [ - { - kind: "varDecl", - localId: "iterator", - init: { - kind: "libCall", - fn: "dyn.iterator", - args: [ref("source"), { kind: "strLit", value: "value", type: STRING, loc }], - type: DYN, - loc, - }, - loc, - }, - { - kind: "varDecl", - localId: "result", - init: { kind: "dynArrLit", elems: [], type: DYN, loc }, - loc, - }, - { - kind: "while", - cond: { kind: "boolLit", value: true, type: BOOL, loc }, - body: [ - { - kind: "varDecl", - localId: "step", - init: { - kind: "dynInvoke", - recv: ref("iterator"), - method: "next", - calleeName: "iterator.next", - args: [], - type: DYN, - loc, - }, - loc, - }, - { - kind: "if", - cond: { kind: "dynTest", test: "truthy", value: property("done"), type: BOOL, loc }, - then: [{ kind: "return", value: ref("result"), loc }], - else_: null, - loc, - }, - { - kind: "exprStmt", - expr: { - kind: "libCall", - fn: "dyn.packPush", - args: [ref("result"), property("value")], - type: VOID, - loc, - }, - loc, - }, - ], - loc, - }, - { kind: "return", value: ref("result"), loc }, - ], - loc, - }); - } - return { kind: "call", callee: name, args: [source], type: DYN, loc }; -} - -/** The `declare module "..."` body (or nested namespace body) a module - * export's declaration sits directly in, or undefined. */ -function builtinModuleExportParent(decl: ts.Node): ts.ModuleBlock | undefined { - const parent = ts.isVariableDeclaration(decl) ? decl.parent?.parent?.parent : decl.parent; - return parent !== undefined && ts.isModuleBlock(parent) ? parent : undefined; -} - -/** A function, class or variable exported by a builtin module's body. */ -function isBuiltinModuleValueDecl(decl: ts.Node): boolean { - return ( - builtinModuleExportParent(decl) !== undefined && - (ts.isFunctionDeclaration(decl) || - ts.isClassDeclaration(decl) || - ts.isVariableDeclaration(decl)) - ); -} - -/** True when `access` reads a member that JavaScript sources attach to a - * function by assignment (`fn.member = value`): the receiver's type is - * callable and every declaration of the member is such an assignment in a - * JS file. */ -function jsFunctionExpandoMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { - if (access.questionDotToken || !ts.isIdentifier(access.name)) return false; - const recvType = lowerer.typeOf(access.expression); - if (lowerer.checker.getCallSignatures(recvType).length === 0) return false; - const member = lowerer.checker.getPropertyOfType(recvType, access.name.text); - const decls = member ? member.declarations : []; - return ( - decls.length > 0 && - decls.every((d) => { - if (!isJsSourceFile(d.getSourceFile())) return false; - const assign = ts.isBinaryExpression(d) ? d : d.parent; - return ( - assign !== undefined && - ts.isBinaryExpression(assign) && - assign.operatorToken.kind === ts.SyntaxKind.EqualsToken && - ts.isPropertyAccessExpression(assign.left) - ); - }) - ); -}