diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index c86c9b301b..0b5b45b2c2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1904,6 +1904,8 @@ export function lowerObjectLiteral( 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, @@ -2162,6 +2164,8 @@ export function lowerObjectLiteral( continue; } if (srcType?.kind !== "record") { + const fallback = dynSpreadLiteral(lowerer, expr, type); + if (fallback) return fallback; lowerer.unsupported( "SC1090", prop, @@ -2175,6 +2179,8 @@ export function lowerObjectLiteral( // Index-signature shapes carry runtime-keyed overflow entries the // field-by-field desugar cannot enumerate — fenced on either side. if (srcShape.indexValue || shape.indexValue) { + const fallback = dynSpreadLiteral(lowerer, expr, type); + if (fallback) return fallback; lowerer.unsupported( "SC1090", prop, @@ -2910,6 +2916,53 @@ function lowerConditionalRecordSpread( }; } +/** 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; + 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; + if (t?.kind === "union") + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => indexed(arm)); + return false; + }; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(e)); + return mapped?.kind === "dyn" || indexed(mapped); +} + +/** `expr` as a checked-dynamic object validated into `type` — the + * copy-then-check a spread of an untyped or runtime-keyed source needs + * (`{ ...item }` out of a `Record`). Null when no spread + * source is of that world or the target shape has no checked extraction: + * the caller keeps its own refusal. Keys the target does not declare drop + * (divergence 36/68's spread stance). */ +function dynSpreadLiteral( + lowerer: Lowerer, + expr: ts.ObjectLiteralExpression, + type: IrType, +): IrExpr | null { + if (type.kind !== "record") return null; + const qualifies = (node: ts.Expression): boolean => dynSpreadSource(lowerer, node); + if ( + !expr.properties.nodes.some((prop) => ts.isSpreadAssignment(prop) && qualifies(prop.expression)) + ) + return null; + if ( + !canDynCheckTo( + type, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return null; + return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), type); +} + /** A conditional spread's carrier property: `name: value` or shorthand, * with an identifier/string-literal name. */ export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & { @@ -3005,6 +3058,8 @@ function lowerDeclaredSpreadMerge( // absent. for (const f of shape.fields) { if (f.type.kind !== "union" || lowerer.armTag(f.type.unionId, UNDEFINED_T) < 0) { + const fallback = dynSpreadLiteral(lowerer, expr, type); + if (fallback) return fallback; lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-object-spread-untyped-source.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-object-spread-untyped-source.ts.json new file mode 100644 index 0000000000..54d1ad10d7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-object-spread-untyped-source.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8502-object-spread-untyped-source.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8502-object-spread-untyped-source.ts b/tests/corpus/8502-object-spread-untyped-source.ts new file mode 100644 index 0000000000..7e15543a9e --- /dev/null +++ b/tests/corpus/8502-object-spread-untyped-source.ts @@ -0,0 +1,50 @@ +// Object spreads over sources the record desugar cannot enumerate: an untyped +// (`unknown`) value and a runtime-keyed `Record`. Node copies +// own enumerable keys in insertion order; the copy is validated into the +// declared record afterwards. +type Resource = { uri: string; name: string }; +function toResource(item: Record): Resource { + return { ...item } as Resource; +} +const plain = toResource({ uri: "u", name: "n" }); +console.log(JSON.stringify(plain)); +const reordered = toResource({ name: "n2", uri: "u2" }); +console.log(JSON.stringify(reordered), Object.keys(reordered).join(",")); + +function withDefault(item: Record): Resource { + return { ...item, name: item.name ?? item.uri } as Resource; +} +console.log(JSON.stringify(withDefault({ uri: "u3" }))); +console.log(JSON.stringify(withDefault({ uri: "u4", name: "given" }))); + +// A typed record re-spread through an untyped hop keeps the same key order. +interface Full { + uri: string; + name: string; + title: string; +} +function clone(item: Full): Full { + const untyped = item as unknown as Record; + return { ...untyped } as unknown as Full; +} +const cloned = clone({ uri: "u5", name: "n5", title: "t5" }); +console.log(JSON.stringify(cloned), Object.keys(cloned).join(",")); + +// A runtime-keyed base under explicit fields: the later explicit field wins, +// and the surviving keys stay in the order Node builds them. +interface Config { + endpoint: string; + retries: number; +} +function config(overrides: Record): Config { + return { ...overrides, endpoint: "https://example.test" } as Config; +} +const built = config({ retries: 3 }); +console.log(JSON.stringify(built), Object.keys(built).join(",")); + +// An 'unknown' source merged with a typed one and a later override. +function merge(base: unknown, retries: number): Config { + return { ...(base as Record), retries } as Config; +} +const merged = merge({ endpoint: "https://merge.test" }, 5); +console.log(JSON.stringify(merged), Object.keys(merged).join(",")); diff --git a/tests/harness/corpus-costs/8502-object-spread-untyped-source.ts.json b/tests/harness/corpus-costs/8502-object-spread-untyped-source.ts.json new file mode 100644 index 0000000000..1aee0866de --- /dev/null +++ b/tests/harness/corpus-costs/8502-object-spread-untyped-source.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 3800 }