From aad9ca84a28270228a8b3b9933e645ae39e1e39d Mon Sep 17 00:00:00 2001 From: Bonobo Date: Wed, 2 Sep 2026 13:52:33 +0200 Subject: [PATCH] fix(dataflow): skip unreachable empty-try catches --- src/dataflow/cfg.ts | 26 ++++++++++++++++---------- test/dataflow.test.ts | 9 +++++++++ test/fixtures/dataflow-app/src/flow.ts | 8 ++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/dataflow/cfg.ts b/src/dataflow/cfg.ts index a8200d0..b567585 100644 --- a/src/dataflow/cfg.ts +++ b/src/dataflow/cfg.ts @@ -163,9 +163,13 @@ function collectStatementNodes(node: Node, out: Node[]): void { out.push(s); for (const clause of s.getClauses()) for (const cs of clause.getStatements()) collectStatementNodes(cs, out); } else if (Node.isTryStatement(s)) { + const beforeTry = out.length; collectStatementNodes(s.getTryBlock(), out); + const tryHasNodes = out.length > beforeTry; const cc = s.getCatchClause(); - if (cc) { + // A catch after a structurally empty try is unreachable. Omitting that dead region keeps + // normal flow on the statement list and preserves the graph's all-nodes-reachable contract. + if (cc && tryHasNodes) { out.push(cc); collectStatementNodes(cc.getBlock(), out); } @@ -377,15 +381,17 @@ class Lowerer { const exits: Dangling[] = []; let catchEntry: number | null = null; if (cc) { - const catchId = this.idOf.get(cc) as number; // binds the exception variable (a def, stage 3) - catchEntry = catchId; - const catchCtx: LowerCtx = { ...ctx, exceptionTarget: afterCatchTarget }; - const catchBody = this.statements(cc.getBlock().getStatements(), catchCtx); - if (catchBody.entry !== null) { - this.addEdge(catchId, catchBody.entry, "fallthrough"); - this.routeThroughFinally(catchBody.exits, finLowered, exits); - } else { - this.routeThroughFinally([{ from: catchId, kind: "fallthrough" }], finLowered, exits); + const catchId = this.idOf.get(cc); + if (catchId !== undefined) { + catchEntry = catchId; // binds the exception variable (a def, stage 3) + const catchCtx: LowerCtx = { ...ctx, exceptionTarget: afterCatchTarget }; + const catchBody = this.statements(cc.getBlock().getStatements(), catchCtx); + if (catchBody.entry !== null) { + this.addEdge(catchId, catchBody.entry, "fallthrough"); + this.routeThroughFinally(catchBody.exits, finLowered, exits); + } else { + this.routeThroughFinally([{ from: catchId, kind: "fallthrough" }], finLowered, exits); + } } } diff --git a/test/dataflow.test.ts b/test/dataflow.test.ts index 7e5a686..0a05e00 100644 --- a/test/dataflow.test.ts +++ b/test/dataflow.test.ts @@ -137,6 +137,15 @@ describe("CFG gate", () => { expect(e).toContainEqual({ source: 6, target: 8, kind: "exception" }); // finally → outward (EXIT) }); + test("an empty try falls through instead of entering its unreachable catch", () => { + const cfg = cfgOf("src/flow.emptyTryCatch"); + expect(cfg.nodes.map((node) => node.kind)).toEqual(["entry", "statement", "exit"]); + expect(cfg.edges).toEqual([ + { source: 0, target: 1, kind: "fallthrough" }, + { source: 1, target: 2, kind: "return" }, + ]); + }); + test("throw with no handler edges to EXIT (parse)", () => { const cfg = cfgOf("src/flow.parse"); const exit = cfg.nodes.length - 1; diff --git a/test/fixtures/dataflow-app/src/flow.ts b/test/fixtures/dataflow-app/src/flow.ts index b57b19b..a93c200 100644 --- a/test/fixtures/dataflow-app/src/flow.ts +++ b/test/fixtures/dataflow-app/src/flow.ts @@ -84,3 +84,11 @@ export function shadow(): number { } return x; } + +export function emptyTryCatch(): number { + try { + } catch { + return 1; + } + return 0; +}