diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7cc96dbd6..337d9657b 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -3576,6 +3576,28 @@ export function conditionalSpreadOf( return { cond: e.condition, props, whenTrue: trueCarries }; } +/** The record arms of a union spread source of the declared-shape merge, + * or null unless every arm is a non-tuple record or the null/undefined + * unit and at least one arm is a record. Unit arms contribute nothing: + * CopyDataProperties skips nullish sources. */ +function declaredMergeUnionArms( + lowerer: Lowerer, + unionId: string, +): { tag: number; shapeId: string; shape: IrRecordShape }[] | null { + const def = lowerer.unions.get(unionId); + if (!def) return null; + const arms: { tag: number; shapeId: string; shape: IrRecordShape }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (arm.kind === "undefinedT" || arm.kind === "nullT") continue; + if (arm.kind !== "record") return null; + const armShape = lowerer.shapes.get(arm.shapeId); + if (!armShape || armShape.tuple) return null; + arms.push({ tag, shapeId: arm.shapeId, shape: armShape }); + } + return arms.length > 0 ? arms : null; +} + /** `{ ...idx, ...idx2 }` typed a DECLARED shape (no index signature) — the * defaults-merge idiom over runtime-keyed sources (`{ ...fromEntries(a), * ...fromEntries(b) }` typed AppConfig). Lowers to ONE interned helper @@ -3591,7 +3613,12 @@ export function conditionalSpreadOf( * untyped copy would store the lie) — and a key naming NO declared field * is DROPPED (the shape cannot represent it; Node keeps it invisibly — * divergence 68). Later contributors overwrite earlier ones - * (last-write-wins). Fixed, hybrid, and pure index records may contribute. */ + * (last-write-wins). Fixed, hybrid, and pure index records may contribute, + * and so may a union of those records with the null/undefined unit: each + * record arm copies through its own shape's helper behind its tag test, + * a unit arm copies nothing (CopyDataProperties skips nullish sources), + * and the union is held in a hidden local, so it still evaluates once, in + * order. */ function lowerDeclaredSpreadMerge( lowerer: Lowerer, expr: ts.ObjectLiteralExpression, @@ -3603,6 +3630,11 @@ function lowerDeclaredSpreadMerge( value: IrExpr; shapeId: string; shape?: IrRecordShape; + /** A union source (`{ ...model.compat }` of `R | undefined`): each + * record arm copies through its own shape's helper behind its tag + * test, and a null or undefined arm copies nothing (CopyDataProperties + * skips nullish sources). */ + arms?: { tag: number; shapeId: string; shape: IrRecordShape }[]; } const srcs: Src[] = []; for (const prop of expr.properties.nodes) { @@ -3612,6 +3644,14 @@ function lowerDeclaredSpreadMerge( srcs.push({ value, shapeId: "dyn" }); continue; } + if (value.type.kind === "union") { + const arms = declaredMergeUnionArms(lowerer, value.type.unionId); + if (arms) { + for (const arm of arms) fenceAccessorSpreadSource(lowerer, prop, arm.shape); + srcs.push({ value, shapeId: "union", arms }); + continue; + } + } const srcShape = value.type.kind === "record" ? lowerer.shapes.get(value.type.shapeId) : undefined; if (value.type.kind !== "record" || !srcShape || srcShape.tuple) { @@ -3763,12 +3803,11 @@ function lowerDeclaredSpreadMerge( loc, }, ]; - for (const source of srcs) { + const mergeHelper = (source: Src, sourceType: IrType): string => { const key = `declmerge-source:${type.shapeId}:${source.shapeId}`; let helper = lowerer.valueHelpers.get(key); if (!helper) { helper = keyedHelperName(lowerer, "%rec.declmerge", key); - const sourceType = source.value.type; const destination = varRef("out.0", type, loc); const receiver = varRef("source.0", sourceType, loc); const locals: IrLocal[] = [ @@ -3952,6 +3991,62 @@ function lowerDeclaredSpreadMerge( // leave an entry that a later literal could reuse without a body. lowerer.valueHelpers.set(key, helper); } + return helper; + }; + for (const source of srcs) { + if (source.arms && source.value.type.kind === "union") { + // Evaluate the union once, then copy the running arm's own keys. + const unionType = source.value.type; + const held = lowerer.declareHiddenLocal("%declMergeSource", unionType); + const heldRef = varRef(held.id, unionType, loc); + stmts.push({ kind: "varDecl", localId: held.id, init: source.value, loc }); + for (const arm of source.arms) { + const armType: IrType = { kind: "record", shapeId: arm.shapeId }; + const helper = mergeHelper( + { value: heldRef, shapeId: arm.shapeId, shape: arm.shape }, + armType, + ); + stmts.push({ + kind: "if", + cond: { + kind: "unionIsTag", + unionId: unionType.unionId, + tag: arm.tag, + negated: false, + value: heldRef, + type: BOOL, + loc, + }, + then: [ + { + kind: "exprStmt", + expr: { + kind: "call", + callee: helper, + args: [ + outRef, + { + kind: "unionNarrow", + unionId: unionType.unionId, + tag: arm.tag, + value: heldRef, + type: armType, + loc, + }, + ], + type, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }); + } + continue; + } + const helper = mergeHelper(source, source.value.type); stmts.push({ kind: "exprStmt", expr: { kind: "call", callee: helper, args: [outRef, source.value], type, loc }, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 3a855f835..b48ec73b6 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -7928,13 +7928,52 @@ export function lowerTry(lowerer: Lowerer, stmt: ts.TryStatement): IrStmt { }; } +/** The refusal for `delete` of an optional `unknown`/`any` field, whose + * slot is the checked-dynamic one and so has no ABSENT state to write. */ +const OPTIONAL_CHECKED_DYN_DELETE = + "'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present)"; + +/** TypeScript's declaration marks property `name` of `receiver` optional + * (`?:`), in every union constituent. A mapped IR type cannot tell an + * optional `unknown`/`any` field from a required one — both ride the + * checked-dynamic slot — so a refused `delete`'s real reason comes from + * the declaration. */ +function declaredOptionalProperty( + lowerer: Lowerer, + receiver: ts.Expression, + name: string, +): boolean { + const receiverType = lowerer.typeOf(receiver); + const parts = + receiverType.flags & ts.TypeFlags.Union ? constituentTypes(receiverType) : [receiverType]; + return parts.every((part) => { + const property = lowerer.checker.getPropertyOfType(part, name); + return property !== undefined && (property.flags & ts.SymbolFlags.Optional) !== 0; + }); +} + +/** `type` — a record or a union whose every arm is a non-tuple record — + * declares `name` in the checked-dynamic slot, which has no ABSENT state: + * an optional such field cannot be removed by a `delete`. */ +function declaresCheckedDynField(lowerer: Lowerer, type: IrType, name: string): boolean { + if (type.kind === "record") { + const shape = lowerer.shapes.get(type.shapeId); + if (!shape || shape.tuple) return false; + return shape.fields.find((f) => f.name === name)?.type.kind === "dyn"; + } + if (type.kind !== "union") return false; + const arms = lowerer.unions.get(type.unionId)?.arms ?? []; + return arms.length > 0 && arms.every((arm) => declaresCheckedDynField(lowerer, arm, name)); +} + /** Statement-position `delete`: process.env keys → process.envUnset * (unsetenv), pure `Record` keys → recordKeyDelete (the * overflow Map delete), declared OPTIONAL fields → the field's ABSENT * state (the slot reads undefined and no longer counts as an own * property). Everything else * fences with the honest reason — a required field is a struct slot no - * runtime can remove. */ + * runtime can remove, and an optional `unknown`/`any` field maps to the + * checked-dynamic slot, which has no ABSENT state yet. */ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrStmt { const loc = locOf(expr); let target: ts.Expression = expr.expression; @@ -8083,6 +8122,21 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt loc, }; } + // The constant property key of the target (dot access or a literal + // bracket), when there is one. + const constantKey = ts.isPropertyAccessExpression(target) + ? target.name.text + : ts.isStringLiteral(target.argumentExpression) + ? target.argumentExpression.text + : null; + // An OPTIONAL `unknown`/`any` field rides the checked-dynamic slot, + // which has no ABSENT state yet (an omitted such field already reads as + // present), so it cannot delete. Name that reason instead of calling + // the field required or blaming the receiver. + const optionalCheckedDynDelete = + constantKey !== null && + declaresCheckedDynField(lowerer, obj.type, constantKey) && + declaredOptionalProperty(lowerer, target.expression, constantKey); if (obj.type.kind === "record") { const shape = lowerer.shapes.get(obj.type.shapeId); if (shape?.indexValue && shape.fields.length === 0 && !shape.tuple) { @@ -8099,14 +8153,9 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt // while a later write makes it present again. Constant keys only // (dot access or a literal bracket); required fields keep the honest // fence below. - const fieldName = ts.isPropertyAccessExpression(target) - ? target.name.text - : ts.isStringLiteral(target.argumentExpression) - ? target.argumentExpression.text - : null; const field = - fieldName !== null && !shape?.tuple - ? shape?.fields.find((f) => f.name === fieldName) + constantKey !== null && !shape?.tuple + ? shape?.fields.find((f) => f.name === constantKey) : undefined; if (field) { const absent = lowerer.absentFieldValue(field.type, loc); @@ -8133,12 +8182,18 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt "'delete' of hybrid index-signature keys (declared struct slots and overflow entries answer differently — model deletable dynamic keys with a pure Record)", ); } + if (optionalCheckedDynDelete) { + lowerer.unsupported("SC1090", expr, OPTIONAL_CHECKED_DYN_DELETE); + } lowerer.unsupported( "SC1090", expr, "'delete' of required record fields (a monomorphic shape cannot remove its slot — only optional fields delete, becoming the undefined arm)", ); } + if (optionalCheckedDynDelete) { + lowerer.unsupported("SC1090", expr, OPTIONAL_CHECKED_DYN_DELETE); + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-union-declared-merge.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-union-declared-merge.ts.json new file mode 100644 index 000000000..f5a641a1e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-union-declared-merge.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/spread-union-declared-merge.ts" + ], + "diags": [] +} diff --git a/tests/corpus/spread-union-declared-merge.ts b/tests/corpus/spread-union-declared-merge.ts new file mode 100644 index 000000000..5a9863ede --- /dev/null +++ b/tests/corpus/spread-union-declared-merge.ts @@ -0,0 +1,39 @@ +// A union source spread beside a runtime-keyed source into a declared record +// (`{ ...model.compat, ...config.compat }`): a record arm copies its own +// keys, a null or undefined arm copies nothing, and the source evaluates +// once, in order. Keys print sorted: key order through the declared merge +// is a separate, pre-existing limitation. +interface Compat { + imageInput?: string; + sendImageOptInHeader?: string; + maxTokensField?: string; +} +type Small = { imageInput?: string; sendImageOptInHeader?: string }; + +function merge(a: Small | undefined, b: Record): Compat { + return { ...a, ...b }; +} +function show(c: Compat): string { + return [c.imageInput, c.sendImageOptInHeader, c.maxTokensField, "imageInput" in c, Object.keys(c).sort().join(",")].join(" "); +} +console.log(show(merge({ imageInput: "base64", sendImageOptInHeader: "yes" }, { maxTokensField: "max_tokens", imageInput: "url" }))); +console.log(show(merge({ sendImageOptInHeader: "no" }, {}))); +console.log(show(merge(undefined, { maxTokensField: "max_tokens", imageInput: "url" }))); +console.log(show(merge(undefined, {}))); + +// Several record arms and a null arm: each arm copies its own keys. +type Other = { maxTokensField: string; extra?: string }; +function mergeArms(a: Small | Other | null, b: Record): Compat { + return { ...b, ...a }; +} +console.log(show(mergeArms({ maxTokensField: "own" }, { maxTokensField: "base", imageInput: "i" }))); +console.log(show(mergeArms({ imageInput: "own" }, { maxTokensField: "base", imageInput: "i" }))); +console.log(show(mergeArms(null, { sendImageOptInHeader: "h" }))); +// The source evaluates once, in order. +let calls = 0; +function source(): Small | undefined { + calls++; + return calls > 1 ? { imageInput: "second" } : undefined; +} +console.log(show(merge(source(), {})), show(merge(source(), { imageInput: "later" })), calls); +export {}; diff --git a/tests/diagnostics/delete-fences.ts b/tests/diagnostics/delete-fences.ts index 2771b5494..e7f70e937 100644 --- a/tests/diagnostics/delete-fences.ts +++ b/tests/diagnostics/delete-fences.ts @@ -6,3 +6,30 @@ type Hybrid = { base: string; [k: string]: string }; const h: Hybrid = { base: "b", extra: "e" }; delete h["extra"]; console.log(h.base); + +// An OPTIONAL checked-dynamic field (`unknown`/`any`) has no ABSENT state +// for a delete to write — the slot cannot represent "no property", and an +// omitted one already reads as present — so the refusal names that reason +// instead of calling the optional field required. +interface Dyn { + content: string[]; + structured?: unknown; +} +const d: Dyn = { content: [] }; +delete d.structured; + +// The same fence on a union of records: every arm declares the field +// optional and checked-dynamic. +interface DynA { + content: string[]; + structured?: unknown; + extra?: any; +} +interface DynB { + content: string[]; + structured?: unknown; +} +function drop(u: DynA | DynB): void { + delete u.structured; +} +drop(d); diff --git a/tests/harness/__snapshots__/delete-fences.ts.txt b/tests/harness/__snapshots__/delete-fences.ts.txt index 04ed2590f..df37f9ece 100644 --- a/tests/harness/__snapshots__/delete-fences.ts.txt +++ b/tests/harness/__snapshots__/delete-fences.ts.txt @@ -3,4 +3,18 @@ delete-fences.ts:7:1 - error SC1090: 'delete' of hybrid index-signature keys (de 6 | const h: Hybrid = { base: "b", extra: "e" }; 7 | delete h["extra"]; | ^~~~~~~~~~~~~~~~~ - 8 | console.log(h.base); \ No newline at end of file + 8 | console.log(h.base); + +delete-fences.ts:19:1 - error SC1090: 'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present) are not supported yet + + 18 | const d: Dyn = { content: [] }; + 19 | delete d.structured; + | ^~~~~~~~~~~~~~~~~~~ + 20 | + +delete-fences.ts:33:3 - error SC1090: 'delete' of optional 'unknown'/'any' fields (the checked-dynamic slot has no absent state yet — an omitted such field already reads as present) are not supported yet + + 32 | function drop(u: DynA | DynB): void { + 33 | delete u.structured; + | ^~~~~~~~~~~~~~~~~~~ + 34 | } \ No newline at end of file diff --git a/tests/harness/corpus-costs/spread-union-declared-merge.ts.json b/tests/harness/corpus-costs/spread-union-declared-merge.ts.json new file mode 100644 index 000000000..8e4dba83a --- /dev/null +++ b/tests/harness/corpus-costs/spread-union-declared-merge.ts.json @@ -0,0 +1 @@ +{ "plain": 4200, "san": 72000 }