mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1ed5ba53aa
commit
04409081f9
@@ -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
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/union-read-after-chain-guard.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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<Credential | undefined> {",
|
||||
" return Promise.resolve({ type: 'api_key', key: 'k' } as Credential);",
|
||||
"}",
|
||||
"async function pick(): Promise<string> {",
|
||||
" 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"');
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
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<Credential | undefined> {
|
||||
return Promise.resolve(stored[i]);
|
||||
}
|
||||
|
||||
async function describe(i: number): Promise<string> {
|
||||
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 ?? "<none>") : "<missing>";
|
||||
const region = credential?.env?.REGION ?? "<no-env>";
|
||||
return `api ${key} ${region}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
for (let i = 0; i < stored.length; i++) console.log(i, await describe(i));
|
||||
}
|
||||
main();
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2200, "san": 2300 }
|
||||
Reference in New Issue
Block a user