diff --git a/src/dataflow/cfg.ts b/src/dataflow/cfg.ts index a8200d0..659d422 100644 --- a/src/dataflow/cfg.ts +++ b/src/dataflow/cfg.ts @@ -471,12 +471,31 @@ export function containsKind(root: Node, kind: SyntaxKind): boolean { return found; } +const THROWING_KINDS = new Set([ + SyntaxKind.CallExpression, + SyntaxKind.NewExpression, + SyntaxKind.AwaitExpression, + SyntaxKind.TaggedTemplateExpression, +]); + /** May evaluating this subtree throw? Over-approximate: any call-like or await counts. */ export function mayThrow(root: Node): boolean { - return ( - containsKind(root, SyntaxKind.CallExpression) || - containsKind(root, SyntaxKind.NewExpression) || - containsKind(root, SyntaxKind.AwaitExpression) || - containsKind(root, SyntaxKind.TaggedTemplateExpression) - ); + if (THROWING_KINDS.has(root.getKind())) return true; + let found = false; + root.forEachDescendant((node, traversal) => { + if (found) { + traversal.stop(); + return; + } + // Match containsKind's callable boundary: nested bodies execute separately from this subtree. + if (isFunctionBoundary(node)) { + traversal.skip(); + return; + } + if (THROWING_KINDS.has(node.getKind())) { + found = true; + traversal.stop(); + } + }); + return found; } diff --git a/test/dataflow.test.ts b/test/dataflow.test.ts index 7e5a686..cadfb75 100644 --- a/test/dataflow.test.ts +++ b/test/dataflow.test.ts @@ -8,8 +8,10 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { Project } from "ts-morph"; import { analyze } from "../src/core"; import { backwardSlice } from "../src/dataflow"; +import { mayThrow } from "../src/dataflow/cfg"; import type { AnalysisOptions } from "../src/options"; import type { CfgEdge, FunctionCfg, ProgramGraphs, SdgEdge } from "../src/schema"; @@ -166,6 +168,24 @@ describe("CFG gate", () => { }); }); +test("throwability scans all throw kinds without entering nested callables", () => { + const project = new Project({ useInMemoryFileSystem: true }); + const source = project.createSourceFile( + "throwability.ts", + [ + "async function outer() {", + " call();", + " new Thing();", + " await task;", + " tag`value`;", + " const nested = () => call();", + "}", + ].join("\n"), + ); + const statements = source.getFunctionOrThrow("outer").getStatements(); + expect(statements.map((statement) => mayThrow(statement))).toEqual([true, true, true, true, false]); +}); + // ------------------------------------------------------------------------------------------------ // Dominance gate (control dependence, hand-computed) // ------------------------------------------------------------------------------------------------