diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 783082288..5170d72d2 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..322cc09fa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -114,10 +114,28 @@ 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. 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" }, @@ -125,13 +143,35 @@ 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++) { - if (!lowerer.coercibleValue(dst.params[i]!, 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); } - 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/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index ed8c6ddce..d4183d5e5 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"; @@ -69,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 @@ -108,7 +110,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 +668,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). @@ -1806,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 @@ -1827,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); } @@ -1847,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; } @@ -1908,6 +1934,32 @@ function flatMapUnionHelper( loc, }; if (plan.arms[tag] === "empty") return []; + if (plan.arms[tag] === "tuple") { + const tuple = arm as IrType & { kind: "record" }; + // 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, + 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. @@ -2642,11 +2694,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. @@ -2655,7 +2711,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/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/src/frontend/lowering/containers/filter-predicates.ts b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts index ba8fb9d53..421d703d1 100644 --- a/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts +++ b/packages/compiler/src/frontend/lowering/containers/filter-predicates.ts @@ -163,7 +163,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); @@ -216,7 +222,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, @@ -279,7 +291,10 @@ function unionConversion( // Every arm the target names must be one of the union's own arms, or a // record target converts from at least one arm. A record target that is // itself an arm keeps that arm's elements as they are, and the arms with - // its fields convert. + // its fields convert. Any other structural target that is no arm converts + // from each arm it can be read from. + if (kept === 0 && recordArmConversions === 0 && to.kind !== "union") + return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc); if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null; body.push( typeError( @@ -289,3 +304,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/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index c1412305d..ef21e737f 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -3014,6 +3014,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/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/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf1013..9cf5556c4 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 @@ -3532,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 @@ -3568,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 @@ -3642,8 +3677,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 +3735,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 +3784,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 +3800,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 +3836,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 +3871,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); + }, ); } @@ -4041,6 +4315,46 @@ 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). 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 && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) + return false; + if (ts.isDeleteExpression(parent)) return false; + if (ts.isPostfixUnaryExpression(parent)) return false; + return ( + !ts.isPrefixUnaryExpression(parent) || + (parent.operator !== ts.SyntaxKind.PlusPlusToken && + parent.operator !== ts.SyntaxKind.MinusMinusToken) + ); +} + export function fieldTarget( lowerer: Lowerer, access: ts.PropertyAccessExpression, @@ -4167,6 +4481,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/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 15dd5423c..f06b97279 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -10076,6 +10076,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 ( @@ -11071,10 +11086,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; } } // `new Map(source)` the checker types `Map` because the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b7595dc74..9081aae54 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14153,6 +14153,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. */ @@ -14160,6 +14173,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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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/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/__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; 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 } 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 } 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 } 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 } 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 } 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 } 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 } 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 } 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-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 } 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 } 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 } 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 } 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 }