From 04409081f990afbc4ff729346cc17217f89ec4d7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:35 -0700 Subject: [PATCH] Lower chain-guarded union reads from the stored union Inside an optional-chain body the checker types the guarded receiver by its non-nullish narrowing, often one record of a discriminated union, while the bound chain value still carries the whole present union: a runtime-optional local that crossed an `await` and a prior `stored?.type === "oauth"` return holds `ApiKeyCredential | OAuthCredential` at runtime. lowerUnionProperty gated on the spelled receiver type being a union, so the read fell through to the generic SC1090 fence ("reading 'type' from a value of type 'ApiKeyCredential'"). Take the union from the chain binding, which is the value lowerExpr is about to read; unionDisc then dispatches on the stored tag and maybeNarrow applies the checker's narrowing to the result. Adds the differential corpus program union-read-after-chain-guard and its emission test. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 24 ++++- .../union-read-after-chain-guard.ts.json | 6 ++ ...on-read-after-chain-guard-emission.test.ts | 89 +++++++++++++++++++ tests/corpus/union-read-after-chain-guard.ts | 48 ++++++++++ .../union-read-after-chain-guard.ts.json | 1 + 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json create mode 100644 packages/compiler/test/union-read-after-chain-guard-emission.test.ts create mode 100644 tests/corpus/union-read-after-chain-guard.ts create mode 100644 tests/harness/corpus-costs/union-read-after-chain-guard.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e2595f465..93ec89099 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3545,9 +3545,21 @@ export function lowerUnionProperty( expr: ts.PropertyAccessExpression, ): IrExpr | null { if (lowerer.chainBlocked(expr)) return null; - const receiverIr = + const spelled = structuralReadType(lowerer, expr.expression) ?? lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + // Inside an optional-chain body the guarded receiver is checker-typed by + // its non-nullish narrowing — often one record of a discriminated union + // — while the bound chain value still carries the whole present union + // (`stored?.type` after an earlier `stored?.type === "oauth"` return + // narrowed `stored` to `ApiKeyCredential`, though the runtime-optional + // storage still holds `ApiKeyCredential | OAuthCredential`). The union + // reader dispatches on the runtime tag, so take the union the chain + // binding hands to `lowerExpr` below instead of the spelled narrowing. + const chainRecv = lowerer.chainRecvByNode.get(expr.expression); + const chainUnion = + spelled?.kind !== "union" && chainRecv?.type.kind === "union" ? chainRecv.type : null; + const receiverIr = chainUnion ?? spelled; // The checker can rule the receiver UNREACHABLE and the assertion that // escapes it (`const x: never = event; (event as any).type` — the // `default:` branch of a switch that exhausts every arm) erases to @@ -3581,6 +3593,16 @@ export function lowerUnionProperty( // extract the instance and test its class, as typed slots do. else value = narrowStoredClassValue(lowerer, value, receiverIr) ?? value; } + // The spelled type is a narrowing, so every path below (dyn, record, + // object, array) was unreachable for this read before it took the stored + // union: answer it only when the union reader can, and otherwise hand the + // read back to the lowering the spelled type used to reach. A stored arm + // the checker ruled out that has no such member (`m1?.name` where the + // stored union still carries a `string` arm) must not turn an accepted + // program into a refusal. + if (chainUnion !== null) { + return value.type.kind === "union" ? lowerUnionValueRead(lowerer, expr, value) : null; + } // A checker-union receiver whose VALUE lowered to a plain RECORD (the // merged-signature fiction — `runner(cmd, args)` where runner joined // a structural runner type with spawnSync's, and the local adopted diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..223001d72 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-read-after-chain-guard.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-read-after-chain-guard.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-read-after-chain-guard-emission.test.ts b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts new file mode 100644 index 000000000..993b7afd4 --- /dev/null +++ b/packages/compiler/test/union-read-after-chain-guard-emission.test.ts @@ -0,0 +1,89 @@ +// A member read on a union receiver inside an optional-chain body: the +// checker narrows the guarded receiver to ONE union arm (a discriminant +// test through `?.`), while the bound chain value still carries the whole +// present union — the runtime-optional local a prior `if (s?.kind === ...) +// return` narrowed after an `await`. The read must dispatch on the value's +// runtime tag (unionDisc), not fall through to the unsupported-property +// fence the spelled single-record type would suggest. +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface ApiKeyCredential { type: 'api_key'; key?: string }", + "interface OAuthCredential { type: 'oauth'; refresh: string; expires: number }", + "type Credential = ApiKeyCredential | OAuthCredential;", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-chain-read-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("a chain-guarded union read dispatches on the stored union", async () => { + const compiled = await compileToIr([ + "function load(): Promise {", + " return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);", + "}", + "async function pick(): Promise {", + " const stored = await load();", + " if (stored?.type === 'oauth') return stored.expires > 0 ? 'oauth' : 'expired';", + " const credential = stored?.type === 'api_key' ? stored : undefined;", + " return credential?.type === 'api_key' ? (credential.key ?? 'none') : 'missing';", + "}", + "pick().then((answer) => console.log(answer));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // The guarded read still sees both stored arms; the tag switch is the + // shape that composes with the discriminant comparison. + const pick = bodies(compiled.module!, "pick"); + expect(pick).toContain('"unionDisc"'); + // The runtime-optional local crosses the await, so the presence test is + // the chain's own guard, not a checked extraction trap on every read. + expect(pick).not.toContain('"unsupported"'); +}); + +test("a chain-guarded read of a non-discriminant field reads the survivor", async () => { + const compiled = await compileToIr([ + "function only(c: Credential | undefined): string {", + " return c?.type === 'oauth' ? c.refresh : 'api';", + "}", + "console.log(only({ type: 'oauth', refresh: 'r', expires: 1 }));", + "console.log(only(undefined));", + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + expect(bodies(compiled.module!, "only")).toContain('"refresh"'); +}); diff --git a/tests/corpus/union-read-after-chain-guard.ts b/tests/corpus/union-read-after-chain-guard.ts new file mode 100644 index 000000000..f7c454f8b --- /dev/null +++ b/tests/corpus/union-read-after-chain-guard.ts @@ -0,0 +1,48 @@ +// A discriminated union held across an `await`: the early `stored?.type === +// "oauth"` return narrows the checker's `stored` to the api-key arm, but the +// runtime-optional storage still holds both arms. The later `s?.type` reads +// dispatch on the stored value's tag, so every credential shape answers as in +// Node — including the arm the earlier guard ruled out at runtime. +type ProviderEnv = Record; +interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: ProviderEnv; +} +interface OAuthCredentials { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +} +interface OAuthCredential extends OAuthCredentials { + type: "oauth"; +} +type Credential = ApiKeyCredential | OAuthCredential; + +const stored: (Credential | undefined)[] = [ + { type: "api_key", key: "sk-live", env: { REGION: "eu" } }, + { type: "oauth", refresh: "r1", access: "a1", expires: 42 }, + { type: "api_key" }, + undefined, +]; + +function load(i: number): Promise { + return Promise.resolve(stored[i]); +} + +async function describe(i: number): Promise { + const found = await load(i); + if (found?.type === "oauth") { + return found.expires > 10 ? `oauth ${found.refresh}/${found.access}` : "oauth expired"; + } + const credential = found?.type === "api_key" ? found : undefined; + const key = credential?.type === "api_key" ? (credential.key ?? "") : ""; + const region = credential?.env?.REGION ?? ""; + return `api ${key} ${region}`; +} + +async function main(): Promise { + for (let i = 0; i < stored.length; i++) console.log(i, await describe(i)); +} +main(); diff --git a/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json new file mode 100644 index 000000000..6bbc1dfa2 --- /dev/null +++ b/tests/harness/corpus-costs/union-read-after-chain-guard.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2300 }