From f9f6d4d23a9cca71f289747c4b3fc0221ed2feeb Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 07:49:32 -0700 Subject: [PATCH] Copy untyped and index-signature spread sources through a checked dynamic object `{ ...item }` over `unknown`, `any`, or a `Record` source was an SC1090 refusal: the record desugar cannot enumerate keys that are runtime state. These sources now build a checked-dynamic object and validate it into the target record, copying own enumerable keys in Node's insertion order. Keys the target does not declare drop, matching the existing spread stance. Corpus 8502 covers a `Record` into a typed record, an `unknown` clone, and a merge with an explicit later field. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 55 +++++++++++++++++++ .../8502-object-spread-untyped-source.ts.json | 6 ++ .../8502-object-spread-untyped-source.ts | 50 +++++++++++++++++ .../8502-object-spread-untyped-source.ts.json | 1 + 4 files changed, 112 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8502-object-spread-untyped-source.ts.json create mode 100644 tests/corpus/8502-object-spread-untyped-source.ts create mode 100644 tests/harness/corpus-costs/8502-object-spread-untyped-source.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 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 }