From 358acbb73f75c153eea726f93826890498145f11 Mon Sep 17 00:00:00 2001 From: Bonobo Date: Wed, 2 Sep 2026 13:57:15 +0200 Subject: [PATCH] fix(dataflow): support non-loop labeled breaks --- src/dataflow/cfg.ts | 24 +++++++++++++++++++++++- test/dataflow.test.ts | 11 +++++++++++ test/fixtures/dataflow-app/src/flow.ts | 12 ++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/dataflow/cfg.ts b/src/dataflow/cfg.ts index a8200d0..6764a44 100644 --- a/src/dataflow/cfg.ts +++ b/src/dataflow/cfg.ts @@ -49,6 +49,7 @@ interface Lowered { } interface LoopLabel { + /** Every JavaScript label is a break target; iteration labels additionally own continue. */ breaks: Dangling[]; continueHeader: number | null; } @@ -205,7 +206,7 @@ class Lowerer { statement(s: Node, ctx: LowerCtx, label?: string): Lowered { if (Node.isBlock(s)) return this.statements(s.getStatements(), ctx); - if (Node.isLabeledStatement(s)) return this.statement(s.getStatement(), ctx, s.getLabel().getText()); + if (Node.isLabeledStatement(s)) return this.labeledStatement(s, ctx); if (Node.isIfStatement(s)) return this.ifStatement(s, ctx); if (Node.isWhileStatement(s) || Node.isForStatement(s) || Node.isForOfStatement(s) || Node.isForInStatement(s)) return this.loop(s, ctx, label); @@ -215,6 +216,27 @@ class Lowerer { return this.leaf(s, ctx); } + private labeledStatement(s: Node, ctx: LowerCtx): Lowered { + if (!Node.isLabeledStatement(s)) throw new Error("unreachable"); + const statement = s.getStatement(); + const label = s.getLabel().getText(); + if ( + Node.isWhileStatement(statement) || + Node.isDoStatement(statement) || + Node.isForStatement(statement) || + Node.isForOfStatement(statement) || + Node.isForInStatement(statement) + ) { + return this.statement(statement, ctx, label); + } + + const breaks: Dangling[] = []; + ctx.labels.set(label, { breaks, continueHeader: null }); + const lowered = this.statement(statement, ctx); + ctx.labels.delete(label); + return { entry: lowered.entry, exits: [...lowered.exits, ...breaks] }; + } + private leaf(s: Node, ctx: LowerCtx): Lowered { const id = this.idOf.get(s) as number; this.exceptionEdgeIfThrows(s, id, ctx); diff --git a/test/dataflow.test.ts b/test/dataflow.test.ts index 7e5a686..f3ff2f3 100644 --- a/test/dataflow.test.ts +++ b/test/dataflow.test.ts @@ -153,6 +153,17 @@ describe("CFG gate", () => { expect(kinds("src/flow.pickDay", (e) => e.kind === "break")).toHaveLength(2); }); + test("break targets every labeled statement, not only labeled loops", () => { + const cfg = cfgOf("src/flow.labeledBlock"); + const edge = cfg.edges.find((candidate) => candidate.kind === "break"); + expect(edge).toBeDefined(); + const source = cfg.nodes[edge?.source ?? -1]; + const target = cfg.nodes[edge?.target ?? -1]; + const moduleSource = fs.readFileSync(path.join(FIXTURE, "src/flow.ts"), "utf8"); + expect(moduleSource.slice(source?.start_offset, source?.end_offset)).toContain("break exit"); + expect(moduleSource.slice(target?.start_offset, target?.end_offset)).toContain("return result"); + }); + test("await suspends via an await_resume edge (fetchTotal)", () => { expect(kinds("src/susp.fetchTotal")).toContainEqual({ source: 2, target: 3, kind: "await_resume" }); }); diff --git a/test/fixtures/dataflow-app/src/flow.ts b/test/fixtures/dataflow-app/src/flow.ts index b57b19b..b9091d3 100644 --- a/test/fixtures/dataflow-app/src/flow.ts +++ b/test/fixtures/dataflow-app/src/flow.ts @@ -84,3 +84,15 @@ export function shadow(): number { } return x; } + +export function labeledBlock(flag: boolean): number { + let result = 0; + exit: { + if (flag) { + result = 1; + break exit; + } + result = 2; + } + return result; +}