From 223973a56b9d65f29428ee2579f0a51c344df432 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:18:19 -0700 Subject: [PATCH 01/14] Store a discarding mapper's answer as an undefined array element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.from({ length: n }, () => undefined)` and `.map(() => undefined)` type as `undefined[]`, which the type mapping represents as an array of unit-union elements. Callback-driven producers take the element from the lowered callback's return type, and a standalone `undefined` in return position maps to the void convention — its answer is erased before it could be stored — so every discarding callback was refused (SC2009; pi's tui terminal-color palette). A producer whose result element is the unit-only union now gives the callback that union as its contextual return, exactly as the array element mapping substitutes it for a `void` element: the arrow's `undefined` answer becomes the present unit value. `[undefined, ...]`, sparse literals and an explicit `undefined[]` annotation already had that representation. Corpus 8527 pins the palette field, the mapper's call order and index, the `.map` and `Array.from(array, ...)` forms, `in`/JSON and the empty case under Node and natively. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 19 +++++++- .../lowering/containers/array-construction.ts | 24 ++++++++-- .../lowering/containers/callback-arguments.ts | 36 +++++++++++++++ ...array-callbacks-undefined-elements.ts.json | 6 +++ ...8527-array-callbacks-undefined-elements.ts | 44 +++++++++++++++++++ ...array-callbacks-undefined-elements.ts.json | 1 + 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json create mode 100644 tests/corpus/8527-array-callbacks-undefined-elements.ts create mode 100644 tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index ed8c6ddce..71d3d0876 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -56,6 +56,7 @@ import { typeKey } from "../../type-mapper.js"; import { lowerArrayCallback, requireProducedArrayElement, + unitOnlyArrayElement, callbackArrayElement, truthyFilterCallback, } from "./callback-arguments.js"; @@ -108,7 +109,14 @@ export function lowerArrayHofCall( } const callback = booleanFilter ? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false } - : lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem)); + : lowerArrayCallback( + lowerer, + argNode, + [valueT], + arrayOf(elem), + false, + method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined, + ); const { fnArg, arity } = callback; // Only an array that can hold elements outside its element type needs to // leave an element its callback ignores unread. @@ -659,7 +667,14 @@ export function lowerArrayUnionHofCall( ? valueArms[0]! : { kind: "union", unionId: lowerer.unions.intern(valueArms) }; const argNode = call.arguments.nodes[0]!; - const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT); + const { fnArg, arity } = lowerArrayCallback( + lowerer, + argNode, + [valueT], + receiverT, + false, + method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined, + ); const fnRet = fnArg.type.ret; if (method === "map" && fnRet.kind === "void") { // `void[]` has no element (see the plain-receiver map path). diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2a3ad64c0..e286a02b8 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -56,6 +56,8 @@ import { import { requireProducedArrayElement, lowerArrayCallback, + lowerArrayCallbackAnswering, + unitOnlyArrayElement, callbackArrayElement, } from "./callback-arguments.js"; import { classGeneratorIterable } from "../class-iterables.js"; @@ -1002,6 +1004,8 @@ export function lowerArrayFromCall( args[1]!, [input.element], arrayOf(input.element), + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, ); if (callback.arity > 2) lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); @@ -1123,7 +1127,14 @@ export function lowerArrayFromCall( if (paramSymbol) lowerer.runtimeOptionalBindingTypes.set(paramSymbol, valueT); let callback: ReturnType; try { - callback = lowerArrayCallback(lowerer, mapper, [valueT], arrT); + callback = lowerArrayCallback( + lowerer, + mapper, + [valueT], + arrT, + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, + ); } finally { if (paramSymbol) { if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(paramSymbol); @@ -1162,7 +1173,14 @@ export function lowerArrayFromCall( // mapper that writes the source changes later reads, as in Node), // so this is a loop over the typed array, not a toArray snapshot. if (src.type.kind === "bytes" && args.length === 2) { - const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64)); + const callback = lowerArrayCallback( + lowerer, + args[1]!, + [F64], + arrayOf(F64), + false, + unitOnlyArrayElement(lowerer, call) ?? undefined, + ); if (callback.arity > 2) lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); const fnRet = callback.fnArg.type.ret; @@ -1245,7 +1263,7 @@ export function lowerArrayFromCall( ); } if (n.type.kind !== "f64") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!)); - const fnArg = lowerer.lowerExpr(args[1]!); + const fnArg = lowerArrayCallbackAnswering(lowerer, args[1]!, unitOnlyArrayElement(lowerer, call)); // The mapper may declare any prefix of (v, i): v is the checker's own // `unknown` (Node passes undefined there — the dyn undefined singleton // here), i the index. The result type must be a legal array element. diff --git a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts index 3b504861a..80b34bd86 100644 --- a/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts +++ b/packages/compiler/src/frontend/lowering/containers/callback-arguments.ts @@ -306,6 +306,42 @@ function readsParameter(argNode: ts.Expression, index: number): boolean { ); } +/** The result array's element when it is the unit-only union (`undefined[]`, + * the sparse-literal/`Array.from(..., () => undefined)` shape): a callback + * that answers `undefined` stores a PRESENT unit element, so the producer + * gives the callback that union as its contextual return. The standalone + * `undefined` in return position otherwise maps to the void convention, + * whose answer is erased before it could be stored. Null for other elements. */ +export function unitOnlyArrayElement(lowerer: Lowerer, call: ts.CallExpression): IrType | null { + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (result?.kind !== "array") return null; + const elem = result.elem; + if (isUnitType(elem)) return elem; + return elem.kind === "union" && + lowerer.unions.get(elem.unionId)?.arms.every((arm) => isUnitType(arm)) === true + ? elem + : null; +} + +/** Lower a callback whose answer becomes an array element, with the result + * element as its contextual return when that element is the unit-only union + * (see unitOnlyArrayElement). Other producers lower the callback directly. */ +export function lowerArrayCallbackAnswering( + lowerer: Lowerer, + fnNode: ts.Expression, + expected: IrType | null, +): IrExpr { + if (expected === null) return lowerer.lowerExpr(fnNode); + const previous = lowerer.contextualFunctionReturns.get(fnNode); + lowerer.contextualFunctionReturns.set(fnNode, expected); + try { + return lowerer.lowerExpr(fnNode); + } finally { + if (previous === undefined) lowerer.contextualFunctionReturns.delete(fnNode); + else lowerer.contextualFunctionReturns.set(fnNode, previous); + } +} + /** Preserve the checker-selected payload ABI when a callback's runtime * return gained only undefined. The array state byte carries that value. */ export function callbackArrayElement( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json new file mode 100644 index 000000000..977cf0e45 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8527-array-callbacks-undefined-elements.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8527-array-callbacks-undefined-elements.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8527-array-callbacks-undefined-elements.ts b/tests/corpus/8527-array-callbacks-undefined-elements.ts new file mode 100644 index 000000000..f8b4a80d3 --- /dev/null +++ b/tests/corpus/8527-array-callbacks-undefined-elements.ts @@ -0,0 +1,44 @@ +// Array producers whose callback answers `undefined`: the result is an +// `undefined[]`, whose elements are PRESENT undefined values (JSON prints +// them as null, `in` sees them), and the mapper still runs once per index. + +type PendingQuery = { + palette: undefined[]; + timer: number | undefined; +}; + +function build(n: number): PendingQuery { + return { + palette: Array.from({ length: n }, () => undefined), + timer: undefined, + }; +} + +const query = build(3); +console.log(query.palette.length); +console.log(query.palette[0] === undefined, query.palette[2] === undefined); +console.log(0 in query.palette, 2 in query.palette, 3 in query.palette); +console.log(query.timer === undefined); + +// The mapper runs once per index, in order. +const seen: number[] = []; +const mapped: undefined[] = Array.from({ length: 4 }, (_value, index) => { + seen.push(index); + return undefined; +}); +console.log(mapped.length, seen.join(",")); +console.log(mapped.every((value) => value === undefined)); + +// Array.from over an existing array, and `.map` over an existing array, +// both with a discarding mapper. +const fromArray: undefined[] = Array.from([1, 2, 3], (_n) => undefined); +console.log(fromArray.length, fromArray[0] === undefined); +const discarded: undefined[] = ["a", "b"].map((_text) => undefined); +console.log(discarded.length, discarded[1] === undefined); + +// A dense literal of undefined elements keeps the same shape. +const literal: undefined[] = [undefined, undefined]; +console.log(literal.length, literal[1] === undefined); + +console.log(JSON.stringify(query.palette)); +console.log(JSON.stringify(mapped)); diff --git a/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json b/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json new file mode 100644 index 000000000..8c0e66c3c --- /dev/null +++ b/tests/harness/corpus-costs/8527-array-callbacks-undefined-elements.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 } From 49b133751c5bce606eef37ec95d02e4349a8eb5c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:48 -0700 Subject: [PATCH 02/14] Adopt a contextual union's map arm for a bare new Map() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Map()` has no type arguments, and the no-argument constructor gives the checker nothing to infer them from: the expression types as Map however the destination spells it. Construction adopted a contextual type only when it mapped to a map, so a nullable or readonly annotation — `Map | undefined`, `Map | null`, `ReadonlyMap | undefined`, an optional record field — refused the very value its slot was built for (SC2003; pi's extension registry, whose `handlers?: Map<...>` fields start empty). A Map value can only inhabit a union's map arm, so construction now adopts that arm when the contextual union has exactly one: the remainder of the annotation is the nullish absence the value never is. Two different map arms stay refused (SC2003), because the layout would be ambiguous. Corpus 8528 pins the nullable, readonly and optional-field annotations, a parameter slot, iteration after the adoption, and remains byte-exact against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 21 ++++++- ...28-new-collection-contextual-union.ts.json | 6 ++ .../8528-new-collection-contextual-union.ts | 59 +++++++++++++++++++ ...28-new-collection-contextual-union.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json create mode 100644 tests/corpus/8528-new-collection-contextual-union.ts create mode 100644 tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 014bbc71a..487f221c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -10071,6 +10071,21 @@ function lowerProgramClassNew( }); } +/** The supported map layout a contextual type pins for `new Map()`: the type + * itself, or the single map arm of a union — a nullable annotation such as + * `Map | undefined`, or `ReadonlyMap | undefined`, where a Map + * value can only inhabit the map arm and the no-argument constructor gives + * the checker nothing to infer the type arguments from. */ +function contextualMapLayout(lowerer: Lowerer, ctx: ts.Type): IrType | null { + const mapped = lowerer.mapTypeOf(ctx); + if (mapped?.kind === "map") return mapped; + if (mapped?.kind !== "union") return null; + const arms = (lowerer.unions.get(mapped.unionId)?.arms ?? []).filter( + (arm): arm is IrType & { kind: "map" } => arm.kind === "map", + ); + return arms.length === 1 ? arms[0]! : null; +} + function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { const parent = expr.parent; if ( @@ -11064,10 +11079,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = lowerer.mapTypeOf(inferred); } else if (mapped?.kind !== "map" || contextualSeed) { const ctx = lowerer.checker.getContextualType(expr); - const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null; - if (ctx && ctxMapped?.kind === "map") { + const ctxMap = ctx ? contextualMapLayout(lowerer, ctx) : null; + if (ctx && ctxMap !== null) { tsType = ctx; - mapped = ctxMapped; + mapped = ctxMap; } } if (seedArg && !entriesLit && mapped?.kind === "map") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..abdf9b9f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8528-new-collection-contextual-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8528-new-collection-contextual-union.ts b/tests/corpus/8528-new-collection-contextual-union.ts new file mode 100644 index 000000000..b5aecc244 --- /dev/null +++ b/tests/corpus/8528-new-collection-contextual-union.ts @@ -0,0 +1,59 @@ +// `new Map()` (no type arguments) in a contextual slot whose type is a +// union: `Map | undefined`, `ReadonlyMap | null`. A Map value +// can only inhabit the map arm, so the construction adopts that layout +// instead of the checker's bare Map. + +function lookup(handlers: Map | undefined, key: string): string { + if (handlers === undefined) return "absent"; + const found = handlers.get(key); + return found === undefined ? "missing" : String(found); +} + +const counts: Map | undefined = new Map(); +if (counts !== undefined) { + counts.set("a", 1); + counts.set("b", counts.size + 2); + console.log(counts.size, counts.get("a"), counts.get("b")); +} +console.log(lookup(counts, "a"), lookup(counts, "z"), lookup(undefined, "a")); +console.log(counts instanceof Map); + +// A nullable annotation takes the same arm. +const nullable: Map | null = new Map(); +console.log(nullable !== null, nullable instanceof Map); + +// A readonly view announces no writes; the value is still a real Map. +const readonlyView: ReadonlyMap | undefined = new Map(); +if (readonlyView !== undefined) { + console.log(readonlyView.size, readonlyView.get("missing") === undefined); +} + +// The contextual type of an optional record field is the same union. +type Registry = { + handlers?: Map; + names?: Set; +}; + +function createRegistry(): Registry { + return { handlers: new Map(), names: new Set() }; +} + +const registry = createRegistry(); +if (registry.handlers !== undefined) { + registry.handlers.set("on", 7); + console.log(registry.handlers.size, registry.handlers.get("on")); +} +if (registry.names !== undefined) { + registry.names.add("first"); + console.log(registry.names.size, registry.names.has("first")); +} + +// A parameter slot infers the same way for Map and Set. +function describe(map: Map | undefined, set: Set | undefined): string { + return `${map === undefined ? "-" : map.size}/${set === undefined ? "-" : set.size}`; +} +console.log(describe(new Map(), new Set()), describe(undefined, undefined)); + +// Iteration and JSON-free surfaces keep the element types. +const entries = new Map([["x", 1], ["y", 2]] as [string, number][]); +console.log([...entries.keys()].join(","), [...entries.values()].join(",")); diff --git a/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 } From 5afe93093fa349caabae9a4b94e0f25ee72c4402 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 15:36:03 -0700 Subject: [PATCH 03/14] Accept a wider lambda whose own signature is already its completed ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda declaring an optional or defaulted trailing parameter — the `(a, b, loose?) => …` semver comparator, the sort callback whose surplus position is a flag — was refused at its birth fence (SC1090) even though every read of it spells that position as `T | undefined`, the completed native ABI. The fence compared the slot's mapped type against the contextual lambda's, which misses two shapes the checker accepts: a lambda whose own `any` parameter mapType leaves unmapped, and a *contextually* typed lambda whose OWN signature is wider than the slot it is written under. requireExactArityValue now admits a lambda whose own call signature maps to the slot position for position, and cleanFuncAdaptable accepts a source with extra trailing parameters when each of them completes with the undefined value, the same absence a call through the slot passes. `sort`/`toSorted` separately required a comparator of at most two parameters; it now adapts the first two and lets the surplus ones complete as undefined, so the imported three-parameter comparator sorts. Corpus 8529 pins the surplus-parameter comparators (optional, defaulted, `any`-typed), the direct calls through them, `toSorted`'s untouched receiver, and remains byte-exact against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/call-signatures.ts | 49 +++++++++++++++++++ .../lowering/coercions/function-adapters.ts | 20 ++++++-- .../lowering/containers/array-callbacks.ts | 13 +++-- ...529-sort-comparator-surplus-params.ts.json | 6 +++ .../8529-sort-comparator-surplus-params.ts | 42 ++++++++++++++++ ...529-sort-comparator-surplus-params.ts.json | 1 + 6 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json create mode 100644 tests/corpus/8529-sort-comparator-surplus-params.ts create mode 100644 tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 45329d077..c37eba707 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -630,6 +630,41 @@ export function checkDefaultParamBodyType( } } +/** Whether a lambda's OWN signature maps to its completed ABI position for + * position — the contract its reads carry (mapType spells `x?: T` as the + * `T | undefined` slot). Two positions can escape the mapped-TYPE + * comparison in requireExactArityValue: a parameter the checker spells + * `any` holds the checked-dynamic value that mapType leaves unmapped when + * its spelling is context-free, and a *contextually* typed lambda's own + * signature need not be the slot's (a comparator with a surplus optional + * parameter under a two-parameter slot). */ +function lambdaSpellsCompletedAbi( + lowerer: Lowerer, + lambda: ts.ArrowFunction | ts.FunctionExpression, + funcType: IrType, +): boolean { + if (funcType.kind !== "func" || funcType.rest === true) return false; + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda)); + if (sigs.length !== 1) return false; + const sig = sigs[0]!; + const params = sig.getParameters(); + if (params.length !== funcType.params.length) return false; + for (let i = 0; i < params.length; i++) { + const pTs = lowerer.checker.getTypeOfSymbol(params[i]!); + const slot = funcType.params[i]!; + if ((pTs.flags & ts.TypeFlags.Any) !== 0) { + if (slot.kind !== "dyn") return false; + continue; + } + const mappedParam = lowerer.mapTypeOf(pTs); + if (!mappedParam || !typeEquals(mappedParam, slot)) return false; + } + const retTs = lowerer.checker.getReturnTypeOfSignature(sig); + if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn"; + const mappedRet = lowerer.mapTypeOf(retTs); + return mappedRet !== null && typeEquals(mappedRet, funcType.ret); +} + /** DECISION (docs/ir.md): function VALUES carry one completed native ABI. * Optional/default parameters appear as literal `T | undefined` slots; * typed rest parameters appear as one trailing typed-array slot plus the @@ -675,6 +710,20 @@ export function requireExactArityValue( ? lowerer.mapTypeOf(lowerer.typeOf(contextual)) : null; if (mapped && typeEquals(mapped, funcType)) return; + // A lambda whose OWN signature already spells the completed ABI is a + // value: every read of it types it that way, and its slot converts it + // (the surplus-parameter and undefined-arm adapters). The mapped-type + // comparison above misses two such lambdas — one whose `any` parameter + // mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number` + // beside a two-parameter slot), and one whose signature is wider than + // the contextual slot it is written under. + if ( + contextual !== null && + (ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) && + lambdaSpellsCompletedAbi(lowerer, contextual, funcType) + ) { + return; + } // An 'any' slot (`C.prototype.m = function (a, b) {…}` through an // untyped receiver), or a slot whose type maps to a checked-dynamic // value (a member of a literal lowered with a generic function's type diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 44bca35e1..50366eeb6 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -114,10 +114,22 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool return false; } +/** Whether an omitted parameter position completes with the native + * undefined value: a checked-dynamic slot takes undefined as-is, and an + * undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI + * spelling. */ +export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true; + return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0; +} + /** Whether all parameters and the result adapt without a trap-only path. * Rest signatures are excluded, except a typed-rest source in a slot with * fixed parameters (the rest packing adapter); extra destination - * parameters are ignored. */ + * parameters are ignored. A source with MORE parameters than the slot is + * clean when every extra trailing one is omittable — the adapter passes + * that absent value explicitly (funcCoerceAdapter's omittedParams), the + * same way a call through the slot would. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -125,8 +137,10 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - if (src.params.length > dst.params.length) return false; - for (let i = 0; i < src.params.length; i++) { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; } if (dst.ret.kind === "void") return true; diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 71d3d0876..1224bce3e 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -70,6 +70,7 @@ import { import { dynamicReceiverThrows } from "./dynamic-receivers.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { checkedFilterNarrowing } from "./filter-predicates.js"; +import { omittableTrailingParam } from "../coercions/function-adapters.js"; /** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct * call of a synthetic module function — one per method + element/result @@ -2657,11 +2658,15 @@ export function lowerArraySortCall( let fnArg = undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT); // Reusable JS comparators often accept checked values. Adapt the array's - // element ABI just as other array callbacks do before sorting. + // element ABI just as other array callbacks do before sorting. A + // comparator declaring MORE parameters than sort passes (`(a, b, loose?)`, + // the imported semver shape) receives the surplus ones as undefined — the + // completion an ordinary two-argument call performs — so it adapts to the + // (a, b) slot like any other wider signature. if ( fnArg.type.kind === "func" && - fnArg.type.params.length <= 2 && - fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param)) + fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) && + fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param)) ) { // Arithmetic on inferred JS values retains a checked result because // it can produce BigInt. The sort callback still requires a number. @@ -2670,7 +2675,7 @@ export function lowerArraySortCall( ? F64 : fnArg.type.ret; const expected = funcOf( - fnArg.type.params.map(() => elem), + fnArg.type.params.slice(0, 2).map(() => elem), result, ); if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..56abd06eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8529-sort-comparator-surplus-params.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8529-sort-comparator-surplus-params.ts b/tests/corpus/8529-sort-comparator-surplus-params.ts new file mode 100644 index 000000000..9b0d0054b --- /dev/null +++ b/tests/corpus/8529-sort-comparator-surplus-params.ts @@ -0,0 +1,42 @@ +// A comparator may declare more parameters than `sort` passes: the surplus +// positions receive the native undefined value, exactly as in a two-argument +// call. The imported `(a, b, loose) => …` comparators and lambdas with an +// optional or defaulted trailing parameter take this shape, and a lambda +// whose own signature already spells its completed ABI is a value wherever +// its slot converts. + +type Comparer = (a: string, b: string, loose?: boolean) => number; + +const seen: string[] = []; +const cmp: Comparer = (a, b, loose) => { + seen.push(loose === undefined ? "absent" : String(loose)); + return a < b ? -1 : a > b ? 1 : 0; +}; + +const versions = ["1.9.0", "1.10.0", "1.2.0"]; +console.log([...versions].sort(cmp).join(",")); +console.log(seen.length > 0 && seen.every((entry) => entry === "absent")); +console.log(versions.join(",")); + +// `toSorted` adapts the same way and leaves the receiver untouched. +console.log(versions.toSorted(cmp).join(","), versions.join(",")); + +// Direct calls through the value complete the same parameters. +console.log(cmp("a", "b"), cmp("b", "a"), cmp("a", "a", true)); + +// An inline comparator with an optional surplus parameter sorts by number. +const nums = [3, 1, 2]; +const descending = (a: number, b: number, dir?: boolean): number => + dir === undefined ? b - a : a - b; +console.log(nums.toSorted(descending).join(",")); +console.log([...nums].sort((a, b, dir?: boolean) => (dir ? a - b : b - a)).join(",")); +console.log(nums.join(",")); + +// A defaulted surplus parameter keeps its default when sort omits it. +const withDefault = (a: string, b: string, scale = 1): number => + (a < b ? -1 : a > b ? 1 : 0) * scale; +console.log(["b", "a", "c"].sort(withDefault).join("")); +console.log(withDefault("a", "b"), withDefault("a", "b", 3)); + +// A one-parameter comparator is still a prefix of the slot. +console.log(["d", "c", "a"].sort((left) => left.charCodeAt(0) - 97).join(",")); diff --git a/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8529-sort-comparator-surplus-params.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 } From 3328ed2aa7ff8038d71ce0dd88525ed84023f159 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:03:59 -0700 Subject: [PATCH 04/14] Lower union member reads the checker narrowed away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a member off a union-typed value is what most pi refusals at `SC1090` came down to: `credential.type` on `ApiKeyCredential | OAuthCredential`, `session.role`, `entry.timestamp`, `tool.operations`, `arms.length`, `renderer.hasOverlayEntries`. Three read shapes reach the same dispatch and now answer: - A checker-NARROWED receiver keeps the wider union it stores, because lowering erases flow narrowing (`stored?.type` after the earlier arm returned, `(entry as { role: string }).role`). The dispatcher now reads the member off whichever arm the value holds — an arm that does not declare it answers undefined, exactly Node's get — and narrows the answer to the checker's type, which traps on an arm the narrowing ruled out (types do not lie). This fallback sits after every existing lowering, so only refusals gain one. - A member the arms answer through an ACCESSOR (a getter inherited from a shared base class, `renderer.hasOverlayEntries`) has no field slot `unionDisc` could read, so the read dispatches on the tag and calls each arm's own getter. Data-field unions keep their `unionDisc` lowering. - `.length` on a union of arrays and strings (`Model[] | ModelRef[]`, `string[] | Buffer[]`) dispatches on the tag and answers each arm's own length. The member read moves out of `lowerUnionProperty` into `lowerUnionValueRead`, which both entry points share, and `missingFieldAsUndefined` threads the narrowed-receiver get semantics through the keyed read. Corpus 8520/8521/8522 pin the three shapes differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 278 +++++++++++++++++- ...8520-union-narrowed-receiver-reads.ts.json | 6 + .../8521-union-accessor-member-read.ts.json | 6 + .../corpus/8522-union-array-length.ts.json | 6 + .../8520-union-narrowed-receiver-reads.ts | 84 ++++++ .../corpus/8521-union-accessor-member-read.ts | 58 ++++ tests/corpus/8522-union-array-length.ts | 37 +++ ...8520-union-narrowed-receiver-reads.ts.json | 1 + .../8521-union-accessor-member-read.ts.json | 1 + .../8522-union-array-length.ts.json | 1 + 10 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json create mode 100644 tests/corpus/8520-union-narrowed-receiver-reads.ts create mode 100644 tests/corpus/8521-union-accessor-member-read.ts create mode 100644 tests/corpus/8522-union-array-length.ts create mode 100644 tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json create mode 100644 tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json create mode 100644 tests/harness/corpus-costs/8522-union-array-length.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf1013..e2595f465 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1617,6 +1617,19 @@ export function lowerPropertyAccessExpr( } const asserted = assertedMemberRead(lowerer, expr.expression, expr.name.text, recvLowered, loc); if (asserted) return asserted; + // Lowering erases flow narrowing, so a checker-NARROWED receiver keeps + // the wider union it stores (`stored?.type` after the earlier arm + // returned, `(entry as { role: string }).role`): the checker's type + // names one record while the value still carries its siblings. Read the + // member off whichever arm the value holds — an arm that does not + // declare it answers undefined, exactly Node's get — and narrow the + // answer to the checker's type, which traps on an arm the checker's + // narrowing ruled out (types do not lie). Reaching this point means no + // lowering above claimed the read, so only refusals gain a lowering. + if (recvLowered.type.kind === "union") { + const read = lowerUnionValueRead(lowerer, expr, recvLowered, true); + if (read) return lowerer.maybeNarrow(read, expr); + } // A checker-`any` receiver whose value is a FRESH typed result of an // earlier step in the chain (`out.split(" ").length`, where split // extracted the string and answered a native string[]): the member read @@ -3642,8 +3655,33 @@ export function lowerUnionProperty( "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 read = lowerUnionValueRead(lowerer, expr, value); + if (read) return read; + lowerer.unsupported( + "SC1090", + expr, + `reading '${expr.name.text}' on a union-typed value (every arm must be an object/record ` + + `with a same-typed field '${expr.name.text}'; ` + + `${NARROW_FIRST})`, + ); +} + +/** The member read itself on a lowered UNION value: `lowerUnionProperty` + * after it has produced the value (retagged to the checker's union when + * the checker removed arms) and the dispatcher's narrowed-receiver + * fallback, which reaches the same read with the value a checker-narrowed + * receiver still stores. Returns null when no arm can answer — the caller + * owns the diagnostic. */ +export function lowerUnionValueRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + value: IrExpr, + missingFieldAsUndefined = false, +): IrExpr | null { + if (value.type.kind !== "union") return null; + const unionId = value.type.unionId; + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); const field = expr.name.text; if (field === "length") { const presentArms = def.arms.filter((arm) => !isUnitType(arm)); @@ -3675,6 +3713,42 @@ export function lowerUnionProperty( }; } } + // A union of arrays and strings (`Model[] | ModelRef[]`, + // `string[] | Buffer[]`): every arm answers its own length, so the + // read dispatches on the tag. Node's `.length` is a number for both, + // and no arm can be absent here (a unit arm would have to throw on + // this read, which the checker's narrowing already excludes). + if ( + def.arms.length > 1 && + def.arms.every((arm) => arm.kind === "array" || arm.kind === "string") + ) { + return perArmUnionRead( + lowerer, + expr, + unionId, + value, + F64, + "%unionLength", + (arm, _tag, receiver) => + arm.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") { @@ -3688,11 +3762,7 @@ export function lowerUnionProperty( field, ); if (receiver === null) { - const helper = lowerer.narrowedArmHelper( - value.type.unionId, - objectType, - locOf(expr.expression), - ); + const helper = lowerer.narrowedArmHelper(unionId, objectType, locOf(expr.expression)); if (helper !== null) receiver = lowerer.checkedNarrowCall(helper, value, objectType, locOf(expr.expression)); } @@ -3708,6 +3778,14 @@ export function lowerUnionProperty( } } } + // A member the arms answer through an ACCESSOR (a getter inherited + // from a shared base class — `renderer.hasOverlayEntries` on + // `FullscreenRenderer | RegularRenderer`): there is no field slot + // `unionDisc` could read, so dispatch on the tag and call each arm's + // own getter. Fires only when some arm's answer is an accessor, so + // data-field unions keep their `unionDisc` lowering. + const accessorRead = lowerUnionAccessorRead(lowerer, expr, unionId, value); + if (accessorRead) return accessorRead; let common: IrType | null = null; for (const arm of def.arms) { let ft: IrType | undefined; @@ -3736,7 +3814,15 @@ export function lowerUnionProperty( // 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); + const keyed = lowerUnionKeyedRead( + lowerer, + expr, + unionId, + value, + key, + field, + missingFieldAsUndefined, + ); 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. @@ -3763,12 +3849,178 @@ export function lowerUnionProperty( const member = lowerIndexedUnionMember(lowerer, value, indexedArms, field, locOf(expr)); if (member) return member; } - lowerer.unsupported( - "SC1090", + return null; +} + +/** Evaluate `value` once into a hidden local and answer the read by + * dispatching on the union's tag: arm `tag` (narrowed from the local) + * answers `answer(arm, tag, narrowed)`. The first non-unit arm's answer + * is the fallback branch, which a representable union never reaches. */ +function perArmUnionRead( + lowerer: Lowerer, + expr: ts.Node, + unionId: string, + value: IrExpr, + type: IrType, + name: string, + answer: (arm: IrType, tag: number, receiver: IrExpr) => IrExpr, +): IrExpr { + const loc = locOf(expr); + const def = lowerer.unions.get(unionId); + if (!def) throw new InternalCompilerError(`lowerer bug: unknown union ${unionId}`); + const stable = lowerer.declareHiddenLocal(name, value.type); + const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: value.type, loc }); + let result: IrExpr | null = null; + def.arms.forEach((arm, tag) => { + if (isUnitType(arm)) return; + const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value: ref(), type: arm, loc }; + const read = answer(arm, tag, receiver); + result = + result === null + ? read + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + value: ref(), + negated: false, + type: BOOL, + loc, + }, + then: read, + else_: result, + type, + loc, + }; + }); + if (result === null) throw new InternalCompilerError(`lowerer bug: no arm answers ${name}`); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: value, loc }], + result, + type, + loc, + }; +} + +/** A member read the arms answer through an ACCESSOR — an inherited + * getter (`this.renderer.hasOverlayEntries`). `unionDisc` reads record + * and class FIELD slots, so a getter arm needs the tag dispatch: each + * arm calls its own getter (once per read, as Node's read does). Data + * arms in the same union read their slot. Every arm must answer one + * type; a unit arm or an arm without the member declines (the caller's + * keyed read and fences own those). Null when no arm answers through an + * accessor — plain field unions keep `unionDisc`. */ +function lowerUnionAccessorRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + unionId: string, + value: IrExpr, +): IrExpr | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + const field = expr.name.text; + const getterName = `get:${field}`; + let type: IrType | null = null; + let anyAccessor = false; + const arms: { tag: number; read: (receiver: IrExpr) => IrExpr }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (isUnitType(arm)) return null; + let answer: IrType; + let read: (receiver: IrExpr) => IrExpr; + if (arm.kind === "record") { + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple) return null; + const data = shape.fields.find((f) => f.name === field); + if (data) { + answer = data.type; + read = (receiver) => ({ + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field, + type: data.type, + loc: locOf(expr), + }); + } else { + const getter = shape.fields.find((f) => f.name === getterName); + if (!getter || getter.type.kind !== "func") return null; + anyAccessor = true; + answer = getter.type.ret; + read = (receiver) => + lowerer.coerceToExpected( + { + kind: "callValue", + callee: { + kind: "recordGet", + obj: receiver, + shapeId: arm.shapeId, + field: getterName, + type: getter.type, + loc: locOf(expr), + }, + args: [], + type: getter.type.kind === "func" ? getter.type.ret : DYN, + loc: locOf(expr), + }, + answer, + ); + } + } else if (arm.kind === "object") { + const info = lowerer.classes.get(arm.className) ?? null; + const slot = info?.fields.get(field); + if (slot !== undefined) { + answer = slot; + read = (receiver) => ({ + kind: "fieldGet", + obj: receiver, + className: arm.className, + field, + type: slot, + loc: locOf(expr), + }); + } else { + const getter = lowerer.findMethodOn(info, getterName); + if (!getter || getter.sig.ret.kind === "void") return null; + anyAccessor = true; + answer = getter.sig.ret; + read = (receiver) => + lowerer.fieldGetExpr( + { + container: "accessor", + obj: receiver, + className: arm.className, + field, + fieldType: getter.sig.ret, + }, + locOf(expr), + expr, + ); + } + } else { + return null; + } + if (answer.kind === "void" || isUnitType(answer)) return null; + if (type !== null && !typeEquals(type, answer)) return null; + type = answer; + arms.push({ tag, read }); + } + if (!anyAccessor || type === null || arms.length < 2) return null; + return perArmUnionRead( + lowerer, expr, - `reading '${field}' on a union-typed value (every arm must be an object/record ` + - `with a same-typed field '${field}'; ` + - `${NARROW_FIRST})`, + unionId, + value, + type, + "%unionMember", + (_arm, tag, receiver) => { + const entry = arms.find((a) => a.tag === tag); + if (!entry) throw new InternalCompilerError("lowerer bug: accessor arm missing"); + return entry.read(receiver); + }, ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..dbb935b8b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8520-union-narrowed-receiver-reads.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..c5f221cdb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8521-union-accessor-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8521-union-accessor-member-read.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json new file mode 100644 index 000000000..bc5e6827e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8522-union-array-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8522-union-array-length.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8520-union-narrowed-receiver-reads.ts b/tests/corpus/8520-union-narrowed-receiver-reads.ts new file mode 100644 index 000000000..05abc2584 --- /dev/null +++ b/tests/corpus/8520-union-narrowed-receiver-reads.ts @@ -0,0 +1,84 @@ +// Lowering erases flow narrowing, so a checker-NARROWED receiver still +// stores its wider union. A member read dispatches on the arm the value +// actually holds: an arm that declares the member answers its slot, an arm +// that does not answers undefined (Node's get), and an arm the narrowing +// ruled out traps in the checked conversion. +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: Record; +} +interface OAuthCredential { + type: "oauth"; + access: string; + refresh: string; + expires: number; +} +type Credential = ApiKeyCredential | OAuthCredential; + +function pick(stored: Credential | undefined): string | undefined { + if (stored?.type === "oauth") return stored.access; + const credential = stored?.type === "api_key" ? stored : undefined; + return credential?.key; +} +console.log("key", pick({ type: "api_key", key: "k1" })); +console.log("key absent", pick({ type: "api_key" })); +console.log("oauth", pick({ type: "oauth", access: "a1", refresh: "r1", expires: 1 })); +console.log("none", pick(undefined)); + +// An assertion narrows the checker's view of arms that carry different +// payloads; every arm declares the asserted member here. +interface Message { + role: string; + content: string; +} +interface ToolResult { + role: string; + output: string[]; +} +type AgentMessage = Message | ToolResult; +function roleOf(message: AgentMessage): string { + return (message as { role: string }).role; +} +console.log("role", roleOf({ role: "user", content: "hi" })); +console.log("role", roleOf({ role: "tool", output: ["x"] })); + +// One arm lacks the member the assertion names: Node's read answers +// undefined for that arm. +interface Stamped { + timestamp?: number; + text: string; +} +interface Unstamped { + text: string; +} +function stamp(entry: Stamped | Unstamped): number | undefined { + return (entry as { timestamp?: number }).timestamp; +} +console.log("stamp", stamp({ timestamp: 5, text: "a" })); +console.log("stamp", stamp({ text: "b" })); + +// A discriminant read re-narrows the union it came from. +interface BashOperations { + exec(command: string): string; +} +interface BashResult { + exitCode: number; +} +type BashEventResult = + | { operations: BashOperations; result?: undefined } + | { operations?: undefined; result: BashResult }; +function describe(result: BashEventResult | undefined): string { + if (result?.result) return `exit ${result.result.exitCode}`; + return `operations ${typeof result?.operations}`; +} +console.log("event", describe({ result: { exitCode: 3 } })); +console.log("event", describe({ operations: { exec: (command) => command } })); +console.log("event", describe(undefined)); + +// A narrowed array whose stored union still owns an absent arm. +function firstLength(models: string[] | undefined): number { + if (models) return models.length; + return -1; +} +console.log("first", firstLength(["a", "b"]), firstLength(undefined)); diff --git a/tests/corpus/8521-union-accessor-member-read.ts b/tests/corpus/8521-union-accessor-member-read.ts new file mode 100644 index 000000000..c6168c078 --- /dev/null +++ b/tests/corpus/8521-union-accessor-member-read.ts @@ -0,0 +1,58 @@ +// A member the arms answer through an ACCESSOR — an inherited getter, or a +// getter a subclass overrides — has no field slot to read, so the read +// dispatches on the union's tag and calls the arm's own getter, once per +// read, as Node does. A data-field arm beside a getter arm reads its slot. +const logs: string[] = []; +class Panel { + items: string[]; + constructor(items: string[]) { + this.items = items; + } + get hasOverlayEntries(): boolean { + logs.push("has"); + return this.items.length > 0; + } + get count(): number { + logs.push("count"); + return this.items.length; + } +} +class MainPanel extends Panel { + mode = "main"; +} +class AltPanel extends Panel { + mode = "alt"; + get count(): number { + logs.push("alt count"); + return this.items.length * 10; + } +} +class FixedPanel { + mode = "fixed"; + items: string[]; + hasOverlayEntries = true; + constructor(items: string[]) { + this.items = items; + } + get count(): number { + logs.push("fixed count"); + return this.items.length + 100; + } +} + +function pickPanel(which: "m" | "a", items: string[]): MainPanel | AltPanel { + return which === "m" ? new MainPanel(items) : new AltPanel(items); +} +for (const which of ["m", "a"] as const) { + const panel = pickPanel(which, which === "m" ? ["x"] : []); + console.log("panel", which, panel.mode, panel.hasOverlayEntries, panel.count); +} + +function pickMixed(which: "a" | "f", items: string[]): AltPanel | FixedPanel { + return which === "a" ? new AltPanel(items) : new FixedPanel(items); +} +for (const which of ["a", "f"] as const) { + const panel = pickMixed(which, ["y", "z"]); + console.log("mixed", which, panel.mode, panel.hasOverlayEntries, panel.count); +} +console.log("logs", logs.join(",")); diff --git a/tests/corpus/8522-union-array-length.ts b/tests/corpus/8522-union-array-length.ts new file mode 100644 index 000000000..5fa467488 --- /dev/null +++ b/tests/corpus/8522-union-array-length.ts @@ -0,0 +1,37 @@ +// `.length` on a union of arrays (and strings): every arm answers its own +// length, so the read dispatches on the tag instead of demanding one +// record arm that carries a `length` field. +interface Model { + id: string; +} +interface ModelRef { + ref: string; +} +function ids(models: Model[] | ModelRef[]): number { + return models.length; +} +console.log("models", ids([{ id: "a" }, { id: "b" }])); +console.log("refs", ids([{ ref: "r" }, { ref: "s" }, { ref: "t" }])); + +function scalars(values: string[] | number[]): number { + return values.length; +} +console.log("strings", scalars(["a", "b", "c"])); +console.log("numbers", scalars([1, 2])); + +function textual(part: string | string[]): number { + return part.length; +} +console.log("text", textual("hello")); +console.log("parts", textual(["a", "b"])); + +// The dispatch reads the value ONCE, even when the receiver is a call. +let calls = 0; +function make(fill: string): string[] { + calls++; + return [fill, fill]; +} +function measured(part: string | string[]): number { + return part.length; +} +console.log("call", measured(make("q")), "calls", calls); diff --git a/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json new file mode 100644 index 000000000..c10c572b5 --- /dev/null +++ b/tests/harness/corpus-costs/8520-union-narrowed-receiver-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 2900 } diff --git a/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json new file mode 100644 index 000000000..26a06ea33 --- /dev/null +++ b/tests/harness/corpus-costs/8521-union-accessor-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2500 } diff --git a/tests/harness/corpus-costs/8522-union-array-length.ts.json b/tests/harness/corpus-costs/8522-union-array-length.ts.json new file mode 100644 index 000000000..bef270fe0 --- /dev/null +++ b/tests/harness/corpus-costs/8522-union-array-length.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 2400 } From e198a9eec235a36fdca3b954c27c4eedd249939a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:06 -0700 Subject: [PATCH 05/14] Dispatch a `keyof` read whose member is `unknown` per declared key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settings[key]` over a settings record with an `unknown` member (`type: unknown`) refuses even though every field reads through a checked-dynamic slot: the checker's answer collapses to `unknown` because the union of the field types absorbs into it, so there is no typed join for the keyed read to dispatch into. Recognize that shape — the read's type is `unknown`, the shape has no index signature, and every declared field's value is checked-dynamic representable — and dispatch per declared key answering a checked- dynamic read. The key type is the record's own `keyof`, so the answer is always present. Corpus 8523 pins the read differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/element-access.ts | 14 +++++++ .../8523-settings-keyof-dynamic-read.ts.json | 6 +++ .../8523-settings-keyof-dynamic-read.ts | 40 +++++++++++++++++++ .../8523-settings-keyof-dynamic-read.ts.json | 1 + 4 files changed, 61 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json create mode 100644 tests/corpus/8523-settings-keyof-dynamic-read.ts create mode 100644 tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 5cb9776f6..1ce873853 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1534,6 +1534,20 @@ export function lowerRecordKeyRead( recordKeyResultOk(lowerer, shape, DYN) ) declared = DYN; + // A `keyof` read over a record that holds an 'unknown' member: the + // checker's answer collapses to 'unknown' (the union of every field + // type absorbs into it), so there is no typed join to dispatch into. + // Each declared field still reads through its checked-dynamic slot — + // the very value the checker's 'unknown' can hold — so dispatch per + // declared key and answer a checked-dynamic read. The key type is the + // record's own `keyof`, so the answer is present whenever the read is. + if ( + !declared && + (lowerer.typeOf(expr).flags & ts.TypeFlags.Unknown) !== 0 && + !shape.indexValue && + recordKeyResultOk(lowerer, shape, DYN) + ) + declared = DYN; if (!declared) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 000000000..e42bdab21 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8523-settings-keyof-dynamic-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8523-settings-keyof-dynamic-read.ts b/tests/corpus/8523-settings-keyof-dynamic-read.ts new file mode 100644 index 000000000..d0460d6df --- /dev/null +++ b/tests/corpus/8523-settings-keyof-dynamic-read.ts @@ -0,0 +1,40 @@ +// A `keyof` read over a record that holds an `unknown` member: the +// checker's answer collapses to `unknown` (the union of every field type +// absorbs into it), so there is no typed join. The read dispatches per +// declared key through the checked-dynamic slot instead. +interface Retry { + baseDelayMs?: number; + enabled?: boolean; +} +interface Settings { + $schema?: string; + enabledModels?: string[]; + branchSummary: unknown; + fullscreenWheelScrollLines?: number | string; + modelThinkingLevels?: { [key: string]: string }; + retry?: Retry; + outputPad?: number; +} +function read(settings: Settings, key: keyof Settings): unknown { + return settings[key]; +} +const settings: Settings = { + branchSummary: { note: "x", count: 2 }, + outputPad: 4, + enabledModels: ["m"], + fullscreenWheelScrollLines: "wide", + modelThinkingLevels: { high: "big" }, + retry: { enabled: true }, +}; +const keys: (keyof Settings)[] = [ + "outputPad", + "enabledModels", + "branchSummary", + "retry", + "$schema", + "fullscreenWheelScrollLines", +]; +for (const key of keys) { + const value = read(settings, key); + console.log(key, typeof value, value === undefined ? "absent" : JSON.stringify(value)); +} diff --git a/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 000000000..10b67e419 --- /dev/null +++ b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2700 } From 94036b088bbfa0997a75bfc30b06096ae059b6b1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:12 -0700 Subject: [PATCH 06/14] Narrow `.filter` predicate results to structural types, not just arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A written type predicate usually names FEWER members than the receiver's element has — `(c): c is { text: string } => ...` over a block that also carries a `textSignature` — and may name a structural type that is no arm of the element union at all. Both shapes refused, so `commitments.filter(pred)` over a union of records with differing members did not compile. Record targets now convert when their members are a SUBSET of the source's: each target member is read and converted, the copy keeps the source's keys in the source's order, and a member the source lacks still has no slot to read and stays refused. When no union arm matches the predicate's type, `structuralUnionConversion` converts tag by tag: each arm the target can be read from returns its converted value and an arm with no such read throws the same checked TypeError a lying predicate throws, so the checked extraction still traps rather than silently passing a foreign value through. Corpus 8524 pins the subset-record and structural-union conversions; `narrow-guard-traps.test.ts` pins the TypeError a predicate that returns true for a foreign value raises. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/filter-predicates.ts | 57 ++++++++++++++++++- .../compiler/test/narrow-guard-traps.test.ts | 26 +++++++++ ...524-filter-predicate-subset-record.ts.json | 6 ++ .../8524-filter-predicate-subset-record.ts | 50 ++++++++++++++++ ...524-filter-predicate-subset-record.ts.json | 1 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json create mode 100644 tests/corpus/8524-filter-predicate-subset-record.ts create mode 100644 tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index 3934edbf0..e960bbf6b 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -161,7 +161,13 @@ function conversionBody( target.indexValue ) return null; - if (source.fields.length !== target.fields.length) return null; + // A written predicate usually names FEWER members than the receiver's + // element has (`(c): c is { text: string } => ...` over a block with a + // `textSignature` and a `type`): the target's members are a subset of + // the source's, each read and converted, and the copy keeps the + // source's keys in the source's order. A member the source lacks has + // no slot to read, so it stays refused. + if (target.fields.length > source.fields.length) return null; const fields: { name: string; value: IrExpr }[] = []; for (const field of target.fields) { const own = source.fields.find((f) => f.name === field.name); @@ -214,7 +220,13 @@ function keepAbsence( } /** A union value to one of its arms, or to a union of some of its arms: - * each arm the target holds returns, any other arm throws. */ + * each arm the target holds returns, any other arm throws. + * + * A written predicate may name a structural type that is no arm at all + * (`(c): c is { text: string; type: string } => ...` over the block union + * above, whose text arm also carries `textSignature`): when no arm matches + * exactly, each arm the target can be read from converts by its own shape, + * tag by tag, and an arm with no such read throws. */ function unionConversion( lowerer: Lowerer, input: IrExpr, @@ -250,6 +262,8 @@ function unionConversion( }); } // Every arm the target names must be one of the union's own arms. + if (kept === 0 && to.kind !== "union") + return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc); if (kept === 0 || kept !== targetArms.length) return null; body.push( typeError( @@ -259,3 +273,42 @@ function unionConversion( ); return body; } + +/** A union value to a structural type that is no arm: each arm the target + * can be read from returns its converted value, any other arm throws. */ +function structuralUnionConversion( + lowerer: Lowerer, + input: IrExpr, + unionId: string, + to: IrType, + typeText: string, + loc: SrcLoc, +): IrStmt[] | null { + const source = lowerer.unions.get(unionId); + if (!source) return null; + const body: IrStmt[] = []; + let kept = 0; + for (let tag = 0; tag < source.arms.length; tag++) { + const arm = source.arms[tag]!; + if (isUnitType(arm)) continue; + const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc }; + const value = converted(lowerer, payload, arm, to, loc); + if (value === null) continue; + kept++; + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc }, + then: [{ kind: "return", value, loc }], + else_: null, + loc, + }); + } + if (kept === 0) return null; + body.push( + typeError( + `a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`, + loc, + ), + ); + return body; +} diff --git a/packages/compiler/test/narrow-guard-traps.test.ts b/packages/compiler/test/narrow-guard-traps.test.ts index be89ad341..01508723e 100644 --- a/packages/compiler/test/narrow-guard-traps.test.ts +++ b/packages/compiler/test/narrow-guard-traps.test.ts @@ -70,3 +70,29 @@ for (const v of inputs) console.log(width(v)); expect(result.stderr.toString()).toBe(UNION_TRAP); }, ); + +test.skipIf(process.platform === "win32")( + "a lying filter predicate throws the checked-extraction TypeError", + async () => { + const result = await run(` +interface Cat { + kind: "cat"; + meows: number; +} +interface Dog { + kind: "dog"; +} +type Pet = Cat | Dog; +const pets: Pet[] = [{ kind: "cat", meows: 2 }, { kind: "dog" }]; +const alwaysCat = (p: Pet): p is Cat => p !== undefined; +try { + console.log("kept", pets.filter(alwaysCat).length); +} catch (e) { + console.log("caught", (e as Error).name); +} +`); + expect(result.error).toBeUndefined(); + // Node keeps the Dog and reports 2; the checked projection throws on it. + expect(result.stdout.toString()).toBe("caught TypeError\n"); + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 000000000..98784a0d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8524-filter-predicate-subset-record.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8524-filter-predicate-subset-record.ts b/tests/corpus/8524-filter-predicate-subset-record.ts new file mode 100644 index 000000000..01b693071 --- /dev/null +++ b/tests/corpus/8524-filter-predicate-subset-record.ts @@ -0,0 +1,50 @@ +// A written type predicate usually names FEWER members than the receiver's +// element (`(b): b is { type: "text"; text: string }` over a block that +// also carries `textSignature`): the predicate's record is no arm of the +// element union, so each kept element converts to it by its own arm, tag +// by tag. An arm the predicate's record cannot be read from throws the +// checked TypeError, because a predicate that returned true for it lied. +type Block = + | { type: "image"; data: string; mimeType: string } + | { type: "text"; text: string; textSignature?: string }; + +const blocks: Block[] = [ + { type: "text", text: "first" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + { type: "text", text: "second", textSignature: "sig" }, +]; + +function texts(list: Block[]): { type: "text"; text: string }[] { + return list.filter((b): b is { type: "text"; text: string } => b.type === "text"); +} +console.log("texts", texts(blocks).map((t) => `${t.type}:${t.text}`).join(",")); + +// The same narrowing through a guard function passed as a value. +function isText(b: Block): b is { type: "text"; text: string } { + return b.type === "text"; +} +console.log("guarded", blocks.filter(isText).length); + +// Every arm converts when the predicate names members all of them carry: +// each kept element is read field by field into the predicate's record. +interface A { + tag: "a"; + n: number; +} +interface B { + tag: "b"; + n: number; +} +function numbers(list: (A | B)[]): { tag: "a" | "b"; n: number }[] { + return list.filter((v): v is { tag: "a" | "b"; n: number } => v.n > 0); +} +console.log( + "numbers", + numbers([ + { tag: "a", n: 1 }, + { tag: "b", n: 0 }, + { tag: "a", n: 3 }, + ]) + .map((v) => `${v.tag}${v.n}`) + .join(","), +); diff --git a/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json new file mode 100644 index 000000000..03a29dd70 --- /dev/null +++ b/tests/harness/corpus-costs/8524-filter-predicate-subset-record.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2800 } From 00467fdcf64923b5bf7b602cf8cd96bb9ebef404 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:17 -0700 Subject: [PATCH 07/14] Append a `flatMap` callback's TUPLE result arms in order A block-bodied callback whose enclosing call has a contextual array type infers a tuple union rather than an array union: `return pair ? [pair] : []` in a `f(): T[]` infers `[] | [pair]`. The flatten plan classified only array arms, so such a callback refused. The empty-arm detection now recognizes the empty tuple (a bare `[]` arm under a contextual array type), and the plan gains a `tuple` arm for a one-field-or-more tuple whose fields all contribute: each field is appended in order, exactly as an array arm's elements are. `checker. isArrayType` is false for tuples, so the arm scan accepts `isTupleType` too. Corpus 8525 pins the tuple arm, a two-field tuple arm, and an array arm beside a scalar arm. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 43 ++++++++++++++--- .../corpus/8525-flatmap-tuple-arms.ts.json | 6 +++ tests/corpus/8525-flatmap-tuple-arms.ts | 46 +++++++++++++++++++ .../8525-flatmap-tuple-arms.ts.json | 1 + 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json create mode 100644 tests/corpus/8525-flatmap-tuple-arms.ts create mode 100644 tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 1224bce3e..97fc18928 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -1822,10 +1822,10 @@ export function lowerArrayFlatMapCall( /** How each arm of a flatMap callback's union result contributes to the * output: an array arm appends its elements, an array arm the checker * types `never[]` (an empty literal such as `s ? s : []`) appends nothing, - * and any other arm appends itself. */ + * a tuple arm appends its fields, and any other arm appends itself. */ interface FlatMapUnionPlan { outElem: IrType; - arms: ("spread" | "empty" | "value")[]; + arms: ("spread" | "empty" | "tuple" | "value")[]; } /** The plan for a callback returning `T | U[]`: every non-array arm must @@ -1843,15 +1843,18 @@ function flatMapUnionPlan( const outElem = out.elem; const arms = lowerer.unions.get(fnRet.unionId)?.arms; if (!arms) return null; - // The `never[]` constituents of the callback's checked result. + // The constituents of the callback's checked result that can hold no + // element: a `never[]` arm, and the empty tuple a bare `[]` arm infers + // when the enclosing call has a contextual array type (`return x ? [a] + // : []` in a `f(): T[]`). const emptyArms: IrType[] = []; const signature = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode))[0]; if (signature) { const ret = lowerer.checker.getReturnTypeOfSignature(signature); for (const part of ret.isUnionType() ? ts.constituentTypes(ret) : [ret]) { - if (!lowerer.checker.isArrayType(part)) continue; - const [element] = lowerer.checker.getTypeArguments(part as ts.TypeReference); - if (element === undefined || (element.flags & ts.TypeFlags.Never) === 0) continue; + if (!lowerer.checker.isArrayType(part) && !lowerer.checker.isTupleType(part)) continue; + const element = lowerer.checker.getTypeArguments(part as ts.TypeReference)[0]; + if (element !== undefined && (element.flags & ts.TypeFlags.Never) === 0) continue; const mapped = lowerer.mapTypeOf(part); if (mapped) emptyArms.push(mapped); } @@ -1863,12 +1866,19 @@ function flatMapUnionPlan( if (outElem.kind !== "union") return false; return lowerer.armTag(outElem.unionId, t) >= 0; }; + const tupleConverts = (arm: IrType & { kind: "record" }): boolean => + (lowerer.shapes.get(arm.shapeId)?.fields ?? []).every((f) => converts(f.type)); const plan: FlatMapUnionPlan["arms"] = []; for (const arm of arms) { if (arm.kind === "array" && emptyArms.some((e) => typeEquals(e, arm))) plan.push("empty"); // An array arm copies its elements as they are stored, a present // undefined included, so its element type is the result's. else if (arm.kind === "array" && typeEquals(arm.elem, outElem)) plan.push("spread"); + // A contextual array type makes a multi-statement body's `[pair]` + // infer as a one-element TUPLE (`return x ? [pair] : []`), whose + // fields contribute in order. + else if (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple && tupleConverts(arm)) + plan.push("tuple"); else if (arm.kind !== "array" && converts(arm)) plan.push("value"); else return null; } @@ -1924,6 +1934,27 @@ function flatMapUnionHelper( loc, }; if (plan.arms[tag] === "empty") return []; + if (plan.arms[tag] === "tuple") { + const tuple = arm as IrType & { kind: "record" }; + return (lowerer.shapes.get(tuple.shapeId)?.fields ?? []).map((field) => { + const read: IrExpr = { + kind: "recordGet", + obj: narrowed, + shapeId: tuple.shapeId, + field: field.name, + type: field.type, + loc, + }; + return arrayValueStore( + lowerer, + outRef(), + outLength(), + lowerer.coerceToExpected(read, plan.outElem), + plan.outElem, + loc, + ); + }); + } if (plan.arms[tag] === "value") { // A unit arm's value is the unit itself, which has no narrowed // representation. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json new file mode 100644 index 000000000..ab705227d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8525-flatmap-tuple-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8525-flatmap-tuple-arms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8525-flatmap-tuple-arms.ts b/tests/corpus/8525-flatmap-tuple-arms.ts new file mode 100644 index 000000000..b4a1d72f7 --- /dev/null +++ b/tests/corpus/8525-flatmap-tuple-arms.ts @@ -0,0 +1,46 @@ +// A `flatMap` callback whose body is a block and whose enclosing call has a +// contextual array type infers a TUPLE union (`return v ? [pair] : []` +// infers `[] | [pair]`), so one arm is a tuple rather than an array. The +// loop appends a tuple arm's fields in order, exactly as it appends an array +// arm's elements, and an empty arm appends nothing. +interface Namespace { + name: string; + description?: string; +} +interface Tool { + name: string; + label?: string; +} +interface Loadout { + callable: Tool[]; + getNamespace(name: string): Namespace | undefined; +} + +function pairsOf(loadout: Loadout): (readonly [string, Namespace])[] { + return loadout.callable.flatMap((tool) => { + const namespace = loadout.getNamespace(tool.name); + return namespace ? [[tool.name, namespace] as const] : []; + }); +} + +const loadout: Loadout = { + callable: [{ name: "docs" }, { name: "skip", label: "S" }, { name: "notes" }], + getNamespace: (name) => + name === "skip" ? undefined : { name: name.toUpperCase(), description: `${name} ns` }, +}; +const pairs = pairsOf(loadout); +console.log("pairs", pairs.length); +for (const [key, namespace] of pairs) console.log("pair", key, namespace.name, namespace.description); +console.log("map size", new Map(pairsOf(loadout)).size); + +// A two-field tuple arm contributes both fields, in order. +function doubled(words: (string | undefined)[]): string[] { + return words.flatMap((word) => (word ? [word, word.toUpperCase()] : [])); +} +console.log("doubled", doubled(["a", undefined, "bc"]).join(",")); + +// An array arm beside a scalar arm keeps the per-value flatten decision. +function pieces(parts: (string | string[])[]): string[] { + return parts.flatMap((part) => (Array.isArray(part) ? part : [part])); +} +console.log("pieces", pieces(["x", ["y", "z"], "w"]).join("-")); diff --git a/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json b/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json new file mode 100644 index 000000000..f0854172c --- /dev/null +++ b/tests/harness/corpus-costs/8525-flatmap-tuple-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 2400 } From 1ed5ba53aa1dbc1c52119fb5000ba9c31e508364 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:23 -0700 Subject: [PATCH 08/14] Lower `u || d` where the checker narrowed `u` to one of its own arms `return typeof options === "string" ? {...} : options || {}` refused: flow narrowing ruled the string arm out, but the stored value keeps the wider union, so the logical operator saw a union-typed left operand outside a condition. When the checker's result is one of the left value's own arms, test the value in its own union and extract the result arm through the checked single-arm narrowing: the falsy arm takes the default, which keeps its lazy evaluation, and a value of a ruled-out arm traps rather than masquerading as the result type. Corpus 8526 pins the narrowed `||` default over an optional interface, a union of records, and lazy default evaluation. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 38 +++++++++++++++++ .../corpus/8526-union-logical-default.ts.json | 6 +++ tests/corpus/8526-union-logical-default.ts | 41 +++++++++++++++++++ .../8526-union-logical-default.ts.json | 1 + 4 files changed, 86 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json create mode 100644 tests/corpus/8526-union-logical-default.ts create mode 100644 tests/harness/corpus-costs/8526-union-logical-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..d4034ba8e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -2964,6 +2964,44 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE loc, }; } + // `u || d` whose checker result is one of u's own arms while u keeps + // arms flow narrowing ruled out (`typeof o === "string" ? ... : o || + // {}` leaves the string arm in the stored union). Test u in its own + // union and extract the result arm through the checked narrowing: a + // value of a ruled-out arm traps rather than masquerading as the + // result type. + if (op === ts.SyntaxKind.BarBarToken && left.type.kind === "union") { + const def = lowerer.unions.get(left.type.unionId); + if ( + target !== null && + def !== undefined && + def.arms.some(isUnitType) && + lowerer.armTag(left.type.unionId, target) >= 0 + ) { + const helper = lowerer.narrowedArmHelper(left.type.unionId, target, loc); + if (helper) { + lowerer.requireTruthyUnion(left.type.unionId, expr); + const stmts: IrStmt[] = []; + let stable: IrExpr = left; + if (!isSafeToRepeat(left)) { + const local = lowerer.declareHiddenLocal("%or", 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: { kind: "call", callee: helper, args: [stable], type: target, loc }, + else_: lowerer.lowerExprExpecting(expr.right, target), + type: target, + loc, + }; + return stmts.length === 0 + ? result + : { kind: "seqExpr", stmts, result, type: target, loc }; + } + } + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json new file mode 100644 index 000000000..425c1eed4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8526-union-logical-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8526-union-logical-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8526-union-logical-default.ts b/tests/corpus/8526-union-logical-default.ts new file mode 100644 index 000000000..0980b4983 --- /dev/null +++ b/tests/corpus/8526-union-logical-default.ts @@ -0,0 +1,41 @@ +// `o || {}` where flow narrowing ruled out the other arm of `o`'s type: +// the stored value keeps its wider union, so the default tests the value in +// its own union and extracts the checker's arm through the checked +// narrowing. The falsy arm takes the default; an object argument is the +// result itself. +interface ExportOptions { + outputPath?: string; + theme?: string; + inlineImages?: boolean; +} + +function fromPath(options?: ExportOptions | string): ExportOptions { + return typeof options === "string" ? { outputPath: options } : options || {}; +} + +const names = (o: ExportOptions): string => `${o.outputPath ?? "-"}/${o.theme ?? "-"}/${o.inlineImages ?? "-"}`; +console.log("none", names(fromPath(undefined))); +console.log("path", names(fromPath("out.html"))); +console.log("object", names(fromPath({ outputPath: "p", theme: "dark", inlineImages: true }))); +console.log("empty", JSON.stringify(fromPath({}))); + +// The same shape over a union of records with a default object. +interface Cursor { + line: number; + column: number; +} +function cursorAt(where: Cursor | undefined): Cursor { + return where || { line: 1, column: 1 }; +} +console.log("cursor", JSON.stringify(cursorAt(undefined)), JSON.stringify(cursorAt({ line: 4, column: 2 }))); + +// The default is evaluated only when the left is falsy. +let defaults = 0; +function counted(): Cursor { + defaults++; + return { line: 0, column: 0 }; +} +const base: Cursor | undefined = { line: 2, column: 3 }; +console.log("lazy", JSON.stringify(base || counted()), defaults); +const missing: Cursor | undefined = undefined; +console.log("fallback", JSON.stringify(missing || counted()), defaults); diff --git a/tests/harness/corpus-costs/8526-union-logical-default.ts.json b/tests/harness/corpus-costs/8526-union-logical-default.ts.json new file mode 100644 index 000000000..61de013e5 --- /dev/null +++ b/tests/harness/corpus-costs/8526-union-logical-default.ts.json @@ -0,0 +1 @@ +{ "plain": 1450, "san": 2350 } From 04409081f990afbc4ff729346cc17217f89ec4d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:35 -0700 Subject: [PATCH 09/14] Lower chain-guarded union reads from the stored union Inside an optional-chain body the checker types the guarded receiver by its non-nullish narrowing, often one record of a discriminated union, while the bound chain value still carries the whole present union: a runtime-optional local that crossed an `await` and a prior `stored?.type === "oauth"` return holds `ApiKeyCredential | OAuthCredential` at runtime. lowerUnionProperty gated on the spelled receiver type being a union, so the read fell through to the generic SC1090 fence ("reading 'type' from a value of type 'ApiKeyCredential'"). Take the union from the chain binding, which is the value lowerExpr is about to read; unionDisc then dispatches on the stored tag and maybeNarrow applies the checker's narrowing to the result. Adds the differential corpus program union-read-after-chain-guard and its emission test. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 24 ++++- .../union-read-after-chain-guard.ts.json | 6 ++ ...on-read-after-chain-guard-emission.test.ts | 89 +++++++++++++++++++ tests/corpus/union-read-after-chain-guard.ts | 48 ++++++++++ .../union-read-after-chain-guard.ts.json | 1 + 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json create mode 100644 packages/compiler/test/union-read-after-chain-guard-emission.test.ts create mode 100644 tests/corpus/union-read-after-chain-guard.ts create mode 100644 tests/harness/corpus-costs/union-read-after-chain-guard.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e2595f465..93ec89099 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3545,9 +3545,21 @@ export function lowerUnionProperty( expr: ts.PropertyAccessExpression, ): IrExpr | null { if (lowerer.chainBlocked(expr)) return null; - const receiverIr = + const spelled = structuralReadType(lowerer, expr.expression) ?? lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + // Inside an optional-chain body the guarded receiver is checker-typed by + // its non-nullish narrowing — often one record of a discriminated union + // — while the bound chain value still carries the whole present union + // (`stored?.type` after an earlier `stored?.type === "oauth"` return + // narrowed `stored` to `ApiKeyCredential`, though the runtime-optional + // storage still holds `ApiKeyCredential | OAuthCredential`). The union + // reader dispatches on the runtime tag, so take the union the chain + // binding hands to `lowerExpr` below instead of the spelled narrowing. + const chainRecv = lowerer.chainRecvByNode.get(expr.expression); + const chainUnion = + spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null; + const receiverIr = chainUnion ?? spelled; // The checker can rule the receiver UNREACHABLE and the assertion that // escapes it (`const x: never = event; (event as any).type` — the // `default:` branch of a switch that exhausts every arm) erases to @@ -3581,6 +3593,16 @@ export function lowerUnionProperty( // extract the instance and test its class, as typed slots do. else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value; } + // The spelled type is a narrowing, so every path below (dyn, record, + // object, array) was unreachable for this read before it took the stored + // union: answer it only when the union reader can, and otherwise hand the + // read back to the lowering the spelled type used to reach. A stored arm + // the checker ruled out that has no such member (`m1?.name` where the + // stored union still carries a `string` arm) must not turn an accepted + // program into a refusal. + if (chainUnion !== null) { + return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null; + } // 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 diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..223001d72 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-read-after-chain-guard.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-read-after-chain-guard-emission.test.ts b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts new file mode 100644 index 000000000..993b7afd4 --- /dev/null +++ b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts @@ -0,0 +1,89 @@ +// A member read on a union receiver inside an optional-chain body: the +// checker narrows the guarded receiver to ONE union arm (a discriminant +// test through `?.`), while the bound chain value still carries the whole +// present union — the runtime-optional local a prior `if (s?.kind === ...) +// return` narrowed after an `await`. The read must dispatch on the value's +// runtime tag (unionDisc), not fall through to the unsupported-property +// fence the spelled single-record type would suggest. +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface ApiKeyCredential { type: 'api_key'; key?: string }", + "interface OAuthCredential { type: 'oauth'; refresh: string; expires: number }", + "type Credential = ApiKeyCredential | OAuthCredential;", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-chain-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("a chain-guarded union read dispatches on the stored union", async () => { + const compiled = await compileToIr([ + "function load(): Promise {", + " return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);", + "}", + "async function pick(): Promise {", + " const stored = await load();", + " if (stored?.type === 'oauth') return stored.expires > 0 ? 'oauth' : 'expired';", + " const credential = stored?.type === 'api_key' ? stored : undefined;", + " return credential?.type === 'api_key' ? (credential.key ?? 'none') : 'missing';", + "}", + "pick().then((answer) => console.log(answer));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // The guarded read still sees both stored arms; the tag switch is the + // shape that composes with the discriminant comparison. + const pick = bodies(compiled.module!, "pick"); + expect(pick).toContain('"unionDisc"'); + // The runtime-optional local crosses the await, so the presence test is + // the chain's own guard, not a checked extraction trap on every read. + expect(pick).not.toContain('"unsupported"'); +}); + +test("a chain-guarded read of a non-discriminant field reads the survivor", async () => { + const compiled = await compileToIr([ + "function only(c: Credential | undefined): string {", + " return c?.type === 'oauth' ? c.refresh : 'api';", + "}", + "console.log(only({ type: 'oauth', refresh: 'r', expires: 1 }));", + "console.log(only(undefined));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + expect(bodies(compiled.module!, "only")).toContain('"refresh"'); +}); diff --git a/tests/corpus/union-read-after-chain-guard.ts b/tests/corpus/union-read-after-chain-guard.ts new file mode 100644 index 000000000..f7c454f8b --- /dev/null +++ b/tests/corpus/union-read-after-chain-guard.ts @@ -0,0 +1,48 @@ +// A discriminated union held across an `await`: the early `stored?.type === +// "oauth"` return narrows the checker's `stored` to the api-key arm, but the +// runtime-optional storage still holds both arms. The later `s?.type` reads +// dispatch on the stored value's tag, so every credential shape answers as in +// Node — including the arm the earlier guard ruled out at runtime. +type ProviderEnv = Record; +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: ProviderEnv; +} +interface OAuthCredentials { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +} +interface OAuthCredential extends OAuthCredentials { + type: "oauth"; +} +type Credential = ApiKeyCredential | OAuthCredential; + +const stored: (Credential | undefined)[] = [ + { type: "api_key", key: "sk-live", env: { REGION: "eu" } }, + { type: "oauth", refresh: "r1", access: "a1", expires: 42 }, + { type: "api_key" }, + undefined, +]; + +function load(i: number): Promise { + return Promise.resolve(stored[i]); +} + +async function describe(i: number): Promise { + const found = await load(i); + if (found?.type === "oauth") { + return found.expires > 10 ? `oauth ${found.refresh}/${found.access}` : "oauth expired"; + } + const credential = found?.type === "api_key" ? found : undefined; + const key = credential?.type === "api_key" ? (credential.key ?? "") : ""; + const region = credential?.env?.REGION ?? ""; + return `api ${key} ${region}`; +} + +async function main(): Promise { + for (let i = 0; i < stored.length; i++) console.log(i, await describe(i)); +} +main(); diff --git a/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..6bbc1dfa2 --- /dev/null +++ b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2300 } From b200bde558cc85e91cbcf212009bf880f1de9f66 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:49:14 -0700 Subject: [PATCH 10/14] Read a generic constraint member the instantiation omits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `options.sessionId` inside a generic function whose type parameter's constraint declares `sessionId?: string` fenced with SC1090 when the call site's options record has no such slot: function applyAuth & Transforms>( model: TModel, options: TOptions | undefined) { const sessionId = options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined; } applyAuth(model, { apiKey, azureApiVersion }); The checker admits the read through the constraint (and, with the `in` guard, through a synthetic `T & Record<"sessionId", unknown>`), while the instantiated record — the value's actual layout — stores no such field. `fieldTarget` found no record slot and the read fell through to the generic unsupported-property fence, even though Node simply reads the property off the object it has: undefined when the caller omitted it, the extra property when a JavaScript caller supplied one. fieldTarget now recognizes a generic receiver (a type parameter, or an `in`-narrowed intersection that contains one) whose stored record lacks the member, and lowers the read as the checked-dynamic keyed read of that record value, entering the static code at the type the checker gave the read. Reads that keep a slot take their record get as before, and the programs this path opens did not compile at all before, so typed code keeps its representation. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 77 ++++++++++++++++ ...ic-constraint-member-read-emission.test.ts | 87 +++++++++++++++++++ .../generic-constraint-member-read.ts.json | 6 ++ .../corpus/generic-constraint-member-read.ts | 54 ++++++++++++ .../generic-constraint-member-read.ts.json | 1 + 5 files changed, 225 insertions(+) create mode 100644 packages/compiler/test/generic-constraint-member-read-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json create mode 100644 tests/corpus/generic-constraint-member-read.ts create mode 100644 tests/harness/corpus-costs/generic-constraint-member-read.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 93ec89099..3c0a8de3c 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4315,6 +4315,34 @@ function checkedDynamicValue(expr: IrExpr): IrExpr | null { return expr.kind === "dynCheck" && expr.value.type.kind === "dyn" ? expr.value : null; } +/** A GENERIC receiver type: a type parameter, or a narrowing of one that + * intersects it (`"k" in options` refines a type variable to + * `T & Record<"k", unknown>`). Its stored layout is whatever the call site + * instantiated it with, and the CONSTRAINT's members are not necessarily + * stored there. */ +function typeParameterReceiver(t: ts.Type): boolean { + try { + if ((t.flags & ts.TypeFlags.TypeParameter) !== 0) return true; + return ( + (t.flags & ts.TypeFlags.Intersection) !== 0 && + ts.constituentTypes(t).some((part) => (part.flags & ts.TypeFlags.TypeParameter) !== 0) + ); + } catch (e) { + if (ts.isUnreadableConstituentsPanic(e)) return false; + throw e; + } +} + +/** Whether `access` is only READ (not the target of an assignment, a + * delete, or an increment). */ +function readOnlyAccess(access: ts.PropertyAccessExpression): boolean { + const parent = access.parent; + if (parent === undefined) return true; + if (ts.isBinaryExpression(parent) && parent.left === access) return false; + if (ts.isDeleteExpression(parent)) return false; + return !ts.isPrefixUnaryExpression(parent) && !ts.isPostfixUnaryExpression(parent); +} + export function fieldTarget( lowerer: Lowerer, access: ts.PropertyAccessExpression, @@ -4441,6 +4469,55 @@ export function fieldTarget( lowerer.lowerExpr(access.expression), ); } + // A generic receiver's member the INSTANTIATION cannot store: the type + // parameter's constraint declares the member (so tsc admits the read), + // while the record the parameter was instantiated with has no such slot — + // a generic options bag read through its constraint (`options.sessionId` + // under an `"sessionId" in options` guard) at a call site whose options + // literal omits the optional member. Node reads the property off the + // object it actually has: undefined when the caller omitted it, the extra + // property when a JavaScript caller supplied one. So the read is the + // checked-dynamic keyed read of the stored record, entering the static + // code at the type the checker gave the read. + if ( + !access.questionDotToken && + readOnlyAccess(access) && + typeParameterReceiver(lowerer.typeOf(access.expression)) + ) { + const storedIr = stored ?? narrowed; + const arm = storedIr?.kind === "union" ? lowerer.stripUndefinedArm(storedIr) : storedIr; + const shape = arm?.kind === "record" ? lowerer.shapes.get(arm.shapeId) : undefined; + const admitted = lowerer.mapTypeOf(lowerer.typeOf(access)); + if ( + shape && + !shape.tuple && + !shape.indexValue && + !shape.fields.some((f) => f.name === access.name.text) && + admitted && + admitted.kind !== "void" + ) { + 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; + return { + container: "dynamic", + obj: lowerer.coerceToExpected(obj, DYN), + field: access.name.text, + fieldType: admitted, + }; + } + } if (receiverIr?.kind === "record") { const shape = lowerer.shapes.get(receiverIr.shapeId); const fieldType = shape?.fields.find((f) => f.name === access.name.text)?.type; diff --git a/packages/compiler/test/generic-constraint-member-read-emission.test.ts b/packages/compiler/test/generic-constraint-member-read-emission.test.ts new file mode 100644 index 000000000..bb7fa7930 --- /dev/null +++ b/packages/compiler/test/generic-constraint-member-read-emission.test.ts @@ -0,0 +1,87 @@ +// A member the checker admits only through a type parameter's constraint +// while the instantiation's record has no slot for it (`options.sessionId` +// under an `"sessionId" in options` guard, at a call site whose options +// literal omits the optional member): the read must lower to the +// checked-dynamic keyed read of the stored record — the object's own +// property, undefined when absent — instead of fencing. +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface RequestOptions { model?: TModel; apiKey?: string; sessionId?: string }", + "interface Transforms {", + " transformHeaders?: (headers: Record) => Record;", + "}", + "function sessionOf & Transforms>(", + " options: TOptions | undefined,", + "): string | undefined {", + " return options && 'sessionId' in options && typeof options.sessionId === 'string'", + " ? options.sessionId", + " : undefined;", + "}", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + lines: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-generic-constraint-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...lines].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("an omitted constraint member reads dynamically off the stored record", async () => { + const compiled = await compileToIr([ + "const withoutKey = { apiKey: 'k' };", + "console.log(String(sessionOf<{ id: string }, typeof withoutKey>(withoutKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + // The stored record has no `sessionId` slot, so the read is the + // checked-dynamic keyed read of the value (dynFrom → key read → check). + expect(body).toContain('"dynFrom"'); + expect(body).toContain('"dynCheck"'); + expect(body).not.toContain('"unsupported"'); +}); + +test("a constraint member the instantiation stores still reads its slot", async () => { + const compiled = await compileToIr([ + "const withKey = { apiKey: 'k', sessionId: 's-1' };", + "console.log(String(sessionOf<{ id: string }, typeof withKey>(withKey)));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const body = bodies(compiled.module!, "sessionOf"); + expect(body).toContain('"recordGet"'); + expect(body).toContain('"sessionId"'); + expect(body).not.toContain('"dynFrom"'); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..903bc4c50 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-constraint-member-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-constraint-member-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-constraint-member-read.ts b/tests/corpus/generic-constraint-member-read.ts new file mode 100644 index 000000000..e958097fd --- /dev/null +++ b/tests/corpus/generic-constraint-member-read.ts @@ -0,0 +1,54 @@ +// A generic function reads OPTIONAL members its constraint declares while the +// call site's own options record omits them (`options.sessionId` under an +// `"sessionId" in options` guard). Node reads the property off the object it +// actually has, so a caller that omits the member answers undefined and one +// that supplies it answers the value — for every instantiation of the +// function, which the program pins one by one. +interface RequestOptions { + model?: TModel; + apiKey?: string; + sessionId?: string; +} +interface Transforms { + transformHeaders?: (headers: Record) => Record; +} + +function describeOptions & Transforms>( + options: TOptions | undefined, +): string { + const sessionId = + options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + const apiKey = + options && "apiKey" in options && typeof options.apiKey === "string" ? options.apiKey : ""; + return `session=${sessionId} key=${apiKey}`; +} + +const withSession = { apiKey: "sk-1", sessionId: "sess-1" }; +const withoutSession = { apiKey: "sk-2" }; +const onlySession = { sessionId: "sess-3" }; +const empty = {}; + +console.log(describeOptions<{ id: string }, typeof withSession>(withSession)); +console.log(describeOptions<{ id: string }, typeof withoutSession>(withoutSession)); +console.log(describeOptions<{ id: number }, typeof onlySession>(onlySession)); +console.log(describeOptions<{ id: number }, typeof empty>(empty)); +console.log(describeOptions<{ id: string }, Transforms>(undefined)); + +// The same read through a generic method on a class, whose instantiation is +// fixed by the call site's own argument type. +class OptionsReader & Transforms> { + read(options: TOptions | undefined): string { + return options && "sessionId" in options && typeof options.sessionId === "string" + ? options.sessionId + : ""; + } +} +const keyedReader = new OptionsReader(); +const bareReader = new OptionsReader(); +console.log( + keyedReader.read(withSession), + bareReader.read(withoutSession), + bareReader.read(undefined), +); diff --git a/tests/harness/corpus-costs/generic-constraint-member-read.ts.json b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json new file mode 100644 index 000000000..cb50f798b --- /dev/null +++ b/tests/harness/corpus-costs/generic-constraint-member-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3400 } From 3c5b9eadd2eba1e2db0aae47ac2f2c0753986c69 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:15:33 -0700 Subject: [PATCH 11/14] Lift a record field whose function value narrows its parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record width conversion copies a function-valued field when the field's signature adapts (cleanFuncAdaptable). That gate asked coercibleValue of each parameter pair, which excludes the checked-dynamic extraction and the structural width the parameter conversion actually performs, so a definition whose `execute`/`renderCall` take a concrete parameter record could not complete a slot declaring `unknown` members — the SC2002 `field 'execute'` / `field 'renderCall'` shape that dominates the census's tool-definition sites. The gate now measures what funcCoerceAdapter converts: cleanParamPair for parameters (so a callback parameter recurses through the same rule one function type down) and cleanResult for the result (a structural width, which carries the promise payload widening, and the void-into-unknown drop). A recursion guard keyed in coercions.planning keeps structurally recursive function aliases from looping, the coinductive answer recordWidthPlan already gives its own back-edges. A parameter that narrows a dynamic member to a record is the checked-dynamic extraction, the same conversion a cast performs: an argument of the wrong shape throws from the adapter rather than reading undefined, as with any checked conversion in the language. The gate widens admission only: a pair the old gate refused was an SC2002 error, so programs that already compiled keep byte-identical IR. Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/function-adapters.ts | 44 +++++-- .../corpus/record-func-field-width.ts.json | 6 + tests/corpus/record-func-field-width.ts | 109 ++++++++++++++++++ .../record-func-field-width.ts.json | 1 + 4 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json create mode 100644 tests/corpus/record-func-field-width.ts create mode 100644 tests/harness/corpus-costs/record-func-field-width.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 50366eeb6..322cc09fa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -129,7 +129,13 @@ export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean * parameters are ignored. A source with MORE parameters than the slot is * clean when every extra trailing one is omittable — the adapter passes * that absent value explicitly (funcCoerceAdapter's omittedParams), the - * same way a call through the slot would. */ + * same way a call through the slot would. A parameter the slot coercion + * converts through a STRUCTURAL width counts (cleanParamPair), which + * recurses through a nested function type: a callback parameter of the + * value taking a narrower record where the slot's takes its `unknown` view + * converts the argument the same way a top-level call does. The result side + * likewise accepts what funcCoerceAdapter converts (cleanResult), including + * a promise whose payload widens. */ export function cleanFuncAdaptable( lowerer: Lowerer, src: IrType & { kind: "func" }, @@ -137,15 +143,35 @@ export function cleanFuncAdaptable( ): boolean { if (restPackingAdapterFits(lowerer, src, dst)) return true; if (src.rest === true || dst.rest === true) return false; - for (let i = dst.params.length; i < src.params.length; i++) { - if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + // Recursive function types (`type Handler = (h: Handler) => void`) reach + // the same pair again one parameter down: assume it converts, the same + // coinductive answer recordWidthPlan gives its own back-edges. + const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`; + if (lowerer.coercions.planning.has(key)) return true; + lowerer.coercions.planning.add(key); + try { + for (let i = dst.params.length; i < src.params.length; i++) { + if (!omittableTrailingParam(lowerer, src.params[i]!)) return false; + } + for (let i = 0; i < src.params.length && i < dst.params.length; i++) { + if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false; + } + return cleanResult(lowerer, src.ret, dst.ret); + } finally { + lowerer.coercions.planning.delete(key); } - for (let i = 0; i < src.params.length && i < dst.params.length; i++) { - if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; - } - if (dst.ret.kind === "void") return true; - if (lowerer.coercibleValue(src.ret, dst.ret)) return true; - return src.ret.kind === "void" && dst.ret.kind === "dyn"; +} + +/** Whether the slot's coercion converts the value's RESULT without a + * trap-only path: the dispositions funcCoerceAdapter's result side accepts + * (a void slot drops the value; coercibleValue; a structural width, which + * includes the promise payload widening and a nested function adaptation; + * the void-into-unknown drop). */ +function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean { + if (expected.kind === "void") return true; + if (lowerer.coercibleValue(actual, expected)) return true; + if (actual.kind === "void" && expected.kind === "dyn") return true; + return lowerer.widthLiftPlan(actual, expected) !== null; } /** Whether the slot's argument type `actual` converts into the function's diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json new file mode 100644 index 000000000..36ec7deb6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-func-field-width.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-func-field-width.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-func-field-width.ts b/tests/corpus/record-func-field-width.ts new file mode 100644 index 000000000..1b9f7bd41 --- /dev/null +++ b/tests/corpus/record-func-field-width.ts @@ -0,0 +1,109 @@ +// A record field holding a function adapts when the slot's function type is a +// structural width of the field's: a parameter record whose `unknown` members +// gain shapes (the checked-dynamic extraction), a callback parameter whose +// record widens the same way one level down, and a promise payload that widens. +// +// `wrapToolDefinition` states the definition's state and details parameters as +// `any`/default `unknown`, so passing a concrete definition crosses the +// `execute` and `renderCall` fields of the record. + +interface Truncation { + content: string; + truncated: boolean; +} + +interface ToolResult { + content: { type: string; text: string }[]; + details: D; + isError: boolean | undefined; +} + +type UpdateCallback = (partial: ToolResult) => void; + +interface ToolDefinition { + name: string; + execute(id: string, onUpdate: UpdateCallback | undefined): Promise>; + renderCall(context: { state: S; label: string }): string; +} + +interface BashState { + startedAt: number; + executionStarted: boolean; +} + +interface BashDetails { + truncation: Truncation | undefined; + fullOutputPath: string | undefined; +} + +function wrapToolDefinition( + definition: ToolDefinition, +): ToolDefinition { + return { + name: definition.name, + execute: definition.execute, + renderCall: definition.renderCall, + }; +} + +function bashDefinition(name: string): ToolDefinition { + return { + name, + execute: (id, onUpdate) => { + const result: ToolResult = { + content: [{ type: "text", text: `ran ${id}` }], + details: { truncation: { content: "tail", truncated: true }, fullOutputPath: undefined }, + isError: undefined, + }; + onUpdate?.(result); + return Promise.resolve(result); + }, + renderCall: (context) => `${context.label} after ${context.state.startedAt}ms`, + }; +} + +// The `execute` field: the slot's update callback takes `details: unknown` +// where the value's takes the concrete record, and the result promise payload +// widens the same way. +function widenDetails(): ToolDefinition { + return wrapToolDefinition(bashDefinition("bash")); +} + +// The `renderCall` field: the slot's context record has `state: unknown` +// where the value's has the concrete state record. +function widenState(definition: ToolDefinition): ToolDefinition { + return wrapToolDefinition(definition); +} + +// The same crossing when the argument is an object literal carrying optional +// members the slot's shape also declares. +interface WithHints { + annotations: { readOnlyHint: boolean | undefined } | undefined; + defaultActive: boolean | undefined; +} + +function widenLiteral(): ToolDefinition { + const hints: WithHints = { annotations: { readOnlyHint: true }, defaultActive: undefined }; + const definition: ToolDefinition & WithHints = { + ...bashDefinition("literal"), + ...hints, + }; + console.log("hints:", definition.annotations?.readOnlyHint, definition.defaultActive); + return wrapToolDefinition(definition); +} + +const widened = widenDetails(); +widened + .execute("ls", (partial) => { + console.log("update:", partial.content[0]?.text, partial.isError); + }) + .then((result) => { + console.log("result:", result.details, result.isError); + }); + +const rendered = widenState(bashDefinition("grep")); +console.log("render:", rendered.renderCall({ state: { startedAt: 12, executionStarted: true }, label: "grep" })); + +const literal = widenLiteral(); +console.log("literal render:", literal.renderCall({ state: { startedAt: 3, executionStarted: false }, label: "ls" })); +console.log("name:", literal.name); diff --git a/tests/harness/corpus-costs/record-func-field-width.ts.json b/tests/harness/corpus-costs/record-func-field-width.ts.json new file mode 100644 index 000000000..31e3551a8 --- /dev/null +++ b/tests/harness/corpus-costs/record-func-field-width.ts.json @@ -0,0 +1 @@ +{ "plain": 3050, "san": 4250 } From e41fee0c4a2c591e695d4c0e74917e352f5e47ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:18:27 -0700 Subject: [PATCH 12/14] Fill an async result from a returned `Promise` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return fatal(...)` where `fatal` is declared `Promise` (a throw-only async helper) is well-typed — `never` converts into anything — but the returned value flattens through the returned-promise await into a void, which then fails the async function's declared record result with SC2002 (`expected '{ cancelled: boolean }', got 'void'`). The IR keeps `Promise` as a promise with a void payload, exactly like `Promise`, so the two are indistinguishable by type alone. The return now asks the TS payload type: a `never` payload means the promise never settles, so the awaited value is unreachable and fills the declared result through divergentValue — the same unreachable-value path a never-returning call already takes in a value position. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 22 +++++++++++ .../tests/corpus/promise-never-return.ts.json | 6 +++ tests/corpus/promise-never-return.ts | 38 +++++++++++++++++++ .../corpus-costs/promise-never-return.ts.json | 1 + 4 files changed, 67 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json create mode 100644 tests/corpus/promise-never-return.ts create mode 100644 tests/harness/corpus-costs/promise-never-return.ts.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..af2086a0a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -13894,6 +13894,19 @@ export class Lowerer { ); } + /** Whether `node`'s TS type is a `Promise` (the throw-only async + * function's declared result): the promise can never settle, so the value + * it would resolve to is unreachable. The IR keeps `Promise` as a + * promise with a void payload, exactly like `Promise`, so the TS + * payload type is what separates the two. */ + private promisePayloadIsNever(node: ts.Expression): boolean { + const t = this.typeOf(node); + if ((t.flags & ts.TypeFlags.Object) === 0) return false; + if (((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return false; + const args = this.checker.getTypeArguments(t as ts.TypeReference); + return args.length === 1 && (args[0]!.flags & ts.TypeFlags.Never) !== 0; + } + /** The value of `return ` (already lowered untyped) in an async or * void context: a promise flattens through the returned-promise await, * everything else converts into the declared result. */ @@ -13901,6 +13914,15 @@ export class Lowerer { if (expected.kind === "void" && e.kind === "unitLit") return null; if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") { const awaited = this.returnedPromiseAwait(e); + // A `Promise` never settles, so the awaited value is + // unreachable: it fills any result shape instead of failing the + // void-to-record conversion. + if ( + expected.kind !== "void" && + e.type.inner.kind === "void" && + this.promisePayloadIsNever(node) + ) + return this.divergentValue(awaited, expected); return this.coerceInto(node, awaited, expected); } return this.coerceInto(node, e, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json new file mode 100644 index 000000000..b69caef07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-never-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/promise-never-return.ts b/tests/corpus/promise-never-return.ts new file mode 100644 index 000000000..b494ee211 --- /dev/null +++ b/tests/corpus/promise-never-return.ts @@ -0,0 +1,38 @@ +// `return fatal(...)` where `fatal` is declared `Promise`: the promise +// can never settle, so the awaited value is unreachable and fills the async +// function's record result instead of failing a void-to-record conversion. + +interface Outcome { + cancelled: boolean; +} + +async function fatal(message: string): Promise { + throw new Error(message); +} + +async function run(flag: boolean): Promise { + try { + if (flag) throw new Error(`zap ${flag}`); + return { cancelled: false }; + } catch (error: unknown) { + return fatal(error instanceof Error ? error.message : "unknown"); + } +} + +async function passthrough(): Promise { + try { + throw new Error("always"); + } catch { + return fatal("passthrough"); + } +} + +run(false) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +run(true) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +passthrough() + .then((value) => console.log("value:", value)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); diff --git a/tests/harness/corpus-costs/promise-never-return.ts.json b/tests/harness/corpus-costs/promise-never-return.ts.json new file mode 100644 index 000000000..6cf92fddb --- /dev/null +++ b/tests/harness/corpus-costs/promise-never-return.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 4100 } From 3ee8c41cbd09581764bd65e54bf6987fe9c89fd2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:12:40 -0500 Subject: [PATCH 13/14] Append long flatMap tuple arms in index order, and read omitted generic members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flatMap callback's tuple arm appended its elements in the stored field order, which sorts the names as strings, so a tuple of eleven or more elements came out as 0, 1, 10, 2, …. The elements now append in index order. A generic receiver's member that the instantiation does not store reads through the checked keyed read, but only for an access that is not a write. The write test took any binary expression's left operand and any unary operand for a write, so `options.key ?? fallback`, `options.key === undefined` and `!options.key` still failed with SC1090. Only an assignment's target, a delete, and `++`/`--` count as writes now. --- .../lowering/containers/array-callbacks.ts | 7 ++++++- .../lowering/expressions/property-access.ts | 18 +++++++++++++++--- .../corpus/flatmap-long-tuple-arms.ts.json | 6 ++++++ .../generic-omitted-member-reads.ts.json | 6 ++++++ tests/corpus/flatmap-long-tuple-arms.ts | 11 +++++++++++ tests/corpus/generic-omitted-member-reads.ts | 16 ++++++++++++++++ .../flatmap-long-tuple-arms.ts.json | 1 + .../generic-omitted-member-reads.ts.json | 1 + 8 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json create mode 100644 tests/corpus/flatmap-long-tuple-arms.ts create mode 100644 tests/corpus/generic-omitted-member-reads.ts create mode 100644 tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json create mode 100644 tests/harness/corpus-costs/generic-omitted-member-reads.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 97fc18928..d4183d5e5 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -1936,7 +1936,12 @@ function flatMapUnionHelper( if (plan.arms[tag] === "empty") return []; if (plan.arms[tag] === "tuple") { const tuple = arm as IrType & { kind: "record" }; - return (lowerer.shapes.get(tuple.shapeId)?.fields ?? []).map((field) => { + // The fields are stored by name ("0", "1", "10", "2", …): the + // elements append in index order. + const elements = [...(lowerer.shapes.get(tuple.shapeId)?.fields ?? [])].sort( + (a, b) => Number(a.name) - Number(b.name), + ); + return elements.map((field) => { const read: IrExpr = { kind: "recordGet", obj: narrowed, diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3c0a8de3c..9cf5556c4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4334,13 +4334,25 @@ function typeParameterReceiver(t: ts.Type): boolean { } /** Whether `access` is only READ (not the target of an assignment, a - * delete, or an increment). */ + * delete, or an increment). The left operand of `??`, `===` or `&&`, and + * the operand of `!`, `-` or `typeof`, are reads. */ function readOnlyAccess(access: ts.PropertyAccessExpression): boolean { const parent = access.parent; if (parent === undefined) return true; - if (ts.isBinaryExpression(parent) && parent.left === access) return false; + if ( + ts.isBinaryExpression(parent) && + parent.left === access && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + return false; if (ts.isDeleteExpression(parent)) return false; - return !ts.isPrefixUnaryExpression(parent) && !ts.isPostfixUnaryExpression(parent); + if (ts.isPostfixUnaryExpression(parent)) return false; + return ( + !ts.isPrefixUnaryExpression(parent) || + (parent.operator !== ts.SyntaxKind.PlusPlusToken && + parent.operator !== ts.SyntaxKind.MinusMinusToken) + ); } export function fieldTarget( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json new file mode 100644 index 000000000..9740b7331 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/flatmap-long-tuple-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/flatmap-long-tuple-arms.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json new file mode 100644 index 000000000..b6135d575 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-omitted-member-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-omitted-member-reads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/flatmap-long-tuple-arms.ts b/tests/corpus/flatmap-long-tuple-arms.ts new file mode 100644 index 000000000..8c6898c94 --- /dev/null +++ b/tests/corpus/flatmap-long-tuple-arms.ts @@ -0,0 +1,11 @@ +// A flatMap callback whose tuple arm has more than ten elements appends +// them in index order. +function spread(values: number[]): number[] { + return values.flatMap((n) => { + const base = n * 100; + return n + ? ([base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11] as const) + : []; + }); +} +console.log(spread([1, 0, 2]).join(",")); diff --git a/tests/corpus/generic-omitted-member-reads.ts b/tests/corpus/generic-omitted-member-reads.ts new file mode 100644 index 000000000..7b31609c0 --- /dev/null +++ b/tests/corpus/generic-omitted-member-reads.ts @@ -0,0 +1,16 @@ +// A generic options bag read through its constraint at a call site whose +// literal omits the member: `??`, `===`, `!` and `typeof` read the +// property, which Node answers as undefined. +interface Options { + key?: string; + label?: string; +} +function describe(options: O): string { + const a = options.key ?? "fallback"; + const b = options.key === undefined; + const c = !options.key; + const d = typeof options.key; + return `${a} ${b} ${c} ${d}`; +} +console.log(describe({ label: "x" })); +console.log(describe({ key: "k", label: "y" })); diff --git a/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json b/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json new file mode 100644 index 000000000..08d57661f --- /dev/null +++ b/tests/harness/corpus-costs/flatmap-long-tuple-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 1455, "san": 3000 } diff --git a/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json b/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json new file mode 100644 index 000000000..15ea2ceb0 --- /dev/null +++ b/tests/harness/corpus-costs/generic-omitted-member-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 1603, "san": 3000 } From 1ace0b4e88ad37991192b59da7086735fb517f78 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 09:14:55 -0500 Subject: [PATCH 14/14] Expect the map and record-width results the PR's lowering now gives --- packages/compiler/test/map-closure-elements.test.ts | 10 +++++----- tests/harness/__snapshots__/record-width.ts.txt | 9 --------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/compiler/test/map-closure-elements.test.ts b/packages/compiler/test/map-closure-elements.test.ts index 2043b2bce..f39281579 100644 --- a/packages/compiler/test/map-closure-elements.test.ts +++ b/packages/compiler/test/map-closure-elements.test.ts @@ -58,7 +58,7 @@ export {}; expect(validateModule(result.module)).toEqual([]); }); -test("a void-returning map callback still has no element to store", async () => { +test("a void-returning map callback stores undefined elements", async () => { const result = await lower(` const items = [1, 2]; const out = items.map((n) => { @@ -67,8 +67,8 @@ const out = items.map((n) => { console.log(out.length); export {}; `); - // `void[]` stays unrepresentable: a void value is erased before it could - // be written. Only the CLOSURE element became an ordinary element. - expect(result.ok).toBe(false); - if (!result.ok) expect(result.codes).toEqual(["SC2009"]); + // A discarding mapper's answer is undefined, which the result array + // stores as its element (as Node's map does). + if (!result.ok) throw new Error(`fenced: ${result.codes.join(", ")}`); + expect(validateModule(result.module)).toEqual([]); }); diff --git a/tests/harness/__snapshots__/record-width.ts.txt b/tests/harness/__snapshots__/record-width.ts.txt index 37be4df54..4c8744a3c 100644 --- a/tests/harness/__snapshots__/record-width.ts.txt +++ b/tests/harness/__snapshots__/record-width.ts.txt @@ -7,15 +7,6 @@ record-width.ts:12:25 - error SC2002: record shapes must match exactly or width- hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead -record-width.ts:26:20 - error SC2002: record shapes must match exactly or width-coerce: expected '{ m: ({ a: number; b: number }) => void }', got '{ extra: number; m: ({ a: number }) => void }' — field 'm': '({ a: number }) => void' does not lift into '({ a: number; b: number }) => void' - - 25 | }; - 26 | const dstM: DstM = srcM; - | ^~~~ - 27 | console.log(dstM); - - hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead - record-width.ts:27:13 - error SC1090: console.log of '{ m: ({ a: number; b: number }) => void }' values (function values have no inspect lowering (no runtime name exists — Node prints '[Function: name]'; pass the function's declared identifier directly to util.inspect for the baked form)) is not supported yet 26 | const dstM: DstM = srcM;