From e86800de677fa737359c264780775aa9e7a28f02 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:17:01 -0700 Subject: [PATCH 01/13] Spread unions with several record arms An object spread of a union with several record arms was refused. Two cases are now lowered. Where the checker narrowed the source to fewer arms than its stored union holds (an optional-chain discriminant test on an element read, a type predicate), the source converts through the checked narrowing, so an excluded arm throws the catchable TypeError, and the narrowed record spreads as before. A union spread into one declared record copies each arm's own keys behind its tag test: a key the running arm lacks keeps the earlier value or stays absent. One record arm emits the same code as before. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 501 ++++++++++-------- .../tests/corpus/record-union-spreads.ts.json | 6 + tests/corpus/record-union-spreads.ts | 92 ++++ .../corpus-costs/record-union-spreads.ts.json | 1 + 4 files changed, 376 insertions(+), 224 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json create mode 100644 tests/corpus/record-union-spreads.ts create mode 100644 tests/harness/corpus-costs/record-union-spreads.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 9699a4fb9..f6a3a58c9 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1881,13 +1881,17 @@ export function lowerObjectLiteral( let srcNode: ts.Expression = prop.expression; while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression); - const srcLowered = capture( + let srcLowered = capture( condRecord !== null ? lowerConditionalRecordSpread(lowerer, condRecord, locOf(prop)) : prop === expr.properties.nodes[0] && leadingSpreadLowered !== null ? leadingSpreadLowered : lowerer.lowerExpr(srcNode), ); + { + const narrowed = checkedSpreadSource(lowerer, srcNode, srcLowered); + if (narrowed !== null) srcLowered = capture(narrowed); + } // Array callbacks can receive a record-or-undefined ABI even when // TypeScript describes a required record. Select the copy strategy // from that stored representation so absent sources copy nothing. @@ -1909,7 +1913,7 @@ export function lowerObjectLiteral( const recArms = def?.arms.filter((a) => a.kind === "record") ?? []; if ( !def || - recArms.length !== 1 || + recArms.length === 0 || !def.arms.every((a) => a.kind === "record" || isUnitType(a)) ) { lowerer.unsupported( @@ -1918,8 +1922,6 @@ export function lowerObjectLiteral( `object spread of '${lowerer.fmt(srcType)}' sources (only known record shapes spread — ${NARROW_FIRST})`, ); } - const recArm = recArms[0]! as IrType & { kind: "record" }; - const recTag = def.arms.indexOf(recArm); // A checked-dynamic VALUE under the union-mapped checker type // (a JS dyn-holding binding): the present/absent desugar tests // union tags a dyn box does not carry — fence honestly instead @@ -1932,242 +1934,218 @@ export function lowerObjectLiteral( `object spread of a checked-dynamic '${lowerer.fmt(srcType)}' source (${NARROW_FIRST})`, ); } - const srcShape = lowerer.shapes.get(recArm.shapeId); - if (!srcShape) - throw new InternalCompilerError(`lowerer bug: spread of unknown shape ${recArm.shapeId}`); - fenceAccessorSpreadSource(lowerer, prop, srcShape); - if (srcShape.indexValue || shape.indexValue) { - const fallback = dynSpreadLiteral(lowerer, expr, type); - if (fallback) return fallback; - lowerer.unsupported( - "SC1090", - prop, - "object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)", - ); - } - const laterNames = new Set(); - for (const later of expr.properties.nodes.slice(expr.properties.nodes.indexOf(prop) + 1)) { - if (ts.isSpreadAssignment(later)) { - if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) - continue; - const lt = laterSpreadType(lowerer, later.expression); - if (lt?.kind === "record") { - // A field the later source may lack defines nothing. - for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) - if (!canLackField(lf.type)) laterNames.add(lf.name); - } - continue; - } - if ( - later.name && - (ts.isIdentifier(later.name) || - ts.isStringLiteral(later.name) || - ts.isNumericLiteral(later.name) || - (ts.isComputedPropertyName(later.name) && - literalComputedKey(lowerer, later.name) !== null)) - ) { - laterNames.add(propNameText(lowerer, later.name)); - } - } - const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode); - const srcNarrowed = (): IrExpr => ({ - kind: "unionNarrow", - unionId: srcType.unionId, - tag: recTag, - value: srcRef(), - type: recArm, - loc: locOf(prop), - }); - // The source PROVEN present: the checked extraction `x!` lowers to - // (narrowedArmHelper + checkedNarrowCall). A required target field - // with no earlier contributor has no absent representation, so - // `{ ...maybeRecord }` into it must find the record: the unit arm - // throws the catchable TypeError instead of leaving the slot unset - // (divergence 38's lying-assertion stance — the checker typed the - // spread's fields optional, so only a cast or an unchecked indexed - // read reaches this shape). - const srcPresent = (): IrExpr => { - const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop)); - return helper - ? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop)) - : srcNarrowed(); - }; - orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true }); - for (const f of srcShape.fields) { - if (laterNames.has(f.name)) continue; - const targetType = fieldTypes.get(f.name); - // No slot on the target shape: the copy DROPS the field - // (divergence 36's stance, same as the plain-record spread). - if (!targetType) continue; - if (conditionalNames.has(f.name)) { - const conditionalDyn = conditionalFallback(); - if (conditionalDyn) return conditionalDyn; + // Several record arms: each arm contributes its own fields behind its + // tag test, wrapping the earlier contributor, so a key that arm lacks + // keeps the earlier value (JS copies only the arm's own keys). + for (const recArmCandidate of recArms) { + const recArm = recArmCandidate as IrType & { kind: "record" }; + const recTag = def.arms.indexOf(recArm); + const srcShape = lowerer.shapes.get(recArm.shapeId); + if (!srcShape) + throw new InternalCompilerError( + `lowerer bug: spread of unknown shape ${recArm.shapeId}`, + ); + fenceAccessorSpreadSource(lowerer, prop, srcShape); + if (srcShape.indexValue || shape.indexValue) { + const fallback = dynSpreadLiteral(lowerer, expr, type); + if (fallback) return fallback; lowerer.unsupported( "SC1090", prop, - `spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`, + "object spread involving index-signature shapes (overflow keys are runtime state — copy the fields you need explicitly)", ); } - const fRead = (present = false): IrExpr => ({ - kind: "recordGet", - obj: present ? srcPresent() : srcNarrowed(), - shapeId: recArm.shapeId, - field: f.name, - type: f.type, - loc: locOf(prop), - }); - let cond: IrExpr = { - kind: "unionIsTag", + const laterNames = new Set(); + for (const later of expr.properties.nodes.slice( + expr.properties.nodes.indexOf(prop) + 1, + )) { + if (ts.isSpreadAssignment(later)) { + if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression)) + continue; + const lt = laterSpreadType(lowerer, later.expression); + if (lt?.kind === "record") { + // A field the later source may lack defines nothing. + for (const lf of lowerer.shapes.get(lt.shapeId)?.fields ?? []) + if (!canLackField(lf.type)) laterNames.add(lf.name); + } + continue; + } + if ( + later.name && + (ts.isIdentifier(later.name) || + ts.isStringLiteral(later.name) || + ts.isNumericLiteral(later.name) || + (ts.isComputedPropertyName(later.name) && + literalComputedKey(lowerer, later.name) !== null)) + ) { + laterNames.add(propNameText(lowerer, later.name)); + } + } + const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode); + const srcNarrowed = (): IrExpr => ({ + kind: "unionNarrow", unionId: srcType.unionId, tag: recTag, - negated: false, value: srcRef(), - type: BOOL, - loc: locOf(prop), - }; - let thenVal: IrExpr; - // A source field that can be absent copies only when present. - const fieldPresent = (): IrExpr => ({ - kind: "logical", - op: "&&", - left: cond, - right: { - kind: "recordHas", - obj: { - kind: "unionNarrow", - unionId: srcType.unionId, - tag: recTag, - value: srcRef(), - type: recArm, - loc: locOf(prop), - }, - shapeId: recArm.shapeId, - field: f.name, - type: BOOL, - loc: locOf(prop), - }, - type: BOOL, + type: recArm, loc: locOf(prop), }); - const canBeAbsent = - f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; - if (typeEquals(f.type, targetType)) { - thenVal = fRead(); - if (canBeAbsent) cond = fieldPresent(); - } else if ( - f.type.kind === "union" && - lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 && - typeEquals(lowerer.stripUndefinedArm(f.type), targetType) - ) { - // Optional source field into a required target slot: present - // means the source holds the record AND the field its value - // arm — the spread-override completion, union-source form. - const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T); - cond = { + // The source PROVEN present: the checked extraction `x!` lowers to + // (narrowedArmHelper + checkedNarrowCall). A required target field + // with no earlier contributor has no absent representation, so + // `{ ...maybeRecord }` into it must find the record: the unit arm + // throws the catchable TypeError instead of leaving the slot unset + // (divergence 38's lying-assertion stance — the checker typed the + // spread's fields optional, so only a cast or an unchecked indexed + // read reaches this shape). + const srcPresent = (): IrExpr => { + const helper = lowerer.narrowedArmHelper(srcType.unionId, recArm, locOf(prop)); + return helper + ? lowerer.checkedNarrowCall(helper, srcRef(), recArm, locOf(prop)) + : srcNarrowed(); + }; + orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true }); + for (const f of srcShape.fields) { + if (laterNames.has(f.name)) continue; + const targetType = fieldTypes.get(f.name); + // No slot on the target shape: the copy DROPS the field + // (divergence 36's stance, same as the plain-record spread). + if (!targetType) continue; + if (conditionalNames.has(f.name)) { + const conditionalDyn = conditionalFallback(); + if (conditionalDyn) return conditionalDyn; + lowerer.unsupported( + "SC1090", + prop, + `spread of '${f.name}' over an earlier conditional spread (the desugar keeps one entry per name — restructure so each name has one contributor)`, + ); + } + const fRead = (present = false): IrExpr => ({ + kind: "recordGet", + obj: present ? srcPresent() : srcNarrowed(), + shapeId: recArm.shapeId, + field: f.name, + type: f.type, + loc: locOf(prop), + }); + let cond: IrExpr = { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: recTag, + negated: false, + value: srcRef(), + type: BOOL, + loc: locOf(prop), + }; + let thenVal: IrExpr; + // A source field that can be absent copies only when present. + const fieldPresent = (): IrExpr => ({ kind: "logical", op: "&&", left: cond, right: { - kind: "unionIsTag", - unionId: f.type.unionId, - tag: ftUndef, - negated: true, - value: fRead(), + kind: "recordHas", + obj: { + kind: "unionNarrow", + unionId: srcType.unionId, + tag: recTag, + value: srcRef(), + type: recArm, + loc: locOf(prop), + }, + shapeId: recArm.shapeId, + field: f.name, type: BOOL, loc: locOf(prop), }, type: BOOL, loc: locOf(prop), - }; - const ftDef = lowerer.unions.get(f.type.unionId); - if (targetType.kind === "union") { - const retag = lowerer.unionRetagHelper( - f.type.unionId, - targetType.unionId, - locOf(prop), - ); - if (!retag) { + }); + const canBeAbsent = + f.type.kind === "union" && lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0; + if (typeEquals(f.type, targetType)) { + thenVal = fRead(); + if (canBeAbsent) cond = fieldPresent(); + } else if ( + f.type.kind === "union" && + lowerer.armTag(f.type.unionId, UNDEFINED_T) >= 0 && + typeEquals(lowerer.stripUndefinedArm(f.type), targetType) + ) { + // Optional source field into a required target slot: present + // means the source holds the record AND the field its value + // arm — the spread-override completion, union-source form. + const ftUndef = lowerer.armTag(f.type.unionId, UNDEFINED_T); + cond = { + kind: "logical", + op: "&&", + left: cond, + right: { + kind: "unionIsTag", + unionId: f.type.unionId, + tag: ftUndef, + negated: true, + value: fRead(), + type: BOOL, + loc: locOf(prop), + }, + type: BOOL, + loc: locOf(prop), + }; + const ftDef = lowerer.unions.get(f.type.unionId); + if (targetType.kind === "union") { + const retag = lowerer.unionRetagHelper( + f.type.unionId, + targetType.unionId, + locOf(prop), + ); + if (!retag) { + lowerer.pushDiag( + recordShapeMismatchDiag( + lowerer.fmt(type), + lowerer.fmt(recArm), + locOf(prop), + `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`, + ), + ); + throw new PoisonError(); + } + thenVal = { + kind: "call", + callee: retag, + args: [fRead()], + type: targetType, + loc: locOf(prop), + }; + } else if ( + ftDef && + ftDef.arms.length === 2 && + lowerer.armTag(f.type.unionId, targetType) >= 0 + ) { + thenVal = { + kind: "unionNarrow", + unionId: f.type.unionId, + tag: lowerer.armTag(f.type.unionId, targetType), + value: fRead(), + type: targetType, + loc: locOf(prop), + }; + } else { lowerer.pushDiag( recordShapeMismatchDiag( lowerer.fmt(type), lowerer.fmt(recArm), locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot re-tag into '${lowerer.fmt(targetType)}' behind the present-test`, + `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`, ), ); throw new PoisonError(); } - thenVal = { - kind: "call", - callee: retag, - args: [fRead()], - type: targetType, - loc: locOf(prop), - }; - } else if ( - ftDef && - ftDef.arms.length === 2 && - lowerer.armTag(f.type.unionId, targetType) >= 0 - ) { - thenVal = { - kind: "unionNarrow", - unionId: f.type.unionId, - tag: lowerer.armTag(f.type.unionId, targetType), - value: fRead(), - type: targetType, - loc: locOf(prop), - }; } else { - lowerer.pushDiag( - recordShapeMismatchDiag( - lowerer.fmt(type), - lowerer.fmt(recArm), - locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' cannot narrow into '${lowerer.fmt(targetType)}' behind the present-test`, - ), - ); - throw new PoisonError(); - } - } else { - // The width-lift fallback (arm wrap, re-tag, nested - // reshape) — the same per-field rule the slot coercion - // applies. Runs AFTER the optional-completion branch: a - // present-test has its own semantics a re-tag's stranded - // undefined trap must not shadow. - const lift = lowerer.widthLiftPlan(f.type, targetType); - if (!lift) { - lowerer.pushDiag( - recordShapeMismatchDiag( - lowerer.fmt(type), - lowerer.fmt(recArm), - locOf(prop), - `spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`, - ), - ); - throw new PoisonError(); - } - thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop)); - if (canBeAbsent) cond = fieldPresent(); - } - const at = fields.findIndex((x) => x.name === f.name); - const elseVal = - at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); - if (!elseVal) { - if (canBeAbsent) { - // The FIELD itself can be missing from a present source: - // no extraction makes it exist. - lowerer.unsupported( - "SC1090", - prop, - `object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`, - ); - } - // No earlier contributor and the target slot cannot be absent: - // the source must be here. Copy through the checked extraction - // (the absent unit arm throws) — the same read the width lift - // below wraps, so a lift still applies. - let strict = fRead(true); - if (!typeEquals(f.type, targetType)) { + // The width-lift fallback (arm wrap, re-tag, nested + // reshape) — the same per-field rule the slot coercion + // applies. Runs AFTER the optional-completion branch: a + // present-test has its own semantics a re-tag's stranded + // undefined trap must not shadow. const lift = lowerer.widthLiftPlan(f.type, targetType); if (!lift) { lowerer.pushDiag( @@ -2180,21 +2158,56 @@ export function lowerObjectLiteral( ); throw new PoisonError(); } - strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop)); + thenVal = lowerer.applyWidthLift(lift, fRead(), targetType, locOf(prop)); + if (canBeAbsent) cond = fieldPresent(); } - fields.push({ name: f.name, value: strict }); - continue; + const at = fields.findIndex((x) => x.name === f.name); + const elseVal = + at >= 0 ? fields[at]!.value : lowerer.absentFieldValue(targetType, locOf(prop)); + if (!elseVal) { + if (canBeAbsent) { + // The FIELD itself can be missing from a present source: + // no extraction makes it exist. + lowerer.unsupported( + "SC1090", + prop, + `object spread of '${lowerer.fmt(srcType)}' sources where '${f.name}' has no earlier contributor (the absent arm leaves the required field unset — spread defaults first: { ...defaults, ...overrides })`, + ); + } + // No earlier contributor and the target slot cannot be absent: + // the source must be here. Copy through the checked extraction + // (the absent unit arm throws) — the same read the width lift + // below wraps, so a lift still applies. + let strict = fRead(true); + if (!typeEquals(f.type, targetType)) { + const lift = lowerer.widthLiftPlan(f.type, targetType); + if (!lift) { + lowerer.pushDiag( + recordShapeMismatchDiag( + lowerer.fmt(type), + lowerer.fmt(recArm), + locOf(prop), + `spread field '${f.name}': '${lowerer.fmt(f.type)}' does not lift into '${lowerer.fmt(targetType)}'`, + ), + ); + throw new PoisonError(); + } + strict = lowerer.applyWidthLift(lift, strict, targetType, locOf(prop)); + } + fields.push({ name: f.name, value: strict }); + continue; + } + const merged: IrExpr = { + kind: "ternary", + cond, + then: thenVal, + else_: elseVal, + type: targetType, + loc: locOf(prop), + }; + if (at >= 0) fields[at] = { name: f.name, value: merged }; + else fields.push({ name: f.name, value: merged }); } - const merged: IrExpr = { - kind: "ternary", - cond, - then: thenVal, - else_: elseVal, - type: targetType, - loc: locOf(prop), - }; - if (at >= 0) fields[at] = { name: f.name, value: merged }; - else fields.push({ name: f.name, value: merged }); } continue; } @@ -2620,6 +2633,32 @@ export function lowerObjectLiteral( : { kind: "seqExpr", stmts: spreadStmts, result, type, loc }; } +/** A spread source whose stored union holds several record arms while the + * checker narrowed this site to fewer (`if (b?.type === "tool") ({ ...b })` + * on an element read, or a type predicate's arm): extract the narrowed + * value through the CHECKED narrowing, so every arm the checker excludes, + * including a hidden absence arm, throws the catchable TypeError instead of + * being copied. Null when the stored union has at most one record arm (the + * present-test desugar owns that case), when the site type is not a + * narrowing of the stored union, or in JavaScript files. */ +function checkedSpreadSource(lowerer: Lowerer, node: ts.Expression, stored: IrExpr): IrExpr | null { + if (stored.type.kind !== "union") return null; + if (isJsSourceFile(ts.getSourceFileOfNode(node))) return null; + const def = lowerer.unions.get(stored.type.unionId); + if (!def || def.arms.filter((arm) => arm.kind === "record").length < 2) return null; + const site = lowerer.mapTypeOf(lowerer.typeOf(node)); + if (!site || typeEquals(site, stored.type)) return null; + const loc = stored.loc; + if (site.kind === "record") { + if (lowerer.armTag(stored.type.unionId, site) < 0) return null; + const helper = lowerer.narrowedArmHelper(stored.type.unionId, site, loc); + return helper ? lowerer.checkedNarrowCall(helper, stored, site, loc) : null; + } + if (site.kind !== "union") return null; + const helper = lowerer.narrowedRetagHelper(node, stored.type.unionId, site.unionId, loc); + return helper ? { kind: "call", callee: helper, args: [stored], type: site, loc } : null; +} + /** The field type is an array, or a union with exactly one array arm. */ function singleArrayArm(lowerer: Lowerer, type: IrType): boolean { if (type.kind === "array") return true; @@ -2753,6 +2792,20 @@ function lowerUnionRecordClone(lowerer: Lowerer, expr: ts.ObjectLiteralExpressio }; } } + // Any other source (a capture, a runtime-optional binding, a member + // read) converts through the same checked re-tag: the arms the site's + // checker type excludes, a hidden absence arm included, throw the + // catchable TypeError instead of being cloned. + if (!typeEquals(source.type, sourceType) && source.type.kind === "union") { + const helper = lowerer.narrowedRetagHelper( + spread.expression, + source.type.unionId, + sourceType.unionId, + source.loc, + ); + if (helper) + source = { kind: "call", callee: helper, args: [source], type: sourceType, loc: source.loc }; + } if (!typeEquals(source.type, sourceType)) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json new file mode 100644 index 000000000..d468d35ae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-union-spreads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-union-spreads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-union-spreads.ts b/tests/corpus/record-union-spreads.ts new file mode 100644 index 000000000..f3698826e --- /dev/null +++ b/tests/corpus/record-union-spreads.ts @@ -0,0 +1,92 @@ +// Object spreads of unions with several record arms. +// 1. The checker narrowed the source to fewer arms than its stored union +// holds (an optional-chain discriminant test on an element read, a type +// predicate): the spread copies the narrowed arm. +// 2. A union source spreads into one declared record: each arm copies only +// the keys it holds, so a key the running arm lacks keeps the earlier +// value or stays absent. +// Key order is printed sorted: per-arm creation order is a separate +// (pre-existing) key-order limitation. + +type Block = + | { type: "text"; text: string; textSignature?: string } + | { type: "tool"; id: string; args: unknown; ns?: string } + | { type: "image"; data: string }; + +function keys(value: object): string { + return Object.keys(value).sort().join(","); +} + +function update(content: Block[], index: number, args: unknown): string { + const block = content[index]; + if (block?.type === "tool") { + content[index] = { ...block, args }; + return "updated"; + } + return "skipped"; +} + +function isTool(block: Block): block is Block & { type: "tool" } { + return block.type === "tool"; +} + +function relabel(content: readonly Block[]): Block[] { + return content.map((block) => (isTool(block) ? { ...block, args: "relabelled" } : block)); +} + +const blocks: Block[] = [ + { type: "tool", id: "1", args: 0 }, + { type: "text", text: "t" }, + { type: "tool", id: "2", args: 0, ns: "n" }, + { type: "image", data: "d" }, +]; +console.log(update(blocks, 0, 5), update(blocks, 1, 5), update(blocks, 2, [1]), update(blocks, 9, 5)); +for (const block of blocks) { + if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), block.ns, keys(block)); + else console.log(block.type, keys(block)); +} +for (const block of relabel(blocks)) { + if (block.type === "tool") console.log(block.id, JSON.stringify(block.args), keys(block)); +} + +interface RequestOptions { + apiKey?: string; + headers?: Record; + env?: string; + maxTokens?: number; + label?: string; +} + +function describe(options: RequestOptions): string { + return [ + options.apiKey, + options.headers?.["x"], + options.env, + options.maxTokens, + options.label, + keys(options), + "apiKey" in options, + ].join(" "); +} + +// The `cond ? { a, b } : {}` credential idiom. +function route(sameProvider: boolean, apiKey: string | undefined, env: string | undefined): string { + const auth = sameProvider ? { apiKey, env } : {}; + return describe({ label: "base", ...auth, maxTokens: 5 }); +} +console.log(route(true, "k", "e")); +console.log(route(true, undefined, "e")); +console.log(route(false, "k", "e")); + +type Source = { env?: string; apiKey: string } | { label: string; headers?: Record } | undefined; + +function merge(base: RequestOptions, source: Source): string { + return describe({ ...base, ...source, maxTokens: 1 }); +} +console.log(merge({ headers: { x: "h0" } }, { apiKey: "k", env: "e" })); +console.log(merge({ headers: { x: "h0" }, apiKey: "k0" }, { apiKey: "k" })); +console.log(merge({ headers: { x: "h0" } }, { label: "l" })); +console.log(merge({ headers: { x: "h0" } }, { label: "l", headers: { x: "h1" } })); +console.log(merge({ env: "e0" }, undefined)); + +export {}; diff --git a/tests/harness/corpus-costs/record-union-spreads.ts.json b/tests/harness/corpus-costs/record-union-spreads.ts.json new file mode 100644 index 000000000..d2e79152f --- /dev/null +++ b/tests/harness/corpus-costs/record-union-spreads.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 7052 } From 5a6f2c0902a564368f1aa032c9a677d0b63d4ebe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:17:05 -0700 Subject: [PATCH 02/13] Destructure `options ?? {}` over records with required members `const { a, ...rest } = options ?? {}` destructures the options record or an empty object. Where the record had a required member, the `{}` fallback was coerced into the record and failed with SC2002. The source now lowers to the union of the record and the empty record, so the union pattern lowering reads each key from the arm that holds it, the empty arm answers undefined and the rest element packs nothing. A record arm without a literal key now answers undefined in unionKeyGet too, which the frontend already asked for and the validator rejected as an internal error. All-optional options keep their lowering. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/expr-dynamic.ts | 13 ++ .../src/frontend/lowering/lower-stmts.ts | 2 + .../lowering/nullish-empty-pattern.ts | 136 ++++++++++++++++++ packages/compiler/src/ir/validate.test.ts | 51 +++++++ packages/compiler/src/ir/validate.ts | 8 ++ .../record-nullish-empty-destructure.ts.json | 6 + .../record-nullish-empty-destructure.ts | 83 +++++++++++ .../record-nullish-empty-destructure.ts.json | 1 + 8 files changed, 300 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json create mode 100644 tests/corpus/record-nullish-empty-destructure.ts create mode 100644 tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7f4b7e48a..0fb9ea4ff 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -547,6 +547,19 @@ export function emitDynamicExpr( B.br(join); return; } + // A declared-only arm without the literal key: the object has no + // such own property, so the read answers undefined. + if (literal !== null && !shape.indexValue) { + const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1; + if (tag < 0 || e.type.kind !== "union") { + throw new InternalCompilerError( + "llvm emitter bug: unionKeyGet missing key without an undefined result arm", + ); + } + B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`); + B.br(join); + return; + } // Index-signature arm (or declared-only shape under a runtime // key): the shared keyed-read chain — a literal key naming no // declared field touches only the overflow map. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..0e20791fd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -2485,6 +2486,7 @@ export function lowerDestructuringDecl( } let init = importMetaBindingSource(lowerer, decl.name, decl.initializer) ?? + nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ?? lowerer.lowerExpr(decl.initializer); const parseArgsDynObject = init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts new file mode 100644 index 000000000..66c1f00e7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts @@ -0,0 +1,136 @@ +import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +function stripParens(node: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; +} + +/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options + * record has a member that cannot be absent. The pattern destructures + * either the record or an empty object, which has no own keys, so the + * source lowers to a union of the record and the empty record: the union + * pattern lowering reads each key from the arm that holds it and answers + * undefined from the empty arm, and a rest element packs the record's + * remaining fields or nothing. The options operand is evaluated once. + * + * Null (the caller keeps its current lowering) for JavaScript files, other + * initializer forms, an operand the checker does not type as one record + * plus nullish arms, and a record whose every field may be absent: the + * existing lowering already builds the empty object as that record with + * every key absent. */ +export function nullishEmptyPatternSource( + lowerer: Lowerer, + pattern: ts.BindingName, + initializer: ts.Expression, +): IrExpr | null { + if (!ts.isObjectBindingPattern(pattern)) return null; + if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null; + const init = stripParens(initializer); + if ( + !ts.isBinaryExpression(init) || + (init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + init.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return null; + const right = stripParens(init.right); + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null; + const loc = locOf(init); + // Decide from the checker's view of the operand before lowering it once. + const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left)); + if (declared?.kind !== "union") return null; + const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? []; + const declaredRecords = declaredArms.filter((arm) => arm.kind === "record"); + if ( + declaredRecords.length !== 1 || + !declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return null; + const declaredShape = lowerer.shapes.get( + (declaredRecords[0] as IrType & { kind: "record" }).shapeId, + ); + if ( + !declaredShape || + declaredShape.tuple || + declaredShape.indexValue || + declaredShape.fields.length === 0 || + declaredShape.fields.every( + (field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent", + ) + ) + return null; + const left = lowerer.lowerExpr(init.left); + // The stored operand can differ from the checker's view. A stored record + // is never nullish (the fallback is dead); any other stored form must be + // the same one record plus nullish arms. + if (left.type.kind === "record") return left; + if (left.type.kind !== "union") { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const sourceId = left.type.unionId; + const arms = lowerer.unions.get(sourceId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const record = records[0] as IrType & { kind: "record" }; + const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) }; + const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) }; + const sourceTag = arms.indexOf(record); + const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type); + const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: sourceId, + tag: sourceTag, + negated: false, + value: ref, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(record), + value: { + kind: "unionNarrow", + unionId: sourceId, + tag: sourceTag, + value: ref, + type: record, + loc, + }, + type: resultType, + loc, + }, + else_: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(empty), + value: { kind: "recordLit", fields: [], type: empty, loc }, + type: resultType, + loc, + }, + type: resultType, + loc, + }, + type: resultType, + loc, + }; +} diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 83e0f9a31..727d350af 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", ( 'in main: assign to catch binding "%error" (frontend must reject)', ]); }); + +test("a record arm without the literal key answers undefined in unionKeyGet", () => { + const uX: IrType = { kind: "union", unionId: "uX" }; + const uS: IrType = { kind: "union", unionId: "uS" }; + const rA: IrType = { kind: "record", shapeId: "rA" }; + const module = (answer: IrType): IrModule => ({ + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { id: "rA", fields: [{ name: "x", type: uX }] }, + { id: "rE", fields: [] }, + ], + unions: [ + { id: "uX", arms: [STRING, UNDEFINED_T] }, + { id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] }, + ], + functions: [ + { + name: "main", + params: [{ localId: "p", name: "p", type: uS }], + locals: [{ id: "p", name: "p", type: uS, mutable: false }], + returnType: answer, + body: [ + { + kind: "return", + value: { + kind: "unionKeyGet", + unionId: "uS", + key: { kind: "strLit", value: "x", type: STRING, loc }, + value: { kind: "varRef", localId: "p", type: uS, loc }, + type: answer, + loc, + }, + loc, + }, + ], + loc, + }, + ], + }); + // The empty arm lacks "x": it has no such own property, so it answers + // the result's undefined arm. + expect(validateModule(module(uX))).toEqual([]); + // Without an undefined arm in the result there is no answer to give. + expect( + validateModule(module(STRING)).some((d) => + d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"), + ), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..4c563d729 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3869,6 +3869,14 @@ function validateFunction( } return; } + // A declared-only arm that lacks a literal key has no such own + // property: it answers undefined, like a unit arm. + if ( + literal !== null && + !shape.indexValue && + resultUnion?.arms.some((a) => a.kind === "undefinedT") + ) + return; if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) { err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..e7da7aa73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-nullish-empty-destructure.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-nullish-empty-destructure.ts b/tests/corpus/record-nullish-empty-destructure.ts new file mode 100644 index 000000000..aff75af76 --- /dev/null +++ b/tests/corpus/record-nullish-empty-destructure.ts @@ -0,0 +1,83 @@ +// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern +// destructures either the options record or an empty object. Each key reads +// undefined from the empty object, a default applies to it, and the rest +// element packs the record's remaining own keys or nothing. The options +// expression is evaluated once. + +interface EditorOptions { + description: string; + paddingX: number; + label?: string; +} + +function editor(options?: EditorOptions): string { + const { description, ...editorOptions } = options ?? {}; + return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`; +} +console.log(editor()); +console.log(editor({ description: "d", paddingX: 2 })); +console.log(editor({ description: "d", paddingX: 2, label: "l" })); + +// Every member optional: the empty object is the same layout with every +// key absent. +interface StreamOptions { + apiKey?: string; + env?: string; + signal?: string; + transformHeaders?: (headers: string) => string; +} + +function stream(options?: StreamOptions): string { + const { transformHeaders, apiKey, ...rest } = options ?? {}; + const headers = transformHeaders ? transformHeaders("h") : "none"; + return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`; +} +console.log(stream()); +console.log(stream({ apiKey: "k", signal: "s" })); +console.log(stream({ transformHeaders: (h) => h + "!", env: "e" })); + +// A nested optional record, renamed keys and a default. +interface Entry { + options?: { deferred: boolean; signal?: string; step: number }; +} + +function submit(entry: Entry): string { + const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {}; + return `${step} ${JSON.stringify(submission)}`; +} +console.log(submit({})); +console.log(submit({ options: { deferred: true, step: 4 } })); +console.log(submit({ options: { deferred: false, signal: "x", step: 0 } })); + +// `||` and a null arm. +interface Retry { + headers: string; + retries: number; +} + +function retry(value: Retry | null): string { + const { headers, retries = 3 } = value || {}; + return `${headers} ${retries} ${typeof retries}`; +} +console.log(retry(null)); +console.log(retry({ headers: "h", retries: 2 })); + +// A generic options parameter. +function generic(options?: TOptions): string { + const { apiKey, ...providerOptions } = options ?? {}; + return `${apiKey} ${JSON.stringify(providerOptions)}`; +} +console.log(generic()); +console.log(generic({ maxTokens: 3, apiKey: "k" })); + +// The options expression runs once. +let calls = 0; +function current(): EditorOptions | undefined { + calls++; + return calls === 1 ? undefined : { description: "second", paddingX: 1 }; +} +const { description: first } = current() ?? {}; +const { description: second, paddingX } = current() ?? {}; +console.log(first, second, paddingX, calls); + +export {}; diff --git a/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..c56636be7 --- /dev/null +++ b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json @@ -0,0 +1 @@ +{ "plain": 6298, "san": 72106 } From 980a1af68233381a37b4fcff9b6dc218512f9b3a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:48 -0700 Subject: [PATCH 03/13] Build nested literals at the enclosing literal's own layout An object literal whose context has no layout builds at its own type, but its nested literals still chose their own contextual layout, so the nested record could not convert into the field the outer literal had built, and SC2002 named the source literal's type as the expected one. A nested literal now builds at the layout the enclosing literal asked for when its contextual layout cannot reach it, and the outer coercion lifts the whole tree into the real destination. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 16 ++- .../async-return-contextual-records.ts.json | 6 + .../corpus/nested-literal-own-layout.ts.json | 6 + .../corpus/async-return-contextual-records.ts | 108 ++++++++++++++++++ tests/corpus/nested-literal-own-layout.ts | 20 ++++ .../async-return-contextual-records.ts.json | 1 + .../nested-literal-own-layout.ts.json | 1 + 7 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json create mode 100644 tests/corpus/async-return-contextual-records.ts create mode 100644 tests/corpus/nested-literal-own-layout.ts create mode 100644 tests/harness/corpus-costs/async-return-contextual-records.ts.json create mode 100644 tests/harness/corpus-costs/nested-literal-own-layout.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index f6a3a58c9..ca40a9cf0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1302,7 +1302,21 @@ export function lowerObjectLiteral( lowerer.unsupported("SC1090", deferredConditionalSpread, CONDITIONAL_SPREAD_FENCE); } if (!mapped || mapped.kind !== "record") lowerer.badType(expr, tsType); - let type: IrType = mapped; + // An enclosing literal that built at its OWN type (its context had no + // layout) asks for the matching own layout of this nested literal. When + // this literal's contextual layout cannot reach that slot, build at it: + // the enclosing literal's coercion then decides, and the width lifts + // (the index-signature overflow capture included) carry the whole tree + // into the real destination. Otherwise the two layers disagree and the + // SC2002 names the source literal's own type as the expected one. + const layout: IrType & { kind: "record" } = + expected && + !typeEquals(mapped, expected) && + lowerer.widthLiftPlan(mapped, expected) === null && + typeEquals(lowerer.mapTypeOf(lowerer.typeOf(expr)) ?? mapped, expected) + ? expected + : mapped; + let type: IrType = layout; // A fresh, plain literal can construct its fields directly for a known // destination with the same field set. This matters for inferred // conditional records: an empty array in one arm has no element layout diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json new file mode 100644 index 000000000..64c1702a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/async-return-contextual-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/async-return-contextual-records.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..5899185f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nested-literal-own-layout.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nested-literal-own-layout.ts" + ], + "diags": [] +} diff --git a/tests/corpus/async-return-contextual-records.ts b/tests/corpus/async-return-contextual-records.ts new file mode 100644 index 000000000..f19af35dc --- /dev/null +++ b/tests/corpus/async-return-contextual-records.ts @@ -0,0 +1,108 @@ +// Object literals returned from async arrows typed by a method signature +// (`resolve(input): Promise`), with destructured +// parameters. The return position's contextual type is +// `AuthResult | undefined | PromiseLike`; the +// literal must build as AuthResult so its nested header record reaches the +// pure index record ProviderHeaders (null-valued keys included), exactly as +// in a sync function. +type ProviderHeaders = Record; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + env?: Record; + source?: string; +} +interface Ctx { + env(name: string): Promise; +} +interface Credential { + type: "api_key"; + key?: string; +} +interface Auth { + name: string; + resolve(input: { ctx: Ctx; credential?: Credential; signal: AbortSignal }): Promise; + peek?(input: { ctx: Ctx }): AuthResult | undefined; +} + +function tokenAuth(): Auth { + return { + name: "token", + resolve: async ({ ctx, credential, signal }) => { + signal.throwIfAborted(); + if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored" }; + const token = await ctx.env("TOKEN"); + if (token) return { auth: { headers: { Authorization: `Bearer ${token}` } }, source: "TOKEN" }; + return undefined; + }, + }; +} + +function gatewayAuth(): Auth { + return { + name: "gateway", + resolve: async ({ ctx }) => { + const key = await ctx.env("KEY"); + if (!key) return undefined; + return { + auth: { + headers: { + "cf-aig-authorization": `Bearer ${key}`, + Authorization: null, + "x-api-key": null, + }, + }, + env: { ACCOUNT: "a1" }, + source: "KEY", + }; + }, + peek: ({ ctx }) => (ctx ? { auth: { headers: { "x-peek": "1" } } } : undefined), + }; +} + +function boundAuth(): Auth { + return { + name: "bound", + resolve: async ({ ctx }) => { + const token = await ctx.env("TOKEN"); + const headers = { Authorization: `Bearer ${token ?? "none"}` }; + return { auth: { headers }, source: "bound" }; + }, + }; +} + +async function plain(flag: boolean): Promise<{ id: string; tags?: Record } | undefined> { + return flag ? { id: "p", tags: { a: "1", b: null } } : undefined; +} + +function show(result: AuthResult | undefined): string { + if (result === undefined) return "undefined"; + const headers = result.auth.headers ?? {}; + const keys = Object.keys(headers).sort(); + return `${result.source ?? "-"} key=${result.auth.apiKey ?? "-"} ${keys.map((k) => `${k}=${String(headers[k])}`).join(" ")} env=${JSON.stringify(result.env ?? null)}`; +} + +async function main(): Promise { + const env = new Map([ + ["KEY", "k1"], + ["TOKEN", "t1"], + ]); + const ctx: Ctx = { env: async (name) => env.get(name) }; + const signal = new AbortController().signal; + const token = tokenAuth(); + console.log(show(await token.resolve({ ctx, credential: { type: "api_key", key: "s1" }, signal }))); + console.log(show(await token.resolve({ ctx, signal }))); + console.log(show(await gatewayAuth().resolve({ ctx, signal }))); + console.log(show(gatewayAuth().peek?.({ ctx }))); + console.log(show(await boundAuth().resolve({ ctx, signal }))); + env.clear(); + console.log(show(await token.resolve({ ctx, signal }))); + console.log(show(await gatewayAuth().resolve({ ctx, signal }))); + console.log(show(await boundAuth().resolve({ ctx, signal }))); + const p = await plain(true); + console.log(p?.id, JSON.stringify(p?.tags), (await plain(false)) === undefined); +} +main(); diff --git a/tests/corpus/nested-literal-own-layout.ts b/tests/corpus/nested-literal-own-layout.ts new file mode 100644 index 000000000..7f313363d --- /dev/null +++ b/tests/corpus/nested-literal-own-layout.ts @@ -0,0 +1,20 @@ +// The outer literal's context (a union with a WeakRef arm) has no layout, +// so it builds at its own type; its nested literals must follow that +// layout instead of their own contextual one, and the outer coercion then +// lifts the whole tree (headers into the index-signature record). +type ProviderHeaders = Record; +interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; +} +interface AuthResult { + auth: ModelAuth; + source?: string; +} +type Answer = AuthResult | WeakRef | undefined; +function make(flag: boolean): Answer { + return flag ? { auth: { headers: { Authorization: "Bearer x" } }, source: "env" } : undefined; +} +const r = make(true) as AuthResult; +console.log(r.source, r.auth.headers?.Authorization); +console.log(make(false) === undefined); diff --git a/tests/harness/corpus-costs/async-return-contextual-records.ts.json b/tests/harness/corpus-costs/async-return-contextual-records.ts.json new file mode 100644 index 000000000..d1cbee83a --- /dev/null +++ b/tests/harness/corpus-costs/async-return-contextual-records.ts.json @@ -0,0 +1 @@ +{ "plain": 9301, "san": 135316 } diff --git a/tests/harness/corpus-costs/nested-literal-own-layout.ts.json b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json new file mode 100644 index 000000000..0c791d2ac --- /dev/null +++ b/tests/harness/corpus-costs/nested-literal-own-layout.ts.json @@ -0,0 +1 @@ +{ "plain": 5031, "san": 84920 } From b0bbae15ac2afcfeb56b328f531bd83c86ef880a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:53 -0700 Subject: [PATCH 04/13] Spread an empty-object default like its source `...(x ?? {})` and `...(x || {})` spread exactly what `...x` spreads, but the defaulted source lowered as a `??` expression into a checked-dynamic value, which failed for records with function fields. When x is a record or an optional record, the spread now lowers as a spread of x. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 47 ++++++++++++++++--- .../tests/corpus/spread-empty-default.ts.json | 6 +++ tests/corpus/spread-empty-default.ts | 45 ++++++++++++++++++ .../corpus-costs/spread-empty-default.ts.json | 1 + 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json create mode 100644 tests/corpus/spread-empty-default.ts create mode 100644 tests/harness/corpus-costs/spread-empty-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index ca40a9cf0..580c3ff95 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -549,8 +549,7 @@ function fenceAccessorSpreadSource( /** Only definitely present later records replace earlier defaults. A * callback binding may store undefined despite its checker annotation. */ function laterSpreadType(lowerer: Lowerer, expr: ts.Expression): IrType | null { - let source = expr; - while (ts.isParenthesizedExpression(source)) source = source.expression; + const source = spreadSourceNode(lowerer, expr); if (ts.isIdentifier(source)) return lowerer.lowerExpr(source).type; return lowerer.mapTypeOf(lowerer.typeOf(source)); } @@ -1892,8 +1891,7 @@ export function lowerObjectLiteral( // earlier ones (JS last-write-wins). Evaluate the source exactly // once, including empty sources and those whose fields are all // overridden. Field snapshots above preserve the copy order. - let srcNode: ts.Expression = prop.expression; - while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; + const srcNode = spreadSourceNode(lowerer, prop.expression); const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression); let srcLowered = capture( condRecord !== null @@ -3022,14 +3020,51 @@ function lowerConditionalRecordSpread( }; } +/** A spread's source expression with parentheses stripped, seeing through + * an empty-object default: `...(x ?? {})` and `...(x || {})` spread exactly + * what `...x` spreads (a nullish or other falsy value is a primitive with + * no own keys, and x evaluates once either way). Only where `x` is a plain + * record or a record-or-unit union, which the field copy and the + * optional-spread desugar handle; index-signature and other sources keep + * their paths. */ +function spreadSourceNode(lowerer: Lowerer, expr: ts.Expression): ts.Expression { + let e: ts.Expression = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if ( + !ts.isBinaryExpression(e) || + (e.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + e.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return e; + let right: ts.Expression = e.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return e; + let left: ts.Expression = e.left; + while (ts.isParenthesizedExpression(left)) left = left.expression; + const plainRecord = (t: IrType | undefined): boolean => + t?.kind === "record" && lowerer.shapes.get(t.shapeId)?.indexValue === undefined; + const source = lowerer.mapTypeOf(lowerer.typeOf(left)); + if (plainRecord(source ?? undefined)) return left; + if (source?.kind === "union") { + const arms = lowerer.unions.get(source.unionId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if ( + records.length === 1 && + plainRecord(records[0]) && + arms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return left; + } + return e; +} + /** A spread source the record desugar cannot enumerate: an untyped * (`unknown`) value, or one whose shape carries an index signature * (`Record`, `{ [key: string]: T }`) so its keys are * runtime state. Node copies own enumerable keys in insertion order, which * the checked-dynamic object reproduces. */ function dynSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean { - let e: ts.Expression = node; - while (ts.isParenthesizedExpression(e)) e = e.expression; + const e = spreadSourceNode(lowerer, node); if ((lowerer.typeOf(e).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true; const indexed = (t: IrType | null): boolean => { if (t?.kind === "record") return lowerer.shapes.get(t.shapeId)?.indexValue !== undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json new file mode 100644 index 000000000..9d75cf30c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/spread-empty-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/spread-empty-default.ts b/tests/corpus/spread-empty-default.ts new file mode 100644 index 000000000..4905b9619 --- /dev/null +++ b/tests/corpus/spread-empty-default.ts @@ -0,0 +1,45 @@ +// `...(options ?? {})` and `...(options || {})` spread exactly what +// `...options` spreads: nullish and other falsy values contribute no keys. +// The optional source is an Omit<> view of an options interface whose +// fields include a function, so a checked-dynamic copy cannot hold it. +interface LoaderOptions { + cwd: string; + agentDir: string; + extraPaths?: string[]; + onLoad?: (paths: string[]) => string[]; +} +interface ServiceOptions { + cwd: string; + loaderOptions?: Omit; +} +class Loader { + readonly options: LoaderOptions; + constructor(options: LoaderOptions) { + this.options = options; + } + describe(): string { + const paths = this.options.onLoad?.(this.options.extraPaths ?? []) ?? []; + return `${this.options.cwd} ${this.options.agentDir} [${paths.join(",")}]`; + } +} +function viaNullish(options: ServiceOptions): Loader { + return new Loader({ ...(options.loaderOptions ?? {}), cwd: options.cwd, agentDir: "/agent" }); +} +function viaOr(options: ServiceOptions): Loader { + return new Loader({ cwd: options.cwd, agentDir: "/or", ...(options.loaderOptions || {}) }); +} +let evaluated = 0; +function counted(options: ServiceOptions): ServiceOptions["loaderOptions"] { + evaluated++; + return options.loaderOptions; +} +function viaCall(options: ServiceOptions): Loader { + return new Loader({ ...(counted(options) ?? {}), cwd: options.cwd, agentDir: "/call" }); +} +const bare: ServiceOptions = { cwd: "/w" }; +const full: ServiceOptions = { cwd: "/w", loaderOptions: { extraPaths: ["x"], onLoad: (p) => [...p, "y"] } }; +console.log(viaNullish(bare).describe()); +console.log(viaNullish(full).describe()); +console.log(viaOr(bare).describe()); +console.log(viaOr(full).describe()); +console.log(viaCall(bare).describe(), viaCall(full).describe(), evaluated); diff --git a/tests/harness/corpus-costs/spread-empty-default.ts.json b/tests/harness/corpus-costs/spread-empty-default.ts.json new file mode 100644 index 000000000..da02be042 --- /dev/null +++ b/tests/harness/corpus-costs/spread-empty-default.ts.json @@ -0,0 +1 @@ +{ "plain": 4589, "san": 7483 } From 312d7be2807dce1ba8352dd9301414dc948e78af Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:58 -0700 Subject: [PATCH 05/13] See Pick and Omit views of classes through type aliases A Pick or Omit view of a program class is held as a live class protocol, and generic methods called through it monomorphize against the class. Both checks read the view from the type's alias symbol, but a view named by a type alias (type View = Pick) carries the user's alias instead, so it mapped to a record of erased method fields that no instance converts to. classViewOf reads the alias declaration and sees the view through non-generic aliases, alias chains included. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/class-views.ts | 51 ++++++++++++ .../src/frontend/lowering/lower-calls.ts | 14 ++-- .../corpus/pick-alias-generic-methods.ts.json | 6 ++ .../pick-alias-view-modules/main.ts.json | 8 ++ tests/corpus/pick-alias-generic-methods.ts | 79 +++++++++++++++++++ tests/corpus/pick-alias-view-modules/ctx.ts | 2 + .../corpus/pick-alias-view-modules/globals.ts | 15 ++++ tests/corpus/pick-alias-view-modules/main.ts | 3 + .../pick-alias-view-modules/registry.ts | 21 +++++ .../pick-alias-generic-methods.ts.json | 1 + .../pick-alias-view-modules/main.ts.json | 1 + 11 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/class-views.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json create mode 100644 tests/corpus/pick-alias-generic-methods.ts create mode 100644 tests/corpus/pick-alias-view-modules/ctx.ts create mode 100644 tests/corpus/pick-alias-view-modules/globals.ts create mode 100644 tests/corpus/pick-alias-view-modules/main.ts create mode 100644 tests/corpus/pick-alias-view-modules/registry.ts create mode 100644 tests/harness/corpus-costs/pick-alias-generic-methods.ts.json create mode 100644 tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/class-views.ts b/packages/compiler/src/frontend/lowering/class-views.ts new file mode 100644 index 000000000..bacde86d3 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/class-views.ts @@ -0,0 +1,51 @@ +import * as ts from "../ts7/adapter.js"; + +/** A `Pick` / `Omit` view: the lib alias it instantiates and + * its source type argument `C`. */ +export interface ClassView { + name: "Pick" | "Omit"; + source: ts.Type; +} + +/** The lib's Pick or Omit alias (declared only in declaration files). */ +function libViewName(symbol: ts.Symbol): "Pick" | "Omit" | null { + const name = ts.symbolName(symbol); + if (name !== "Pick" && name !== "Omit") return null; + return symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile) + ? name + : null; +} + +/** `type` as a Pick/Omit view, spelled inline (`Pick`) or through + * non-generic user aliases (`type View = Pick`, `type V2 = View`). + * An aliased instantiation carries the USER's alias symbol and no alias + * type arguments, so the alias declaration's type node names the view. + * Generic user aliases (`type V = Pick`) are not resolved. */ +export function classViewOf(checker: ts.TypeChecker, type: ts.Type, depth = 0): ClassView | null { + const alias = type.getAliasSymbol(); + if (alias === undefined) return null; + const direct = libViewName(alias); + if (direct !== null) { + const source = type.getAliasTypeArguments()[0]; + return source === undefined ? null : { name: direct, source }; + } + if (depth >= 8 || type.getAliasTypeArguments().length > 0) return null; + const decl = alias.declarations.find(ts.isTypeAliasDeclaration); + if (decl === undefined || (decl.typeParameters?.nodes.length ?? 0) > 0) return null; + let node: ts.TypeNode = decl.type; + while (ts.isParenthesizedTypeNode(node)) node = node.type; + if (!ts.isTypeReferenceNode(node)) return null; + let target = checker.getSymbolAtLocation(node.typeName); + if (target !== undefined && (target.flags & ts.SymbolFlags.Alias) !== 0) + target = checker.getAliasedSymbol(target); + if (target === undefined) return null; + const viewName = libViewName(target); + if (viewName !== null) { + const argument = node.typeArguments?.nodes[0]; + return argument === undefined + ? null + : { name: viewName, source: checker.getTypeFromTypeNode(argument) }; + } + if ((target.flags & ts.SymbolFlags.TypeAlias) === 0) return null; + return classViewOf(checker, checker.getDeclaredTypeOfSymbol(target), depth + 1); +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index fcdce59f0..32de3731e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4,6 +4,7 @@ import { lowerArrayClassStaticCall, } from "./builtins/array-classes.js"; import { structuralCallInstance } from "./structural-instances.js"; +import { classViewOf } from "./class-views.js"; import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js"; import { assertFunctionAliasOf } from "./lower-assert.js"; import { keyedHelperName } from "./stable-names.js"; @@ -11365,7 +11366,8 @@ function maybePromiseHandlerResult( return stmt; } -/** `view.m(args)` where `view` is a `Pick` / `Omit` view of a +/** `view.m(args)` where `view` is a `Pick` / `Omit` view (inline + * or through type aliases, classViewOf) of a * program class C and `m` is one of C's GENERIC methods (its overloads * included): generic methods have no dispatchable method value, so the * view's live receiver is extracted as the C instance it holds (a checked @@ -11378,11 +11380,9 @@ function pickViewGenericMethodCall( access: ts.PropertyAccessExpression, recvTs: ts.Type, ): IrExpr | null { - const alias = recvTs.getAliasSymbol(); - if (alias === undefined || (ts.symbolName(alias) !== "Pick" && ts.symbolName(alias) !== "Omit")) - return null; - if (!alias.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)) return null; - const source = recvTs.getAliasTypeArguments()[0]; + const view = classViewOf(lowerer.checker, recvTs); + if (view === null) return null; + const source = view.source; const symbol = source?.getSymbol(); const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined; if (!info || call.questionDotToken || access.questionDotToken) return null; @@ -11402,7 +11402,7 @@ function pickViewGenericMethodCall( lowerer.unsupported( "SC1090", access, - `calling the generic method '${access.name.text}' through a '${ts.symbolName(alias)}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`, + `calling the generic method '${access.name.text}' through a '${view.name}' view of '${info.def.name}' that holds a '${lowerer.fmt(lowered.type)}'`, ); } const receiver: IrExpr = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..8927822a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-generic-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/pick-alias-generic-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..1fd543dbe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/pick-alias-view-modules/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/pick-alias-view-modules/registry.ts", + "/tests/corpus/pick-alias-view-modules/globals.ts", + "/tests/corpus/pick-alias-view-modules/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/pick-alias-generic-methods.ts b/tests/corpus/pick-alias-generic-methods.ts new file mode 100644 index 000000000..a15d25579 --- /dev/null +++ b/tests/corpus/pick-alias-generic-methods.ts @@ -0,0 +1,79 @@ +// Generic class methods called through a Pick<>/Omit<> view of the class +// that is NAMED by a type alias (`type View = Pick`), also +// through an alias of that alias. Inline Pick<> views already compile; the +// alias hides the Pick from the view's type, which then mapped to a +// record of erased method fields that a Registry instance cannot convert to. +interface ChatModel { + kind: "chat"; + id: string; +} +interface ImageModel { + kind: "image"; + id: string; + size: number; +} +interface ModelMap { + chat: ChatModel; + image: ImageModel; +} +type ModelKind = keyof ModelMap; + +class Registry { + private readonly all: (ChatModel | ImageModel)[] = [ + { kind: "chat", id: "c1" }, + { kind: "image", id: "i1", size: 512 }, + { kind: "chat", id: "c2" }, + ]; + modelsOfKind(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(kind: K, id: string): ModelMap[K] | undefined { + for (const m of this.modelsOfKind(kind)) if (m.id === id) return m; + return undefined; + } + async availableOfKind(kind: K, options?: { tag?: string }): Promise { + return this.modelsOfKind(kind).filter(() => options?.tag !== "none"); + } + label(): string { + return "registry"; + } +} + +type RegistryView = Pick; +type ReadOnlyView = RegistryView; +type NoAsyncView = Omit; + +function toKind(value: unknown): ModelKind { + return value === "image" ? "image" : "chat"; +} + +function describe(models: RegistryView, args: unknown[]): string { + const [kind, id] = args; + const list = models.modelsOfKind(toKind(kind)).map((m) => m.id); + const one = models.modelOfKind(toKind(kind), String(id)); + return `${models.label()} ${list.join(",")} ${one === undefined ? "none" : one.id}`; +} + +function globals(models: ReadOnlyView): Record unknown> { + return { + list: (args) => models.modelsOfKind(toKind(args[0]), args[1] === undefined ? undefined : String(args[1])).length, + size: (args) => models.modelOfKind("image", String(args[0]))?.size ?? -1, + }; +} + +function sizes(models: NoAsyncView): string { + return models.modelsOfKind("image").map((m) => `${m.id}:${m.size}`).join(","); +} + +async function main(): Promise { + const registry = new Registry(); + console.log(describe(registry, ["chat", "c2"])); + console.log(describe(registry, ["image", "zz"])); + const g = globals(registry); + console.log(g.list!(["chat"]), g.list!(["chat", "none"]), g.size!(["i1"]), g.size!(["c1"])); + console.log(sizes(registry)); + const view: RegistryView = registry; + const available = await view.availableOfKind("image", { tag: "x" }); + console.log(available.map((m) => `${m.id}:${m.size}`).join(","), (await view.availableOfKind("chat", { tag: "none" })).length); +} +main(); diff --git a/tests/corpus/pick-alias-view-modules/ctx.ts b/tests/corpus/pick-alias-view-modules/ctx.ts new file mode 100644 index 000000000..57bff4a07 --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/ctx.ts @@ -0,0 +1,2 @@ +import type { Registry } from "./registry.ts"; +export interface Ctx { registry: Registry; label: string } diff --git a/tests/corpus/pick-alias-view-modules/globals.ts b/tests/corpus/pick-alias-view-modules/globals.ts new file mode 100644 index 000000000..7d0f7f07c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/globals.ts @@ -0,0 +1,15 @@ +import type { Ctx } from "./ctx.ts"; +import type { ModelKind, RegistryView } from "./registry.ts"; +function toKind(value: unknown): ModelKind { + return value === "image" ? "image" : "chat"; +} +function globals(models: RegistryView): (args: unknown[]) => string { + return (args) => { + const [kind] = args; + return models.modelsOfKind(toKind(kind)).map((m) => m.id).join(","); + }; +} +export function run(ctx: Ctx | undefined): string { + const list = ctx ? globals(ctx.registry) : undefined; + return list ? `${ctx?.label} ${list(["chat"])} ${list(["image"])}` : "none"; +} diff --git a/tests/corpus/pick-alias-view-modules/main.ts b/tests/corpus/pick-alias-view-modules/main.ts new file mode 100644 index 000000000..86c15c49c --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/main.ts @@ -0,0 +1,3 @@ +import { Registry } from "./registry.ts"; +import { run } from "./globals.ts"; +console.log(run({ registry: new Registry(), label: "L" }), run(undefined)); diff --git a/tests/corpus/pick-alias-view-modules/registry.ts b/tests/corpus/pick-alias-view-modules/registry.ts new file mode 100644 index 000000000..5cdf4bd4b --- /dev/null +++ b/tests/corpus/pick-alias-view-modules/registry.ts @@ -0,0 +1,21 @@ +export interface ChatModel { kind: "chat"; id: string } +export interface ImageModel { kind: "image"; id: string; size: number } +export interface ModelMap { chat: ChatModel; image: ImageModel } +export type ModelKind = keyof ModelMap; +export class Registry { + private readonly all: (ChatModel | ImageModel)[] = [ + { kind: "chat", id: "c1" }, + { kind: "image", id: "i1", size: 512 }, + ]; + modelsOfKind(kind: K, provider?: string): readonly ModelMap[K][] { + return this.all.filter((m) => m.kind === kind && provider !== "none") as ModelMap[K][]; + } + modelOfKind(kind: K, id: string): ModelMap[K] | undefined { + for (const m of this.modelsOfKind(kind)) if (m.id === id) return m; + return undefined; + } + label(): string { + return "registry"; + } +} +export type RegistryView = Pick; diff --git a/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json new file mode 100644 index 000000000..3d92741c6 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-generic-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 7191, "san": 12536 } diff --git a/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json new file mode 100644 index 000000000..296b09a53 --- /dev/null +++ b/tests/harness/corpus-costs/pick-alias-view-modules/main.ts.json @@ -0,0 +1 @@ +{ "plain": 5283, "san": 8250 } From acb0a4c9ed4c3250d6ca8d15ce41e679be1271d3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:30:15 -0700 Subject: [PATCH 06/13] Adapt method fields across instantiations with checked conversions A record width copy such as Provider<"beta"> into Provider failed on method fields whose parameter record depends on the instantiation: TypeScript admits them by method bivariance, but the adapter only took mechanical parameter conversions. Inside a record's field plan, such a field now takes a checked adapter: parameters convert with the record width plans, and union arms without a home throw the catchable TypeError. Top-level function conversions and every pair that planned before are unchanged. The safe direction lifts too: record-width's method field whose parameter record widens now plans, so its SC2002 refusal gives way to the later honest SC1090 (a function value has no inspect lowering). Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/function-adapters.ts | 13 ++- .../src/frontend/lowering/coercions/state.ts | 7 ++ .../lowering/coercions/structural-plans.ts | 57 +++++++++++- .../frontend/lowering/coercions/structural.ts | 43 +++++++-- .../src/frontend/lowering/width-lift.ts | 7 ++ ...ethod-field-instantiation-adapters.test.ts | 87 +++++++++++++++++++ ...ethod-field-instantiation-adapters.ts.json | 6 ++ .../method-field-instantiation-adapters.ts | 51 +++++++++++ tests/diagnostics/record-width.ts | 6 +- .../harness/__snapshots__/record-width.ts.txt | 9 -- ...ethod-field-instantiation-adapters.ts.json | 1 + 11 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 packages/compiler/test/method-field-instantiation-adapters.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json create mode 100644 tests/corpus/method-field-instantiation-adapters.ts create mode 100644 tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index 78c273b66..03c7f4259 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -126,11 +126,20 @@ export function cleanFuncAdaptable( 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; + // Checked mode (a method field's adapter, WidthLift funcAdaptChecked): + // parameters and results may also take the record width conversions, + // checked retags included. + const checked = lowerer.coercions.checkedRetags > 0; for (let i = 0; i < src.params.length; i++) { - if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false; + if ( + !lowerer.coercibleValue(dst.params[i]!, src.params[i]!) && + !(checked && lowerer.widthLiftPlan(dst.params[i]!, src.params[i]!) !== null) + ) + return false; } if (dst.ret.kind === "void") return true; if (lowerer.coercibleValue(src.ret, dst.ret)) return true; + if (checked && lowerer.widthLiftPlan(src.ret, dst.ret) !== null) return true; return src.ret.kind === "void" && dst.ret.kind === "dyn"; } @@ -390,7 +399,7 @@ export function funcCoerceAdapter( strandRet = false; // A record result the slot's result is a structural width of. if (strandRet && lowerer.widthLiftPlan(fromT.ret, toT.ret)) strandRet = false; - const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`; + const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}${lowerer.coercions.checkedRetags > 0 ? ":checked" : ""}`; const existing = lowerer.coercions.retags.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%fn.adapt", key); diff --git a/packages/compiler/src/frontend/lowering/coercions/state.ts b/packages/compiler/src/frontend/lowering/coercions/state.ts index 9fa815081..1760df174 100644 --- a/packages/compiler/src/frontend/lowering/coercions/state.ts +++ b/packages/compiler/src/frontend/lowering/coercions/state.ts @@ -9,6 +9,13 @@ export class CoercionState { // plan checks its other fields. Remove every assumption on unwind; // generated helpers must intern their names before building their bodies. readonly planning = new Set(); + /** > 0 while a record width plan plans or builds its fields: method + * fields may take checked function adapters (WidthLift funcAdaptChecked). */ + methodFieldDepth = 0; + /** > 0 while a checked function adapter plans or builds: contravariant + * parameters may width-lift, and union fields may retag with trapping + * arms (WidthLift checkedRetag). */ + checkedRetags = 0; // Registry revisions invalidate only fully validated copy-only routes. readonly copyRetags = new Map(); // Provenance for checked extraction; consumers must not infer it from names. diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a1d75331..721f027c2 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -1,4 +1,4 @@ -import { planRecordUnionLift, planRecordUnionWrap } from "../union-retag.js"; +import { planRecordUnionLift, planRecordUnionWrap, planUnionRetag } from "../union-retag.js"; import type { WidthLift } from "../width-lift.js"; import type { IrRecordShape, IrType } from "../../../ir/ir.js"; import { canDynCheckTo, isUnitType, typeEquals, UNDEFINED_T } from "../../../ir/ir.js"; @@ -57,6 +57,33 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0 ) return { how: "unitRetag" }; + // Inside a checked method adapter (contravariant positions): source + // arms with no destination home trap instead of refusing the pair. + if (lowerer.coercions.checkedRetags > 0) { + const from = lowerer.unions.get(src.unionId); + const to = lowerer.unions.get(dst.unionId); + if (from && to) { + const trappable: number[] = []; + from.arms.forEach((arm, tag) => { + if (isUnitType(arm) || lowerer.armTag(dst.unionId, arm) >= 0) return; + if (to.arms.some((d) => !isUnitType(d) && lowerer.widthLiftPlan(arm, d) !== null)) + return; + trappable.push(tag); + }); + if ( + trappable.length > 0 && + trappable.length < from.arms.length && + planUnionRetag( + from, + to, + (id) => lowerer.shapes.get(id), + (s, d) => lowerer.widthLiftPlan(s, d), + new Set(trappable), + ) !== null + ) + return { how: "checkedRetag", trappable }; + } + } return null; } // A unit-typed source can't wrap here (unionWrap requires the @@ -71,7 +98,7 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width return { how: "discriminantWrap" }; return { how: "wrap", tag }; } - const candidates: { tag: number; arm: IrType }[] = []; + const candidates: { tag: number; arm: IrType; checked: boolean }[] = []; def.arms.forEach((arm, i) => { if (isUnitType(arm)) return; const sameFamily = @@ -87,9 +114,15 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width arm.kind === "array") || (src.kind === "object" && arm.kind === "record") || (src.kind === "record" && arm.kind === "object"); - if (sameFamily && lowerer.widthLiftPlan(src, arm) !== null) candidates.push({ tag: i, arm }); + const armLift = sameFamily ? lowerer.widthLiftPlan(src, arm) : null; + if (armLift !== null) + candidates.push({ tag: i, arm, checked: armLift.how === "funcAdaptChecked" }); }); let selected = candidates; + // A clean function candidate always wins over a checked one, so every + // selection that resolves today resolves the same way. + if (selected.some((c) => c.checked) && selected.some((c) => !c.checked)) + selected = selected.filter((c) => !c.checked); if (selected.length > 1 && src.kind === "record") { const source = lowerer.shapes.get(src.shapeId); // An inferred JS union may widen its tag while retaining variants @@ -216,6 +249,22 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width if (dst.kind === "func" && src.kind === "func" && lowerer.cleanFuncAdaptable(src, dst)) { return { how: "funcAdapt" }; } + // A method FIELD of a record width copy whose parameters convert only + // through a checked narrowing (method bivariance across instantiations): + // a checked adapter. Top-level function conversions never see it. + if ( + dst.kind === "func" && + src.kind === "func" && + lowerer.coercions.methodFieldDepth > 0 && + lowerer.coercions.checkedRetags === 0 + ) { + lowerer.coercions.checkedRetags++; + try { + if (lowerer.cleanFuncAdaptable(src, dst)) return { how: "funcAdaptChecked" }; + } finally { + lowerer.coercions.checkedRetags--; + } + } if (dst.kind === "record" && src.kind === "record") { // An INDEX-SIGNATURE destination takes the overflow CAPTURE reshape: // declared fields copy off the source, every other source field keys @@ -370,6 +419,7 @@ export function recordWidthPlan( // fields answers "assume coercible" — see CoercionState.planning. if (lowerer.coercions.planning.has(key)) return new Map(); lowerer.coercions.planning.add(key); + lowerer.coercions.methodFieldDepth++; try { const plan = new Map(); for (const tf of to.fields) { @@ -405,6 +455,7 @@ export function recordWidthPlan( } return plan; } finally { + lowerer.coercions.methodFieldDepth--; lowerer.coercions.planning.delete(key); } } diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index a1d3283b3..7cdcdd8aa 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -131,6 +131,34 @@ export function applyWidthLift( throw new InternalCompilerError("lowerer bug: planned retag lift failed to intern"); return { kind: "call", callee: retag, args: [value], type: dst, loc }; } + case "checkedRetag": { + if (dst.kind !== "union" || value.type.kind !== "union") + throw new InternalCompilerError("lowerer bug: checked retag lift shape"); + const retag = lowerer.unionRetagHelper( + value.type.unionId, + dst.unionId, + loc, + new Set(lift.trappable), + ); + if (!retag) + throw new InternalCompilerError("lowerer bug: planned checked retag lift failed to intern"); + return { kind: "call", callee: retag, args: [value], type: dst, loc }; + } + case "funcAdaptChecked": { + if (dst.kind !== "func" || value.type.kind !== "func") + throw new InternalCompilerError("lowerer bug: checked funcAdapt lift shape"); + lowerer.coercions.checkedRetags++; + try { + const adapter = lowerer.funcCoerceAdapter(value.type, dst, loc); + if (!adapter) + throw new InternalCompilerError( + "lowerer bug: planned checked funcAdapt lift failed to intern", + ); + return { kind: "call", callee: adapter, args: [value], type: dst, loc }; + } finally { + lowerer.coercions.checkedRetags--; + } + } case "unitRetag": { if (dst.kind !== "union" || value.type.kind !== "union") throw new InternalCompilerError("lowerer bug: unit retag lift shape"); @@ -527,11 +555,16 @@ export function recordWidthHelper( lowerer.coercionOriginNode = null; lowerer.coercionFieldOrigin = null; try { - lowerer.liftedFns.push( - buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) => - declared === null ? null : fieldOrigin(lowerer.checker, declared, field), - ), - ); + lowerer.coercions.methodFieldDepth++; + try { + lowerer.liftedFns.push( + buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) => + declared === null ? null : fieldOrigin(lowerer.checker, declared, field), + ), + ); + } finally { + lowerer.coercions.methodFieldDepth--; + } } finally { lowerer.coercionOriginNode = outerNode; lowerer.coercionFieldOrigin = outerField; diff --git a/packages/compiler/src/frontend/lowering/width-lift.ts b/packages/compiler/src/frontend/lowering/width-lift.ts index 59209dc50..1e2c75c7e 100644 --- a/packages/compiler/src/frontend/lowering/width-lift.ts +++ b/packages/compiler/src/frontend/lowering/width-lift.ts @@ -42,6 +42,13 @@ export type WidthLift = | { how: "dynOut" } | { how: "upcast" } | { how: "funcAdapt" } + /** A method field whose parameter records differ per instantiation + * (`Provider<"x">` into `Provider`, admitted by method bivariance): + * funcCoerceAdapter built in checked mode. Record-field planning only. */ + | { how: "funcAdaptChecked" } + /** A union into a union where the `trappable` source arms have no home: + * they throw the catchable TypeError. Checked mode only. */ + | { how: "checkedRetag"; trappable: number[] } /** A promise whose payload widens (`Promise` into `Promise`): * the checked payload widening coerceToExpected applies at a promise slot, * which keeps the promise's identity and settlement turn. */ diff --git a/packages/compiler/test/method-field-instantiation-adapters.test.ts b/packages/compiler/test/method-field-instantiation-adapters.test.ts new file mode 100644 index 000000000..189901675 --- /dev/null +++ b/packages/compiler/test/method-field-instantiation-adapters.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +// A Provider<"beta"> held as Provider receives an alpha model through +// method bivariance: Model<"beta">'s compat holds only undefined, so the +// alpha model's compat object cannot convert. Native throws the checked +// conversion's TypeError (types don't lie); Node prints `beta cancel a1 lie` +// and `passed through` instead, because its method call never checks the +// argument's shape. That divergence is the documented checked-conversion +// policy, so this case is a native package test rather than a differential +// corpus program. +const program = `// A Provider<"beta"> held as Provider receives an alpha model through +// method bivariance: Model<"beta">'s compat holds only undefined, so the +// alpha model's compat object cannot convert. Native throws the checked +// conversion's TypeError (types don't lie); Node passes the lie through. +type Api = "alpha" | "beta"; +interface AlphaCompat { strict?: boolean } +interface Model { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + cancel?(model: Model, handle: string): Promise; +} +function betaProvider(): Provider<"beta"> { + return { + id: "b", + cancel: async (model, handle) => { + console.log("beta cancel", model.id, handle); + }, + }; +} +async function main(): Promise { + const providers: Provider[] = [betaProvider()]; + const beta = providers[0]!; + await beta.cancel?.({ id: "b1", api: "beta" }, "ok"); + try { + await beta.cancel?.({ id: "a1", api: "alpha", compat: { strict: true } }, "lie"); + console.log("passed through"); + } catch (error) { + console.log(error instanceof TypeError, (error as Error).message); + } +} +main(); +`; + +test.each(["llvm"] as const)( + "checked method-field adapters throw on an instantiation mismatch (%s)", + async (backend) => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-method-field-adapters-")); + try { + const entry = join(dir, "main.ts"); + writeFileSync(entry, program); + const result = await compile(entry, { + backend, + sanitize, + outDir: dir, + outPath: join(dir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const child = spawnSync(result.binaryPath, { encoding: "utf8" }); + const stderr = + sanitize && process.platform === "linux" + ? child.stderr.replace( + /^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext functions and may produce false positives in some cases!\n/gm, + "", + ) + : child.stderr; + expect(stderr).toBe(""); + expect(child.status).toBe(0); + expect(child.stdout).toBe( + "beta cancel b1 ok\ntrue a '{ strict: boolean | undefined }' value is not representable in the target union (a value narrowed or asserted past it still held it)\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..095817660 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/method-field-instantiation-adapters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/method-field-instantiation-adapters.ts" + ], + "diags": [] +} diff --git a/tests/corpus/method-field-instantiation-adapters.ts b/tests/corpus/method-field-instantiation-adapters.ts new file mode 100644 index 000000000..5fda08000 --- /dev/null +++ b/tests/corpus/method-field-instantiation-adapters.ts @@ -0,0 +1,51 @@ +// Provider<"alpha"> and Provider<"beta"> into Provider: method fields +// whose parameter is the instantiation-dependent Model (a +// conditional compat member that is `never` for beta) convert through a +// checked adapter. TypeScript admits the pair by method bivariance; every +// call here passes each provider its own models, so nothing traps. +type Api = "alpha" | "beta"; +interface AlphaCompat { + strict?: boolean; +} +interface Model { + id: string; + api: TApi; + compat?: TApi extends "alpha" ? AlphaCompat : never; +} +interface Provider { + readonly id: string; + models(): Model[]; + cancel?(model: Model, handle: string): Promise; +} +function alphaProvider(): Provider<"alpha"> { + return { + id: "a", + models: () => [{ id: "a1", api: "alpha", compat: { strict: true } }], + cancel: async (model, handle) => { + console.log("cancel", model.id, handle, String(model.compat?.strict)); + }, + }; +} +function betaProvider(): Provider<"beta"> { + return { + id: "b", + models: () => [{ id: "b1", api: "beta" }], + cancel: async (model, handle) => { + console.log("beta cancel", model.id, handle, model.compat === undefined); + }, + }; +} +function plainProvider(): Provider<"beta"> { + return { id: "p", models: () => [] }; +} +function all(): Provider[] { + return [alphaProvider(), betaProvider(), plainProvider()]; +} +async function main(): Promise { + for (const p of all()) { + const [m] = p.models(); + console.log(p.id, m?.id, m?.api, p.cancel === undefined); + if (m) await p.cancel?.(m, "h"); + } +} +main(); diff --git a/tests/diagnostics/record-width.ts b/tests/diagnostics/record-width.ts index c02b4e294..f42decd88 100644 --- a/tests/diagnostics/record-width.ts +++ b/tests/diagnostics/record-width.ts @@ -12,9 +12,9 @@ const loose: Loose = { a: 1 }; const picked: PickedA = loose; console.log(picked); -// A method field whose parameter shapes relate only BIVARIANTLY: neither -// parameter type converts into the other mechanically, so the field does -// not lift. +// A method field whose parameter shapes relate only BIVARIANTLY: the field +// lifts as a checked adapter, so only the console.log below refuses (a +// function value has no inspect lowering). type SrcM = { m(x: { a: number }): void; extra: number }; type DstM = { m(x: { a: number; b: number }): void }; const srcM: SrcM = { 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/method-field-instantiation-adapters.ts.json b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json new file mode 100644 index 000000000..ae4c04177 --- /dev/null +++ b/tests/harness/corpus-costs/method-field-instantiation-adapters.ts.json @@ -0,0 +1 @@ +{ "plain": 6706, "san": 112967 } From 53f5efadb591fda49f3674f8da1db3ae34357e5f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:15:21 -0700 Subject: [PATCH 07/13] Copy a record's kept source keys through the index-signature capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record built from the checked-dynamic tree keeps the own keys its shape does not declare, and every JS copy carries them. `%rec.capture` (the closed-to-index-signature width reshape) rebuilt the target from the source's DECLARED fields only, so those keys vanished with no diagnostic. The shape that made this visible is an inline, unannotated async arrow in a generic call: interface T { transformHeaders?(h: H): H | Promise } async function f(o: O | undefined) { … } f({ transformHeaders: async (h) => ({ ...h, x: "1" }) }) No contextual type reaches the arrow through the type parameter, so its return slot is contextualized by tsc's own inference, which drops the spread's index signature: the closure is typed Promise<{ x }> and its body lowers through the checked-dynamic spread into that closed record — the keys survive in the record's kept source view. The call site reads the member through the declared H and adapts the function value, and that ovf capture dropped `accept`. Node prints "accept,x". When the source kept such a view, rebuild the target from that live view instead of the static field list: the same validated keyed conversion the checked record builder performs, which copies exactly the keys Node copies. Everything else keeps the existing field-by-field copy. A program whose functions never convert a checked-dynamic value into a closed record cannot keep such a view anywhere, so the capture asks the program too: one lazy scan over every lowered function settles whether a kept source is possible at all, and only then does the helper finalize its body with the branch. The scan looks through union arms, promise values, and array elements; a union reached while it is already being visited contributes nothing until the outer union settles, and an answer that depended on such a cycle is not memoized, because another entry into the cycle may reach the arm it could not see. Programs without such a conversion keep the previous helper byte for byte and link no checked-dynamic conversion code. tests/corpus/record-source-keys-index-capture.ts pins the drop both ways: the inline block-bodied async arrow (assigned through the await and read into a local first) and the async method form print the caller keys on the base compiler and match Node byte for byte with this change, while the non-generic, expression-bodied, nested-field and sync-arrow forms are controls that already matched on both. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/indexed-objects.ts | 45 +++++- .../compiler/src/frontend/lowering/lowerer.ts | 22 +++ .../src/frontend/lowering/record-sources.ts | 71 +++++++++- .../record-source-keys-index-capture.ts.json | 6 + .../record-source-keys-index-capture.ts | 130 ++++++++++++++++++ .../record-source-keys-index-capture.ts.json | 1 + 6 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json create mode 100644 tests/corpus/record-source-keys-index-capture.ts create mode 100644 tests/harness/corpus-costs/record-source-keys-index-capture.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 92a724b6b..655812dc3 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -21,12 +21,14 @@ import { type SrcLoc, UNDEFINED_T, arrayOf, + canDynCheckTo, isRefCounted, isSupportedIndexValue, isUnitType, typeEquals, } from "../../../ir/ir.js"; import { tryLowerExpression } from "../expressions/try-lower-expression.js"; +import { recordMayKeepSource, recordSourceTest } from "../record-sources.js"; import { locOf } from "../../program.js"; import { typeKey } from "../../type-mapper.js"; import { type WidthLift } from "../lowerer.js"; @@ -1213,8 +1215,47 @@ export function lowerRecordOvfCaptureHelper( ), ); } + // A CLOSED source can still hold own keys its shape does not declare: a + // record built from the checked-dynamic tree keeps them beside it (the + // record-sources.ts view), and every JS copy — spread, Object.assign, + // JSON — carries them. The field-by-field rebuild above cannot see + // them, so when the source kept such a view, rebuild the target from + // that live view instead: the validated keyed conversion the checked + // record builder performs, which copies exactly the keys Node copies. + // The declared-field copies before this branch are pure writes into a + // fresh local, so the redone work is only wasted slot stores on the + // rare path where the view exists. + // The conversion is only honest when the checked-dynamic tree can check it, + // and the view can only exist if the program converts something into a + // closed record at all. Both facts are settled before the shape helpers + // finalize their bodies, so the tail is chosen there. + const sourcedFromView = + fIv === null && + recordMayKeepSource(lowerer, fromT) && + canDynCheckTo( + toT, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ); body.push({ kind: "return", value: outRef, loc }); const suffix = body.slice(1 + fieldStmts.size); + const keptSourceTail = (): IrStmt[] => { + const fromView: IrExpr = { + kind: "dynCheck", + value: { kind: "dynFrom", value: sRef, type: DYN, loc }, + type: toT, + loc, + }; + const fromViewReturn: IrStmt = { kind: "return", value: fromView, loc }; + const fromViewBranch: IrStmt = { + kind: "if", + cond: recordSourceTest(sRef, loc), + then: [fromViewReturn], + else_: null, + loc, + }; + return [fromViewBranch, ...suffix]; + }; const fn: IrFunction = { name, params: [{ localId: "s.0", name: "s", type: fromT }], @@ -1236,13 +1277,15 @@ export function lowerRecordOvfCaptureHelper( lowerer.shapeOrderHelperFinalizers.push(() => { const current = lowerer.shapes.get(fromId) ?? from; const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name); + let tail: IrStmt[] = suffix; + if (sourcedFromView && lowerer.recordSourcesPossible) tail = keptSourceTail(); fn.body = [ body[0]!, ...currentOrder.flatMap((field) => { const stmt = fieldStmts.get(field); return stmt ? [stmt] : []; }), - ...suffix, + ...tail, ]; }); lowerer.liftedFns.push(fn); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 6ae1ea9f5..7057f45c3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -93,6 +93,7 @@ import { import { identityPreservingWidening } from "./coercions/identity.js"; import { scanDivergences } from "./divergences.js"; import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js"; +import { functionKeepsRecordSources } from "./record-sources.js"; import { exprFunctionReference } from "../../ir/function-references.js"; import { everyStmtList, observeRecordLiteralRebuilds } from "../../ir/traverse.js"; import { checkedClassAssertion } from "./class-assertions.js"; @@ -3303,6 +3304,11 @@ export class Lowerer { * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ readonly shapeOrderHelperFinalizers: (() => void)[] = []; + /** Whether any lowered function converts a checked-dynamic value into a + * closed record, the only conversion that can leave a record holding + * undeclared source keys (record-sources.ts). Settled once lowering ends, + * before the shape helpers finalize their bodies. */ + recordSourcesPossible = false; /** Construction witnesses that choose each shape's enumeration order * once lowering settles (key-order-witness.ts). */ readonly keyOrder = new KeyOrderNotes(); @@ -10427,6 +10433,22 @@ export class Lowerer { ...this.implicitFns, ]); observeRecordLiteralRebuilds(null); + // A program whose functions never convert a checked-dynamic value into a + // closed record cannot keep a source view anywhere, so the captures that + // consult one keep their static copy (record-sources.ts). + const allFunctions = [ + ...orderedUnits, + ...initFunctions, + ...instanceFunctions, + ...this.liftedFns, + ...this.implicitFns, + ]; + const sourceScan = { memo: new Map(), visiting: new Set() }; + for (const fn of allFunctions) { + if (!functionKeepsRecordSources(this, fn, sourceScan)) continue; + this.recordSourcesPossible = true; + break; + } for (const finalize of this.shapeOrderHelperFinalizers) finalize(); this.settleSharedHelperLocations([ ...orderedUnits, diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 57ac9a6b0..1e7ce254d 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -8,9 +8,12 @@ * serialization, untyped views, inspect, Object.keys/values/entries — * answer through the record's live checked view instead of its static * field list. This module names the record types that can carry such a - * source and the runtime test the surfaces branch on. */ -import type { IrExpr, IrType, SrcLoc } from "../../ir/ir.js"; + * source, the runtime test the surfaces branch on, and whether the program + * converts anything into one at all (a program without such a conversion + * answers the test false everywhere, so its captures keep static copies). */ +import type { IrExpr, IrFunction, IrType, SrcLoc } from "../../ir/ir.js"; import { BOOL, DYN, recordTextCodecClass, shapeHasAccessorSlots } from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; import { varRef } from "../../ir/build.js"; import type { Lowerer } from "./lowerer.js"; import { isPerfEntryType } from "./builtins/performance.js"; @@ -37,6 +40,70 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc }; } +/** A union revisited while it is being visited answers as if its cycle arm + * were empty (`keeps: false`), so the caller's answer is incomplete until the + * outer union settles. `cycled` marks that incompleteness: such an answer is + * never memoized, because another entry point into the same cycle could reach + * the missing arm. */ +interface KeepsSourceAnswer { + keeps: boolean; + cycled: boolean; +} + +/** Shared work list of the whole-program scan: settled union answers, and the + * unions on the current path. */ +export interface KeepsSourceScan { + memo: Map; + visiting: Set; +} + +/** Whether a checked conversion into `target` can leave a record holding + * undeclared source keys. The conversion reads one record at a time, so the + * records nested in a union arm, a promise, or an array count too. */ +function conversionKeepsSource( + lowerer: Lowerer, + target: IrType, + scan: KeepsSourceScan, +): KeepsSourceAnswer { + if (target.kind === "record") + return { keeps: recordMayKeepSource(lowerer, target), cycled: false }; + if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan); + if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan); + if (target.kind !== "union") return { keeps: false, cycled: false }; + const known = scan.memo.get(target.unionId); + if (known !== undefined) return { keeps: known, cycled: false }; + if (scan.visiting.has(target.unionId)) return { keeps: false, cycled: true }; + const union = lowerer.unions.get(target.unionId); + if (union === undefined) return { keeps: false, cycled: false }; + scan.visiting.add(target.unionId); + let keeps = false; + let cycled = false; + for (const arm of union.arms) { + const answer = conversionKeepsSource(lowerer, arm, scan); + keeps = keeps || answer.keeps; + cycled = cycled || answer.cycled; + } + scan.visiting.delete(target.unionId); + if (!cycled) scan.memo.set(target.unionId, keeps); + return { keeps, cycled }; +} + +/** Whether `fn` holds a validated conversion that can note a record source: + * a `dynCheck` whose target accepts `recordMayKeepSource`. Only such a + * conversion creates the kept view `recordSourceTest` answers, so a program + * without one never needs a capture to consult the view. */ +export function functionKeepsRecordSources( + lowerer: Lowerer, + fn: IrFunction, + scan: KeepsSourceScan, +): boolean { + return !everyStmtList(fn.body, { + expr: (expr) => + expr.kind !== "dynCheck" || !conversionKeepsSource(lowerer, expr.type, scan).keeps, + stmt: () => true, + }); +} + /** A reflective answer over a record value that branches on its kept * source: `staticAnswer` walks the declared fields, `viewAnswer` answers * from the live checked view (typed `type`, the static answer's type). The diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..17cabc69b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-capture.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-capture.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-source-keys-index-capture.ts b/tests/corpus/record-source-keys-index-capture.ts new file mode 100644 index 000000000..6c45ce872 --- /dev/null +++ b/tests/corpus/record-source-keys-index-capture.ts @@ -0,0 +1,130 @@ +// A record built from the checked-dynamic tree keeps the own keys its shape +// does not declare, and every JS copy carries them. Converting such a record +// into an index-signature record must copy them too. +// +// The shape that lost them: an inline, UNANNOTATED arrow in a generic call +// whose member is declared as `(h: H) => H | Promise`. No contextual type +// reaches the arrow through the type parameter, so its body is typed by tsc's +// own inference, which drops the spread's index signature: the closure is +// typed Promise<{ x }>, `{ ...h, x }` lowers through the checked-dynamic +// spread into that closed record, and the caller's keys survive only in the +// record's kept source view. The call site reads the member through the +// declared H and adapts the function value; converting `{ x }` into H must +// copy the kept keys. Node prints "accept,x"; before the fix this program +// printed "x" for the arrow, local and method lines, with no diagnostic. +type H = Record; + +interface T { + transformHeaders?: (h: H) => H | Promise; +} + +function keys(r: H): string { + return Object.keys(r).sort().join(",") || "(none)"; +} + +// The failing shape: block-bodied inline arrow, generic parameter. +async function viaArrow(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} + +// The same call read into a local first (the pi repro's shape). +async function viaLocal(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) { + const r = await o.transformHeaders(h); + console.log("local-mid:", keys(r)); + h = r; + } + return h; +} + +interface Methods { + transform(h: H): Promise; +} + +// The method form drops the same keys through the same capture. +async function viaMethod(o: O): Promise { + return await o.transform({ accept: "a" }); +} + +// A non-generic parameter: the arrow's contextual slot is the declared member, +// so these keep the index signature and never needed the capture. +async function viaArrowNonGeneric(o: T | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} + +interface Hooks { + transform?: (h: H) => H | Promise; +} + +interface Nested { + hooks: Hooks; +} + +async function viaNested(o: O | undefined): Promise { + let h: H = { accept: "a" }; + const t = o?.hooks.transform; + if (t) h = await t(h); + return h; +} + +function syncArrow H }>(o: O): H { + return o.transform ? o.transform({ accept: "a" }) : { accept: "a" }; +} + +async function main(): Promise { + const arrow = await viaArrow({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("arrow:", keys(arrow), JSON.stringify(arrow)); + + const local = await viaLocal({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("local:", keys(local)); + + const method = await viaMethod({ + async transform(h) { + return { ...h, x: "1" }; + }, + }); + console.log("method:", keys(method)); + + const generic = await viaArrowNonGeneric({ + transformHeaders: async (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("non-generic:", keys(generic)); + + const expression = await viaArrowNonGeneric({ + transformHeaders: async (h) => ({ ...h, x: "1" }), + }); + console.log("expression:", keys(expression)); + + const nested = await viaNested({ + hooks: { + transform: async (h) => { + return { ...h, x: "1" }; + }, + }, + }); + console.log("nested:", keys(nested)); + + const sync = syncArrow({ + transform: (h) => { + return { ...h, x: "1" }; + }, + }); + console.log("sync:", keys(sync)); +} + +void main(); diff --git a/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json new file mode 100644 index 000000000..81f013248 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-capture.ts.json @@ -0,0 +1 @@ +{ "plain": 1450, "san": 2550 } From eccb061a121e56527a6663e04cdad9df2c406b73 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 08:51:27 -0500 Subject: [PATCH 08/13] Find kept record sources through index signatures and record fields The capture into an index-signature record consults a record's kept source view only when some checked conversion in the program can keep one. That scan accepted a closed record target and looked through union arms, promises and arrays, but stopped at an index-signature record or a record whose fields hold closed records. A conversion into `Record` keeps each value's undeclared keys, so a program whose only such conversion had that shape dropped them in the capture. The scan now looks through record fields and index-signature values too, memoizing records by shape as it does unions. --- .../src/frontend/lowering/record-sources.ts | 52 ++++++++++++------- .../record-source-keys-index-values.ts.json | 6 +++ .../corpus/record-source-keys-index-values.ts | 19 +++++++ .../record-source-keys-index-values.ts.json | 1 + 4 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json create mode 100644 tests/corpus/record-source-keys-index-values.ts create mode 100644 tests/harness/corpus-costs/record-source-keys-index-values.ts.json diff --git a/packages/compiler/src/frontend/lowering/record-sources.ts b/packages/compiler/src/frontend/lowering/record-sources.ts index 1e7ce254d..692b4e1f8 100644 --- a/packages/compiler/src/frontend/lowering/record-sources.ts +++ b/packages/compiler/src/frontend/lowering/record-sources.ts @@ -40,9 +40,9 @@ export function recordSourceTest(value: IrExpr, loc: SrcLoc): IrExpr { return { kind: "libCall", fn: "record.sourceHas", args: [value], type: BOOL, loc }; } -/** A union revisited while it is being visited answers as if its cycle arm - * were empty (`keeps: false`), so the caller's answer is incomplete until the - * outer union settles. `cycled` marks that incompleteness: such an answer is +/** A union or record revisited while it is being visited answers as if its + * cycle member were empty (`keeps: false`), so the caller's answer is + * incomplete until the outer one settles. `cycled` marks that incompleteness: such an answer is * never memoized, because another entry point into the same cycle could reach * the missing arm. */ interface KeepsSourceAnswer { @@ -50,8 +50,8 @@ interface KeepsSourceAnswer { cycled: boolean; } -/** Shared work list of the whole-program scan: settled union answers, and the - * unions on the current path. */ +/** Shared work list of the whole-program scan: settled union and record + * answers, and the unions and records on the current path, by id. */ export interface KeepsSourceScan { memo: Map; visiting: Set; @@ -59,33 +59,47 @@ export interface KeepsSourceScan { /** Whether a checked conversion into `target` can leave a record holding * undeclared source keys. The conversion reads one record at a time, so the - * records nested in a union arm, a promise, or an array count too. */ + * records nested in a union arm, a promise, an array, a record field or an + * index signature's values count too (`Record` keeps + * the sources of its closed values). Unions and records are memoized by id; + * one revisited on the current path answers `cycled`. */ function conversionKeepsSource( lowerer: Lowerer, target: IrType, scan: KeepsSourceScan, ): KeepsSourceAnswer { - if (target.kind === "record") - return { keeps: recordMayKeepSource(lowerer, target), cycled: false }; if (target.kind === "promise") return conversionKeepsSource(lowerer, target.inner, scan); if (target.kind === "array") return conversionKeepsSource(lowerer, target.elem, scan); - if (target.kind !== "union") return { keeps: false, cycled: false }; - const known = scan.memo.get(target.unionId); + if (target.kind === "record" && recordMayKeepSource(lowerer, target)) + return { keeps: true, cycled: false }; + let id: string; + let members: IrType[]; + if (target.kind === "union") { + id = target.unionId; + members = lowerer.unions.get(target.unionId)?.arms ?? []; + } else if (target.kind === "record") { + id = target.shapeId; + const shape = lowerer.shapes.get(target.shapeId); + members = [ + ...(shape?.fields.map((field) => field.type) ?? []), + ...(shape?.indexValue !== undefined ? [shape.indexValue] : []), + ]; + } else return { keeps: false, cycled: false }; + const known = scan.memo.get(id); if (known !== undefined) return { keeps: known, cycled: false }; - if (scan.visiting.has(target.unionId)) return { keeps: false, cycled: true }; - const union = lowerer.unions.get(target.unionId); - if (union === undefined) return { keeps: false, cycled: false }; - scan.visiting.add(target.unionId); + if (scan.visiting.has(id)) return { keeps: false, cycled: true }; + scan.visiting.add(id); let keeps = false; let cycled = false; - for (const arm of union.arms) { - const answer = conversionKeepsSource(lowerer, arm, scan); + for (const member of members) { + const answer = conversionKeepsSource(lowerer, member, scan); keeps = keeps || answer.keeps; cycled = cycled || answer.cycled; + if (keeps) break; } - scan.visiting.delete(target.unionId); - if (!cycled) scan.memo.set(target.unionId, keeps); - return { keeps, cycled }; + scan.visiting.delete(id); + if (!cycled || keeps) scan.memo.set(id, keeps); + return { keeps, cycled: cycled && !keeps }; } /** Whether `fn` holds a validated conversion that can note a record source: diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..72d9a1314 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-source-keys-index-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-source-keys-index-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-source-keys-index-values.ts b/tests/corpus/record-source-keys-index-values.ts new file mode 100644 index 000000000..92d6077e2 --- /dev/null +++ b/tests/corpus/record-source-keys-index-values.ts @@ -0,0 +1,19 @@ +// A checked conversion into an index-signature record whose values are +// closed records keeps each value's undeclared source keys, so converting +// such a value into an index-signature record later copies them. This +// program has no checked conversion into a closed record itself, so the +// capture learns that kept sources are possible from the index signature's +// values. Node prints "extra,x". +type H = Record; +interface T { + transformHeaders?: (h: H) => H | Promise; +} +const parsed: Record = JSON.parse('{"p":{"x":"1","extra":"e"}}'); +async function viaArrow(o: O | undefined): Promise { + let h: H = { accept: "a" }; + if (o?.transformHeaders) h = await o.transformHeaders(h); + return h; +} +viaArrow({ transformHeaders: async (h) => parsed["p"]! }).then((r) => + console.log(Object.keys(r).sort().join(",")), +); diff --git a/tests/harness/corpus-costs/record-source-keys-index-values.ts.json b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json new file mode 100644 index 000000000..143bf22a5 --- /dev/null +++ b/tests/harness/corpus-costs/record-source-keys-index-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1959, "san": 3000 } From 8ea6b21f56aa2305b3de197bfad8cb0b856b92d1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:28:08 -0500 Subject: [PATCH 09/13] Reject import() of specifiers nothing resolves with Node's ERR_MODULE_NOT_FOUND A literal import() whose specifier resolves to no module lowered to a deferred SC2012 refusal. It now rejects when the call runs, as Node does: a package nothing installed provides rejects with "Cannot find package", and a relative or absolute path to a file that does not exist rejects with "Cannot find module" and the file's URL as the error's url property. Builtins and URL specifiers keep their current lowering. error.nodeThrow takes an optional fourth argument for the url, which the runtime sets through scr_throw_node_coded_url. --- .../compiler/src/backend/llvm/lib-process.ts | 17 ++++++-- .../src/frontend/lowering/lower-modules.ts | 40 ++++++++++++++----- .../src/frontend/lowering/lower-stdlib.ts | 2 +- packages/compiler/src/frontend/npm.ts | 27 +++++++++++++ packages/compiler/src/frontend/program.ts | 3 ++ packages/compiler/src/ir/build.ts | 5 ++- .../compiler/src/ir/builtin-signatures.ts | 7 ++-- packages/compiler/src/ir/validate.ts | 10 ++++- .../corpus/dynamic-import-not-found.mjs.json | 6 +++ packages/runtime/src/scr_dyn.h | 4 ++ packages/runtime/src/scr_json.c | 14 +++++++ tests/corpus/dynamic-import-not-found.mjs | 26 ++++++++++++ .../dynamic-import-not-found.mjs.json | 1 + 13 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json create mode 100644 tests/corpus/dynamic-import-not-found.mjs create mode 100644 tests/harness/corpus-costs/dynamic-import-not-found.mjs.json diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 01efd3354..bd7fb2ca2 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -811,10 +811,19 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr const kind = host.emitExpr(e.args[0]!); const code = host.emitExpr(e.args[1]!); const msg = host.emitExpr(e.args[2]!); - host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`); - B.line( - `call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`, - ); + if (e.args.length > 3) { + // The fourth argument: the error's own `url` property. + const url = host.emitExpr(e.args[3]!); + host.declare(`declare void @scr_throw_node_coded_url(double, ptr, ptr, ptr)`); + B.line( + `call void @scr_throw_node_coded_url(double ${kind.name}, ptr ${code.name}, ptr ${msg.name}, ptr ${url.name})`, + ); + } else { + host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`); + B.line( + `call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`, + ); + } const ty = host.llType(e.type); if (ty === "void") { host.emitAlwaysPendingUnwind(); diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index f82728c6e..6045e8c98 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -24,7 +24,12 @@ import { sharedModuleRegistrations, } from "./lower-shared-modules.js"; import { dirname as dirnamePath, resolve as resolvePath } from "node:path"; -import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js"; +import { + packageNameOfPath, + probeNodeFileImportRefusal, + probeNodeImportRefusal, + type NodeImportRefusal, +} from "../npm.js"; import { isNpmStaticPackage } from "../npm-static.js"; import { isJsSourceFileName } from "../tsc-codes.js"; import { isRelativeSpecifier } from "../workspace-registry.js"; @@ -753,23 +758,38 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { * runtime-computed name). */ /** The refusal a literal import() rejects with, or null when it loads: * Node's ERR_MODULE_NOT_FOUND for a package the project marks never loaded - * (package-shims.ts), or the first refusal in the static graph of a module - * only import() loads (LoadResult.dynamicImportRejections). */ + * (package-shims.ts), the first refusal in the static graph of a module + * only import() loads (LoadResult.dynamicImportRejections), or Node's + * resolution refusal for a specifier nothing resolves (a package nothing + * installed provides, a relative path to a file that does not exist). */ function dynamicImportRejectionOf( lowerer: Lowerer, sf: ts.SourceFile, spec: string, call: ts.CallExpression, ): StartupCrash | null { - if (isUnloadedPackage(spec)) { - const refusal = probeNodeImportRefusal(sf.fileName, spec); - if (refusal === null) return null; - return { message: refusal.message, className: "%Error", code: refusal.code, loc: locOf(call) }; - } - if (lowerer.dynamicImportRejections.size === 0) return null; + const rejection = (refusal: NodeImportRefusal | null): StartupCrash | null => + refusal === null + ? null + : { + message: refusal.message, + className: "%Error", + code: refusal.code, + ...(refusal.url === undefined ? {} : { url: refusal.url }), + loc: locOf(call), + }; + if (isUnloadedPackage(spec)) return rejection(probeNodeImportRefusal(sf.fileName, spec)); const root = resolveImport(lowerer.program, sf, spec) ?? npmStaticDepSf7(lowerer.program, sf, spec); - return root === null ? null : (lowerer.dynamicImportRejections.get(root) ?? null); + if (root !== null) return lowerer.dynamicImportRejections.get(root) ?? null; + // Builtins, and URL specifiers (file:, data:), are not package names. + if (canonicalBuiltinModule(spec) !== null || /^[a-z][a-z0-9+.-]*:/i.test(spec)) return null; + return rejection( + probeNodeFileImportRefusal(sf.fileName, spec) ?? + (spec.startsWith(".") || spec.startsWith("/") + ? null + : probeNodeImportRefusal(sf.fileName, spec)), + ); } function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 44f2b7370..0c0c7131f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2289,7 +2289,7 @@ function lowerRejectedImport( { kind: "exprStmt", expr: hop, loc }, { kind: "exprStmt", - expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc), + expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc, crash.url), loc, }, { kind: "return", value: dynUndefinedExpr(loc), loc }, diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index 6cb1d3b5e..fff437ac0 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -1,5 +1,6 @@ /** Node package resolution shared by preflight and native module introspection. */ import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js"; import { trackedDirectoryExists, @@ -142,6 +143,32 @@ export function probeNodeImportRefusal( } } +/** Node's import() refusal for a relative or absolute specifier whose + * target does not exist: ERR_MODULE_NOT_FOUND naming the resolved path, with + * its file URL. An existing file or directory answers null, as do other + * specifier forms. ESM resolution adds no extension, so the path is the + * specifier resolved against the importer's directory. */ +export function probeNodeFileImportRefusal( + fromFile: string, + specifier: string, + host: NpmGraphHost = realNpmGraphHost, +): NodeImportRefusal | null { + const relative = + specifier.startsWith("./") || + specifier.startsWith("../") || + specifier === "." || + specifier === ".."; + if (!relative && !specifier.startsWith("/")) return null; + const importer = resolve(fromFile); + const target = resolve(dirname(importer), specifier); + if (host.isFile(target) || host.isDirectory(target)) return null; + return { + code: "ERR_MODULE_NOT_FOUND", + message: `Cannot find module '${target}' imported from ${importer}`, + url: pathToFileURL(target).href, + }; +} + export function probeNodeRequireRefusal( fromFile: string, specifier: string, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 7836b8918..e422af2bd 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -555,6 +555,9 @@ export interface StartupCrash { loc: { file: string; start: number; end: number }; /** Node's error code, for the refusals that carry one. */ code?: string; + /** The error's own `url` property: the missing file's URL, for Node's + * ERR_MODULE_NOT_FOUND of an import() whose target does not exist. */ + url?: string; } /** True when a coverage mapping names one exact bare host module. TypeScript diff --git a/packages/compiler/src/ir/build.ts b/packages/compiler/src/ir/build.ts index 77259450c..8a384676f 100644 --- a/packages/compiler/src/ir/build.ts +++ b/packages/compiler/src/ir/build.ts @@ -59,13 +59,15 @@ export function dynUndefinedExpr(loc: SrcLoc): IrExpr { /** An always-throwing Node error with the replaced expression's type. * Kinds: 0 Error, 1 TypeError, 2 RangeError, 5 ReferenceError, 6 EvalError. An empty - * code omits the error code property. */ + * code omits the error code property; a `url` becomes the error's own `url` + * property (Node's ERR_MODULE_NOT_FOUND for a missing file). */ export function nodeThrowExpr( kind: 0 | 1 | 2 | 5 | 6, code: string, message: string, type: IrType, loc: SrcLoc, + url?: string, ): IrExpr { return { kind: "libCall", @@ -74,6 +76,7 @@ export function nodeThrowExpr( { kind: "numLit", value: kind, type: F64, loc }, { kind: "strLit", value: code, type: STRING, loc }, { kind: "strLit", value: message, type: STRING, loc }, + ...(url === undefined ? [] : [{ kind: "strLit", value: url, type: STRING, loc } as const]), ], type, loc, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 3287ac54a..3ed177d65 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3764,9 +3764,10 @@ function buildLibFnSignatures() { * arms (ERR_INVALID_THIS receivers, ERR_MISSING_ARGS arity ladders, * the symbol-to-string TypeError): args are [error-kind f64 (the * SCR_ERR_* index: 0 Error, 1 TypeError, 2 RangeError), code (empty = - * no code slot), message]. ALWAYS THROWS catchably; the result type is - * the replaced expression's own (never materialized — the - * global.undefRead pattern). May-throw seed. */ + * no code slot), message], and optionally a fourth string, the error's + * own `url` property (ir/validate.ts accepts it). ALWAYS THROWS + * catchably; the result type is the replaced expression's own (never + * materialized — the global.undefRead pattern). May-throw seed. */ "error.nodeThrow": { argTypes: [F64, STRING, STRING], result: VOID }, /** JS ToString over a dyn value WITH the object protocol (a user * toString/valueOf member is CALLED and its throw propagates; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f591c6dd4..562024e0e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4189,9 +4189,17 @@ function validateFunction( e.fn === "readable.unpipe" || e.fn === "rl.create" || e.fn === "rl.createSignal"; - if (variadic ? e.args.length < sig.argTypes.length : e.args.length !== sig.argTypes.length) { + // error.nodeThrow takes an optional fourth argument: the error's own + // `url` property (a string). + const optionalUrl = e.fn === "error.nodeThrow" && e.args.length === 4; + if ( + variadic + ? e.args.length < sig.argTypes.length + : e.args.length !== sig.argTypes.length && !optionalUrl + ) { err(`libCall ${e.fn}: ${e.args.length} args, expected ${sig.argTypes.length}`, e.loc); } + if (optionalUrl) expectType(e.args[3]!, STRING, `libCall ${e.fn} arg 3`); e.args.forEach((a, i) => { checkExpr(a); const want = sig.argTypes[i]; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..bda8f524b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-import-not-found.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-import-not-found.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..1a554ad86 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1710,6 +1710,10 @@ void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */ /* Sets an own property on an error: its checked-dynamic node, which casts * and util.inspect read. Moves value. */ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *value); +/* scr_throw_node_coded with an own `url` property (error.nodeThrow's + * fourth argument). Borrows the strings; always throws. */ +SCR_COLD void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg, + const ScrStr *url); ScrDyn *scr_errdyn_view_of(const ScrError *e); /* borrowed or NULL */ /* ToBoolean over the dyn kind (JS-exact); borrowed, never throws. */ bool scr_dyn_truthy(const ScrDyn *d); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index bee032446..6d4e60f8d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -10709,6 +10709,20 @@ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *va scr_dyn_release(view); } +/* scr_throw_node_coded with Node's `url` own property: the + * ERR_MODULE_NOT_FOUND of an import() whose relative target does not + * exist carries the target's file URL. Borrows the strings; always throws + * (catchably — call sites are compiler-emitted pending checks). */ +void scr_throw_node_coded_url(double kind, const ScrStr *code, const ScrStr *msg, const ScrStr *url) { + ScrError *e = scr_error_new((int)kind, (ScrStr *)msg); /* borrowed in */ + if (code->len > 0) { + scr_str_release(e->code); /* NULL-safe */ + e->code = scr_str_retain((ScrStr *)code); + } + scr_error_set_prop(e, "url", 3, scr_dyn_new_str((ScrStr *)url)); + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + /* The error's checked-dynamic node when it has one (borrowed), else NULL: * util.inspect reads its extra own properties. */ ScrDyn *scr_errdyn_view_of(const ScrError *e) { diff --git a/tests/corpus/dynamic-import-not-found.mjs b/tests/corpus/dynamic-import-not-found.mjs new file mode 100644 index 000000000..2f1abf7e4 --- /dev/null +++ b/tests/corpus/dynamic-import-not-found.mjs @@ -0,0 +1,26 @@ +// import() of a specifier nothing resolves rejects with Node's +// ERR_MODULE_NOT_FOUND when the call runs: a package nothing installed +// provides, and a relative path to a file that does not exist, whose error +// also carries the missing file's URL. The program itself loads, and each +// call rejects again when repeated. +const dirs = (text) => text.replace(/\/[^\s']*\//g, "/"); + +async function probe(label, load) { + try { + await load(); + console.log(label, "loaded"); + } catch (e) { + console.log(label, e instanceof Error, e.name, e.code); + console.log(" ", dirs(e.message)); + console.log(" url:", e.url === undefined ? "none" : dirs(e.url)); + } +} + +console.log("start"); +await probe("bare", () => import("no-such-package-anywhere")); +await probe("bare subpath", () => import("no-such-package-anywhere/sub.js")); +await probe("scoped", () => import("@nobody/no-such-package")); +await probe("relative", () => import("./no-such-file.mjs")); +await probe("relative again", () => import("./no-such-file.mjs")); +await probe("parent", () => import("../no-such-dir/no-such-file.js")); +console.log("end"); diff --git a/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json new file mode 100644 index 000000000..cbb390ba4 --- /dev/null +++ b/tests/harness/corpus-costs/dynamic-import-not-found.mjs.json @@ -0,0 +1 @@ +{ "plain": 4900, "san": 6100 } From 2cbdc9207fb2eb100e8d21b12a289de3cccbb164 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:20:02 -0500 Subject: [PATCH 10/13] Read and write record fields through keys whose literal type names fields of one type --- .../lowering/expressions/element-access.ts | 80 ++++++++++++++++++- .../expressions/keyed-field-targets.ts | 29 +++++++ .../corpus/record-literal-key-unions.ts.json | 6 ++ tests/corpus/record-literal-key-unions.ts | 78 ++++++++++++++++++ .../record-literal-key-unions.ts.json | 1 + 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json create mode 100644 tests/corpus/record-literal-key-unions.ts create mode 100644 tests/harness/corpus-costs/record-literal-key-unions.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 1ce873853..85be6bb53 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -84,7 +84,13 @@ import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js"; import { withInheritedMethods } from "../record-inherited.js"; import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { isClassCallback } from "../class-callbacks.js"; -import { isKeyedFieldAccess } from "./keyed-field-targets.js"; +import { + isKeyedFieldAccess, + keyedFieldRead, + keyedFieldWrite, + sameTypedFieldKeys, + type KeyedFieldTarget, +} from "./keyed-field-targets.js"; import { lowerFieldCompoundValue } from "./updates.js"; import { readsHeldEvolvingArray } from "../lower-stmts.js"; import { @@ -1548,6 +1554,36 @@ export function lowerRecordKeyRead( recordKeyResultOk(lowerer, shape, DYN) ) declared = DYN; + // A key whose literal type names only declared fields of one type, on a + // record whose other fields have other types, reads the field it names. + // A called access (`r[key]()`) keeps the refusal. + const named = + !declared && litKey === null && !isCalledAccess(expr) + ? sameTypedFieldKeys(lowerer, shape, keyNode) + : null; + if (named) { + const stmts: IrStmt[] = []; + const settle = (value: IrExpr, name: string, force = false): IrExpr => { + if (!force && isSafeToRepeat(value)) return value; + const temp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + return varRef(temp.id, temp.type, loc); + }; + const receiver = settle(obj, "%keyedReceiver", !isSafeToDiscard(key)); + const target: KeyedFieldTarget = { + container: "recordKeyed", + obj: receiver, + shapeId, + field: named.keys[0]!, + keys: named.keys, + key: settle(key, "%keyedKey"), + fieldType: named.fieldType, + }; + const get = keyedFieldRead(lowerer, target, loc, true); + const read: IrExpr = + stmts.length === 0 ? get : { kind: "seqExpr", stmts, result: get, type: get.type, loc }; + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); + } if (!declared) { lowerer.unsupported( "SC1090", @@ -2571,6 +2607,48 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): }; } } + // A key whose literal type names only declared fields of one type + // writes the field it names. JavaScript evaluates the receiver, then + // the key, then the value; the dispatch reads the saved receiver and + // key once per named field. + const named = + litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null; + if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) { + const loc = locOf(expr); + const body: IrStmt[] = []; + const save = (value: IrExpr, name: string): IrExpr => { + if (value.kind === "varRef") return value; + const temp = lowerer.declareHiddenLocal(name, value.type); + body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + return varRef(temp.id, temp.type, loc); + }; + const obj = save(lowerer.lowerExpr(target.expression), "%keyedReceiver"); + const rawKey = lowerRecordPropertyKey( + lowerer, + lowerer.lowerExpr(target.argumentExpression), + target.argumentExpression, + ); + if (rawKey.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + target.argumentExpression, + "indexing records with non-string or non-number keys", + ); + } + const key = save(rawKey, "%keyedKey"); + const value = lowerer.lowerExprExpecting(expr.right, named.fieldType); + const keyed: KeyedFieldTarget = { + container: "recordKeyed", + obj, + shapeId: receiverIr.shapeId, + field: named.keys[0]!, + keys: named.keys, + key, + fieldType: named.fieldType, + }; + body.push(keyedFieldWrite(lowerer, keyed, value, loc)); + return { kind: "block", body, loc }; + } if (!shape.indexValue) { // A SIGNATURE-FREE shape whose declared fields share ONE type // writes through the same per-shape dispatch (the mockable-clock diff --git a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts index 932e2e4b6..267628572 100644 --- a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts +++ b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts @@ -5,9 +5,12 @@ import { DYN, F64, STRING, + shapeHasAccessorSlots, typeEquals, type IrExpr, + type IrRecordShape, type IrStmt, + type IrType, type SrcLoc, } from "../../../ir/ir.js"; import { isJsSourceFile } from "../../program.js"; @@ -141,6 +144,32 @@ export function keyedFieldTarget( }; } +/** The declared fields a computed key names when the key's literal type + * allows only names of declared fields stored with one type + * (`stats[counter]` with `counter: "hits" | "misses"` on a record whose + * other fields have other types). Null for any other key or shape: + * accessor-carrying shapes, index-signature shapes and JavaScript sources + * keep their own keyed access. */ +export function sameTypedFieldKeys( + lowerer: Lowerer, + shape: IrRecordShape, + keyNode: ts.Expression, +): { keys: string[]; fieldType: IrType } | null { + if ( + shape.tuple || + shape.indexValue !== undefined || + shapeHasAccessorSlots(shape) || + isJsSourceFile(ts.getSourceFileOfNode(keyNode)) + ) + return null; + const keys = literalFieldKeys(lowerer, keyNode); + if (!keys) return null; + const fields = keys.map((k) => shape.fields.find((f) => f.name === k)); + const first = fields[0]; + if (!first || fields.some((f) => !f || !typeEquals(f.type, first.type))) return null; + return { keys, fieldType: first.type }; +} + /** Does `key` name `field`? */ function keyIs(key: IrExpr, field: string, loc: SrcLoc): IrExpr { return { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json new file mode 100644 index 000000000..b10d37dd3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-literal-key-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-literal-key-unions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-literal-key-unions.ts b/tests/corpus/record-literal-key-unions.ts new file mode 100644 index 000000000..afe328e98 --- /dev/null +++ b/tests/corpus/record-literal-key-unions.ts @@ -0,0 +1,78 @@ +// Keyed reads and writes whose key is a union of literal names of declared +// fields stored with one type, on records whose other fields have other +// types: plain reads, plain writes, compound updates, keys from parameters +// and loops, and JavaScript evaluation order with effectful receivers and +// keys. +type Stats = { hits: number; misses: number; bytes: number; name: string; ok: boolean }; +type Counter = "hits" | "misses" | "bytes"; +type Flags = { name: string; verbose: boolean; strict: boolean; level: number }; +type Labels = { id: number; title: string; summary: string; tags: string[] }; + +function read(s: Stats, key: Counter): number { + return s[key]; +} + +function bump(s: Stats, key: Counter, by: number): void { + s[key] = s[key] + by; +} + +function toggle(flags: Flags, key: "verbose" | "strict"): boolean { + flags[key] = !flags[key]; + return flags[key]; +} + +function relabel(labels: Labels, key: "title" | "summary", text: string): string { + const before = labels[key]; + labels[key] = text.toUpperCase(); + return `${before} -> ${labels[key]}`; +} + +const stats: Stats[] = []; +for (let i = 0; i < 4; i++) stats.push({ hits: 0, misses: 0, bytes: 0, name: "s" + i, ok: true }); + +let seed = 7; +for (let i = 0; i < 1000; i++) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + const s = stats[seed & 3]!; + bump(s, seed % 5 === 0 ? "misses" : "hits", 1); + bump(s, "bytes", (seed >>> 8) & 255); +} +for (const s of stats) console.log(s.name, read(s, "hits"), read(s, "misses"), read(s, "bytes"), s.ok); + +// Keys from a loop over a constant list of names. +const counters = ["hits", "misses", "bytes"] as const; +const totals: Stats = { hits: 0, misses: 0, bytes: 0, name: "total", ok: false }; +for (const s of stats) for (const key of counters) totals[key] += s[key]; +console.log(totals.name, totals.hits, totals.misses, totals.bytes); + +// Booleans and strings. +const flags: Flags = { name: "build", verbose: false, strict: true, level: 2 }; +console.log(toggle(flags, "verbose"), toggle(flags, "strict"), toggle(flags, "verbose"), flags.level); +const labels: Labels = { id: 1, title: "draft", summary: "none", tags: ["a"] }; +console.log(relabel(labels, "title", "final"), relabel(labels, "summary", "short"), labels.tags.length); + +// The receiver, then the key, then the value, each once. +const order: string[] = []; +function pick(i: number): Stats { + order.push("receiver " + i); + return stats[i]!; +} +function name(key: Counter): Counter { + order.push("key " + key); + return key; +} +function amount(n: number): number { + order.push("value " + n); + return n; +} +pick(1)[name("misses")] = amount(40); +console.log(order.join(", "), stats[1]!.misses); +order.length = 0; +console.log(pick(2)[name("bytes")], order.join(", ")); + +// A key that names one field. +function only(s: Stats, key: "bytes"): number { + s[key] = s[key] * 2; + return s[key]; +} +console.log(only(stats[3]!, "bytes")); diff --git a/tests/harness/corpus-costs/record-literal-key-unions.ts.json b/tests/harness/corpus-costs/record-literal-key-unions.ts.json new file mode 100644 index 000000000..719a1099c --- /dev/null +++ b/tests/harness/corpus-costs/record-literal-key-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 5800, "san": 62700 } From 43c72a8e9bb3a7bf6f740f47ea2d8ddaa8d8d1df Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:18:04 -0500 Subject: [PATCH 11/13] Save the receiver and key locals of a literal-union keyed write --- .../lowering/expressions/element-access.ts | 4 ++-- tests/corpus/record-literal-key-unions.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 85be6bb53..c94bfa802 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -2610,14 +2610,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): // A key whose literal type names only declared fields of one type // writes the field it names. JavaScript evaluates the receiver, then // the key, then the value; the dispatch reads the saved receiver and - // key once per named field. + // key once per named field. Locals are saved too, because the key or + // the value can reassign them. const named = litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null; if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) { const loc = locOf(expr); const body: IrStmt[] = []; const save = (value: IrExpr, name: string): IrExpr => { - if (value.kind === "varRef") return value; const temp = lowerer.declareHiddenLocal(name, value.type); body.push({ kind: "varDecl", localId: temp.id, init: value, loc }); return varRef(temp.id, temp.type, loc); diff --git a/tests/corpus/record-literal-key-unions.ts b/tests/corpus/record-literal-key-unions.ts index afe328e98..cb9336455 100644 --- a/tests/corpus/record-literal-key-unions.ts +++ b/tests/corpus/record-literal-key-unions.ts @@ -76,3 +76,23 @@ function only(s: Stats, key: "bytes"): number { return s[key]; } console.log(only(stats[3]!, "bytes")); + +// A key or value that reassigns the receiver or key local: the write goes +// to the receiver and key evaluated before them. +let target = stats[0]!; +let slot: Counter = "hits"; +target[slot] = ((slot = "misses"), 500); +console.log(stats[0]!.hits, stats[0]!.misses, slot); +target[slot] = ((target = stats[2]!), 600); +console.log(stats[0]!.misses, stats[2]!.misses, target === stats[2]); +function retarget(): Counter { + target = stats[3]!; + return "bytes"; +} +target = stats[1]!; +target[retarget()] = 700; +console.log(stats[1]!.bytes, stats[3]!.bytes === 700); +target = stats[0]!; +slot = "hits"; +target[slot] += ((slot = "bytes"), (target = stats[1]!), 1); +console.log(stats[0]!.hits, stats[0]!.bytes, stats[1]!.hits); From ee621414c69c5f00d9889a94c2d2eaa7d61a2ae2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:26:24 -0500 Subject: [PATCH 12/13] Resolve relative import() specifiers as URLs for the not-found rejection probeNodeFileImportRefusal resolved a relative or absolute specifier as a filesystem path, so a percent escape, a query or a fragment became part of the path: "./a%20b.mjs" was looked up as "a%20b.mjs", and "./x.mjs?v=1" of an existing file rejected. It now resolves the specifier as a URL against the importer's file URL, as Node's ESM resolution does: the message names the decoded path without the query or fragment, and the error's url is the resolved URL with them. A path ending in "/", an encoded "/" or "\" and a malformed escape, which Node refuses with other errors, keep the current lowering. An absolute specifier that CommonJS resolution finds (for example a file named without its extension) gets Node's "Did you mean to import" hint. Node resolves the hint of a relative specifier against the process's working directory, so relative specifiers get none. npm-file-import.test.ts compares the probe with Node's import() rejections for 29 specifiers, and the dynamic-import-not-found corpus program covers an escaped specifier with a query and fragment. --- .../src/frontend/npm-file-import.test.ts | 83 ++++++++++++++++++ packages/compiler/src/frontend/npm.ts | 85 ++++++++++++++++--- tests/corpus/dynamic-import-not-found.mjs | 7 +- 3 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/src/frontend/npm-file-import.test.ts diff --git a/packages/compiler/src/frontend/npm-file-import.test.ts b/packages/compiler/src/frontend/npm-file-import.test.ts new file mode 100644 index 000000000..daa0270ac --- /dev/null +++ b/packages/compiler/src/frontend/npm-file-import.test.ts @@ -0,0 +1,83 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { expect, test } from "vitest"; +import { probeNodeFileImportRefusal } from "./npm.js"; + +// probeNodeFileImportRefusal against Node itself: for each specifier, Node's +// import() from the same importer either rejects with ERR_MODULE_NOT_FOUND, +// whose code, message and url the probe must reproduce, or does something +// else (loads, or refuses with another error), where the probe answers null. +// Node runs in an empty directory: it resolves a relative specifier's +// "Did you mean" hint against its working directory, so there it adds none. +test("relative and absolute import() refusals match Node's", () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-file-import-"))); + writeFileSync(join(dir, "has space.mjs"), "export {};\n"); + writeFileSync(join(dir, "plain.mjs"), "export {};\n"); + writeFileSync(join(dir, "lit%20pct.mjs"), "export {};\n"); + writeFileSync(join(dir, "helper.js"), "export {};\n"); + mkdirSync(join(dir, "sub dir")); + mkdirSync(join(dir, "pkgdir")); + writeFileSync(join(dir, "pkgdir/package.json"), JSON.stringify({ main: "entry" })); + writeFileSync(join(dir, "pkgdir/entry.js"), "export {};\n"); + mkdirSync(join(dir, "indexed")); + writeFileSync(join(dir, "indexed/index.json"), "{}\n"); + mkdirSync(join(dir, "empty")); + const importer = join(dir, "main.mjs"); + writeFileSync( + importer, + `const results = []; +for (const specifier of JSON.parse(process.argv[2])) { + try { + await import(specifier); + results.push(null); + } catch (error) { + results.push(error.code === "ERR_MODULE_NOT_FOUND" ? { code: error.code, message: error.message, url: error.url } : null); + } +} +process.stdout.write(JSON.stringify(results)); +`, + ); + const specifiers = [ + "./no-such-file.mjs", + "./no%20such.mjs", + "./has%20space.mjs", + "./has space.mjs", + "./lit%20pct.mjs", + "./lit%2520pct.mjs", + "./plain.mjs?x=1", + "./plain.mjs#frag", + "./missing.mjs?x=1", + "./missing.mjs#frag", + "./sub%2Fx.mjs", + "./sub%5Cx.mjs", + "./bad%zz.mjs", + "./sub dir/", + "./no-such-dir/", + "./helper", + "./pkgdir", + "./pkgdir/", + "./indexed", + `../${basename(dir)}/missing.mjs`, + `../${basename(dir)}/helper`, + `${dir}/nope.mjs#h`, + `${dir}/has%20space.mjs`, + `${dir}/helper`, + `${dir}/helper?q`, + `${dir}/pkgdir/entry`, + `${dir}/lit%20pct.mjs`, + `${dir}/no-such`, + `${dir}/indexed/`, + ]; + const node = JSON.parse( + execFileSync(process.execPath, [importer, JSON.stringify(specifiers)], { + cwd: join(dir, "empty"), + encoding: "utf8", + }), + ) as unknown[]; + const probed = specifiers.map((specifier) => probeNodeFileImportRefusal(importer, specifier)); + expect(Object.fromEntries(specifiers.map((s, i) => [s, probed[i]]))).toEqual( + Object.fromEntries(specifiers.map((s, i) => [s, node[i]])), + ); +}); diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts index fff437ac0..c525eb9f6 100644 --- a/packages/compiler/src/frontend/npm.ts +++ b/packages/compiler/src/frontend/npm.ts @@ -1,6 +1,6 @@ /** Node package resolution shared by preflight and native module introspection. */ import { dirname, join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { packageNameOfSpecifier as packageNameOf } from "./workspace-registry.js"; import { trackedDirectoryExists, @@ -145,28 +145,85 @@ export function probeNodeImportRefusal( /** Node's import() refusal for a relative or absolute specifier whose * target does not exist: ERR_MODULE_NOT_FOUND naming the resolved path, with - * its file URL. An existing file or directory answers null, as do other - * specifier forms. ESM resolution adds no extension, so the path is the - * specifier resolved against the importer's directory. */ + * the resolved URL. ESM resolution reads the specifier as a URL relative to + * the importer's file URL and adds no extension: percent escapes are + * decoded in the path, and a query or fragment stays in the URL only. An + * existing file or directory answers null, as do other specifier forms and + * the specifiers Node refuses with another error (a path ending in "/", an + * encoded "/" or "\", a malformed escape). */ export function probeNodeFileImportRefusal( fromFile: string, specifier: string, host: NpmGraphHost = realNpmGraphHost, ): NodeImportRefusal | null { - const relative = + if (!isRelativeImportSpecifier(specifier) && !specifier.startsWith("/")) return null; + const importer = resolve(fromFile); + let url: URL; + let target: string; + try { + url = new URL(specifier, pathToFileURL(importer)); + if (url.pathname.endsWith("/") || /%2f|%5c/i.test(url.pathname)) return null; + target = fileURLToPath(url); + } catch { + return null; + } + if (host.isFile(target) || host.isDirectory(target)) return null; + const hint = specifier.startsWith("/") ? commonJsResolveFile(specifier, host) : null; + return { + code: "ERR_MODULE_NOT_FOUND", + message: + `Cannot find module '${target}' imported from ${importer}` + + (hint === null || hint === specifier + ? "" + : `\nDid you mean to import ${JSON.stringify(hint)}?`), + url: url.href, + }; +} + +function isRelativeImportSpecifier(specifier: string): boolean { + return ( specifier.startsWith("./") || specifier.startsWith("../") || specifier === "." || - specifier === ".."; - if (!relative && !specifier.startsWith("/")) return null; - const importer = resolve(fromFile); - const target = resolve(dirname(importer), specifier); - if (host.isFile(target) || host.isDirectory(target)) return null; - return { - code: "ERR_MODULE_NOT_FOUND", - message: `Cannot find module '${target}' imported from ${importer}`, - url: pathToFileURL(target).href, + specifier === ".." + ); +} + +/* Node's ERR_MODULE_NOT_FOUND hint names the file CommonJS resolution of + * the literal specifier finds: the exact file, the .js/.json/.node + * extensions, or a directory's package.json main or index. Node resolves a + * relative specifier for the hint against the process's working directory, + * which the compiled program only knows when it runs, so only an absolute + * specifier gets the hint here; the found path is the hint itself. */ +const COMMONJS_EXTENSIONS = [".js", ".json", ".node"]; + +function commonJsResolveFile(request: string, host: NpmGraphHost): string | null { + const file = (path: string): string | null => (host.isFile(path) ? host.realpath(path) : null); + const withExtensions = (path: string): string | null => { + for (const ext of COMMONJS_EXTENSIONS) { + const found = file(path + ext); + if (found !== null) return found; + } + return null; }; + const found = file(request) ?? withExtensions(request); + if (found !== null) return found; + if (!host.isDirectory(request)) return null; + const manifest = host.readFile(join(request, "package.json")); + if (manifest !== null) { + let main: unknown; + try { + main = (JSON.parse(manifest) as PkgJson).main; + } catch { + main = undefined; + } + if (typeof main === "string" && main !== "") { + const entry = resolve(request, main); + const found = file(entry) ?? withExtensions(entry) ?? withExtensions(join(entry, "index")); + if (found !== null) return found; + } + } + return withExtensions(join(request, "index")); } export function probeNodeRequireRefusal( diff --git a/tests/corpus/dynamic-import-not-found.mjs b/tests/corpus/dynamic-import-not-found.mjs index 2f1abf7e4..757eae920 100644 --- a/tests/corpus/dynamic-import-not-found.mjs +++ b/tests/corpus/dynamic-import-not-found.mjs @@ -1,8 +1,10 @@ // import() of a specifier nothing resolves rejects with Node's // ERR_MODULE_NOT_FOUND when the call runs: a package nothing installed // provides, and a relative path to a file that does not exist, whose error -// also carries the missing file's URL. The program itself loads, and each -// call rejects again when repeated. +// also carries the missing file's URL. The specifier is a URL: its escapes +// are decoded in the message's path, and its query and fragment stay in +// the url only. The program itself loads, and each call rejects again when +// repeated. const dirs = (text) => text.replace(/\/[^\s']*\//g, "/"); async function probe(label, load) { @@ -23,4 +25,5 @@ await probe("scoped", () => import("@nobody/no-such-package")); await probe("relative", () => import("./no-such-file.mjs")); await probe("relative again", () => import("./no-such-file.mjs")); await probe("parent", () => import("../no-such-dir/no-such-file.js")); +await probe("escaped", () => import("./no%20such%20file.mjs?v=1#top")); console.log("end"); From 7befe5d506509e003a56c4ad37e68b41b49595a2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 21:52:03 -0500 Subject: [PATCH 13/13] Regenerate the IR codec for partial record views codec.generated.ts was not regenerated when partialView was added to IR expressions, so IR read through the binary codec dropped the field and the stale-codec check failed. --- packages/compiler/src/ir/codec.generated.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts index 557f85592..4dc292756 100644 --- a/packages/compiler/src/ir/codec.generated.ts +++ b/packages/compiler/src/ir/codec.generated.ts @@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js"; /** A digest of the coders below: an encoding written with other coders is * refused instead of misread, also after a change that keeps IR_VERSION. */ -export const IR_CODEC_LAYOUT = "a1813b4b2a32bfd0"; +export const IR_CODEC_LAYOUT = "b964edeec6535396"; /** Writes `mod` field by field (codec.ts describes the bytes). */ export function encodeIrModule(w: IrWriter, mod: IrModule): void { @@ -150,7 +150,7 @@ const KEYS122: readonly string[] = ["kind","fnName","captures","adapts","type"," const KEYS123: readonly string[] = ["kind","value","type","loc"]; const KEYS124: readonly string[] = ["kind","elems","type","loc"]; const KEYS125: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"]; -const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"]; +const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","partialView","inferredSite","origin","type","loc"]; const KEYS127: readonly string[] = ["kind","value","spelling","firstProp","type","loc"]; const KEYS128: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"]; const KEYS129: readonly string[] = ["kind","key","negated","value","type","loc"]; @@ -2923,6 +2923,11 @@ function encIrExpr(w: IrWriter, v: IrExpr): void { w.byte(1); w.bool(v.exact); } + if (v.partialView === undefined) w.byte(0); + else { + w.byte(1); + w.bool(v.partialView); + } if (v.inferredSite === undefined) w.byte(0); else { w.byte(1); @@ -3953,11 +3958,12 @@ function decIrExpr(r: IrReader): IrExpr { const f1 = r.tag(2, "IrFunction.body[].arr.preserveRefs") === 0 ? undefined : (r.bool() as NonNullable["preserveRefs"]>); const f2 = r.tag(2, "IrFunction.body[].arr.adopt") === 0 ? undefined : (r.bool() as NonNullable["adopt"]>); const f3 = r.tag(2, "IrFunction.body[].arr.exact") === 0 ? undefined : (r.bool() as NonNullable["exact"]>); - const f4 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str(); - const f5 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num(); - const f6 = decIrType(r); - const f7 = decSrcLoc(r); - const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { inferredSite: f4 } : {}), ...(f5 !== undefined ? { origin: f5 } : {}), type: f6, loc: f7 }; + const f4 = r.tag(2, "IrFunction.body[].arr.partialView") === 0 ? undefined : (r.bool() as NonNullable["partialView"]>); + const f5 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str(); + const f6 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num(); + const f7 = decIrType(r); + const f8 = decSrcLoc(r); + const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { partialView: f4 } : {}), ...(f5 !== undefined ? { inferredSite: f5 } : {}), ...(f6 !== undefined ? { origin: f6 } : {}), type: f7, loc: f8 }; return o; } case 26: {