mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-07 08:33:50 +08:00
Build union spread merges as the checked-dynamic object
`{ ...base, ...override }` over union-typed sources has one field set per
combination of source arms, so the merged value can carry fields from
several arms at once. When the per-arm distribution (slice 1a) cannot pick
one arm, the merge was refused with SC2003 and the site did not compile
(provider-composer's all-optional compat unions, mcp's aggregate config).
Build the object Node builds instead: the checked-dynamic literal, whose
keys are written in source order through the existing dynamic-object path
(field chunks, spreads by copy-data-properties, computed keys by
propertyKey), then converted into the declared union by the existing
checked conversion. Reflection, JSON and inspection see the keys Node
keeps; a value the union cannot hold throws the catchable TypeError at the
conversion rather than being reinterpreted as one arm. The fallback stays
narrow: union-typed spread source, auto tier, no capture buffer, and a
union the checked extraction can validate — everything that compiles
today lowers exactly as before, and `--tier=static-only` keeps the refusal
that tests/diagnostics/union-spread-merge.ts pins.
Corpus union-spread-merge.ts pins key order, last-writer-wins, nested
identity, the accepting typed consumer and the snapshot semantics
differentially; dyncheck's "union spread merge" covers the refusing
single-arm reinterpretation scriptc-only, since Node never checks a cast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8d6f723ed5
commit
b767c38e9e
@@ -1322,9 +1322,8 @@ export function lowerObjectLiteral(
|
||||
// Distribute against the contextual union when there is one (its arms
|
||||
// carry the discriminants), else against the literal's own union.
|
||||
const ctx = lowerer.mapTypeOf(tsType);
|
||||
const distributed = exact
|
||||
? null
|
||||
: lowerDistributedUnionSpread(lowerer, expr, ctx?.kind === "union" ? ctx : mapped);
|
||||
const unionCtx = ctx?.kind === "union" ? ctx : mapped;
|
||||
const distributed = exact ? null : lowerDistributedUnionSpread(lowerer, expr, unionCtx);
|
||||
if (distributed) return distributed;
|
||||
// A spread merge over UNION-typed sources (`{ ...base, ...override }`
|
||||
// with `base`, `override` of arm-union types) has one field set per
|
||||
@@ -1332,8 +1331,13 @@ export function lowerObjectLiteral(
|
||||
// No single arm of the contextual union can hold that value without
|
||||
// dropping the fields the other arms contributed (Node keeps them
|
||||
// all), and a runtime tag would be a guess about which arm the value
|
||||
// "is". Refuse, naming the candidate arms.
|
||||
// "is". Build the object Node builds instead — the checked-dynamic
|
||||
// copy — and let the union's checked conversion validate it.
|
||||
if (mapped?.kind === "union") {
|
||||
if (!exact && unionCtx?.kind === "union") {
|
||||
const merged = lowerUnionSpreadMergeDyn(lowerer, expr, unionCtx);
|
||||
if (merged) return merged;
|
||||
}
|
||||
const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType];
|
||||
const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))];
|
||||
lowerer.pushDiag(
|
||||
@@ -2886,6 +2890,47 @@ function lowerDistributedUnionSpread(
|
||||
};
|
||||
}
|
||||
|
||||
/** `{ ...a, ...b }` over union-typed sources that lowerDistributedUnionSpread
|
||||
* declined: no single destination arm holds every key the contributors may
|
||||
* write (`provider-composer`'s two all-optional compat unions, an aggregate
|
||||
* config merged from a http|stdio union plus a patch naming both). Node
|
||||
* builds ONE plain object: each source's own enumerable keys copy in source
|
||||
* order (later writers win), then the explicit fields write in their own
|
||||
* positions. Build exactly that object — the checked-dynamic literal — and
|
||||
* validate it into the declared union at the slot. The value keeps its
|
||||
* runtime keys (reflection, JSON, and inspection see what Node sees); a
|
||||
* value the union cannot hold throws the catchable TypeError at the
|
||||
* conversion instead of being reinterpreted as one arm.
|
||||
*
|
||||
* Null when the shape is not the SC2003 one (no union-typed spread source),
|
||||
* when the build must not demote (inside a capture buffer, or under
|
||||
* `--tier=static-only`, which keeps today's refusal), or when the union has
|
||||
* no checked extraction. The caller then keeps its refusal. */
|
||||
function lowerUnionSpreadMergeDyn(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.ObjectLiteralExpression,
|
||||
target: IrType & { kind: "union" },
|
||||
): IrExpr | null {
|
||||
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
|
||||
const spreadsUnion = expr.properties.nodes.some((prop) => {
|
||||
if (!ts.isSpreadAssignment(prop)) return false;
|
||||
let node: ts.Expression = prop.expression;
|
||||
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
||||
return lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "union";
|
||||
});
|
||||
if (!spreadsUnion) return null;
|
||||
if (lowerer.tierMode !== "auto" || lowerer.diagSink !== null) return null;
|
||||
if (
|
||||
!canDynCheckTo(
|
||||
target,
|
||||
(id) => lowerer.shapes.get(id),
|
||||
(id) => lowerer.unions.get(id),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), target);
|
||||
}
|
||||
|
||||
/** A union of plain records can preserve its existing variant while
|
||||
* replacing shared fields. Dispatch before constructing the copy: only
|
||||
* the selected arm evaluates overrides, and recordClone snapshots fields
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/union-spread-merge.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// A spread merge over union-typed sources whose destination union has no
|
||||
// single arm for the result: `{ ...base, ...override }` can carry fields from
|
||||
// several arms at once, and Node keeps them all. Where the per-arm
|
||||
// distribution cannot pick one arm (see union-spread-distribution.ts), the
|
||||
// merge builds the object Node builds — a checked-dynamic copy that writes
|
||||
// keys in source order and validates the value at its typed slots.
|
||||
// JSON pins key order, last-writer-wins and the kept keys; the identity check
|
||||
// shows nested values are copied by reference; the typed consumer and the
|
||||
// post-merge write exercise the checked conversion.
|
||||
|
||||
interface OpenAiCompat {
|
||||
supportsStore?: boolean;
|
||||
}
|
||||
|
||||
interface AnthropicCompat {
|
||||
supportsLongCacheRetention?: boolean;
|
||||
}
|
||||
|
||||
type Compat = OpenAiCompat | AnthropicCompat;
|
||||
|
||||
// 1. The provider-composer merge: every arm is all-optional, so no arm can
|
||||
// represent the union of both key sets. The keys land in source order.
|
||||
function mergeCompat(base: Compat | undefined, override: Compat | undefined): Compat | undefined {
|
||||
if (!override) return base;
|
||||
return { ...base, ...override } as NonNullable<Compat>;
|
||||
}
|
||||
|
||||
const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true });
|
||||
if (merged !== undefined) console.log("merged", JSON.stringify(merged));
|
||||
const kept = mergeCompat({ supportsStore: true }, undefined);
|
||||
if (kept !== undefined) console.log("no-override", JSON.stringify(kept));
|
||||
const filled = mergeCompat(undefined, { supportsLongCacheRetention: true });
|
||||
if (filled !== undefined) console.log("no-base", JSON.stringify(filled));
|
||||
|
||||
// 2. Overlapping keys: the later source wins the value, the earlier source
|
||||
// keeps the key's position.
|
||||
interface Left {
|
||||
first?: string;
|
||||
shared?: string;
|
||||
}
|
||||
interface Right {
|
||||
shared?: string;
|
||||
second?: string;
|
||||
}
|
||||
type Side = Left | Right;
|
||||
function overlap(base: Side, override: Side): Side {
|
||||
return { ...base, ...override } as Side;
|
||||
}
|
||||
console.log(
|
||||
"overlap",
|
||||
JSON.stringify(overlap({ first: "f", shared: "from-base" }, { shared: "from-override", second: "s" })),
|
||||
);
|
||||
|
||||
// 3. Nested values are copied by reference, not deep-copied.
|
||||
interface Inner {
|
||||
v: number;
|
||||
}
|
||||
interface XArm {
|
||||
kind: "x";
|
||||
inner: Inner;
|
||||
x?: number;
|
||||
}
|
||||
interface YArm {
|
||||
kind: "y";
|
||||
inner: Inner;
|
||||
y?: number;
|
||||
}
|
||||
type XY = XArm | YArm;
|
||||
const inner: Inner = { v: 9 };
|
||||
const nested = { ...({ kind: "x", inner: { v: 1 }, x: 2 } as XY), ...({ kind: "y", inner, y: 3 } as XY) } as XY;
|
||||
if (nested.kind === "y") console.log("nested", JSON.stringify(nested), nested.inner.v, nested.inner === inner);
|
||||
|
||||
// 4. A typed consumer accepts the merged value; the checked conversion at the
|
||||
// parameter is what validates it.
|
||||
function describe(compat: Compat): string {
|
||||
return JSON.stringify(compat);
|
||||
}
|
||||
if (merged !== undefined) console.log("describe", describe(merged));
|
||||
console.log("describe-single", describe({ supportsStore: false }));
|
||||
|
||||
// 5. The merge snapshots its sources: a later write to a source is not seen.
|
||||
const source: OpenAiCompat = { supportsStore: true };
|
||||
const snapshot = mergeCompat(source, { supportsLongCacheRetention: true });
|
||||
source.supportsStore = false;
|
||||
if (snapshot !== undefined) console.log("snapshot", JSON.stringify(snapshot), JSON.stringify(source));
|
||||
|
||||
// 6. A merge with an explicit field after the spreads.
|
||||
function withExtra(base: Compat, override: Compat): Compat {
|
||||
return { ...base, ...override, supportsStore: true } as NonNullable<Compat>;
|
||||
}
|
||||
console.log("extra", JSON.stringify(withExtra({ supportsStore: false }, { supportsLongCacheRetention: true })));
|
||||
@@ -4,6 +4,12 @@
|
||||
// can represent it (naming one would drop the other arms' fields, which Node
|
||||
// keeps), and no runtime tag would be honest. The refusal names the candidate
|
||||
// arms; the repair is one arm per merge or one open all-optional record type.
|
||||
//
|
||||
// This fixture pins the refusal under `--tier=static-only`. The default tier
|
||||
// builds the object Node builds instead — a checked-dynamic copy whose keys
|
||||
// are written in source order and validated at each typed slot (see
|
||||
// tests/corpus/union-spread-merge.ts and the dyncheck fixture
|
||||
// "union spread merge") — so it is static-only that keeps this diagnostic.
|
||||
interface OpenAiCompat {
|
||||
kind: "openai";
|
||||
supportsStore?: boolean;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
union-spread-merge.ts:21:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable<Compat>' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat')
|
||||
union-spread-merge.ts:27:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable<Compat>' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat')
|
||||
|
||||
20 | if (!override) return base;
|
||||
21 | const merged = { ...base, ...override } as NonNullable<Compat>;
|
||||
26 | if (!override) return base;
|
||||
27 | const merged = { ...base, ...override } as NonNullable<Compat>;
|
||||
| ^~~~~~~~~~~~~~~~~~~~~~~~
|
||||
22 | return merged;
|
||||
28 | return merged;
|
||||
|
||||
hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 5100, "san": 3300 }
|
||||
@@ -1123,6 +1123,40 @@ console.log("recovered");
|
||||
expect(r.stdout).toBe("TypeError true\nscalar: TypeError\nrecovered\n");
|
||||
});
|
||||
|
||||
test("a union spread merge's checked-dynamic object refuses a single-arm reinterpretation catchably", async () => {
|
||||
// Where the per-arm distribution cannot pick one arm, a spread merge
|
||||
// over union sources builds the object Node builds — the checked-
|
||||
// dynamic copy — and the union's checked conversion validates it at
|
||||
// every typed slot. Node keeps the merged keys through a narrower
|
||||
// `as`; scriptc refuses to reinterpret them silently and throws the
|
||||
// catchable TypeError instead. That makes this path scriptc-only:
|
||||
// corpus union-spread-merge.ts pins the accepting paths differentially.
|
||||
const r = await compileAndRun(
|
||||
"union-spread-merge-dyn",
|
||||
`interface OpenAiCompat { supportsStore?: boolean }
|
||||
interface AnthropicCompat { supportsLongCacheRetention?: boolean }
|
||||
type Compat = OpenAiCompat | AnthropicCompat;
|
||||
function mergeCompat(base: Compat, override: Compat): Compat {
|
||||
return { ...base, ...override } as Compat;
|
||||
}
|
||||
const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true });
|
||||
console.log(JSON.stringify(merged));
|
||||
try {
|
||||
const single = merged as OpenAiCompat;
|
||||
console.log("unreachable", JSON.stringify(single));
|
||||
} catch (e) {
|
||||
console.log((e as Error).name, (e as Error).message.includes("not representable in the target union"));
|
||||
}
|
||||
console.log("recovered");
|
||||
`,
|
||||
);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toBe(
|
||||
'{"supportsStore":true,"supportsLongCacheRetention":true}\nTypeError true\nrecovered\n',
|
||||
);
|
||||
expect(r.stderr).toBe("");
|
||||
});
|
||||
|
||||
test("a unit smuggled into a PLAIN non-nullable slot throws the stranded trap catchably", async () => {
|
||||
// The stranded-UNIT trap without a union in sight: `null!` / `null as
|
||||
// any as T` into a plain typed slot (string, array, class, function).
|
||||
|
||||
Reference in New Issue
Block a user