mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Spread unions with an absent arm into declared records; explain the optional-unknown delete refusal (#2639)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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<string, T>` 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<string, T>)",
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/spread-union-declared-merge.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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 {};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
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 | }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4200, "san": 72000 }
|
||||
Reference in New Issue
Block a user