diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 721f027c2..6e3f87f0e 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -193,6 +193,26 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width if (exact.length === 1) selected = exact; else if (exact.length === 0 && lossless.length === 1) selected = lossless; } + // An 'unknown' (dyn) source whose union destination has no dyn arm of + // its own (`details: any` into `Details | undefined`, a tool result's + // `structuredContent: unknown` into the JSON-value union): the checked + // extraction, the same dynCheck coerceToExpected performs for a dyn + // value in a union slot. A dyn source has no arm family, so every arm + // rule above has passed it by, and every plan that exists for a dyn + // source is the one above (its own arm) — this is exactly the case + // that returned null (SC2002) before, so no convertible value changes + // its lowering. + if ( + selected.length !== 1 && + src.kind === "dyn" && + canDynCheckTo( + dst, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + return { how: "dynOut" }; + } if (selected.length !== 1) return null; return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm }; } diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index d4183d5e5..ff4d9efaf 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -632,7 +632,14 @@ function arrayHofHelper( * original concrete array arm — never a copied `(T | U)[]` — and an * arm-specific function adapter wraps the element and receiver arguments * into the union-wide callback ABI. That preserves sparse-array reads, - * callback mutation, and the callback's third-argument identity. */ + * callback mutation, and the callback's third-argument identity. + * + * `filter` joins them: the kept elements are the checker's result element + * (`(b): b is TextContent` over `(TextContent | ImageContent)[] | + * (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm + * filters its own array and converts its survivors through the checked + * narrowing — a predicate that kept an element it does not describe throws + * the TypeError. An arm with no such conversion leaves the fence in place. */ export function lowerArrayUnionHofCall( lowerer: Lowerer, call: ts.CallExpression, @@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall( receiverT: IrType & { kind: "union" }, ): IrExpr | null { const method = access.name.text; - if (method !== "map" && method !== "forEach") return null; + if (method !== "map" && method !== "forEach" && method !== "filter") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(receiverT.unionId); if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null; @@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall( lowerer.badType(call, lowerer.typeOf(call)); } if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet); - const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID; - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; + if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) { + if (fnRet.kind === "void") { + lowerer.unsupported( + "SC1090", + call, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + lowerer.badType(call, lowerer.typeOf(call)); + } + // `map` stores the callback's answer; `filter` keeps the elements its + // predicate named. The kept element is the CHECKER's result element, which + // for a written type predicate is narrower than every arm's own: + // `(b): b is TextContent` over `(TextContent | ImageContent)[] | + // (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm + // filters its own concrete array and converts its survivors to that + // element through the checked narrowing — a predicate that kept an element + // it does not describe throws instead of misreading it. No conversion the + // arms cannot make honestly means the fence stays. + let outElem: IrType = VOID; + const narrowing: (string | null)[] = []; + if (method === "map") { + outElem = callbackArrayElement(lowerer, call, fnRet); + } else if (method === "filter") { + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!result || result.kind !== "array") return null; + outElem = result.elem; + const typeText = filterTypeText(lowerer, call); + for (const array of arrays) { + if (typeEquals(array.elem, outElem)) { + narrowing.push(null); + continue; + } + const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call)); + if (helper === null) return null; + narrowing.push(helper); + } + } + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); for (const array of arrays) { const armFnT = funcOf( [arrayValueType(lowerer, array.elem), F64, array].slice(0, arity), @@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall( arity, fnRet, outElem, + narrowing, locOf(call), ); return { @@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall( function arrayUnionHofHelper( lowerer: Lowerer, - method: "map" | "forEach", + method: "map" | "forEach" | "filter", receiverT: IrType & { kind: "union" }, arrays: (IrType & { kind: "array" })[], callbackT: IrType & { kind: "func" }, arity: number, fnRet: IrType, outElem: IrType, + narrowing: (string | null)[], loc: SrcLoc, ): string { - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; - const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`; + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); + // A filter's per-arm conversions are part of what the helper does, so two + // sites that share receiver, callback and result but convert differently + // keep their own helper. `map`/`forEach` never narrow, and leaving their + // key untouched keeps the helpers a program already emitted byte-identical. + const narrowingKey = + method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : ""; + const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%arr.union", key); lowerer.arrHofHelpers.set(key, name); const receiver = varRef("a.0", receiverT, loc); const callback = varRef("f.0", callbackT, loc); - const branch = (array: IrType & { kind: "array" }): IrStmt[] => { + const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => { const tag = lowerer.armTag(receiverT.unionId, array); const concrete: IrExpr = { kind: "unionNarrow", @@ -751,16 +803,38 @@ function arrayUnionHofHelper( if (!typeEquals(adapted.type, armFnT)) { throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting"); } - const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem); - const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc }; + const callee = arrayHofHelper( + lowerer, + method, + array.elem, + fnRet, + arity, + loc, + method === "filter" ? array.elem : outElem, + ); + const invoke: IrExpr = { + kind: "call", + callee, + args: [concrete, adapted], + type: method === "filter" ? arrayOf(array.elem) : resultT, + loc, + }; + // Survivors of this arm's own array, converted to the checker's result + // element. The helper throws for a kept element the predicate does not + // describe, so a lying predicate never reinterprets a value. + const armNarrow = narrowing[index] ?? null; + const answer: IrExpr = + armNarrow === null + ? invoke + : { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc }; return resultT.kind === "void" ? [ { kind: "exprStmt", expr: invoke, loc }, { kind: "return", value: null, loc }, ] - : [{ kind: "return", value: invoke, loc }]; + : [{ kind: "return", value: answer, loc }]; }; - let body = branch(arrays[arrays.length - 1]!); + let body = branch(arrays[arrays.length - 1]!, arrays.length - 1); for (let i = arrays.length - 2; i >= 0; i--) { const array = arrays[i]!; body = [ @@ -775,7 +849,7 @@ function arrayUnionHofHelper( type: BOOL, loc, }, - then: branch(array), + then: branch(array, i), else_: body, loc, }, diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 9545df829..aa23fb0a2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -38,6 +38,28 @@ import { arrayValueStore, primitiveElement } from "../array-values.js"; import { classGeneratorIterable } from "../class-iterables.js"; import { iteratorCanStep } from "../iterator-consumption.js"; +/** The arm an EMPTY `[]` literal builds as: `arms`' first array arm, or + * null when the literal is not empty (an element selects the arm) or no + * arm is an array. + * + * An empty literal carries no element of its own — tsc infers `never[]`, + * whose f64-element mapping is the uninhabited element's representation, + * not one of the destination's arms — and every empty array is the same + * value whichever array family stores it, so several array arms raise no + * ambiguity. This is the ONE statement of that rule: a union slot, a + * contextual union, and the `??` default all ask it. A lone array slot + * states the same thing without asking. */ +export function emptyArrayLiteralArm( + node: ts.Expression, + arms: readonly IrType[], +): (IrType & { kind: "array" }) | null { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return null; + const arm = arms.find((candidate) => candidate.kind === "array"); + return (arm as (IrType & { kind: "array" }) | undefined) ?? null; +} + /** JavaScript: whether `expr` is or holds an existing checked-dynamic * object — a binding stored as a dyn value whose checker type is an * object, or an array or object literal with such an element. Building @@ -206,16 +228,12 @@ export function lowerArrayLiteral( mapped = arrayOf(unitOnlyUnion(lowerer.unions)); // An EMPTY literal in a union slot (`const t: string[] | undefined = // []`, the `[]` default argument against `string[] | undefined`) has - // no useful own type — tsc infers `never[]`, whose f64-element - // mapping is a representation for the uninhabited, not the slot's - // arm. When the contextual union has exactly ONE array arm there is - // no ambiguity: build the literal as that arm and let the slot's - // coercion wrap it. Two array arms would need tsc's own best-fit - // choice — none arises in practice; the fence stands. - if (ctxUnion && expr.elements.nodes.length === 0) { + // no useful own type: it builds as the union's first array arm (the + // emptyArrayLiteralArm rule) and the slot's coercion wraps it. + if (ctxUnion) { const def = lowerer.unions.get(ctxUnion.unionId); - const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length === 1) mapped = arrayArms[0]!; + const arm = emptyArrayLiteralArm(expr, def?.arms ?? []); + if (arm) mapped = arm; } // A NON-EMPTY literal whose own type has no static home (the JS dyn // fallback — a null/dyn mapping, or an inference that degraded to a diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 48ea9c490..c204b40f4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -73,6 +73,7 @@ import { nsWritableTarget } from "../lower-namespaces.js"; import { arrayValueStore, tryLowerBytesReadNumberEquality } from "../array-values.js"; import { cjsExportMemberWriteGlobalOf, cjsInFileExportGlobalOf } from "../cjs-exports-object.js"; import { lowerUnionFieldWrite } from "./union-fields.js"; +import { emptyArrayLiteralArm } from "./array-literals.js"; import { lowerAbstractEquality } from "../abstract-equality.js"; import { lowerInExpression } from "./relational.js"; import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js"; @@ -431,6 +432,23 @@ export function lowerNullishPair( const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); return { kind: "nullish", left, right, type: rest[0]!, loc }; } + // An EMPTY array-literal default (`''.match(re) ?? []`) has no element of + // its own, so it builds as the left's array arm (the emptyArrayLiteralArm + // rule the union slot and the contextual union share) and the whole + // expression answers the left's array type instead of the checker's + // `never[]`-flavored second arm. A JavaScript `[]` is `any[]` and keeps + // the dynamic path. + if ( + rest.length === 1 && + rest[0]!.kind === "array" && + !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) + ) { + const arm = emptyArrayLiteralArm(expr.right, rest); + if (arm) { + const right = lowerer.lowerExprExpecting(expr.right, arm); + if (typeEquals(right.type, arm)) return { kind: "nullish", left, right, type: arm, loc }; + } + } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations // call the left side present. Preserve a differently typed primitive diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 2e0bc53d1..9239668d5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1359,20 +1359,38 @@ export function lowerObjectLiteral( ) { const own = lowerer.shapes.get(type.shapeId)!; const target = lowerer.shapes.get(expected.shapeId)!; - if ( - !own.tuple && + // The field set to compare is the LITERAL's: its checker type. The + // contextual type a few lines up can be wider than what the literal + // writes (an unannotated method checked against a generic slot's + // signature: `{ content, details }` under a contextual + // `AgentToolResult` whose optional members the literal never + // mentions), and the destination still names the same fields as the + // literal, so the build can construct them directly. Only when the + // contextual build has no conversion into the destination at all: a + // value that converts today keeps its exact construction. + const ownType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const ownChecked = + ownType?.kind === "record" && + ownType.shapeId !== type.shapeId && + lowerer.widthLiftPlan(type, expected) === null + ? lowerer.shapes.get(ownType.shapeId) + : undefined; + const fits = (from: IrRecordShape): boolean => + !from.tuple && !target.tuple && - !own.indexValue && + !from.indexValue && !target.indexValue && - !shapeHasAccessorSlots(own) && + !shapeHasAccessorSlots(from) && !shapeHasAccessorSlots(target) && target.fields.every( (field) => - own.fields.some((candidate) => candidate.name === field.name) || + from.fields.some((candidate) => candidate.name === field.name) || (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0), ) && - own.fields.every((field) => target.fields.some((candidate) => candidate.name === field.name)) - ) { + from.fields.every((field) => + target.fields.some((candidate) => candidate.name === field.name), + ); + if (fits(own) || (ownChecked !== undefined && fits(ownChecked))) { type = { kind: "record", shapeId: expected.shapeId }; } } diff --git a/packages/compiler/src/frontend/lowering/lib-boundary.ts b/packages/compiler/src/frontend/lowering/lib-boundary.ts index 8a264d346..06213531e 100644 --- a/packages/compiler/src/frontend/lowering/lib-boundary.ts +++ b/packages/compiler/src/frontend/lowering/lib-boundary.ts @@ -122,6 +122,19 @@ function coerceSlot(lowerer: Lowerer, arg: IrExpr, want: IrType, what: string): ); } if (arg.type.kind === "union" && want.kind !== "union" && !containsDynOrUnit(want)) { + // A stored union at a slot whose signature names one concrete type: the + // checker approved the call, so the SITE's type is that slot type and the + // stored union is the declaration's wider one — an absence arm the + // compiler itself carries (a checked-dynamic JSON stringify's + // `string | undefined`), a stale capture, a widened inferred return. + // That is coerceToExpected's checked ARM rule, not a refusal to lay the + // union out: the proven arm's payload comes out, every other arm throws + // the existing catchable TypeError (divergence 38 — sound narrowing + // never reaches them). A union with no such arm keeps the refusal. + if (lowerer.armTag(arg.type.unionId, want) >= 0) { + const narrowed = lowerer.coerceToExpected(arg, want); + if (typeEquals(narrowed.type, want)) return narrowed; + } lowerer.pushDiag(unionMismatchDiag(lowerer.fmt(want), lowerer.fmt(arg.type), arg.loc)); throw new PoisonError(); } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 57f9c92fe..0fb5f9d8d 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -782,6 +782,7 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { partialAssertionFields } from "./partial-assertions.js"; +import { emptyArrayLiteralArm } from "./expressions/array-literals.js"; import { lowerTernary } from "./lower-exprs.js"; import { maybePromiseReturnStmt, @@ -14069,6 +14070,10 @@ export class Lowerer { ); } } + // An EMPTY literal carries no element to select an arm by: it + // builds as the union's first array arm (the emptyArrayLiteralArm + // rule the contextual union and the `?? []` default share). + if (!arm) arm = emptyArrayLiteralArm(x, arms) ?? undefined; if (arm) { const built = this.lowerArrayLiteral( x, @@ -14229,9 +14234,49 @@ export class Lowerer { if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); if (this.returnsFreshCollection(node) && expected.kind !== "void") return this.lowerExprExpecting(node, expected); + // A fresh object literal into a record result: the same + // destination-directed construction. An unannotated method inside a + // contextually typed literal is checked against the CONTEXTUAL + // signature's result while its own return type stays the literal's + // inferred (narrower) record — `ToolDefinition` + // methods returning `{ content, details }`. Building at the + // contextual type would then coerce that wider record into the + // declared result and refuse; the result slot is the layout. + if (this.returnsFreshRecord(node, expected)) return this.lowerExprExpecting(node, expected); return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); } + /** A fresh object literal returned into a RECORD result whose ordinary + * construction cannot reach that result: not a thenable, so it is built + * at the result type like a synchronous return. An unannotated method + * inside a contextually typed literal is checked against the CONTEXTUAL + * signature's result while its own return type stays the literal's + * inferred (narrower) record — `ToolDefinition` methods + * returning `{ content, details }`. Building at the contextual type + * would then coerce that wider record into the declared result and + * refuse; the result slot is the layout. A literal that declares `then` + * is awaited instead, and a literal whose contextual build does convert + * keeps the ordinary lowering, so no convertible value changes. */ + private returnsFreshRecord(node: ts.Expression, expected: IrType): boolean { + if (expected.kind !== "record") return false; + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isObjectLiteralExpression(e)) return false; + if (this.checker.getPropertyOfType(this.typeOf(e), "then") !== undefined) return false; + // The record the ordinary path builds: the literal's context, with an + // async return's `T | PromiseLike` stripped to the awaited record + // (object-literals.ts does the same before mapping). + let context = this.checker.getContextualType(e) ?? this.typeOf(e); + if ( + context.isUnionType() && + ts.constituentTypes(context).some((t) => ts.symbolNameOf(t.getSymbol()) === "PromiseLike") + ) + context = this.checker.getAwaitedType(context) ?? context; + const built = this.mapTypeOf(context); + if (!built || built.kind !== "record") return false; + return this.widthLiftPlan(built, expected) === null; + } + /** An array literal or a `new Map(...)`, `new Set(...)` or `new * Array(...)` of the builtins: a fresh collection, never a thenable, so * an async return takes the declared result type as its construction @@ -14406,7 +14451,8 @@ export class Lowerer { if ( this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected) && - !this.returnsFreshCollection(node) + !this.returnsFreshCollection(node) && + !this.returnsFreshRecord(node, expected) ) { // The async return lowers its operand untyped first (a promise // adopts before its payload converts); a maybe-promise union needs diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..54a7a2931 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5798-record-field-dyn-into-union.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..11487dedc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5799-async-return-record-literal-slot.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json new file mode 100644 index 000000000..3ebcb0e28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8530-union-array-filter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json new file mode 100644 index 000000000..1e26cfe54 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/empty-array-union-slot.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..be3905a1b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/lib-slot-union-arm-extraction.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..c3bb5cdf7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nullish-empty-array-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-filter-emission.test.ts b/packages/compiler/test/union-array-filter-emission.test.ts new file mode 100644 index 000000000..0ff81a032 --- /dev/null +++ b/packages/compiler/test/union-array-filter-emission.test.ts @@ -0,0 +1,90 @@ +// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over +// `A | B`, and the checker's result element is the arm the predicate names. +// The runtime value is one concrete arm's array, so the lowering must +// dispatch on that arm (unionIsTag), filter the arm's own array, and convert +// the survivors to the named element through the checked narrowing — never +// hand a union receiver to a static array intrinsic. +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 TextContent { type: 'text'; text: string }", + "interface ImageContent { type: 'image'; data: string }", + "interface ThinkingContent { type: 'thinking'; thinking: string }", + "type MessageContent =", + " | TextContent[]", + " | (ImageContent | TextContent)[]", + " | (ThinkingContent | TextContent)[];", +]; + +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-array-filter-")); + 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 }); + } +} + +function named(module: IrModule, part: string): string { + return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part))); +} + +test("filter on a union of arrays dispatches on the arm", async () => { + const compiled = await compileToIr([ + "function texts(content: MessageContent): string {", + " return content", + " .filter((block): block is TextContent => block.type === 'text')", + " .map((block) => block.text)", + " .join(' ');", + "}", + "const blocks: MessageContent = [{ type: 'text', text: 'a' }];", + "console.log(texts(blocks));", + ]); + expect(compiled.diagnostics).toBe(""); + expect(compiled.ok).toBe(true); + const module = compiled.module!; + // The tag dispatch that reads the runtime arm… + const union = named(module, "%arr.union"); + expect(union).toContain("unionIsTag"); + // …and the checked narrowing each arm's survivors pass through, so a + // predicate that kept an element it does not describe throws. + expect(named(module, "%arr.narrow")).toContain("%narrow.checked"); +}); + +test("filter on a union of arrays keeps the fence without a narrowing", async () => { + // A predicate that names no narrower element answers the union-wide + // element; converting an arm's array to that element is a widening this + // lowering does not build, so the call stays a refusal instead of + // compiling to a different array layout. + const compiled = await compileToIr([ + "function counts(content: MessageContent): number {", + " return content.filter((block): boolean => block.type !== 'image').length;", + "}", + "console.log(counts([]));", + ]); + expect(compiled.ok).toBe(false); + expect(compiled.diagnostics).toContain("SC2020"); +}); diff --git a/packages/compiler/test/union-array-lowering.test.ts b/packages/compiler/test/union-array-lowering.test.ts index 2830c6fdf..09ef0993d 100644 --- a/packages/compiler/test/union-array-lowering.test.ts +++ b/packages/compiler/test/union-array-lowering.test.ts @@ -139,3 +139,25 @@ export {}; if (!result.ok) return; expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0); }); + +test("an empty array literal takes the union's first array arm instead of trapping", async () => { + const result = await lower(` +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + content: TextContent[] | ImageContent[]; +} +const message: Message = { content: [] }; +function lists(): string[] | number[] { + return []; +} +console.log(JSON.stringify(message.content), JSON.stringify(lists())); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + // An empty literal carries no element of its own, so it is built as the + // union's first array arm: no stranded-source trap and no element rebuild. + expect(helpers(result.ir, "%union.strand")).toEqual([]); + expect(helpers(result.ir, "%union.arr")).toEqual([]); +}); diff --git a/tests/corpus/5798-record-field-dyn-into-union.ts b/tests/corpus/5798-record-field-dyn-into-union.ts new file mode 100644 index 000000000..b273a89ce --- /dev/null +++ b/tests/corpus/5798-record-field-dyn-into-union.ts @@ -0,0 +1,85 @@ +// A record field the checker types `any`/`unknown` flowing into a slot whose +// declared type is a UNION: the checked extraction (dynCheck) is the +// conversion, exactly as for a dyn value in a top-level union slot. Before +// this, such a field only lifted into a NON-union destination, so a tool +// result's `details: unknown` could not reach `Details | undefined`, a +// `structuredContent: unknown` could not reach a JSON-value union, and a +// spread's dynamic key could not reach an index-signature member. +interface Details { + limit: number; + flag?: boolean; +} + +interface Wide { + label: string; + details: any; +} +interface Narrow { + label: string; + details: Details | undefined; +} + +function narrow(w: Wide): Narrow { + return w; +} + +type Json = string | number | boolean | null | { [key: string]: Json } | Json[]; +interface Payload { + text: string; + structuredContent: any; +} +interface JsonPayload { + text: string; + structuredContent: Json | undefined; +} + +function widen(p: Payload): JsonPayload { + return p; +} + +// A spread whose source carries the dynamic field: the same field lift. +interface Built { + content: string; + sections: any; +} +interface Message { + role: string; + content: string; + sections: { [key: string]: string | null } | undefined; + timestamp: number; +} +function fromState(flag: boolean): Built { + return { content: flag ? "on" : "off", sections: { preamble: "p" } }; +} +function message(flag: boolean): Message { + return { role: "system", ...fromState(flag), timestamp: 0 }; +} + +// A callback whose result parameter narrows the dynamic field back to a +// record type adapts into the slot's dynamic-details signature. +interface SlotResult { + content: { type: string; text: string }[]; + details: any; +} +interface ToolResult { + content: { type: string; text: string }[]; + details: { limit: number } | undefined; +} +type Renderer = (result: SlotResult, expanded: boolean) => string; +function format(result: ToolResult, expanded: boolean): string { + return `${result.content.length}:${result.details?.limit ?? "none"}:${expanded}`; +} +const render: Renderer = (result, expanded) => format(result, expanded); + +const wide: Wide = { label: "a", details: { limit: 3 } }; +const n = narrow(wide); +console.log(n.label, n.details === undefined ? "none" : n.details.limit); + +const p = widen({ text: "t", structuredContent: { nested: [1, true, null] } }); +console.log(p.text, JSON.stringify(p.structuredContent ?? null)); + +const m = message(true); +console.log(m.role, m.content, m.timestamp, m.sections?.["preamble"] ?? "-"); + +console.log(render({ content: [{ type: "text", text: "abc" }], details: { limit: 7 } }, true)); +console.log(render({ content: [], details: undefined }, false)); diff --git a/tests/corpus/5799-async-return-record-literal-slot.ts b/tests/corpus/5799-async-return-record-literal-slot.ts new file mode 100644 index 000000000..fa5824046 --- /dev/null +++ b/tests/corpus/5799-async-return-record-literal-slot.ts @@ -0,0 +1,76 @@ +// A tool definition whose `execute` is an unannotated async method inside a +// contextually typed object literal. tsc checks the method against the +// contextual signature's result (`AgentToolResult`) but INFERS its +// own return type from the returned literal (`{ content: { type, text }[]; +// details: { loaded } }`). The fresh literal must construct at the declared +// result slot, not at the contextual type it was checked against: the +// contextual type's `content` is the wider content-part union array, which +// no copy narrows back into the inferred element record. +interface TextContent { + type: string; + text: string; + textSignature?: string; +} +interface ImageContent { + type: string; + data: string; + mimeType: string; +} +interface Candidate { + name: string; + score: number; +} +interface Definition { + name: string; + search(query: string): Promise; + execute(id: string, query: string): Promise>; +} +interface Result { + content: (TextContent | ImageContent)[]; + details: D; + structuredContent?: unknown; + isError?: boolean; +} +interface Loaded { + loaded: string[]; +} + +function rank(query: string, names: string[]): Candidate[] { + return names.filter((name) => name.includes(query)).map((name) => ({ name, score: name.length })); +} + +function makeDefinition(names: string[]): Definition { + return { + name: "search", + async search(query: string) { + return { loaded: rank(query, names).map((candidate) => candidate.name) }; + }, + async execute(_id: string, query: string) { + return { + content: [{ type: "text", text: rank(query, names).map((c) => c.name).join(",") }], + details: { loaded: rank(query, names).map((c) => c.name) }, + }; + }, + }; +} + +// The same shape through a record of definitions, each with its own details. +type AnyDefinition = Definition; +function makeNamed(names: string[]): Record<"search", AnyDefinition> { + return { search: makeDefinition(names) }; +} + +const definition = makeDefinition(["find-file", "grep-tree", "read-file"]); +definition + .search("file") + .then((loaded) => { + console.log("search:", loaded.loaded.join(",")); + return definition.execute("call-1", "file"); + }) + .then((result) => { + console.log("execute:", result.content.length, result.content[0]!.type, result.details.loaded.join(",")); + return makeNamed(["read-file"]).search.execute("call-2", "file"); + }) + .then((second) => { + console.log("second:", second.content.length, second.details.loaded.join(",")); + }); diff --git a/tests/corpus/8530-union-array-filter.ts b/tests/corpus/8530-union-array-filter.ts new file mode 100644 index 000000000..0c1f74f93 --- /dev/null +++ b/tests/corpus/8530-union-array-filter.ts @@ -0,0 +1,86 @@ +// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union +// rule): the predicate runs over `A | B`, and the checker's result element +// is the one the predicate names — not any arm's own element. The runtime +// value is one concrete arm's array, so the filter dispatches on that arm, +// keeps the arm's survivors in order, and converts each survivor to the +// named element. +interface TextContent { + type: "text"; + text: string; +} +interface ImageContent { + type: "image"; + data: string; +} +interface ThinkingContent { + type: "thinking"; + thinking: string; +} +interface ToolCall { + type: "tool"; + name: string; +} +type MessageContent = + | TextContent[] + | (ImageContent | TextContent)[] + | (ThinkingContent | ToolCall | TextContent)[]; + +function textOf(content: MessageContent): string { + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +const contents: MessageContent[] = [ + [{ type: "text", text: "a" }, { type: "text", text: "b" }], + [ + { type: "image", data: "AA" }, + { type: "text", text: "c" }, + ], + [ + { type: "thinking", thinking: "t" }, + { type: "tool", name: "n" }, + { type: "text", text: "d" }, + ], +]; +for (const content of contents) console.log("text", textOf(content)); + +// A predicate written as a function EXPRESSION rather than an arrow: the +// same receiver, the same per-arm dispatch. +function expressions(content: MessageContent): string { + return content + .filter(function (block): block is TextContent { + return block.type === "text"; + }) + .map((block) => block.text) + .join(" "); +} +console.log("expr", expressions(contents[1]!), expressions(contents[2]!)); + +// The predicate sees the union-wide element, so an arm-specific test is +// legal; the survivors still convert per arm. +function longTexts(content: MessageContent): string[] { + return content + .filter((block): block is TextContent => block.type === "text" && block.text !== "c") + .map((block) => block.text); +} +console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+")); + +// Node's `filter` reads the length once and visits elements in order; the +// arm dispatch must not change either, and the result is a fresh array. +let seen = 0; +function counted(content: MessageContent): number { + const kept = content.filter((block): block is TextContent => { + seen++; + return block.type === "text"; + }); + return kept.length; +} +console.log("counted", counted(contents[2]!), "seen", seen); +// The empty array is spelled with its arm's type: a bare `[]` literal +// assigned to this three-arm array union is a separate, unrelated gap (the +// literal's `number[]` element has no arm conversion), so it would obscure +// what this program pins. +const empty: MessageContent = [] as TextContent[]; +console.log("empty", textOf(empty), counted(empty), "seen", seen); diff --git a/tests/corpus/empty-array-union-slot.ts b/tests/corpus/empty-array-union-slot.ts new file mode 100644 index 000000000..ee6809498 --- /dev/null +++ b/tests/corpus/empty-array-union-slot.ts @@ -0,0 +1,106 @@ +// An EMPTY array literal in a union of array slots (`content: TextContent[] | +// ImageContent[]`, `string[] | number[]`). tsc types the literal `never[]`, an +// element with no representation of its own: the literal used to lower as +// never's f64-element array (`number[]`) and the slot's retag threw +// "a 'number[]' value is not representable in the target union". An empty +// array is the same value whichever array family stores it, so the literal is +// built as the union's first array arm — the rule a lone array slot states — +// and every shape below reaches Node's answer. +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + role: string; + content: TextContent[] | ImageContent[]; +} + +// A nested record field: the reported shape. +const empty: Message = { role: "user", content: [] }; +console.log(JSON.stringify(empty)); + +// A declaration, its initialization and a later reassignment. +let wide: string[] | number[] = []; +console.log(JSON.stringify(wide)); +wide = [1, 2]; +console.log(JSON.stringify(wide)); +wide = []; +console.log(JSON.stringify(wide)); + +// A declared return, a ternary arm, and an async promise's payload. +function none(): string[] | number[] { + return []; +} +console.log(JSON.stringify(none()), none() !== none()); + +function either(take: boolean): string[] | number[] { + return take ? [] : [1, 2]; +} +console.log(JSON.stringify(either(true)), JSON.stringify(either(false))); + +function bothWays(): string[] | number[] { + return false ? [] : []; +} +console.log(JSON.stringify(bothWays())); + +async function settled(): Promise { + return []; +} +void settled().then((value) => console.log("settled", JSON.stringify(value))); + +// An argument slot, and a default parameter that is itself the slot. +function report(label: string, value: string[] | number[]): void { + console.log(label, JSON.stringify(value)); +} +report("argument", []); +report("argument-filled", ["x"]); + +function withDefault(value: string[] | number[] = []): void { + console.log("default", JSON.stringify(value)); +} +withDefault(); +withDefault([3]); + +// Deeper nesting, a class field, and a discriminated record's field. +interface Nested { + inner: { list: readonly string[] | readonly number[]; tag: string }; +} +const nested: Nested = { inner: { list: [], tag: "t" } }; +console.log(JSON.stringify(nested)); + +class Store { + lists: string[] | number[] = []; +} +const store = new Store(); +console.log("class", JSON.stringify(store.lists), store.lists !== undefined); + +// An optional arm (`| undefined`) keeps the empty literal on its array arm. +const maybe: string[] | number[] | undefined = []; +console.log("maybe", maybe !== undefined, JSON.stringify(maybe)); + +// The empty literal as an element of an array, and through collections whose +// element slot is the union. +const rows: (string[] | number[])[] = [[], [1, 2], []]; +console.log("rows", JSON.stringify(rows)); +const grouped = new Set(); +grouped.add([]); +grouped.add(["s"]); +console.log("set", JSON.stringify([...grouped])); +const byKey = new Map(); +byKey.set("a", []); +byKey.set("b", [7]); +console.log("map", JSON.stringify([...byKey.entries()])); + +// A callback argument, and the assertion form of the same value. +function apply(fn: (value: string[] | number[]) => string): string { + return fn([]); +} +console.log("callback", apply((value) => JSON.stringify(value))); +const asserted = { role: "tool", content: [] } as Message; +console.log("asserted", JSON.stringify(asserted)); + +// Every evaluation builds a fresh empty array, as Node does. +function fresh(): string[] | number[] { + return []; +} +const first = fresh(); +const second = fresh(); +console.log("fresh", first !== second, JSON.stringify(first), JSON.stringify(second)); diff --git a/tests/corpus/lib-slot-union-arm-extraction.ts b/tests/corpus/lib-slot-union-arm-extraction.ts new file mode 100644 index 000000000..9e4960fc3 --- /dev/null +++ b/tests/corpus/lib-slot-union-arm-extraction.ts @@ -0,0 +1,33 @@ +// A checked-dynamic JSON stringify — a record or array carrying an `unknown` +// field — answers `string | undefined` in the IR (the runtime walker can +// return undefined for a dyn root) while the checker types the call `string`. +// Reading a string member of that result is the checked ARM extraction at the +// stdlib slot: the proven arm's payload comes out, and the absent arm would +// throw the catchable TypeError instead of the call being refused. + +interface Tool { + name: string; + parameters: unknown; +} + +function sizes(tools: Tool[], extra: { note: string; value: unknown }): string { + const text = JSON.stringify(tools); + let chars = 0; + chars += text.length; + if (tools.length > 0) chars += JSON.stringify(tools[0]).length; + chars += JSON.stringify(extra).slice(0, 24).length; + chars += JSON.stringify({ tools }).toUpperCase().slice(0, 12).length; + chars += JSON.stringify(tools).indexOf('"name"'); + chars += `raw:${JSON.stringify(extra)}`.length > 0 ? 1 : 0; + return `${chars} ${text.length} ${JSON.stringify({ tools }).slice(0, 12)}`; +} + +console.log(sizes([{ name: "alpha", parameters: { a: 1 } }], { note: "n", value: null })); +console.log(sizes([{ name: "beta", parameters: undefined }], { note: "empty", value: [1, 2] })); +console.log(sizes([], { note: "none", value: { nested: { deep: true } } })); +console.log(sizes([{ name: "gamma", parameters: "s" }], { note: "deep", value: { a: [1, { b: 2 }] } })); + +// The same string result feeding a comparison and a concatenation. +const one = JSON.stringify({ name: "x", parameters: 1 }); +const two = JSON.stringify({ parameters: 1, name: "x" }); +console.log(one === two, one < two, ("p" + one).length); diff --git a/tests/corpus/nullish-empty-array-default.ts b/tests/corpus/nullish-empty-array-default.ts new file mode 100644 index 000000000..49595a1f1 --- /dev/null +++ b/tests/corpus/nullish-empty-array-default.ts @@ -0,0 +1,40 @@ +// `''.match(re) ?? []`: the empty array-literal default of a `??` adopts the +// left's present array arm. tsc gives the literal the uninhabited `never[]` +// element, which the compiler maps through never's f64 value representation +// (`number[]`), so the whole expression used to be `RegExpMatchArray | +// number[]` — a result no single array slot can receive. + +// A union return (`"all" | readonly string[]`) whose array arm is the +// `?? []` result. +function reach(code: unknown): "all" | readonly string[] { + if (typeof code !== "string") return []; + return /\bALL\b/.test(code) ? "all" : (code.match(/\w+/g) ?? []); +} +console.log(JSON.stringify(reach(7))); +console.log(JSON.stringify(reach("ALL"))); +console.log(JSON.stringify(reach("a.b c"))); +console.log(JSON.stringify(reach(""))); + +// The default's element type is the left's: string element reads and a +// map callback over the value compile as strings, and an absent match +// answers the empty array Node does. +const names = "a-b c".match(/[a-z]+/g) ?? []; +console.log(names.map((name) => name.toUpperCase()).join("+")); +const none = "123".match(/[a-z]+/g) ?? []; +console.log(none.length, JSON.stringify(none), names.length); + +// A declared optional array keeps its own type through the same default, +// and the fresh empty array is a value of its own (mutation, identity). +function fill(source: number[] | undefined, with_: number): number[] { + const list = source ?? []; + list.push(with_); + return list; +} +console.log(JSON.stringify(fill(undefined, 1)), JSON.stringify(fill([7, 8], 9))); +const first = fill(undefined, 2); +const second = fill(undefined, 3); +console.log(first !== second, JSON.stringify(first), JSON.stringify(second)); + +// A parenthesized default and one inside a longer expression. +const longest = ("abbbc".match(/b+/g) ?? []).length; +console.log(longest, `${JSON.stringify("xyz".match(/q/g) ?? [])}!`); diff --git a/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } diff --git a/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } diff --git a/tests/harness/corpus-costs/8530-union-array-filter.ts.json b/tests/harness/corpus-costs/8530-union-array-filter.ts.json new file mode 100644 index 000000000..b563858c9 --- /dev/null +++ b/tests/harness/corpus-costs/8530-union-array-filter.ts.json @@ -0,0 +1 @@ +{ "plain": 1900, "san": 2200 } diff --git a/tests/harness/corpus-costs/empty-array-union-slot.ts.json b/tests/harness/corpus-costs/empty-array-union-slot.ts.json new file mode 100644 index 000000000..b7f02e592 --- /dev/null +++ b/tests/harness/corpus-costs/empty-array-union-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3400 } diff --git a/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..9316b6552 --- /dev/null +++ b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1 @@ +{ "plain": 1517, "san": 2343 } diff --git a/tests/harness/corpus-costs/nullish-empty-array-default.ts.json b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..b1c421307 --- /dev/null +++ b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json @@ -0,0 +1 @@ +{ "plain": 922, "san": 1502 }