mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-03 07:54:40 +08:00
Merge a union's promise arms into one promise of their payload union
`void | Promise<A> | Promise<void> | Decision` (the agent-loop `finishTurn` hook, codemode's remote client) had no representation: a union arm's index is its runtime tag and a promise arm's tag IS the `instanceof Promise` test, so a second promise arm had no tag. The union was refused (SC2009) and every use site inherited the blocker. JavaScript cannot tell the arms apart: `instanceof Promise` answers true for both and `await` settles to either payload. The union therefore maps to one promise arm `Promise<A | undefined>`: payloads join like union arms (`void` becomes the undefined unit, an already-union payload flattens, identical payloads dedupe, a checked-dynamic payload absorbs the arms the dynamic representation holds faithfully). A payload with no union arm home (a RegExp payload, a promise-of-a-promise) keeps the whole union fenced, and the refusal now says why. A `Promise<A>` value flowing into the merged arm converts with the existing checked promise-payload widening (promisePayloadWidens / promiseWiden): the promise keeps its identity and its settlement turn, and the settled payload is checked against the payload slot. Cost: the merge only runs for a union that already has two promise arms, which is refused today. Every other union interns the same arms in the same order, so a fully typed program without such a union compiles to identical IR. Corpus 8414-promise-union-arms pins identity through the union slot, `instanceof Promise` narrowing, `await`, `.then`, the void arm, the rejection path and a non-promise data arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ab5b603e1f
commit
ea23feeb21
@@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
) {
|
||||
return DYN;
|
||||
}
|
||||
// SEVERAL PROMISE ARMS are ONE: `Promise<A> | Promise<B>` is
|
||||
// `Promise<A | B>`. A promise arm's tag IS the `instanceof Promise`
|
||||
// test, so JavaScript cannot tell the arms apart at run time (both
|
||||
// answer true, both `await` to a value of their payload), and the
|
||||
// merged payload union is exactly what `await` produces either way.
|
||||
// The merge happens here, in the union the checker gave us, so every
|
||||
// use site (a declaration slot, an argument, an `await`, `.then`)
|
||||
// sees the same one-promise-arm layout; the conversion of a
|
||||
// `Promise<A>` value into it is the checked promise-payload widening
|
||||
// (promisePayloadWidens / promiseWiden). A payload that cannot ride
|
||||
// that union (a promise of a promise, a regex or generator payload)
|
||||
// keeps the whole union fenced.
|
||||
const mergedArms = mergePromiseArms(arms, ctx);
|
||||
if (mergedArms === null) return null;
|
||||
// Promise arms were the whole union (`Promise<void> | Promise<undefined>`
|
||||
// merges to the plain `Promise<void>`): a one-armed union has no
|
||||
// representation and needs none — the merge left the arm alone.
|
||||
if (mergedArms.length === 1) {
|
||||
if (unions.recursivePending(widened)) return null;
|
||||
return mergedArms[0]!;
|
||||
}
|
||||
if (
|
||||
arms.some(
|
||||
mergedArms.some(
|
||||
(a) =>
|
||||
a.kind === "void" ||
|
||||
a.kind === "union" ||
|
||||
a.kind === "dyn" ||
|
||||
// A generator arm only comes from an Iterable<T> (an optional
|
||||
// one keeps its unit arms beside the iterable union's).
|
||||
(a.kind === "generator" && iterableArmsElem(arms) === null),
|
||||
(a.kind === "generator" && iterableArmsElem(mergedArms) === null),
|
||||
) ||
|
||||
!maybePromiseUnionOk(parts, arms, ctx)
|
||||
!maybePromiseUnionOk(parts, mergedArms, ctx)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
mergedArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
const discriminant =
|
||||
unionDiscriminant(recordParts, arms, ctx) ??
|
||||
unionDiscriminant(recordParts, mergedArms, ctx) ??
|
||||
(substitutedUnions.length === 1
|
||||
? remapUnionDiscriminant(substitutedUnions[0]!, arms)
|
||||
? remapUnionDiscriminant(substitutedUnions[0]!, mergedArms)
|
||||
: undefined);
|
||||
if (unions.recursivePending(widened)) {
|
||||
// The knot closed through this union. A frame that resolved
|
||||
@@ -5446,9 +5467,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
// same ts.Type answers differently per instantiation — so
|
||||
// recursive generic-open unions stay fenced.
|
||||
if (contextResolutions !== sensitivityAtEntry) return null;
|
||||
return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) };
|
||||
return {
|
||||
kind: "union",
|
||||
unionId: unions.finalizeRecursive(widened, mergedArms, discriminant),
|
||||
};
|
||||
}
|
||||
return { kind: "union", unionId: unions.intern(arms, discriminant) };
|
||||
return { kind: "union", unionId: unions.intern(mergedArms, discriminant) };
|
||||
} finally {
|
||||
unions.inProgress.delete(widened);
|
||||
}
|
||||
@@ -8871,6 +8895,79 @@ function maybePromiseUnionOk(
|
||||
return maybePromiseThenableArm(parts, ctx) === null;
|
||||
}
|
||||
|
||||
/** Merge the promise arms of one union into a single `Promise<A | B>`
|
||||
* (mapTypeInner's union rule): JavaScript-wise the arms are
|
||||
* indistinguishable — `instanceof Promise` answers true for both, and
|
||||
* `await` settles to the payload union — so the merged arm is the same
|
||||
* value in the same slot. Payloads join like union arms: a `void` payload
|
||||
* becomes the undefined unit, a payload that is already a union flattens,
|
||||
* and a checked-dynamic payload (`Promise<unknown>`) absorbs the arms the
|
||||
* checked-dynamic representation holds faithfully (dynSubsumableUnionArm).
|
||||
* Null keeps the whole union fenced: a promise-of-a-promise payload, a
|
||||
* payload with no union home (regex, generator), or a dyn payload beside an
|
||||
* arm whose semantics would degrade. */
|
||||
function mergePromiseArms(arms: IrType[], ctx: TypeMapperCtx): IrType[] | null {
|
||||
let promiseArms = 0;
|
||||
for (const arm of arms) if (arm.kind === "promise") promiseArms++;
|
||||
if (promiseArms < 2) return arms;
|
||||
const payloads: IrType[] = [];
|
||||
let dynamic = false;
|
||||
for (const arm of arms) {
|
||||
if (arm.kind !== "promise") continue;
|
||||
const inner = arm.inner;
|
||||
if (inner.kind === "void") {
|
||||
payloads.push(UNDEFINED_T);
|
||||
continue;
|
||||
}
|
||||
if (inner.kind === "dyn") {
|
||||
dynamic = true;
|
||||
continue;
|
||||
}
|
||||
if (inner.kind === "union") {
|
||||
const substituted = ctx.unions.get(inner.unionId);
|
||||
if (!substituted?.arms.length) return null;
|
||||
payloads.push(...substituted.arms);
|
||||
continue;
|
||||
}
|
||||
payloads.push(inner);
|
||||
}
|
||||
if (dynamic) {
|
||||
if (!payloads.every((arm) => dynSubsumableUnionArm(arm, ctx))) return null;
|
||||
payloads.length = 0;
|
||||
payloads.push(DYN);
|
||||
}
|
||||
const byKey = new Map<string, IrType>();
|
||||
for (const arm of payloads) {
|
||||
// A promise payload has no arm home beside the others: `await` of the
|
||||
// merged promise would adopt it as a thenable, which no union tag does.
|
||||
if (arm.kind === "promise") return null;
|
||||
byKey.set(typeKey(arm), arm);
|
||||
}
|
||||
const payload = [...byKey.values()];
|
||||
if (payload.length === 0) return null;
|
||||
// Every arm is the undefined unit: the promise never settles a value,
|
||||
// exactly `Promise<void>`.
|
||||
if (payload.every((arm) => arm.kind === "undefinedT")) payload.length = 0;
|
||||
// One payload arm is that arm itself (a regex or a promise payload has a
|
||||
// promise slot but no union arm); a JOINT payload is a union, so its arms
|
||||
// need union homes.
|
||||
const inner: IrType | null =
|
||||
payload.length === 0
|
||||
? VOID
|
||||
: payload.length === 1
|
||||
? payload[0]!
|
||||
: payload.every((arm) => armHasUnionHome(arm))
|
||||
? {
|
||||
kind: "union",
|
||||
unionId: ctx.unions.intern(
|
||||
[...payload].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const merged: IrType | null = inner === null ? null : { kind: "promise", inner };
|
||||
return merged === null ? null : [...arms.filter((arm) => arm.kind !== "promise"), merged];
|
||||
}
|
||||
|
||||
/** The first non-promise constituent of a promise-armed union whose type
|
||||
* has a `then` member (a thenable data arm), or null. */
|
||||
function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null {
|
||||
@@ -9025,7 +9122,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx):
|
||||
}
|
||||
const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise");
|
||||
if (promiseArms.length > 1) {
|
||||
return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`;
|
||||
return `the union shape is supported, but its promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}') do not merge into one promise (their payloads have no common union layout)`;
|
||||
}
|
||||
if (promiseArms.length === 1 && dataArms.length > 1) {
|
||||
const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func");
|
||||
|
||||
@@ -77,16 +77,62 @@ test("an equal promise arm stays a plain copy", async () => {
|
||||
expect(promisePayloadConversions(retag)).toBe(0);
|
||||
});
|
||||
|
||||
test("two destination promise arms keep the conversion fence", async () => {
|
||||
test("two destination promise arms merge into one widened payload", async () => {
|
||||
// `Promise<unknown> | Promise<string>` is `Promise<unknown>`: the
|
||||
// checked-dynamic payload absorbs the faithful one, so the destination is
|
||||
// the same one-promise-arm union the single-arm case has.
|
||||
const result = await compileSource(`
|
||||
function settle(flag: boolean): Promise<void> | undefined {
|
||||
return flag ? Promise.resolve() : undefined;
|
||||
}
|
||||
const ambiguous: Promise<unknown> | Promise<string> | undefined = settle(true);
|
||||
console.log(ambiguous === undefined);
|
||||
const merged: Promise<unknown> | Promise<string> | undefined = settle(true);
|
||||
console.log(merged === undefined);
|
||||
export {};
|
||||
`);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.diagnostics.map((d) => d.code)).toContain("SC2003");
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag."));
|
||||
expect(retag).toHaveLength(1);
|
||||
expect(promisePayloadConversions(retag)).toBe(1);
|
||||
});
|
||||
|
||||
test("several promise arms settle to one promise of the payload union", async () => {
|
||||
const result = await compileSource(`
|
||||
interface Decision { kind: "stop"; note: string }
|
||||
interface Settled { reason: string }
|
||||
function pick(n: number): void | Promise<Settled> | Promise<void> | Decision {
|
||||
if (n === 0) return;
|
||||
if (n === 1) return Promise.resolve({ reason: "one" });
|
||||
return { kind: "stop", note: "x" };
|
||||
}
|
||||
async function main(): Promise<void> {
|
||||
const hit = pick(1);
|
||||
if (hit instanceof Promise) console.log("then", await hit.then((v) => v === undefined));
|
||||
const decided = await pick(2);
|
||||
console.log("decision", decided === undefined ? "?" : "note" in decided ? decided.note : "?");
|
||||
}
|
||||
void main();
|
||||
export {};
|
||||
`);
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const unions = result.module.unions ?? [];
|
||||
// The IR invariant the merge establishes: no union holds two promise arms
|
||||
// (the promise arm's tag IS the `instanceof Promise` test).
|
||||
for (const union of unions)
|
||||
expect(union.arms.filter((arm) => arm.kind === "promise").length).toBeLessThan(2);
|
||||
// The declared union is the promise beside the undefined unit.
|
||||
const armed = unions.filter((union) => union.arms.some((arm) => arm.kind === "promise"));
|
||||
expect(armed.length).toBeGreaterThan(0);
|
||||
const promiseArm = armed[0]!.arms.find((arm) => arm.kind === "promise");
|
||||
expect(promiseArm?.kind).toBe("promise");
|
||||
// `void | Promise<Settled> | Promise<void>` merges to a payload UNION:
|
||||
// the settled record beside the undefined unit.
|
||||
if (promiseArm?.kind === "promise") {
|
||||
expect(promiseArm.inner.kind).toBe("union");
|
||||
if (promiseArm.inner.kind === "union") {
|
||||
const payload = unions.find((union) => union.id === promiseArm.inner.unionId);
|
||||
expect(payload?.arms.map((arm) => arm.kind).sort()).toEqual(["record", "undefinedT"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8417-promise-union-arms.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Several promise arms in one union: `void | Promise<A> | Promise<void> |
|
||||
// Decision` is one promise arm `Promise<A | undefined>` — `instanceof
|
||||
// Promise` is true for either arm and `await` settles to the payload union.
|
||||
interface Decision {
|
||||
kind: "stop" | "continue";
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface Settled {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type TurnResult = void | Promise<Settled> | Promise<void> | Decision;
|
||||
|
||||
function pick(n: number): TurnResult {
|
||||
if (n === 0) return;
|
||||
if (n === 1) return Promise.resolve({ reason: "one" });
|
||||
if (n === 2) return Promise.resolve();
|
||||
return { kind: "continue", note: "x" };
|
||||
}
|
||||
|
||||
function fail(): void | Promise<Settled> {
|
||||
return Promise.reject(new Error("boom"));
|
||||
}
|
||||
|
||||
class Agent {
|
||||
finishTurn?: (turn: number) => TurnResult;
|
||||
|
||||
async turn(n: number): Promise<string> {
|
||||
const hook = this.finishTurn;
|
||||
if (!hook) return "none";
|
||||
const out = await hook(n);
|
||||
if (out === undefined) return "void";
|
||||
if ("note" in out) return `decision:${out.note}`;
|
||||
return `settled:${out.reason}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// A promise stored in the union is the same object: the payload
|
||||
// conversion never re-boxes it.
|
||||
const original = Promise.resolve({ reason: "one" });
|
||||
const stored: TurnResult = original;
|
||||
console.log("identity", stored === original);
|
||||
|
||||
const hit = pick(1);
|
||||
console.log("promise", hit instanceof Promise, pick(3) instanceof Promise);
|
||||
if (hit instanceof Promise) {
|
||||
const settled = await hit;
|
||||
console.log("narrowed", settled === undefined ? "void" : "object");
|
||||
console.log("then", await hit.then((value) => (value === undefined ? "u" : "s")));
|
||||
}
|
||||
|
||||
console.log("void", (await pick(0)) === undefined);
|
||||
const settled = await pick(1);
|
||||
console.log("awaited", typeof settled === "object" && "reason" in settled ? settled.reason : "?");
|
||||
const decided = await pick(3);
|
||||
console.log("decision", typeof decided === "object" && "note" in decided ? decided.note : "?");
|
||||
|
||||
const empty = pick(2);
|
||||
if (empty instanceof Promise) await empty.then(() => console.log("then void"));
|
||||
|
||||
try {
|
||||
await fail();
|
||||
} catch (error) {
|
||||
console.log("rejected", (error as Error).message);
|
||||
}
|
||||
|
||||
const agent = new Agent();
|
||||
console.log("no hook", await agent.turn(1));
|
||||
agent.finishTurn = pick;
|
||||
console.log("hook", await agent.turn(1), await agent.turn(3), await agent.turn(0));
|
||||
}
|
||||
void main();
|
||||
@@ -10,11 +10,13 @@ function thenable(): Thenish | Promise<number> {
|
||||
}
|
||||
console.log(typeof thenable);
|
||||
|
||||
// Two promise arms: the union tag cannot tell them apart by instanceof.
|
||||
function twoPromises(): Promise<number> | Promise<string> | boolean {
|
||||
// Promise arms merge into one promise of their payload union (`await` settles
|
||||
// to either payload). They do not merge when a payload has no union arm home:
|
||||
// a RegExp payload is the promise slot's own, not an arm.
|
||||
function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
|
||||
return true;
|
||||
}
|
||||
console.log(typeof twoPromises);
|
||||
console.log(typeof splitPromises);
|
||||
|
||||
// Promises have no inspect lowering, so neither does a union holding one.
|
||||
function maybe(b: boolean): string | Promise<string> {
|
||||
|
||||
@@ -16,36 +16,36 @@ maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blo
|
||||
|
||||
hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it
|
||||
|
||||
maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<number>' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise<string>' and 'Promise<number>'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)
|
||||
maybe-promise-fences.ts:16:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<RegExp>' cannot be compiled: the union shape is supported, but its promise arms ('Promise<string>' and 'Promise<RegExp>') do not merge into one promise (their payloads have no common union layout)
|
||||
|
||||
13 | // Two promise arms: the union tag cannot tell them apart by instanceof.
|
||||
14 | function twoPromises(): Promise<number> | Promise<string> | boolean {
|
||||
| ^~~~~~~~~~~
|
||||
15 | return true;
|
||||
15 | // a RegExp payload is the promise slot's own, not an arm.
|
||||
16 | function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
|
||||
| ^~~~~~~~~~~~~
|
||||
17 | return true;
|
||||
|
||||
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
|
||||
|
||||
maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration
|
||||
maybe-promise-fences.ts:19:20 - error SC2004: uses of 'splitPromises' inherit the blocker on its declaration
|
||||
|
||||
16 | }
|
||||
17 | console.log(typeof twoPromises);
|
||||
| ^~~~~~~~~~~
|
||||
18 |
|
||||
18 | }
|
||||
19 | console.log(typeof splitPromises);
|
||||
| ^~~~~~~~~~~~~
|
||||
20 |
|
||||
|
||||
hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it
|
||||
hint: the declaration of 'splitPromises' did not compile — fix the diagnostic reported there and these sites clear with it
|
||||
|
||||
maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
|
||||
maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
|
||||
|
||||
22 | }
|
||||
23 | console.log(maybe(true));
|
||||
24 | }
|
||||
25 | console.log(maybe(true));
|
||||
| ^~~~~~~~~~~
|
||||
24 |
|
||||
26 |
|
||||
|
||||
maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
|
||||
maybe-promise-fences.ts:29:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
|
||||
|
||||
26 | // must be that arm's own promise type.
|
||||
27 | const widened = Promise.resolve<string | number>(maybe(false));
|
||||
28 | // must be that arm's own promise type.
|
||||
29 | const widened = Promise.resolve<string | number>(maybe(false));
|
||||
| ^~~~~~~~~~~~
|
||||
28 | console.log(typeof widened);
|
||||
30 | console.log(typeof widened);
|
||||
|
||||
hint: the promise arm passes through identically, so it must already be the result's promise type
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 4400, "san": 5400 }
|
||||
Reference in New Issue
Block a user