diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2cccda19c..44213e2d2 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -398,6 +398,7 @@ import { } from "./shapes.js"; import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; import { INLINE_LITERAL_BYTES, literalEqualityDefinition } from "./string-literal-equality.js"; +import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js"; export { LlvmUnsupportedError } from "./unsupported.js"; @@ -10356,11 +10357,16 @@ export class LlEmitter { if (canStackUnion(e, this.boxedUnionsById)) return emitStackUnion(this, e).value; if (e.kind === "unionNarrow") { const union = this.emitUnionProjection(e.value); + emitUnionNarrowGuard(this, union.name, e.unionId, e.tag); return { name: this.unionPeek(union.name, e.unionId), type: e.type }; } if ((e.kind === "downcast" || e.kind === "upcast") && e.value.type.kind === "object") { - // Prefix layouts: a class cast reads the operand's own pointer. - return { name: this.emitReadReceiver(e.value).name, type: e.type }; + // Prefix layouts: a class cast reads the operand's own pointer; a + // downcast verifies its target class (narrow-guards.ts). + const receiver = this.emitReadReceiver(e.value); + if (e.kind === "downcast" && e.type.kind === "object") + emitDowncastGuard(this, receiver.name, e.value.type.className, e.type.className); + return { name: receiver.name, type: e.type }; } // A nullable-pointer field has no box to borrow. Projections use // emitUnionProjection; other borrowers get an owned heap box. diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 059af307b..b1d810a3f 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -32,7 +32,6 @@ import { mangleVtStruct, } from "../mangle.js"; import { - classMembershipIntervals, classEnvironmentIndex, classStructSym, vtEntriesFor, @@ -52,6 +51,7 @@ import { inputSuffixWrites, } from "./borrowed-inputs.js"; import type { Writes } from "./reference-writes.js"; +import { emitClassMembership, emitDowncastGuard } from "./narrow-guards.js"; export function emitCallExpr( host: LlvmEmitterContext, @@ -1129,8 +1129,11 @@ export function emitCallExpr( // Prefix layout: both directions are reinterprets of the SAME // pointer — no RC traffic, ownership transfers from the operand // temp to the result temp (struck so the one +1 releases exactly - // once, under the RESULT type's release). + // once, under the RESULT type's release). A downcast verifies the + // class tsc's narrowing proved (narrow-guards.ts). const v = host.emitExpr(e.value); + if (e.kind === "downcast" && e.type.kind === "object" && v.type.kind === "object") + emitDowncastGuard(host, v.name, v.type.className, e.type.className); if (isRefCounted(v.type)) host.moveTemp(v); return host.own({ name: v.name, type: e.type }); } @@ -1142,24 +1145,14 @@ export function emitCallExpr( // The test only reads the vtable word: a borrowable operand needs no // retain/release pair around it. const v = emitBorrowedInputs(host, [e.value])[0]!; - const target = host.classMetaOf(e.className); - const pre = host.loadVtPre(v.name, e.value.type.className); - let result: string | undefined; - for (const interval of classMembershipIntervals(host.classMeta, target.def.name)) { - const ge = B.tmp(), - le = B.tmp(), - test = B.tmp(); - B.line(`${ge} = icmp uge ${host.sizeType} ${pre}, ${interval.pre}`); - B.line(`${le} = icmp ule ${host.sizeType} ${pre}, ${interval.post}`); - B.line(`${test} = and i1 ${ge}, ${le} ; instanceof ${e.className}`); - if (result === undefined) result = test; - else { - const combined = B.tmp(); - B.line(`${combined} = or i1 ${result}, ${test}`); - result = combined; - } - } - return { name: result!, type: e.type }; + const result = emitClassMembership( + host, + v.name, + e.value.type.className, + e.className, + `instanceof ${e.className}`, + ); + return { name: result, type: e.type }; } case "virtualCall": { // Dispatch through the receiver's vtable: the slot lives on the diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 9a8ef28fb..76df9795d 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -1,4 +1,9 @@ import { classMembershipIntervals } from "./classes.js"; +import { + emitCaughtInstanceof, + emitCaughtNarrowGuard, + emitUnionNarrowGuard, +} from "./narrow-guards.js"; import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; import { preservesDynTest } from "./checked-value-lifetimes.js"; import { typedRefConstructor } from "./shapes.js"; @@ -295,13 +300,14 @@ export function emitDynamicExpr( return host.own({ name: host.unionNewOwned(e.unionId, e.tag, v), type: e.type }); } case "unionNarrow": { - // Tag-UNCHECKED payload extraction: the frontend emits this only - // where tsc's control-flow narrowing proved the tag. Ref payloads - // come out +1. The receiver is consumed before any later expression. + // Payload extraction where tsc's control-flow narrowing proved the + // tag; the guard verifies it (narrow-guards.ts). Ref payloads come + // out +1. The receiver is consumed before any later expression. const u = host.emitUnionProjection(e.value); const arm = e.type; if (isUnitType(arm)) throw new InternalCompilerError(`llvm emitter bug: unionNarrow to unit arm ${arm.kind}`); + emitUnionNarrowGuard(host, u.name, e.unionId, e.tag); const v = host.unionExtract(u.name, e.unionId, arm); return host.own({ name: v, type: arm }); } @@ -974,56 +980,7 @@ export function emitDynamicExpr( // borrowed. SCR_EXC_STR = 3, SCR_EXC_F64 = 1, SCR_EXC_BOOL = 2. const c = host.emitReadReceiver(e.value); if (e.test === "instanceof") { - const target = host.classMetaOf(e.className!); - if (!target.hierarchy) { - // Standalone class payloads have no vtable word. Their retain - // adapter identifies the exact native layout in the snapshot. - const adapterSlot = B.tmp(); - const adapter = B.tmp(); - B.line(`${adapterSlot} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 5`); - B.line(`${adapter} = load ptr, ptr ${adapterSlot}`); - let result: string | undefined; - const intervals = classMembershipIntervals(host.classMeta, target.def.name); - for (const candidate of host.classMeta.values()) { - if ( - candidate.hierarchy || - !intervals.some((range) => range.pre <= candidate.pre && candidate.pre <= range.post) - ) - continue; - const rc = vAdapters(host.shapeHost, { kind: "object", className: candidate.def.name }); - const matches = B.tmp(); - B.line(`${matches} = icmp eq ptr ${adapter}, ${rc.retain}`); - if (result === undefined) result = matches; - else { - const joined = B.tmp(); - B.line(`${joined} = or i1 ${result}, ${matches}`); - result = joined; - } - } - if (result === undefined) - throw new InternalCompilerError("empty standalone class membership"); - if (e.negated !== true) return { name: result, type: e.type }; - const negated = B.tmp(); - B.line(`${negated} = xor i1 ${result}, true`); - return { name: negated, type: e.type }; - } - host.declare( - `declare zeroext i1 @scr_caught_instanceof(ptr, ${host.sizeType}, ${host.sizeType})`, - ); - let t: string | undefined; - for (const interval of classMembershipIntervals(host.classMeta, target.def.name)) { - const test = B.tmp(); - B.line( - `${test} = call zeroext i1 @scr_caught_instanceof(ptr ${c.name}, ${host.sizeType} ${interval.pre}, ${host.sizeType} ${interval.post})`, - ); - if (t === undefined) t = test; - else { - const combined = B.tmp(); - B.line(`${combined} = or i1 ${t}, ${test}`); - t = combined; - } - } - if (t === undefined) throw new InternalCompilerError("empty class membership"); + const t = emitCaughtInstanceof(host, c.name, e.className!); if (e.negated !== true) return { name: t, type: e.type }; const n = B.tmp(); B.line(`${n} = xor i1 ${t}, true`); @@ -1097,10 +1054,18 @@ export function emitDynamicExpr( return out; } case "caughtNarrow": { - // Checker-trusted extraction (the matching caughtTest was proven - // by tsc's narrowing): scalars read the snapshot's slots, - // refcounted payloads come out retained (+1). Box borrowed. + // Extraction where tsc's narrowing proved the matching caughtTest; + // the guard verifies it (narrow-guards.ts). Scalars read the + // snapshot's slots, refcounted payloads come out retained (+1). Box + // borrowed. const c = host.emitReadReceiver(e.value); + if ( + e.type.kind === "f64" || + e.type.kind === "bool" || + e.type.kind === "string" || + e.type.kind === "object" + ) + emitCaughtNarrowGuard(host, c.name, e.type); if (e.type.kind === "f64") { const p = B.tmp(); const v = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/narrow-guards.ts b/packages/compiler/src/backend/llvm/narrow-guards.ts new file mode 100644 index 000000000..883045b95 --- /dev/null +++ b/packages/compiler/src/backend/llvm/narrow-guards.ts @@ -0,0 +1,191 @@ +/* Runtime guards for checker-proven narrowings. + * + * `unionNarrow`, `downcast` and `caughtNarrow` extract a payload whose arm, + * class or kind TypeScript's control-flow narrowing proved. Emitted code + * verifies that proof once at the narrowing site: the tag, class interval or + * caught kind is compared and a mismatch stops the program through the + * runtime trap funnel instead of reading the payload as the wrong type. A + * correct program never takes the failing branch: the frontend lowers every + * narrowing it cannot prove through checked extraction, which throws the + * catchable TypeError. A failing guard is therefore a compiler bug, so it + * aborts rather than unwinding through code that assumed the extraction + * could not throw. The guards are cheap: one load and compare per site, + * which LLVM folds where an identical test already dominates. */ +import { InternalCompilerError } from "../../errors.js"; +import { classMembershipIntervals } from "./classes.js"; +import type { LlvmEmitterContext } from "./expr-context.js"; +import { vAdapters } from "./shapes.js"; + +export const NARROW_GUARD_MESSAGES = { + union: "scriptc: internal error: a narrowed union value holds a different arm\n", + downcast: "scriptc: internal error: a downcast object is not an instance of its target class\n", + caught: "scriptc: internal error: a narrowed catch binding holds a different value\n", +} as const; + +/** Continues in a fresh block where `ok` holds; otherwise traps with the + * message for `kind`. */ +export function emitNarrowGuard( + host: LlvmEmitterContext, + ok: string, + kind: keyof typeof NARROW_GUARD_MESSAGES, +): void { + const B = host.B; + host.declare(`declare i1 @llvm.expect.i1(i1, i1)`); + host.declare(`declare void @scr_trap(ptr)`); + const expected = B.tmp(); + B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${ok}, i1 true)`); + const pass = B.newLabel("narrow.ok"); + const fail = B.newLabel("narrow.bad"); + B.condBr(expected, pass, fail); + B.startBlock(fail); + B.line(`call void @scr_trap(ptr ${host.cstr(NARROW_GUARD_MESSAGES[kind])})`); + B.terminate("unreachable"); + B.startBlock(pass); +} + +/** Verifies that the union value `unionName` holds arm `tag`. A two-arm + * union holds it exactly when it does not hold the other arm; testing the + * other arm lets LLVM fold the guard under a dominating `!== other` test as + * well as under `=== tag` (a tag outside the arms is not a union value). */ +export function emitUnionNarrowGuard( + host: LlvmEmitterContext, + unionName: string, + unionId: string, + tag: number, +): void { + const ok = host.B.tmp(); + const actual = host.unionTag(unionName, unionId); + if (host.unionsById.get(unionId)?.arms.length === 2) + host.B.line(`${ok} = icmp ne i32 ${actual}, ${1 - tag}`); + else host.B.line(`${ok} = icmp eq i32 ${actual}, ${tag}`); + emitNarrowGuard(host, ok, "union"); +} + +/** The i1 `instanceof` test of the borrowed object `objName`, statically a + * `staticClassName`, against `targetClassName`'s preorder intervals. */ +export function emitClassMembership( + host: LlvmEmitterContext, + objName: string, + staticClassName: string, + targetClassName: string, + comment: string, +): string { + const B = host.B; + const target = host.classMetaOf(targetClassName); + const pre = host.loadVtPre(objName, staticClassName); + let result: string | undefined; + for (const interval of classMembershipIntervals(host.classMeta, target.def.name)) { + const ge = B.tmp(), + le = B.tmp(), + test = B.tmp(); + B.line(`${ge} = icmp uge ${host.sizeType} ${pre}, ${interval.pre}`); + B.line(`${le} = icmp ule ${host.sizeType} ${pre}, ${interval.post}`); + B.line(`${test} = and i1 ${ge}, ${le} ; ${comment}`); + if (result === undefined) result = test; + else { + const combined = B.tmp(); + B.line(`${combined} = or i1 ${result}, ${test}`); + result = combined; + } + } + return result!; +} + +/** Verifies that the borrowed object `objName` is a `targetClassName`. */ +export function emitDowncastGuard( + host: LlvmEmitterContext, + objName: string, + staticClassName: string, + targetClassName: string, +): void { + const ok = emitClassMembership( + host, + objName, + staticClassName, + targetClassName, + `downcast ${targetClassName}`, + ); + emitNarrowGuard(host, ok, "downcast"); +} + +/** The i1 `e instanceof C` test of the borrowed catch snapshot `caught`. + * Hierarchy classes compare the OBJ payload's vtable preorder; standalone + * class payloads have no vtable word, so their retain adapter identifies + * the exact native layout. */ +export function emitCaughtInstanceof( + host: LlvmEmitterContext, + caught: string, + className: string, +): string { + const B = host.B; + const target = host.classMetaOf(className); + const intervals = classMembershipIntervals(host.classMeta, target.def.name); + if (!target.hierarchy) { + const adapterSlot = B.tmp(); + const adapter = B.tmp(); + B.line(`${adapterSlot} = getelementptr inbounds %ScrCaught, ptr ${caught}, i64 0, i32 5`); + B.line(`${adapter} = load ptr, ptr ${adapterSlot}`); + let result: string | undefined; + for (const candidate of host.classMeta.values()) { + if ( + candidate.hierarchy || + !intervals.some((range) => range.pre <= candidate.pre && candidate.pre <= range.post) + ) + continue; + const rc = vAdapters(host.shapeHost, { kind: "object", className: candidate.def.name }); + const matches = B.tmp(); + B.line(`${matches} = icmp eq ptr ${adapter}, ${rc.retain}`); + if (result === undefined) result = matches; + else { + const joined = B.tmp(); + B.line(`${joined} = or i1 ${result}, ${matches}`); + result = joined; + } + } + if (result === undefined) throw new InternalCompilerError("empty standalone class membership"); + return result; + } + host.declare( + `declare zeroext i1 @scr_caught_instanceof(ptr, ${host.sizeType}, ${host.sizeType})`, + ); + let result: string | undefined; + for (const interval of intervals) { + const test = B.tmp(); + B.line( + `${test} = call zeroext i1 @scr_caught_instanceof(ptr ${caught}, ${host.sizeType} ${interval.pre}, ${host.sizeType} ${interval.post})`, + ); + if (result === undefined) result = test; + else { + const combined = B.tmp(); + B.line(`${combined} = or i1 ${result}, ${test}`); + result = combined; + } + } + if (result === undefined) throw new InternalCompilerError("empty class membership"); + return result; +} + +/** SCR_EXC_F64 = 1, SCR_EXC_BOOL = 2, SCR_EXC_STR = 3 (scr_exception.h). */ +export const CAUGHT_SCALAR_KINDS = { f64: 1, bool: 2, string: 3 } as const; + +/** Verifies that the borrowed catch snapshot `caught` holds a value of the + * narrowed type: a scalar's kind tag, or an object's class. */ +export function emitCaughtNarrowGuard( + host: LlvmEmitterContext, + caught: string, + type: { kind: "f64" | "bool" | "string" } | { kind: "object"; className: string }, +): void { + const B = host.B; + let ok: string; + if (type.kind === "object") { + ok = emitCaughtInstanceof(host, caught, type.className); + } else { + const kp = B.tmp(); + const k = B.tmp(); + ok = B.tmp(); + B.line(`${kp} = getelementptr inbounds %ScrCaught, ptr ${caught}, i64 0, i32 1`); + B.line(`${k} = load i32, ptr ${kp}`); + B.line(`${ok} = icmp eq i32 ${k}, ${CAUGHT_SCALAR_KINDS[type.kind]}`); + } + emitNarrowGuard(host, ok, "caught"); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts b/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts index 791df1340..0c4da61be 100644 --- a/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts +++ b/packages/compiler/src/frontend/lowering/expressions/evaluation-safety.ts @@ -6,9 +6,10 @@ import type { IrExpr } from "../../../ir/ir.js"; export function isSafeToRepeat(expr: IrExpr): boolean { if (expr.kind === "varRef") return true; if (expr.kind === "recordGet" || expr.kind === "fieldGet") return isSafeToRepeat(expr.obj); - // The trust-the-checker narrowing bridges over a pure read: extraction - // and downcast are reads too (the +1 on ref payloads is RC bookkeeping, - // not an observable effect — each emission owns its own copy). + // The narrowing bridges over a pure read: extraction and downcast are + // reads too (their guards only compare, and the +1 on ref payloads is RC + // bookkeeping, not an observable effect — each emission owns its own + // copy). if (expr.kind === "unionNarrow" || expr.kind === "downcast") return isSafeToRepeat(expr.value); return false; } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 0fb81e2da..18c034221 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -3128,9 +3128,9 @@ export function lowerTernary( * union-typed reference at use sites (`if (r.kind === "ok") { ...r... }` * types `r` as the ok-arm inside the branch); the IR value is still the * tagged union, so the read is bridged with a `unionNarrow` extracting - * the arm's payload. The extraction is tag-UNCHECKED at runtime — - * soundness rests entirely on tsc having proven the tag (the project's - * trust-the-checker thesis; see docs/ir.md). A checker type that maps to + * the arm's payload. The extraction relies on tsc having proven the tag; + * backends verify it once and trap on a mismatch, so a wrong proof stops + * the program instead of reading another arm. A checker type that maps to * the same union (unnarrowed use), to a SUB-union (partial narrowing — * unrepresentable without a re-tag), or to nothing (`never` in an * exhaustive default) leaves the expression union-typed. */ @@ -3320,8 +3320,8 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx } // instanceof narrowing for classes: tsc types this USE as a subclass of // the IR value's class (only a dynamic instanceof test narrows a class - // type), so the read bridges with an unchecked static downcast — the - // same trust-the-checker contract as the union bridge below. + // type), so the read bridges with a static downcast that backends verify + // at run time, like the union bridge below. if (expr.type.kind === "object") { const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node)); if (narrowed?.kind === "union") { @@ -3972,7 +3972,7 @@ function runtimeOptionalAssertionErases( * (`w = 5; if (w !== null)`), the comparison is statically decided and * folds to a bool literal — that drops only a side-effect-free read * (flow narrowing applies to references, never to calls), and soundness - * is the same trust-the-checker bet unionNarrow already makes. Null when + * rests on the same narrowing proof unionNarrow relies on. Null when * neither side is a unit literal (not a unit comparison). */ export function lowerUnitComparison( lowerer: Lowerer, @@ -11904,8 +11904,8 @@ function lowerCjsExportTableThisMember( /** A read of a catch binding — the caught analog of maybeNarrow. Where * tsc's control-flow narrowing has proven a supported test (`instanceof` * over a hierarchy class, `typeof` over a primitive), the read bridges - * with a kind-unchecked `caughtNarrow` extraction (trust-the-checker, - * exactly like unionNarrow). Every OTHER read is the narrowness fence: + * with a `caughtNarrow` extraction whose kind backends verify at run time, + * exactly like unionNarrow. Every OTHER read is the narrowness fence: * the binding's payload is a typed exception snapshot, not a dyn, so * un-narrowed uses have nothing sound to lower to. */ export function caughtRead( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 2cc51e789..e21409aee 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5405,7 +5405,7 @@ function lowerBranchSwitch( * cell, deliberately narrower than dyn: the supported uses are the * narrowing tests (`e instanceof C` over hierarchy classes, `typeof e * === "string"/"number"/"boolean"`), reads under a proven narrow - * (caughtRead bridges them with caughtNarrow, trust-the-checker like + * (caughtRead bridges them with caughtNarrow, verified at run time like * unionNarrow), and rethrow (`throw e`). Unsupported raw uses and * assignments are fenced (SC1063 and friends); destructuring patterns * are SC1062. Captures retain the snapshot beyond the catch scope. diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 4ae8eec67..5bd563d9d 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -11,9 +11,10 @@ import type { Lowerer } from "./lowerer.js"; * after assignments and tests, and keeps that narrowing across calls: a * callback, method or getter that clears or replaces the property (or a * closure that reassigns the binding) leaves the reference typed by its old - * value. Lowering bridges a narrowed union read with an UNCHECKED payload - * extraction and folds `?.`, truthiness and `typeof` by the narrowed type, - * so such a read would interpret the wrong arm. This analysis decides when + * value. Lowering bridges a narrowed union read with a payload extraction + * that traps on the wrong arm and folds `?.`, truthiness and `typeof` by the + * narrowed type, so such a read would abort or fold to the old value instead + * of throwing the catchable TypeError. This analysis decides when * the narrowing is provably current: * * - every property in the reference is construction-only (no store to its @@ -30,6 +31,10 @@ import type { Lowerer } from "./lowerer.js"; * A loop around the use that starts inside that span contributes its * whole body, since its back edge reaches the use again. * + * A binding initialized or assigned from a read whose narrowing may be + * stale inherits that narrowing (`const held: Expr = current` keeps the arm + * `current` was typed with), so its narrowed reads are never trusted. + * * TypeScript never carries a property narrowing into a nested function, * and every narrowing (an assignment, test, predicate or assertion of the * reference) is an occurrence of the reference, so a narrowed use always @@ -42,6 +47,7 @@ export class NarrowingStability { private readonly verdicts = new Map(); private readonly effects = new Map(); private readonly capturedMutable = new Map(); + private readonly staleBindings = new Map(); private readonly identifierWrites = new Map>(); private readonly widened = new Map(); private readonly plainContainers = new Map(); @@ -90,7 +96,7 @@ export class NarrowingStability { } if (ts.isIdentifier(node)) { const symbol = this.bindingOf(node); - if (!symbol || !this.isCapturedMutable(symbol)) return false; + if (!symbol || !this.mayHoldStaleNarrowing(symbol)) return false; return !this.trusted(node); } return false; @@ -113,7 +119,8 @@ export class NarrowingStability { declared = this.declaredPropertyType(node); } else { const symbol = this.bindingOf(node); - declared = symbol && this.isCapturedMutable(symbol) ? checker.getTypeOfSymbol(symbol) : null; + declared = + symbol && this.mayHoldStaleNarrowing(symbol) ? checker.getTypeOfSymbol(symbol) : null; } if (declared === null) return null; const narrowedTs = checker.getTypeAtLocation(node); @@ -245,6 +252,10 @@ export class NarrowingStability { ) return false; } + if (ts.isIdentifier(chain.root)) { + const root = this.bindingOf(chain.root); + if (root !== null && this.inheritsStaleNarrowing(root)) return false; + } const rootStable = chain.root.kind === ts.SyntaxKind.ThisKeyword || (ts.isIdentifier(chain.root) && !this.rootCapturedMutable(chain.root)); @@ -346,6 +357,63 @@ export class NarrowingStability { return !program.wildcard && !program.mutable.has(name) && !program.accessors.has(name); } + /** A binding whose narrowed reads may observe a narrowing that no longer + * holds: another function assigns it, or it inherits a stale narrowing. */ + private mayHoldStaleNarrowing(symbol: ts.Symbol): boolean { + return this.isCapturedMutable(symbol) || this.inheritsStaleNarrowing(symbol); + } + + /** The binding is initialized or assigned from a narrowed read that may be + * stale, so TypeScript's narrowing of the binding may be stale too. */ + private inheritsStaleNarrowing(symbol: ts.Symbol): boolean { + const cached = this.staleBindings.get(symbol); + if (cached !== undefined) return cached; + // A cycle of bindings assigned from each other contributes nothing. + this.staleBindings.set(symbol, false); + let result = false; + try { + const decl = this.lowerer.checker.valueDeclarationOf(symbol); + if (decl !== undefined && ts.isVariableDeclaration(decl)) { + if (decl.initializer !== undefined && this.mayBeStale(decl.initializer)) result = true; + else { + for (const write of this.writesIn(decl.getSourceFile()).get(symbol.name) ?? []) { + const assignment = write.parent; + if ( + ts.isBinaryExpression(assignment) && + assignment.left === write && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + this.lowerer.resolveValueSymbol(write) === symbol && + this.mayBeStale(assignment.right) + ) { + result = true; + break; + } + } + } + } + } finally { + this.staleBindings.set(symbol, result); + } + return result; + } + + /** A reference TypeScript narrows below its declared type where the + * narrowing may no longer hold. */ + private mayBeStale(expression: ts.Expression): boolean { + let e = expression; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e)) return false; + const checker = this.lowerer.checker; + let declared: ts.Type | null; + if (ts.isPropertyAccessExpression(e)) declared = this.declaredPropertyType(e); + else { + const symbol = this.bindingOf(e); + declared = symbol === null ? null : checker.getTypeOfSymbol(symbol); + } + if (declared === null || checker.getTypeAtLocation(e) === declared) return false; + return !this.trusted(e); + } + /** A `let`/`var`/parameter binding assigned from a function other than * the one that declares it: any call may change it. */ private isCapturedMutable(symbol: ts.Symbol): boolean { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 9591c684a..80ec0e972 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -344,8 +344,8 @@ export type IrType = * Runtime representation is a refcounted snapshot box (ScrCaught) holding * the taken exception: a kind tag plus the payload. Even NARROWER than * dyn: the only expressions a caught value may appear in are `caughtTest` - * (kind/instanceof tests), `caughtNarrow` (checker-trusted extraction - * under a proven test), and the `rethrow` statement — the frontend + * (kind/instanceof tests), `caughtNarrow` (extraction under a proven + * test, verified at run time), and the `rethrow` statement — the frontend * rejects every other use with the narrowing hint. */ | { kind: "caught" } | { kind: "promise"; inner: IrType } // heap, refcounted, settled-once @@ -2953,11 +2953,12 @@ export type IrExpr = * fulfillment payload (scr_await_void) and rejections flow untyped. * Ownership of the operand transfers to the result, like upcast. */ | { kind: "promiseVoidWiden"; value: IrExpr; type: IrType; loc: SrcLoc } - /** Checker-trusted narrowing of a base-class value to a subclass (`type` - * is the subclass). The frontend emits this only where tsc's control-flow - * narrowing has already proven the dynamic class (an `instanceof` guard) - * — the same trust-the-checker contract as unionNarrow: no runtime check, - * a pointer reinterpret with ownership transferring like upcast. */ + /** Narrowing of a base-class value to a subclass (`type` is the + * subclass). The frontend emits this only where tsc's control-flow + * narrowing has already proven the dynamic class (an `instanceof` guard), + * like unionNarrow. Backends verify the class once and trap on a mismatch + * a correct program never reaches, then reinterpret the pointer with + * ownership transferring like upcast. */ | { kind: "downcast"; value: IrExpr; type: IrType; loc: SrcLoc } /** `x instanceof C` where x's static class and C are both in extends- * hierarchies: an O(1) preorder-interval check against the vtable the @@ -3386,12 +3387,13 @@ export type IrExpr = type: IrType; loc: SrcLoc; } - /** Checker-trusted extraction of a catch binding's payload as `type` — - * the caught analog of unionNarrow: the frontend emits this only where - * tsc's control-flow narrowing has already proven the matching test - * (`e instanceof C` / `typeof e === "string"`), so the read is - * kind-UNCHECKED at runtime. `type` is f64, bool, string, or a - * class object; refcounted results come out retained (+1). */ + /** Extraction of a catch binding's payload as `type` — the caught + * analog of unionNarrow: the frontend emits this only where tsc's + * control-flow narrowing has already proven the matching test + * (`e instanceof C` / `typeof e === "string"`). Backends verify the kind + * or class once and trap on a mismatch a correct program never reaches. + * `type` is f64, bool, string, or a class object; refcounted results come + * out retained (+1). */ | { kind: "caughtNarrow"; value: IrExpr; type: IrType; loc: SrcLoc } /** CHECKED extraction of a catch binding's payload as a hierarchy-class * instance — the caught analog of dynCheck, emitted for `e as C` casts @@ -3417,11 +3419,12 @@ export type IrExpr = * varRef (borrowed); `type` is dyn; the result is a fresh tree (+1), * never aliasing the payload. Never throws. */ | { kind: "caughtToDyn"; value: IrExpr; type: IrType; loc: SrcLoc } - /** Union payload extraction, tag-UNCHECKED: `value` is union-typed, - * `type` is arms[tag], and the backend reads the payload assuming the tag - * — SOUNDNESS RESTS ON tsc's control-flow narrowing (the frontend emits - * this only where the checker has already narrowed the expression to that - * arm; see docs/ir.md). Refcounted payloads come out retained (+1). */ + /** Union payload extraction: `value` is union-typed and `type` is + * arms[tag]. The frontend emits this only where tsc's control-flow + * narrowing has already narrowed the expression to that arm; backends + * verify the tag once and trap on a mismatch a correct program never + * reaches, so a wrong proof cannot read the payload as another arm. + * Refcounted payloads come out retained (+1). */ | { kind: "unionNarrow"; unionId: string; tag: number; value: IrExpr; type: IrType; loc: SrcLoc } /** Discriminant read `r.kind` on a union receiver: every arm is a * record/class possessing field `field` with the SAME primitive IR type diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f7d30a1bb..d7211e756 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -2907,7 +2907,7 @@ function validateFunction( case "downcast": { // A pointer reinterpret is sound exactly between hierarchy // relatives: upcast widens a STRICT descendant to an ancestor, - // downcast (checker-trusted) narrows an ancestor to a STRICT + // downcast (verified at run time) narrows an ancestor to a STRICT // descendant. Upcast additionally widens CLASS VALUES // (classval:D → classval:C): the same pointer, type-only — legal // exactly when D strictly descends from C AND the two completed diff --git a/packages/compiler/test/narrow-guard-emission.test.ts b/packages/compiler/test/narrow-guard-emission.test.ts new file mode 100644 index 000000000..251809345 --- /dev/null +++ b/packages/compiler/test/narrow-guard-emission.test.ts @@ -0,0 +1,119 @@ +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 } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-narrow-guards-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The trap call guarding narrowings of `kind`, after its message constant. */ +function guardCount(llvm: string, message: string): number { + const constant = [...llvm.matchAll(/^(@[\w.]+) = [^\n]*c"([^"]*)"/gm)].find(([, , text]) => + text!.startsWith(message), + ); + if (constant === undefined) return 0; + return llvm.split(`call void @scr_trap(ptr ${constant[1]})`).length - 1; +} + +test("typeof narrowing verifies the union tag before reading the payload", async () => { + const llvm = await emitLlvm(` +function width(v: string | number): number { + if (typeof v === "string") return v.length; + return v; +} +console.log(width("abc") + width(4)); +`); + expect(guardCount(llvm, "scriptc: internal error: a narrowed union value")).toBeGreaterThan(0); +}); + +/** The tag compares that feed narrowing guards, as `eq K` / `ne K`. */ +function guardCompares(llvm: string): string[] { + const compares: string[] = []; + for (const fn of llvm.split("\ndefine ")) { + for (const [, expected, ok] of fn.matchAll( + /(%[\w.]+) = call i1 @llvm\.expect\.i1\(i1 (%[\w.]+), i1 true\)/g, + )) { + if (!fn.includes(`br i1 ${expected}, label %narrow.ok`)) continue; + const def = new RegExp(`^\\s*${ok} = icmp (eq|ne) i32 %[\\w.]+, (\\d+)$`, "m").exec(fn); + if (def) compares.push(`${def[1]} ${def[2]}`); + } + } + return compares; +} + +test("two-arm union guards test the other arm; wider unions test the arm", async () => { + const two = await emitLlvm(` +function width(v: string | number): number { + if (typeof v === "string") return v.length; + return v; +} +console.log(width("abc") + width(4)); +`); + const twoCompares = guardCompares(two); + expect(twoCompares.length).toBeGreaterThan(0); + expect(twoCompares.every((c) => c.startsWith("ne "))).toBe(true); + const three = await emitLlvm(` +function width(v: string | number | boolean): number { + if (typeof v === "string") return v.length; + if (typeof v === "number") return v; + return v ? 1 : 0; +} +console.log(width("abc") + width(4) + width(true)); +`); + const threeCompares = guardCompares(three); + expect(threeCompares.length).toBeGreaterThan(0); + expect(threeCompares.every((c) => c.startsWith("eq "))).toBe(true); +}); + +test("instanceof narrowing verifies the downcast class interval", async () => { + const llvm = await emitLlvm(` +class Base { id = 1; } +class Child extends Base { values = [2, 4]; } +function count(value: Base): number { + if (value instanceof Child) return value.values.length; + return value.id; +} +console.log(count(new Child()), count(new Base())); +`); + expect(guardCount(llvm, "scriptc: internal error: a downcast object")).toBeGreaterThan(0); +}); + +test("catch-binding narrowing verifies the caught kind and class", async () => { + const llvm = await emitLlvm(` +class Failure extends Error { code = 7; } +function run(which: number): string { + try { + if (which === 0) throw "text"; + throw new Failure("bad"); + } catch (e) { + if (typeof e === "string") return e; + if (e instanceof Failure) return String(e.code); + return "other"; + } +} +console.log(run(0), run(1)); +`); + expect(guardCount(llvm, "scriptc: internal error: a narrowed catch binding")).toBeGreaterThan(1); +}); + +test("programs without narrowings carry no guards", async () => { + const llvm = await emitLlvm(` +let total = 0; +for (let i = 0; i < 10; i++) total += i; +console.log(total); +`); + expect(llvm).not.toContain("scriptc: internal error: a narrowed"); + expect(llvm).not.toContain("scriptc: internal error: a downcast"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5770-guarded-union-narrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5770-guarded-union-narrows.ts.json new file mode 100644 index 000000000..18a2666e2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5770-guarded-union-narrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5770-guarded-union-narrows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5771-guarded-downcasts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5771-guarded-downcasts.ts.json new file mode 100644 index 000000000..c2570951b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5771-guarded-downcasts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5771-guarded-downcasts.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5772-guarded-catch-narrows.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5772-guarded-catch-narrows.ts.json new file mode 100644 index 000000000..6de65b731 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5772-guarded-catch-narrows.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5772-guarded-catch-narrows.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5773-stale-narrowing-inherited.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5773-stale-narrowing-inherited.ts.json new file mode 100644 index 000000000..7a82f68e4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5773-stale-narrowing-inherited.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5773-stale-narrowing-inherited.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5770-guarded-union-narrows.ts b/tests/corpus/5770-guarded-union-narrows.ts new file mode 100644 index 000000000..2673fb048 --- /dev/null +++ b/tests/corpus/5770-guarded-union-narrows.ts @@ -0,0 +1,88 @@ +// Union narrowings that TypeScript proves are verified at run time: every +// narrowing below reads its payload through a tag guard. Correct programs +// never trip it, so output matches Node across typeof, nullable, tagged, +// switch, early-return, loop and closure narrowings, including payloads +// borrowed for field reads and owned ones stored into arrays. +type Shape = + | { kind: "circle"; r: number } + | { kind: "rect"; w: number; h: number } + | { kind: "label"; text: string }; + +class Node2 { + value: number; + next: Node2 | null; + constructor(value: number, next: Node2 | null) { + this.value = value; + this.next = next; + } +} + +function describe(v: string | number | boolean): string { + if (typeof v === "string") return `string:${v.length}:${v.toUpperCase()}`; + if (typeof v === "number") return `number:${(v * 2).toFixed(1)}`; + return `boolean:${v ? "yes" : "no"}`; +} + +function area(s: Shape): number { + switch (s.kind) { + case "circle": + return Math.round(Math.PI * s.r * s.r); + case "rect": + return s.w * s.h; + case "label": + return s.text.length; + } +} + +function sumList(head: Node2 | null): number { + let total = 0; + let cur = head; + while (cur !== null) { + total += cur.value; + cur = cur.next; + } + return total; +} + +function firstWord(text: string | undefined): string { + if (text === undefined) return ""; + const words = text.split(" "); + return words[0] ?? ""; +} + +function pickNames(items: (string | number)[]): string[] { + const names: string[] = []; + for (const item of items) { + if (typeof item !== "string") continue; + names.push(item); + } + return names; +} + +function makeFormatter(prefix: string | null): (n: number) => string { + const p = prefix ?? "#"; + return (n) => `${p}${n}`; +} + +const values: (string | number | boolean)[] = ["abc", 4.25, true, "", 0, false]; +for (const v of values) console.log(describe(v)); + +const shapes: Shape[] = [ + { kind: "circle", r: 2 }, + { kind: "rect", w: 3, h: 5 }, + { kind: "label", text: "hello" }, +]; +let totalArea = 0; +for (let round = 0; round < 1000; round++) for (const s of shapes) totalArea += area(s); +console.log("area", totalArea); + +let list: Node2 | null = null; +for (let i = 1; i <= 100; i++) list = new Node2(i, list); +console.log("list", sumList(list), sumList(null)); + +console.log(firstWord("guarded narrows"), firstWord(undefined), firstWord("")); +console.log(pickNames(["a", 1, "b", 2, "c"]).join(",")); +const fmt = makeFormatter(null); +const fmt2 = makeFormatter("n="); +console.log(fmt(1), fmt2(2)); + diff --git a/tests/corpus/5771-guarded-downcasts.ts b/tests/corpus/5771-guarded-downcasts.ts new file mode 100644 index 000000000..a85b2e08f --- /dev/null +++ b/tests/corpus/5771-guarded-downcasts.ts @@ -0,0 +1,81 @@ +// instanceof narrowing downcasts objects to subclasses; the downcast is +// verified against the target's preorder intervals at run time. Covers +// multi-level hierarchies, abstract bases, generic classes, and receivers +// borrowed for field reads as well as owned values passed on. +abstract class Animal { + name: string; + constructor(name: string) { + this.name = name; + } + abstract sound(): string; +} +class Dog extends Animal { + tricks = ["sit", "roll"]; + sound(): string { + return "woof"; + } +} +class Puppy extends Dog { + age = 1; + sound(): string { + return "yip"; + } +} +class Cat extends Animal { + lives = 9; + sound(): string { + return "meow"; + } +} + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } +} +class LabeledBox extends Box { + label: string; + constructor(value: T, label: string) { + super(value); + this.label = label; + } +} + +function describe(a: Animal): string { + if (a instanceof Puppy) return `${a.name} puppy age ${a.age} ${a.tricks.length} tricks`; + if (a instanceof Dog) return `${a.name} dog ${a.tricks.join("+")}`; + if (a instanceof Cat) return `${a.name} cat ${a.lives} lives`; + return `${a.name} unknown`; +} + +function keepDogs(all: Animal[]): Dog[] { + const dogs: Dog[] = []; + for (const a of all) if (a instanceof Dog) dogs.push(a); + return dogs; +} + +function labelOf(b: Box): string { + return b instanceof LabeledBox ? `${b.label}=${b.value}` : `box=${b.value}`; +} + +function labelOfText(b: Box): string { + return b instanceof LabeledBox ? `${b.label}:${b.value}` : `text:${b.value}`; +} + +const zoo: Animal[] = [new Dog("rex"), new Cat("tom"), new Puppy("bit"), new Cat("kit")]; +for (const a of zoo) console.log(describe(a), a.sound()); +console.log( + "dogs", + keepDogs(zoo) + .map((d) => d.name) + .join(","), +); + +let tricks = 0; +for (let round = 0; round < 2000; round++) + for (const a of zoo) if (a instanceof Dog) tricks += a.tricks.length; +console.log("tricks", tricks); + +console.log(labelOf(new Box(1)), labelOf(new LabeledBox(2, "two"))); +console.log(labelOfText(new Box("a")), labelOfText(new LabeledBox("b", "bee"))); diff --git a/tests/corpus/5772-guarded-catch-narrows.ts b/tests/corpus/5772-guarded-catch-narrows.ts new file mode 100644 index 000000000..a29bda9a0 --- /dev/null +++ b/tests/corpus/5772-guarded-catch-narrows.ts @@ -0,0 +1,63 @@ +// Catch bindings narrowed by typeof or instanceof extract their payload +// through a kind or class guard. Covers string, number and boolean throws, +// Error subclass hierarchies, and a thrown standalone class. +class AppError extends Error { + code: number; + constructor(message: string, code: number) { + super(message); + this.name = "AppError"; + this.code = code; + } +} +class NotFound extends AppError { + path: string; + constructor(path: string) { + super(`missing ${path}`, 404); + this.name = "NotFound"; + this.path = path; + } +} +class Signal { + reason: string; + constructor(reason: string) { + this.reason = reason; + } +} + +function fail(which: number): void { + if (which === 0) throw "text"; + if (which === 1) throw 7.5; + if (which === 2) throw false; + if (which === 3) throw new NotFound("/etc/x"); + if (which === 4) throw new AppError("bad", 500); + if (which === 5) throw new RangeError("range"); + if (which === 6) throw new Signal("stop"); +} + +function classify(which: number): string { + try { + fail(which); + return "ok"; + } catch (e) { + if (typeof e === "string") return `string ${e.length} ${e}`; + if (typeof e === "number") return `number ${e * 2}`; + if (typeof e === "boolean") return `boolean ${!e}`; + if (e instanceof NotFound) return `notfound ${e.path} ${e.code}`; + if (e instanceof AppError) return `app ${e.code} ${e.message}`; + if (e instanceof Error) return `error ${e.name}: ${e.message}`; + if (e instanceof Signal) return `signal ${e.reason}`; + return "unknown"; + } +} + +for (let which = 0; which <= 7; which++) console.log(which, classify(which)); + +let codes = 0; +for (let i = 0; i < 500; i++) { + try { + fail(3 + (i % 2)); + } catch (e) { + if (e instanceof AppError) codes += e.code; + } +} +console.log("codes", codes); diff --git a/tests/corpus/5773-stale-narrowing-inherited.ts b/tests/corpus/5773-stale-narrowing-inherited.ts new file mode 100644 index 000000000..a63ed4614 --- /dev/null +++ b/tests/corpus/5773-stale-narrowing-inherited.ts @@ -0,0 +1,74 @@ +// A binding initialized or assigned from a stale narrowed read inherits the +// stale narrowing: after reset() stores a Lit, TypeScript still types +// `current` as Neg, and `const held: Expr = current` narrows `held` to Neg +// too. Reads of such bindings observe the value stored now, as Node does: +// members every arm has read the current arm, and an arm-only member of +// the wrong arm throws the catchable TypeError instead of reading another +// record's slot. +type Lit = { kind: "lit"; value: number }; +type Neg = { kind: "neg"; operand: Expr }; +type Expr = Lit | Neg; + +function show(e: Expr): string { + return e.kind === "lit" ? String(e.value) : `-${show(e.operand)}`; +} + +function fromInitializer(replace: boolean): string { + let current: Expr | null = null; + const reset = (): void => { + current = { kind: "lit", value: 7 }; + }; + current = { kind: "neg", operand: { kind: "lit", value: 1 } }; + if (replace) reset(); + if (current === null) return "null"; + const held: Expr = current; + const kind = held.kind; + try { + return `${kind} ${show(held.operand)}`; + } catch (e) { + return `${kind} caught ${(e as Error).name}`; + } +} + +function fromAssignment(replace: boolean): string { + let current: Expr | null = null; + const reset = (): void => { + current = { kind: "lit", value: 8 }; + }; + current = { kind: "neg", operand: { kind: "lit", value: 2 } }; + if (replace) reset(); + let held: Expr = { kind: "lit", value: 0 }; + if (current === null) return show(held); + held = current; + try { + return `${held.kind} ${show(held.operand)}`; + } catch (e) { + return `${held.kind} caught ${(e as Error).name}`; + } +} + +class Holder { + expr: Expr | null = null; + replace(): void { + this.expr = { kind: "lit", value: 9 }; + } +} + +function fromProperty(replace: boolean): string { + const h = new Holder(); + h.expr = { kind: "neg", operand: { kind: "lit", value: 3 } }; + if (replace) h.replace(); + if (h.expr === null) return "null"; + const held: Expr = h.expr; + try { + return `${held.kind} ${show(held.operand)}`; + } catch (e) { + return `${held.kind} caught ${(e as Error).name}`; + } +} + +for (const replace of [false, true]) { + console.log("initializer:", fromInitializer(replace)); + console.log("assignment:", fromAssignment(replace)); + console.log("property:", fromProperty(replace)); +}