Name the deepest failing path when a checked cast fails inside an optional record (#1548)

This commit is contained in:
Chris Tate
2026-10-20 17:52:53 -05:00
committed by GitHub
2 changed files with 83 additions and 0 deletions
+43
View File
@@ -39,6 +39,7 @@ import {
isDirentShape,
isDynTypedRefType,
isRefCounted,
isUnitType,
typeKey,
} from "../../ir/ir.js";
import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
@@ -697,6 +698,46 @@ export class LlDyn {
/** Emit the standard in-helper pending check: on a pending exception
* run `cleanup` and return `dummy`. */
/** The failure block of a union dynCheck whose value matched no arm. A
* value of the one container kind exactly one arm can take (an object
* for the union's only record arm, an array for its only array arm,
* beside unit and scalar arms) runs that arm's builder, which throws the
* deepest failing path and the reason there (`expected string at
* $.order.owner.id, got undefined`) instead of the union's own outer
* path. Other values, and an arm builder that unexpectedly succeeds,
* fall through to the union's message. Only the failure path changes. */
private unionArmFailure(B: BlockBuilder, def: IrUnionDef, kind: string): void {
const scalar = (arm: IrType): boolean =>
isUnitType(arm) ||
arm.kind === "f64" ||
arm.kind === "bool" ||
arm.kind === "string" ||
arm.kind === "bigint";
const containers = def.arms.filter((arm) => !scalar(arm));
const arm = containers.length === 1 ? containers[0]! : null;
const container =
arm?.kind === "array"
? DYN_KIND.ARR
: arm?.kind === "record" && !this.host.recordsById.get(arm.shapeId)?.tuple
? DYN_KIND.OBJ
: null;
if (!arm || container === null) return;
const host = this.host;
const deep = B.newLabel("dcu.deep");
const outer = B.newLabel("dcu.outer");
const same = B.tmp();
B.line(`${same} = icmp eq i32 ${kind}, ${container}`);
B.condBr(same, deep, outer);
B.startBlock(deep);
const built = B.tmp();
B.line(`${built} = call ptr @${this.dynCheckHelper(arm)}(ptr %d, ptr %path)`);
this.pendingBail(B, "dcu.deep", () => {}, "ptr null");
const rc = vAdapters(host, arm);
B.line(`call void ${rc.release}(ptr ${built})`);
B.br(outer);
B.startBlock(outer);
}
private pendingBail(B: BlockBuilder, hint: string, cleanup: () => void, dummy: string): void {
const p = B.tmp();
for (const line of this.host.pendingTestLines(p)) B.line(line.trimStart());
@@ -2291,6 +2332,7 @@ export class LlDyn {
B.line(`call void @scr_dyn_release_v(ptr ${materialized})`);
B.terminate(`ret ptr ${checked}`);
B.startBlock(lFail);
this.unionArmFailure(B, def, kind);
B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`);
B.terminate(`ret ptr null`);
break;
@@ -2359,6 +2401,7 @@ export class LlDyn {
);
B.terminate(`ret ptr ${checked}`);
B.startBlock(lFail);
this.unionArmFailure(B, def, kind);
B.line(`call void @scr_dyn_check_fail(ptr %path, ptr ${want}, ptr %d)`);
B.terminate(`ret ptr null`);
break;
+40
View File
@@ -305,6 +305,46 @@ console.log("unreachable", c.host);
);
});
test("optional record member: the failure names the deepest path inside it", async () => {
// `Partial<T>` makes every member an `X | undefined` union. A present
// object that fails X used to report only the union's own path
// ("expected object | undefined at $.order, got object"); the record
// arm's builder now reports where inside it the value fails.
const r = await compileAndRun(
"optional-record-deep",
`interface Order {
owner: { id: string; plan: { tier: string } };
total: number;
}
interface Envelope { order: Order; tags?: string[] }
const texts = [
'{"order":{"owner":{"id":"u1"},"total":3}}',
'{"order":{"owner":{"id":7,"plan":{"tier":"pro"}},"total":3}}',
'{"order":{"owner":{"id":"u1","plan":{"tier":"pro"}},"total":3},"tags":["a",2]}',
'{"order":5}',
];
for (const text of texts) {
try {
const e = JSON.parse(text) as Partial<Envelope>;
console.log("ok", e.order?.owner.id);
} catch (err) {
if (err instanceof TypeError) console.log(err.message);
}
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
[
"expected object at $.order.owner.plan, got undefined",
"expected string at $.order.owner.id, got number",
"expected string at $.tags[1], got number",
"expected object | undefined at $.order, got number",
"",
].join("\n"),
);
});
test("JSON null matches nothing", async () => {
const r = await compileAndRun(
"null-value",