From 18cd67bc5c5dd4e21ae792564e0e7ce5f3ba68d5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 20 Sep 2026 09:51:47 -0500 Subject: [PATCH] Add static union receiver calls --- docs/src/app/limitations/page.mdx | 2 +- .../src/frontend/lowering/lower-calls.ts | 228 ++++++++++++++++++ .../src/frontend/lowering/lower-containers.ts | 152 ++++++++++++ .../src/frontend/lowering/lower-exprs.ts | 20 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../test/ts7/baselines/order-parity.json | 12 + packages/compiler/test/ts7/fixtures.ts | 15 ++ tests/corpus/2930-union-receiver-calls.ts | 135 +++++++++++ tests/corpus/2931-union-dynamic-field-call.js | 51 ++++ tests/harness/npm-static.test.ts | 7 +- 10 files changed, 612 insertions(+), 13 deletions(-) create mode 100644 tests/corpus/2930-union-receiver-calls.ts create mode 100644 tests/corpus/2931-union-dynamic-field-call.js diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index 2899d50c7..9b45fae34 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -20,7 +20,7 @@ These are rejected at compile time with an `SC` code, a code frame, and usually - Arbitrary-precision `bigint` values compile statically across literals, arithmetic and bitwise operators, comparisons, conversions, arrays, records, classes, closures, unions, promises, Buffer 64-bit reads/writes, and DataView 64-bit access. `BigInt64Array`/`BigUint64Array`, BigInt filesystem stats, JSON serialization, Map/Set keys, and crossing a BigInt through `unknown`, `any`, or the dynamic island remain fenced. - **Record shapes are exact structs.** Passing `{a, b}` where `{a}` is expected is SC2002. Where the compiler does accept a strict field-subset flow, it copies the record — see divergences below. -- Union edges: unions used whole where a per-arm answer is needed (reading `u.length` on `string | string[]` — narrow first), union-into-union widening outside the re-tag and width rules, function arms beside data arms. (Printing a whole union is fine: `console.log(u)` dispatches per arm.) +- Union edges: same-ABI methods on class unions and `map`/`forEach` on array-arm unions dispatch by runtime arm, while shared or joinable field reads compile too. Remaining edges include operations with no common per-arm ABI (such as reading `u.length` on `string | string[]` — narrow first), union-into-union widening outside the re-tag and width rules, and function arms beside data arms. (Printing a whole union is fine: `console.log(u)` dispatches per arm.) - Watch for **tuple inference**: `Promise.all([work(1), work(2)])` infers a tuple type, and tuple edges (like `.join` on a tuple) are fenced. Type the array first: ```ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index ab82c40d6..a58e07414 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -9656,6 +9656,12 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I access: ts.PropertyAccessExpression,): IrExpr | null { if (lowerer.chainBlocked(access, call)) return null; const mappedReceiver = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + if (mappedReceiver?.kind === "union") { + const dispatched = + lowerUnionObjectMethodCall(lowerer, call, access, mappedReceiver) ?? + lowerUnionObjectDynFieldCall(lowerer, call, access, mappedReceiver); + if (dispatched) return dispatched; + } const receiverIr = mappedReceiver?.kind === "object" ? mappedReceiver : mappedReceiver?.kind === "union" @@ -9799,3 +9805,225 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I loc: locOf(call), }); } + +/** A method TypeScript proves callable on every arm of an unrelated class + * union. Each runtime tag keeps its own direct/virtual dispatch; only the + * completed argument ABI must agree. Results flow into the checker-selected + * call type through the ordinary wrap/retag/width machinery. */ +function lowerUnionObjectMethodCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + receiverT: IrType & { kind: "union" }, +): IrExpr | null { + const def = lowerer.unions.get(receiverT.unionId); + if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "object")) return null; + const method = access.name.text; + const plans: { + arm: IrType & { kind: "object" }; + info: ClassInfo; + found: NonNullable>; + }[] = []; + for (const arm of def.arms as (IrType & { kind: "object" })[]) { + const info = lowerer.classes.get(arm.className); + if (!info) { + lowerer.flushDeferredClass(arm.className); + return null; + } + const found = lowerer.findMethodOn(info, method); + if (!found) return null; + if (found.sig.abstract === true && !lowerer.overrideBelow(info, method)) return null; + plans.push({ arm, info, found }); + } + const shapes = plans[0]!.found.sig.params; + if (!plans.every((plan) => paramAbisEqual(shapes, plan.found.sig.params))) return null; + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!resultT || isUnitType(resultT)) return null; + const loc = locOf(call); + const receiver = lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), receiverT); + const args = lowerer.completeArgs(call.arguments, shapes, loc, call); + const helper = unionObjectMethodHelper(lowerer, call, receiverT, method, plans, shapes, resultT, loc); + return { kind: "call", callee: helper, args: [receiver, ...args], type: resultT, loc }; +} + +function paramAbisEqual(left: readonly ParamShape[], right: readonly ParamShape[]): boolean { + return left.length === right.length && left.every((shape, i) => { + const other = right[i]; + return other !== undefined && shape.mode === other.mode && typeEquals(shape.type, other.type); + }); +} + +/** The JS-class sibling of union method dispatch: every arm stores the + * named callable in a checked-dynamic field. A helper selects and retains + * the field value before the dynCall evaluates its source arguments, so an + * argument that overwrites the field cannot change this invocation. */ +function lowerUnionObjectDynFieldCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + receiverT: IrType & { kind: "union" }, +): IrExpr | null { + const def = lowerer.unions.get(receiverT.unionId); + if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "object")) return null; + const field = access.name.text; + const plans: { arm: IrType & { kind: "object" }; info: ClassInfo }[] = []; + for (const arm of def.arms as (IrType & { kind: "object" })[]) { + const info = lowerer.classes.get(arm.className); + if (!info || info.fields.get(field)?.kind !== "dyn") return null; + plans.push({ arm, info }); + } + if (call.arguments.some(ts.isSpreadElement)) { + lowerer.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); + } + const loc = locOf(call); + const receiver = lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), receiverT); + const key = `${receiverT.unionId}:dyn-field:${field}`; + let helper = lowerer.unionCallHelpers.get(key); + if (!helper) { + helper = `%union.call.${lowerer.unionCallHelpers.size}`; + lowerer.unionCallHelpers.set(key, helper); + const receiverRef = varRef("this.0", receiverT, loc); + const branch = (plan: typeof plans[number]): IrStmt[] => { + const concrete: IrExpr = { + kind: "unionNarrow", + unionId: receiverT.unionId, + tag: lowerer.armTag(receiverT.unionId, plan.arm), + value: receiverRef, + type: plan.arm, + loc, + }; + return [{ + kind: "return", + value: { + kind: "fieldGet", + obj: concrete, + className: plan.info.def.name, + field, + type: DYN, + loc, + }, + loc, + }]; + }; + let body = branch(plans[plans.length - 1]!); + for (let i = plans.length - 2; i >= 0; i--) { + const plan = plans[i]!; + body = [{ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: receiverT.unionId, + tag: lowerer.armTag(receiverT.unionId, plan.arm), + negated: false, + value: receiverRef, + type: BOOL, + loc, + }, + then: branch(plan), + else_: body, + loc, + }]; + } + const params: IrParam[] = [ + { localId: "this.0", name: "this", type: receiverT }, + ]; + const locals: IrLocal[] = params.map((param) => ({ id: param.localId, name: param.name, type: param.type, mutable: false })); + lowerer.liftedFns.push({ name: helper, params, returnType: DYN, locals, body, loc }); + } + const callee: IrExpr = { kind: "call", callee: helper, args: [receiver], type: DYN, loc }; + const args = call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); + return { kind: "dynCall", callee, calleeName: access.getText(), args, type: DYN, loc }; +} + +function unionObjectMethodHelper( + lowerer: Lowerer, + sourceCall: ts.CallExpression, + receiverT: IrType & { kind: "union" }, + method: string, + plans: { + arm: IrType & { kind: "object" }; + info: ClassInfo; + found: NonNullable>; + }[], + shapes: readonly ParamShape[], + resultT: IrType, + loc: SrcLoc, +): string { + const key = `${receiverT.unionId}:${method}:${shapes.map((shape) => `${shape.mode}:${typeKey(shape.type)}`).join(",")}:${typeKey(resultT)}`; + const existing = lowerer.unionCallHelpers.get(key); + if (existing) return existing; + const name = `%union.call.${lowerer.unionCallHelpers.size}`; + lowerer.unionCallHelpers.set(key, name); + const receiver = varRef("this.0", receiverT, loc); + const argRefs = shapes.map((shape, i) => varRef(`a.${i}`, shape.type, loc)); + const branch = (plan: typeof plans[number]): IrStmt[] => { + const concrete: IrExpr = { + kind: "unionNarrow", + unionId: receiverT.unionId, + tag: lowerer.armTag(receiverT.unionId, plan.arm), + value: receiver, + type: plan.arm, + loc, + }; + let invoke: IrExpr; + if (plan.found.declarer.builtinError) { + invoke = { + kind: "libCall", + fn: "error.toString", + args: [lowerer.upcastTo(concrete, plan.found.declarer.def.name)], + type: STRING, + loc, + }; + } else if (lowerer.overrideBelow(plan.info, method)) { + lowerer.noteVirtualEdge(plan.info, method); + invoke = { + kind: "virtualCall", + className: plan.info.def.name, + method, + args: [lowerer.upcastTo(concrete, plan.info.def.name), ...argRefs], + type: plan.found.sig.ret, + loc, + }; + } else { + lowerer.noteEdge(`%${plan.found.declarer.def.name}.${method}`); + invoke = { + kind: "call", + callee: `%${plan.found.declarer.def.name}.${method}`, + args: [lowerer.upcastTo(concrete, plan.found.declarer.def.name), ...argRefs], + type: plan.found.sig.ret, + loc, + }; + } + invoke = reconcileOverloadReturn(lowerer, sourceCall, invoke); + const result = lowerer.coerceInto(sourceCall, invoke, resultT); + return resultT.kind === "void" + ? [{ kind: "exprStmt", expr: result, loc }, { kind: "return", value: null, loc }] + : [{ kind: "return", value: result, loc }]; + }; + let body = branch(plans[plans.length - 1]!); + for (let i = plans.length - 2; i >= 0; i--) { + const plan = plans[i]!; + body = [{ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: receiverT.unionId, + tag: lowerer.armTag(receiverT.unionId, plan.arm), + negated: false, + value: receiver, + type: BOOL, + loc, + }, + then: branch(plan), + else_: body, + loc, + }]; + } + const params: IrParam[] = [ + { localId: "this.0", name: "this", type: receiverT }, + ...shapes.map((shape, i) => ({ localId: `a.${i}`, name: `a${i}`, type: shape.type })), + ]; + const locals: IrLocal[] = params.map((param) => ({ id: param.localId, name: param.name, type: param.type, mutable: false })); + lowerer.liftedFns.push({ name, params, returnType: resultT, locals, body, loc }); + return name; +} diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 66b99ea01..4dc351594 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -114,6 +114,10 @@ function fenceProducedArrayElem(lowerer: Lowerer, node: ts.Node, producer: strin probedUntyped = true; } } + if (receiverIr?.kind === "union") { + const unionHof = lowerArrayUnionHofCall(lowerer, call, access, receiverIr); + if (unionHof) return unionHof; + } if (receiverIr?.kind !== "array") return null; if (!probedUntyped && !lowerer.isStdlibMember(access)) return null; let elem = receiverIr.elem; @@ -1184,6 +1188,154 @@ function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { return name; } +/** The TS 5.2+ callable-union array rule: a `T[] | U[]` receiver exposes + * common HOFs with a callback over `T | U`. The runtime value remains the + * original concrete array arm — never a copied `(T | U)[]` — and an + * arm-specific function adapter wraps the element and receiver arguments + * into the union-wide callback ABI. That preserves sparse-array reads, + * callback mutation, and the callback's third-argument identity. */ +function lowerArrayUnionHofCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + receiverT: IrType & { kind: "union" }, +): IrExpr | null { + const method = access.name.text; + if (method !== "map" && method !== "forEach") return null; + if (!lowerer.isStdlibMember(access)) return null; + const def = lowerer.unions.get(receiverT.unionId); + if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null; + if (call.arguments.length !== 1 || !call.arguments[0]) return null; + const arrays = def.arms as (IrType & { kind: "array" })[]; + const valueArms: IrType[] = []; + const addValueArm = (type: IrType): boolean => { + if (type.kind === "dyn" || type.kind === "jsval" || type.kind === "caught" || type.kind === "void") return false; + if (type.kind === "union") { + const inner = lowerer.unions.get(type.unionId); + if (!inner) return false; + for (const arm of inner.arms) { + if (!valueArms.some((candidate) => typeEquals(candidate, arm))) valueArms.push(arm); + } + return true; + } + if (!valueArms.some((candidate) => typeEquals(candidate, type))) valueArms.push(type); + return true; + }; + for (const array of arrays) { + if (!addValueArm(array.elem)) return null; + } + const valueT: IrType = valueArms.length === 1 + ? valueArms[0]! + : { kind: "union", unionId: lowerer.unions.intern(valueArms) }; + const argNode = call.arguments[0]!; + const { fnArg, arity } = hofCallbackArg(lowerer, argNode, [valueT], receiverT); + const fnRet = fnArg.type.ret; + if (method === "map" && (fnRet.kind === "void" || fnRet.kind === "func")) { + lowerer.badType(call, lowerer.typeOf(call)); + } + if (method === "map") fenceProducedArrayElem(lowerer, call, "'.map()'", fnRet); + const outElem = method === "map" ? callbackArrayElem(lowerer, call, fnRet) : VOID; + const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; + for (const array of arrays) { + const armFnT = funcOf([arrayValueType(lowerer, array.elem), F64, array].slice(0, arity), fnRet) as IrType & { kind: "func" }; + if (!lowerer.cleanFuncAdaptable(fnArg.type, armFnT)) return null; + } + const helper = arrayUnionHofHelper( + lowerer, + method, + receiverT, + arrays, + fnArg.type, + arity, + fnRet, + outElem, + locOf(call), + ); + return { + kind: "call", + callee: helper, + args: [lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), receiverT), fnArg], + type: resultT, + loc: locOf(call), + }; +} + +function arrayUnionHofHelper( + lowerer: Lowerer, + method: "map" | "forEach", + receiverT: IrType & { kind: "union" }, + arrays: (IrType & { kind: "array" })[], + callbackT: IrType & { kind: "func" }, + arity: number, + fnRet: IrType, + outElem: IrType, + loc: SrcLoc, +): string { + const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; + const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`; + const existing = lowerer.arrHofHelpers.get(key); + if (existing) return existing; + const name = `%arr.union.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, name); + const receiver = varRef("a.0", receiverT, loc); + const callback = varRef("f.0", callbackT, loc); + const branch = (array: IrType & { kind: "array" }): IrStmt[] => { + const tag = lowerer.armTag(receiverT.unionId, array); + const concrete: IrExpr = { + kind: "unionNarrow", + unionId: receiverT.unionId, + tag, + value: receiver, + type: array, + loc, + }; + const armFnT = funcOf([arrayValueType(lowerer, array.elem), F64, array].slice(0, arity), fnRet) as IrType & { kind: "func" }; + const adapted = lowerer.coerceToExpected(callback, armFnT); + if (!typeEquals(adapted.type, armFnT)) { + throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting"); + } + const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem); + const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc }; + return resultT.kind === "void" + ? [{ kind: "exprStmt", expr: invoke, loc }, { kind: "return", value: null, loc }] + : [{ kind: "return", value: invoke, loc }]; + }; + let body = branch(arrays[arrays.length - 1]!); + for (let i = arrays.length - 2; i >= 0; i--) { + const array = arrays[i]!; + body = [{ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: receiverT.unionId, + tag: lowerer.armTag(receiverT.unionId, array), + negated: false, + value: receiver, + type: BOOL, + loc, + }, + then: branch(array), + else_: body, + loc, + }]; + } + lowerer.liftedFns.push({ + name, + params: [ + { localId: "a.0", name: "a", type: receiverT }, + { localId: "f.0", name: "f", type: callbackT }, + ], + returnType: resultT, + locals: [ + { id: "a.0", name: "a", type: receiverT, mutable: false }, + { id: "f.0", name: "f", type: callbackT, mutable: false }, + ], + body, + loc, + }); + return name; +} + /** READ-ONLY array methods on TUPLE receivers — `t.slice(...)` and * `t.map(f)`: a tuple is a fixed-shape record, but these methods never * write, so the positions snapshot into a fresh array (the for-of-over- diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index e43ebb400..da65e5b49 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -7826,22 +7826,24 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr // 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: dense arrays hold - // exactly the indices [0, length), so `3 in xs` is a length test (and - // a negative/fractional literal is a constant miss — the receiver - // still evaluates once through its own length read). + // 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 len: IrExpr = { kind: "arrIntrinsic", method: "length", receiver: recvArr, args: [], type: F64, loc }; const n = Number(kNode.text); - if (Number.isInteger(n) && n >= 0) { - return { kind: "bin", op: "<", left: { kind: "numLit", value: n, type: F64, loc }, right: len, type: BOOL, loc }; - } - return { kind: "bin", op: "<", left: len, right: { kind: "numLit", value: 0, type: F64, loc }, type: BOOL, loc }; + return { + kind: "arrayHas", + arr: recvArr, + index: { kind: "numLit", value: n, type: F64, loc }, + type: BOOL, + loc, + }; } } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2a7b32e24..45fd78ef9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1451,6 +1451,9 @@ export class Lowerer { /** Synthetic array-HOF loop functions (map/filter/forEach desugar), * interned per method + element/callback-result type: key → fn name. */ readonly arrHofHelpers = new Map(); + /** Per-arm calls through union-typed class receivers, interned by the + * receiver union, member, completed argument ABI, and result type. */ + readonly unionCallHelpers = new Map(); /** Derived shape metadata that depends on another shape's declaration * order. These settle before helper bodies rebuild from that metadata. */ readonly shapeOrderMetadataFinalizers: (() => void)[] = []; diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 458fea266..2786d87fe 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6373,6 +6373,18 @@ ], "diags": [] }, + "/tests/corpus/2930-union-receiver-calls.ts": { + "order": [ + "/tests/corpus/2930-union-receiver-calls.ts" + ], + "diags": [] + }, + "/tests/corpus/2931-union-dynamic-field-call.js": { + "order": [ + "/tests/corpus/2931-union-dynamic-field-call.js" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" diff --git a/packages/compiler/test/ts7/fixtures.ts b/packages/compiler/test/ts7/fixtures.ts index 3f4613613..cf5f6b9a9 100644 --- a/packages/compiler/test/ts7/fixtures.ts +++ b/packages/compiler/test/ts7/fixtures.ts @@ -46,6 +46,21 @@ const nn = maybe!; void nn; void (x satisfies number); void typeof tpl; const neg = -x; const post = ys.length; void [neg, post]; +class UnionLeft { + leftOnly(): string { return "left"; } + common(prefix: string): string { return prefix + ":left"; } +} +class UnionRight { + rightOnly(): string { return "right"; } + common(prefix: string): string { return prefix + ":right"; } +} +function unionCalls(items: UnionLeft[] | UnionRight[], value: UnionLeft | UnionRight): string[] { + items.forEach((item) => void item.common("each")); + const mapped = items.map((item, index, array) => item.common(String(index + array.length))); + mapped.push(value.common("value")); + return mapped; +} +void unionCalls; export default over; `, }; diff --git a/tests/corpus/2930-union-receiver-calls.ts b/tests/corpus/2930-union-receiver-calls.ts new file mode 100644 index 000000000..1876c7fee --- /dev/null +++ b/tests/corpus/2930-union-receiver-calls.ts @@ -0,0 +1,135 @@ +// Calls through class and array unions dispatch on the runtime arm without +// copying receivers. Array HOF adapters preserve holes, callback-array +// identity, mutation timing, and receiver-before-argument evaluation. +class Left { + value: number; + + constructor(value: number) { + this.value = value; + } + + leftOnly(): string { + return "left-only"; + } + + describe(prefix = "left"): string { + return `${prefix}:L${this.value}`; + } + + mixed(): number { + return this.value * 10; + } +} + +class Right { + value: number; + + constructor(value: number) { + this.value = value; + } + + rightOnly(): string { + return "right-only"; + } + + describe(prefix = "right"): string { + return `${prefix}:R${this.value}`; + } + + mixed(): string { + return `R${this.value}`; + } +} + +function values(which: boolean): Left[] | Right[] { + if (which) return [new Left(1), new Left(2), new Left(3)]; + const out: Right[] = []; + out.length = 3; + out[1] = new Right(4); + return out; +} + +function show(value: Left | Right): void { + console.log(value.describe(), value.describe("set"), value.mixed()); +} + +show(new Left(5)); +show(new Right(6)); + +class LeftBase { + leftBaseOnly(): boolean { + return true; + } + + speak(word: string): string { + return `LB:${word}`; + } +} + +class LeftChild extends LeftBase { + speak(word: string): string { + return `LC:${word}`; + } +} + +class RightBase { + rightBaseOnly(): boolean { + return true; + } + + speak(word: string): string { + return `RB:${word}`; + } +} + +class RightChild extends RightBase { + speak(word: string): string { + return `RC:${word}`; + } +} + +function speak(value: LeftBase | RightBase): string { + return value.speak("hello"); +} + +console.log(speak(new LeftBase()), speak(new LeftChild())); +console.log(speak(new RightBase()), speak(new RightChild())); + +function inspect(items: Left[] | Right[], mutate: () => void): void { + const seen: string[] = []; + let mutated = false; + const mapped = items.map((item, index, array) => { + seen.push(`${index}:${array === items}:${item.describe("map")}`); + if (!mutated) { + mutated = true; + mutate(); + } + return item.value + index; + }); + const each: string[] = []; + items.forEach((item, index, array) => { + each.push(`${index}:${array === items}:${item.describe("each")}`); + }); + console.log(seen.join("|")); + console.log(mapped.length, 0 in mapped, 1 in mapped, 2 in mapped, mapped.join(",")); + console.log(each.join("|")); +} + +const left = values(true) as Left[]; +inspect(left, () => { + left.pop(); +}); +const right = values(false) as Right[]; +inspect(right, () => {}); + +const order: string[] = []; +function orderedReceiver(): Left[] | Right[] { + order.push("receiver"); + return values(true); +} +function orderedCallback(): (item: Left | Right) => number { + order.push("callback"); + return (item) => item.value; +} +console.log(orderedReceiver().map(orderedCallback()).join(",")); +console.log(order.join(",")); diff --git a/tests/corpus/2931-union-dynamic-field-call.js b/tests/corpus/2931-union-dynamic-field-call.js new file mode 100644 index 000000000..2402bc80d --- /dev/null +++ b/tests/corpus/2931-union-dynamic-field-call.js @@ -0,0 +1,51 @@ +// A JS class union whose arms carry the same any-typed callable field: +// select the field by union tag, evaluate arguments once, and invoke the +// checked-dynamic function without requiring an engine. +class Left { + constructor() { + /** @type {*} */ + this.run = undefined; + this.leftOnly = true; + } +} + +class Right { + constructor() { + /** @type {*} */ + this.run = undefined; + this.rightOnly = true; + } +} + +/** @param {Left | Right} target @param {*} value */ +function invoke(target, value) { + return target.run(value); +} + +const left = new Left(); +left.run = () => "left"; +const right = new Right(); +right.run = () => "right"; +let evaluations = 0; +function argument() { + evaluations++; + return 42; +} +console.log(invoke(left, argument()), evaluations); +console.log(invoke(right, argument()), evaluations); + +left.run = () => "old-left"; +right.run = () => "old-right"; +function replaceBoth() { + left.run = () => "new-left"; + right.run = () => "new-right"; + return 0; +} +/** @param {Left | Right} target */ +function invokeWhileReplacing(target) { + return target.run(replaceBoth()); +} +console.log(invokeWhileReplacing(left), invoke(left, 0)); +left.run = () => "old-left-2"; +right.run = () => "old-right-2"; +console.log(invokeWhileReplacing(right), invoke(right, 0)); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index cbb8e707c..44d5b6520 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -204,14 +204,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { const total = coverage.stats.statementsTotal + (coverage.unreached?.stats.statementsTotal ?? 0); const failed = coverage.stats.statementsFailed + (coverage.unreached?.stats.statementsFailed ?? 0); expect(total).toBeGreaterThan(1200); // the whole package joined the program - expect((total - failed) / total).toBeGreaterThanOrEqual(0.94); - expect(total - failed).toBeGreaterThanOrEqual(1180); + expect((total - failed) / total).toBeGreaterThanOrEqual(0.95); + expect(total - failed).toBeGreaterThanOrEqual(1200); // Two promise-chain locals intentionally remain checked-dynamic: their // first assignment reads the preceding undefined value, so promoting // them to a scalar promise slot would be unsound. - expect(coverage.runtimeFences?.length ?? 0).toBeLessThanOrEqual(61); + expect(coverage.runtimeFences?.length ?? 0).toBeLessThanOrEqual(56); const fenceMessages = (coverage.runtimeFences ?? []).map((f) => f.message).join("\n"); expect(fenceMessages).not.toMatch(/storing 'm5\.Command' values|holding 'm5\.Command|ChildProcess' is expected/); + expect(fenceMessages).not.toMatch(/Command\[\] \| Option\[\]\.(?:map|forEach)|target\.parseArg/); const binary = await buildStatic(entry, ["commander"]); const argv = ["add", "20", "22"];