From 256b7cd1128bb408181737a34df78096337908ca Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 04:57:30 -0700 Subject: [PATCH] Inspect program-declared Error subclasses with Node's constructor-name header Inspecting (util.format, %s, util.inspect, console) a value of an Error class whose hierarchy contains program-declared subclasses was refused: errorRenderable counted the subclass layout's repeated builtin prefix fields (name, message) as declared fields. Those are the runtime slots the error base already renders, so field-less subclasses now render. The runtime builds the stackless base from the instance's `name` (`[Error: m]`); Node's improveStack also names the constructor. Each program class (most derived first) is an instanceof arm applying that rule: `[Plain [Error]: m]`, or `[FetchFailedError: m]` when the constructor name contains the error's name. Runtime error classes keep their own header. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-inspect.ts | 198 +++++++++++++++++- .../corpus/6530-error-subclass-header.ts.json | 6 + tests/corpus/6530-error-subclass-header.ts | 32 +++ 3 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6530-error-subclass-header.ts.json create mode 100644 tests/corpus/6530-error-subclass-header.ts diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 3be155402..20c48a21d 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -12,6 +12,7 @@ import { type IrExpr, type IrFunction, type IrStmt, + type IrStrIntrinsicMethod, type IrType, RUNTIME_ERROR_CLASSES, STRING, @@ -110,13 +111,192 @@ function inspectKey(name: string): string { function errorRenderable(info: ClassInfo): boolean { // The builtin classes' name/message/code live in runtime slots (the // ScrError prefix insp.error reads), not IR fields; only USER-declared - // fields are own properties the runtime rendering would miss. #private - // fields are NOT own properties — Node never prints them — so they - // don't disqualify. - if (!info.builtinError && info.def.fields.some((f) => !f.name.startsWith("#"))) return false; + // fields are own properties the runtime rendering would miss. A user + // class's layout repeats its builtin base's prefix fields, which are + // those same slots. #private fields are NOT own properties — Node never + // prints them — so they don't disqualify. + if (!info.builtinError) { + const inherited = new Set(); + for (let c = info.base; c; c = c.base) { + if (c.builtinError || c.def.runtime) for (const f of c.def.fields) inherited.add(f.name); + } + if (info.def.fields.some((f) => !f.name.startsWith("#") && !inherited.has(f.name))) + return false; + } return info.subclasses.every((s) => errorRenderable(s)); } +/** The program-declared classes among `info` and its descendants, most + * derived first (an instance tests against its own class before an + * ancestor's). Runtime-provided error classes are not among them. */ +function userErrorClasses(info: ClassInfo): ClassInfo[] { + const out: ClassInfo[] = []; + const visit = (c: ClassInfo): void => { + for (const s of c.subclasses) visit(s); + if (!c.builtinError && !c.def.runtime) out.push(c); + }; + visit(info); + return out; +} + +/** An error value whose runtime class may be a program-declared subclass: + * the runtime renders the stackless base from the instance's `name` + * (`[Error: m]`), and Node's improveStack then names the constructor too + * (`[Plain [Error]: m]`). Each program class is an instanceof arm (the + * static class itself unconditionally) applying that header rule; other + * values keep the runtime rendering. */ +function userErrorInspectHelper( + lowerer: Lowerer, + t: IrType & { kind: "object" }, + info: ClassInfo, + loc: SrcLoc, +): string { + const key = `insp.userError:${typeKey(t)}`; + const existing = lowerer.inspectHelpers.get(key); + if (existing) return existing; + const name = `%util.insp.${lowerer.inspectHelpers.size}`; + lowerer.inspectHelpers.set(key, name); + const v = (): IrExpr => varRef("v.0", t, loc); + const base = (): IrExpr => ({ + kind: "libCall", + fn: "insp.error", + args: [v(), varRef("r.0", F64, loc), varRef("d.0", F64, loc)], + type: STRING, + loc, + }); + const body: IrStmt[] = []; + for (const c of userErrorClasses(info)) { + const display = c.def.jsName ?? c.decl?.name?.text ?? c.def.name.replace(/^%/, ""); + const ret: IrStmt = { + kind: "return", + value: improvedErrorBase(lowerer, v(), base(), display, loc), + loc, + }; + if (c === info) { + body.push(ret); + break; + } + body.push({ + kind: "if", + cond: { kind: "instanceOf", value: v(), className: c.def.name, type: BOOL, loc }, + then: [ret], + else_: null, + loc, + }); + } + if (body.at(-1)?.kind !== "return") body.push({ kind: "return", value: base(), loc }); + lowerer.liftedFns.push({ + name, + params: [ + { localId: "v.0", name: "v", type: t }, + { localId: "r.0", name: "r", type: F64 }, + { localId: "d.0", name: "d", type: F64 }, + ], + returnType: STRING, + locals: [ + { id: "v.0", name: "v", type: t, mutable: false }, + { id: "r.0", name: "r", type: F64, mutable: false }, + { id: "d.0", name: "d", type: F64, mutable: false }, + { id: "name.0", name: "name", type: STRING, mutable: true }, + { id: "raw.0", name: "raw", type: STRING, mutable: true }, + ], + body, + loc, + }); + return name; +} + +/** Node's improveStack over the stackless base (`[Error: m]`): when the + * instance's constructor name differs from its `name` and the name ends in + * "Error", the header names both — `[Plain [Error]: m]`, or + * `[FetchError: m]` when the constructor name already contains it. */ +function improvedErrorBase( + lowerer: Lowerer, + value: IrExpr, + base: IrExpr, + display: string, + loc: SrcLoc, +): IrExpr { + const str = ( + method: IrStrIntrinsicMethod, + receiver: IrExpr, + args: IrExpr[], + type: IrType, + ): IrExpr => ({ kind: "strIntrinsic", method, receiver, args, type, loc }); + const and = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "logical", + op: "&&", + left, + right, + type: BOOL, + loc, + }); + const eq = (left: IrExpr, right: IrExpr, negated = false): IrExpr => ({ + kind: "strEq", + negated, + left, + right, + type: BOOL, + loc, + }); + const nameRef = (): IrExpr => varRef("name.0", STRING, loc); + const baseRef = (): IrExpr => varRef("raw.0", STRING, loc); + const after = (): IrExpr => ({ + kind: "bin", + op: "+", + left: str("length", nameRef(), [], F64), + right: numLit(1, loc), + type: F64, + loc, + }); + const next = (): IrExpr => str("charAt", baseRef(), [after()], STRING); + const cond = and( + and( + eq(nameRef(), strLit(display, loc), true), + str("endsWith", nameRef(), [strLit("Error", loc)], BOOL), + ), + and(str("startsWith", baseRef(), [concatAll([strLit("[", loc), nameRef()], loc)], BOOL), { + kind: "logical", + op: "||", + left: eq(next(), strLit(":", loc)), + right: eq(next(), strLit("]", loc)), + type: BOOL, + loc, + }), + ); + const rest = (): IrExpr => str("slice", baseRef(), [after()], STRING); + const improved: IrExpr = { + kind: "ternary", + cond: str("includes", strLit(display, loc), [nameRef()], BOOL), + then: concatAll([strLit(`[${display}`, loc), rest()], loc), + else_: concatAll([strLit(`[${display} [`, loc), nameRef(), strLit("]", loc), rest()], loc), + type: STRING, + loc, + }; + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: "name.0", + init: { + kind: "fieldGet", + obj: lowerer.upcastTo(value, "%Error"), + className: "%Error", + field: "name", + type: STRING, + loc, + }, + loc, + }, + { kind: "varDecl", localId: "raw.0", init: base, loc }, + ], + result: { kind: "ternary", cond, then: improved, else_: baseRef(), type: STRING, loc }, + type: STRING, + loc, + }; +} + function isErrorClass(lowerer: Lowerer, className: string): boolean { if (RUNTIME_ERROR_CLASSES.has(className)) return true; return lowerer.isSubclassOf(className, "%Error"); @@ -372,6 +552,16 @@ export function inspectExpr( }; } if (isErrorClass(lowerer, t.className)) { + const info = lowerer.classes.get(t.className); + if (info && userErrorClasses(info).length > 0) { + return { + kind: "call", + callee: userErrorInspectHelper(lowerer, t, info, loc), + args: [value, recurse, depth], + type: STRING, + loc, + }; + } return { kind: "libCall", fn: "insp.error", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6530-error-subclass-header.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6530-error-subclass-header.ts.json new file mode 100644 index 000000000..084c4817a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6530-error-subclass-header.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6530-error-subclass-header.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6530-error-subclass-header.ts b/tests/corpus/6530-error-subclass-header.ts new file mode 100644 index 000000000..608b4e9be --- /dev/null +++ b/tests/corpus/6530-error-subclass-header.ts @@ -0,0 +1,32 @@ +// Inspecting an instance of a program-declared Error subclass names its +// constructor the way Node's improveStack does: `[Plain [Error]: m]`, or +// `[FetchFailedError: m]` when the constructor name already contains the +// error's name; runtime error classes keep their own header. (The stack is +// cleared: compiled errors carry none.) +import * as util from "node:util"; + +class Plain extends Error {} +class FetchFailedError extends Error {} +class Deeper extends Plain {} + +function clean(error: Error): Error { + error.stack = ""; + return error; +} + +function show(error: Error): void { + console.log(util.format(error), "|", util.format("%s", error)); + console.log(util.inspect({ nested: error })); +} + +show(clean(new Plain("p"))); +show(clean(new FetchFailedError("f"))); +show(clean(new Deeper("d"))); +show(clean(new Error("plain"))); +show(clean(new TypeError("type"))); +const typed: Plain = new Deeper("typed as the base"); +typed.stack = ""; +console.log(util.inspect(typed)); +const listed = [new Plain("in an array")]; +listed[0]!.stack = ""; +console.log(util.inspect(listed));