diff --git a/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts new file mode 100644 index 000000000..bac4e3e0d --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts @@ -0,0 +1,512 @@ +/** + * Java def/use harvester (#2195 U4, plan KTD2) — the Java analogue of + * {@link import('./typescript-harvest.js').TsHarvester} and the closely-related + * {@link import('./csharp-harvest.js').CsharpHarvester}. + * + * Runs in the parse worker next to the Java CFG visitor, extracting per-statement + * variable definition/use facts that ride the side channel for the reaching-defs + * / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[]) + * plus {@link StatementFacts} the visitor attaches to blocks as it walks. + * + * TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / C# harvesters): + * the CFG walk is NOT source-order (`visitFor` builds the init block after the + * body, `visitDoWhile` the condition before the body), so resolving names against + * a scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans + * the whole function subtree once into a completed lexical scope tree; phase 2 + * resolves defs/uses against that finished tree from any walk order. + * + * v1 def-semantics scope: + * - `local_variable_declaration` → `variable_declarator` (an INITIALIZED local + * is a def; a bare `int x;` with no initializer writes nothing at runtime — + * not a def, like the TS bare-`var` rule). + * - `assignment_expression` (plain + compound `+=` etc.) and `update_expression` + * (`x++` / `--x`) — define and (for compound / update) also use the lvalue. + * - parameters (`formal_parameter` / `spread_parameter` → `name`), the + * enhanced-for loop variable (`enhanced_for_statement` field `name`), and + * catch parameters (`catch_formal_parameter` → `name`), incl. multi-catch. + * EXCLUDED, deliberately (TypeScript-CFA precedent): field / array writes + * (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their identifiers are uses + * only. Nested-function (lambda) bodies are opaque in BOTH directions (writes to + * and reads of captured outer variables are invisible). + * + * MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right + * operand of `&&` / `||` (`a && (x = f())`), a ternary arm, or a switch case test + * — is a may-def (gen without kill), so the not-taken path's prior def is not + * falsely killed. (Java has no `??`.) + * + * Identifiers with no in-function declaration (fields, statics, imported names) + * resolve to a SYNTHETIC module-level binding (`name@module`), applied + * identically by def and use harvesting. + * + * NOTE: nothing serialized here may carry a field named `nodeId` — the durable + * parsedfile-store reviver dedups objects keyed on that field name. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { BindingEntry, StatementFacts } from '../types.js'; + +/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */ +const NESTED_FUNCTION_TYPES = new Set([ + 'lambda_expression', + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', +]); + +/** + * Nodes that open a lexical scope for block-local declarations. A `block` is one + * scope; the loop constructs open a scope for their loop variable; a + * `catch_clause` scopes its exception name; a `try_with_resources_statement` + * scopes its resource declarations; a switch group/rule scopes its statements. + */ +const SCOPE_TYPES = new Set([ + 'block', + 'for_statement', + 'enhanced_for_statement', + 'while_statement', + 'do_statement', + 'catch_clause', + 'try_with_resources_statement', + 'switch_block_statement_group', + 'switch_rule', +]); + +/** Comment node types tree-sitter-java surfaces (NOT `comment`). */ +const COMMENT_TYPES = new Set(['line_comment', 'block_comment']); + +interface Scope { + readonly parent: Scope | null; + /** name → binding index */ + readonly table: Map; +} + +export class JavaHarvester { + private readonly bindings: BindingEntry[] = []; + private readonly scopeByNode = new Map(); + private readonly root: Scope = { parent: null, table: new Map() }; + private readonly synthetic = new Map(); + private readonly fnId: number; + /** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */ + private readonly nearestScopeCache = new Map(); + /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */ + private conditionalDepth = 0; + + constructor(private readonly fnNode: SyntaxNode) { + this.fnId = fnNode.id; + this.scopeByNode.set(fnNode.id, this.root); + this.declareParams(fnNode); + const body = this.bodyOf(fnNode); + if (body && body.type === 'block') this.prescan(body, this.openScope(body)); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + table(): readonly BindingEntry[] { + return this.bindings; + } + + /** The function/lambda body node (a `block`, or an expression for `x -> expr`). */ + private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined { + const body = fnNode.childForFieldName('body'); + if (body) return body; + return fnNode.namedChildren.find((c) => c.type === 'block'); + } + + // ── phase 1: declaration pre-scan ──────────────────────────────────────── + + private openScope(node: SyntaxNode): Scope { + const existing = this.scopeByNode.get(node.id); + if (existing) return existing; + const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() }; + this.scopeByNode.set(node.id, scope); + return scope; + } + + private nearestScopeOf(node: SyntaxNode): Scope { + for (let p = node.parent; p; p = p.parent) { + const s = this.scopeByNode.get(p.id); + if (s) return s; + if (p.id === this.fnId) break; + } + return this.root; + } + + private declare(nameNode: SyntaxNode, kind: BindingEntry['kind'], scope: Scope): void { + const name = nameNode.text; + if (!name || scope.table.has(name)) return; + scope.table.set(name, this.bindings.length); + this.bindings.push({ + name, + declLine: nameNode.startPosition.row + 1, + declColumn: nameNode.startPosition.column, + kind, + }); + } + + private declareParams(fnNode: SyntaxNode): void { + const params = fnNode.childForFieldName('parameters'); + if (!params) { + // Lambda single un-parenthesized parameter: `x -> …` (a bare identifier). + const lambdaParam = fnNode.namedChildren.find((c) => c.type === 'identifier'); + if (fnNode.type === 'lambda_expression' && lambdaParam) { + this.declare(lambdaParam, 'param', this.root); + } + // Lambda inferred parameters: `(x, y) -> …`. + const inferred = fnNode.namedChildren.find((c) => c.type === 'inferred_parameters'); + if (inferred) { + for (let i = 0; i < inferred.namedChildCount; i++) { + const p = inferred.namedChild(i); + if (p?.type === 'identifier') this.declare(p, 'param', this.root); + } + } + return; + } + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'formal_parameter' && p?.type !== 'spread_parameter') continue; + const name = this.paramName(p); + if (name) this.declare(name, 'param', this.root); + } + } + + /** The `name` identifier of a `formal_parameter` / `spread_parameter`. */ + private paramName(param: SyntaxNode): SyntaxNode | undefined { + const named = param.childForFieldName('name'); + if (named) return named; + // `spread_parameter` (`int... xs`) exposes its name through a nested + // variable_declarator rather than a `name` field. + const declarator = param.namedChildren.find((c) => c.type === 'variable_declarator'); + return declarator?.childForFieldName('name'); + } + + private prescan(node: SyntaxNode, scope: Scope): void { + this.nearestScopeCache.set(node.id, scope); + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + // A nested function / lambda body is opaque — do not descend. + return; + } + + let childScope = scope; + if (SCOPE_TYPES.has(t)) childScope = this.openScope(node); + + switch (t) { + case 'local_variable_declaration': + this.declareVariableDeclaration(node, childScope); + break; + case 'enhanced_for_statement': { + const name = node.childForFieldName('name'); + if (name) this.declare(name, 'var', childScope); + break; + } + case 'resource': { + // `try (var f = open())` — the resource binds in the try scope. + const name = node.childForFieldName('name'); + if (name) this.declare(name, 'var', childScope); + break; + } + case 'catch_clause': { + const param = node.namedChildren.find((c) => c.type === 'catch_formal_parameter'); + const name = param?.childForFieldName('name'); + if (name) this.declare(name, 'catch', childScope); + break; + } + default: + break; + } + + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.prescan(c, childScope); + } + } + + /** Declare every `variable_declarator` name in a `local_variable_declaration`. */ + private declareVariableDeclaration(declNode: SyntaxNode, scope: Scope): void { + for (let i = 0; i < declNode.namedChildCount; i++) { + const d = declNode.namedChild(i); + if (d?.type !== 'variable_declarator') continue; + const name = d.childForFieldName('name'); + if (name) this.declare(name, 'var', scope); + } + } + + // ── phase 2: per-statement fact extraction ─────────────────────────────── + + /** Def/use facts for one statement (or construct-header expression) node. */ + facts(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.walkValue(node, acc); + return acc.finish(); + } + + /** Facts for an expression whose WHOLE evaluation is conditional (case tests). */ + factsConditional(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.conditional(() => this.walkValue(node, acc)); + return acc.finish(); + } + + /** Facts for a `for (T name : value)` head: name binds, value is used. */ + forEachHeadFacts(stmt: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + const name = stmt.childForFieldName('name'); + const value = stmt.childForFieldName('value'); + if (name) this.def(name, acc); + if (value) this.walkValue(value, acc); + return acc.finish(); + } + + /** Facts for the resource-close finalizer: each resource is USED on close. */ + resourceCloseFacts(resources: readonly SyntaxNode[]): StatementFacts | undefined { + const first = resources[0]; + if (!first) return undefined; + const acc = new FactAccumulator(first.startPosition.row + 1); + for (const r of resources) { + const name = r.childForFieldName('name'); + if (name) this.use(name, acc); + } + return acc.useCount() ? acc.finish() : undefined; + } + + /** ENTRY-block facts for the function's parameters (defs only). */ + paramFacts(): StatementFacts | undefined { + const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); + const params = this.fnNode.childForFieldName('parameters'); + if (params) { + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'formal_parameter' && p?.type !== 'spread_parameter') continue; + const name = this.paramName(p); + if (name) this.def(name, acc); + } + } else if (this.fnNode.type === 'lambda_expression') { + const lambdaParam = this.fnNode.namedChildren.find((c) => c.type === 'identifier'); + if (lambdaParam) this.def(lambdaParam, acc); + const inferred = this.fnNode.namedChildren.find((c) => c.type === 'inferred_parameters'); + if (inferred) { + for (let i = 0; i < inferred.namedChildCount; i++) { + const p = inferred.namedChild(i); + if (p?.type === 'identifier') this.def(p, acc); + } + } + } + return acc.defCount() ? acc.finish() : undefined; + } + + /** Def fact for a `catch (T e)` parameter — prepend to the handler entry block. */ + catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined { + const param = catchClause.namedChildren.find((c) => c.type === 'catch_formal_parameter'); + const name = param?.childForFieldName('name'); + if (!name) return undefined; + const acc = new FactAccumulator(catchClause.startPosition.row + 1); + this.def(name, acc); + return acc.defCount() ? acc.finish() : undefined; + } + + private resolve(nameNode: SyntaxNode): number { + const name = nameNode.text; + const cached = this.nearestScopeCache.get(nameNode.id); + let startScope: Scope | null = cached ?? null; + if (!startScope) { + for (let p: SyntaxNode | null = nameNode; p; p = p.parent) { + const scope = this.scopeByNode.get(p.id) ?? this.nearestScopeCache.get(p.id); + if (scope) { + startScope = scope; + break; + } + if (p.id === this.fnId) { + startScope = this.root; + break; + } + } + } + for (let s: Scope | null = startScope; s; s = s.parent) { + const idx = s.table.get(name); + if (idx !== undefined) return idx; + } + let idx = this.synthetic.get(name); + if (idx === undefined) { + idx = this.bindings.length; + this.synthetic.set(name, idx); + this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true }); + } + return idx; + } + + private def(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode)); + else acc.addDef(this.resolve(nameNode)); + } + + private use(nameNode: SyntaxNode, acc: FactAccumulator): void { + acc.addUse(this.resolve(nameNode)); + } + + /** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */ + private conditional(fn: () => void): void { + this.conditionalDepth++; + try { + fn(); + } finally { + this.conditionalDepth--; + } + } + + /** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */ + private unwrapLvalue(node: SyntaxNode): SyntaxNode { + let n = node; + let hops = 8; + while (n.type === 'parenthesized_expression' && hops-- > 0) { + const inner = n.namedChildren.find((c) => !COMMENT_TYPES.has(c.type)); + if (!inner) break; + n = inner; + } + return n; + } + + /** Value-position walk: collect uses; route def positions to the lvalue handler. */ + private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + // Opaque nested function / lambda — captured reads/writes are invisible. + return; + } + + switch (t) { + case 'identifier': + this.use(node, acc); + return; + case 'local_variable_declaration': { + for (let i = 0; i < node.namedChildCount; i++) { + const d = node.namedChild(i); + if (d?.type !== 'variable_declarator') continue; + const name = d.childForFieldName('name'); + const value = d.childForFieldName('value'); + // Only an INITIALIZED declarator writes (`int x = e;`). A bare + // `int x;` is not a def (it writes nothing at runtime), matching the + // TS bare-`var` rule. + if (name && value) this.def(name, acc); + if (value) this.walkValue(value, acc); + } + return; + } + case 'assignment_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const op = node.childForFieldName('operator')?.text ?? '='; + if (left) { + const lv = this.unwrapLvalue(left); + if (lv.type === 'identifier') { + this.def(lv, acc); + if (op !== '=') this.use(lv, acc); // compound assign reads too + } else { + this.walkValue(lv, acc); // field/array target — uses only + } + } + if (right) this.walkValue(right, acc); + return; + } + case 'update_expression': { + // `x++` / `++x` / `x--` / `--x` — the only writing unary ops. The operand + // is an anonymous (non-field) child; treat as def+use when it's an + // identifier. + const operand = this.updateOperand(node); + const lv = operand ? this.unwrapLvalue(operand) : null; + if (lv?.type === 'identifier') { + this.def(lv, acc); + this.use(lv, acc); + } else if (operand) { + this.walkValue(operand, acc); + } + return; + } + case 'binary_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const op = node.childForFieldName('operator')?.text ?? ''; + if (left) this.walkValue(left, acc); + if (right) { + if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc)); + else this.walkValue(right, acc); + } + return; + } + case 'ternary_expression': { + const cond = node.childForFieldName('condition'); + const cons = node.childForFieldName('consequence'); + const alt = node.childForFieldName('alternative'); + if (cond) this.walkValue(cond, acc); + if (cons) this.conditional(() => this.walkValue(cons, acc)); + if (alt) this.conditional(() => this.walkValue(alt, acc)); + return; + } + case 'field_access': { + // `a.b` — value read of the object root only; the field name is not a + // scalar binding. Mirrors the TS member-read use semantics. + const obj = node.childForFieldName('object'); + if (obj) this.walkValue(obj, acc); + return; + } + default: + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.walkValue(c, acc); + } + } + } + + /** The operand identifier of an `update_expression` (`x++` / `--x`). */ + private updateOperand(node: SyntaxNode): SyntaxNode | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c && !COMMENT_TYPES.has(c.type)) return c; + } + return undefined; + } +} + +/** Ordered, deduplicating def/use collector for one statement record. */ +class FactAccumulator { + private readonly defs: number[] = []; + private readonly uses: number[] = []; + private readonly mayDefs: number[] = []; + private readonly defSeen = new Set(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + + constructor(private readonly line: number) {} + + addDef(idx: number): void { + if (this.defSeen.has(idx)) return; + this.defSeen.add(idx); + this.defs.push(idx); + } + + addMayDef(idx: number): void { + if (this.mayDefSeen.has(idx)) return; + this.mayDefSeen.add(idx); + this.mayDefs.push(idx); + } + + addUse(idx: number): void { + if (this.useSeen.has(idx)) return; + this.useSeen.add(idx); + this.uses.push(idx); + } + + defCount(): number { + return this.defs.length + this.mayDefs.length; + } + + useCount(): number { + return this.uses.length; + } + + finish(): StatementFacts { + return { + line: this.line, + defs: this.defs, + uses: this.uses, + ...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}), + }; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/java.ts b/gitnexus/src/core/ingestion/cfg/visitors/java.ts new file mode 100644 index 000000000..009c71481 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/java.ts @@ -0,0 +1,995 @@ +/** + * Java CfgVisitor (#2195 U4, plan KTD2). + * + * Walks a Java method/constructor/lambda's tree-sitter AST and drives the + * language-agnostic {@link CfgBuilder} to produce a serializable + * {@link FunctionCfg}, plus a def/use harvest ({@link JavaHarvester}) for the + * reaching-defs / CDG solvers. Structured like the C# visitor — a + * `visit_` dispatch over the statement taxonomy, driving a + * per-function {@link ControlFlowContext} — because Java shares C#'s `finally` + * semantics (try/finally, try-with-resources auto-close = finally, labeled + * break/continue), which the finalizer-frame + labeled-frame machinery in + * `control-flow-context.ts` models. + * + * Every node type and field literal below was grammar-validated against + * tree-sitter-java via the introspection probe before use (mandatory pre-step, + * KTD5). Known Java surprises pre-empted (verified by a real parse): + * - Generics are `generic_type` (NOT `parameterized_type`, which this grammar + * does not have). + * - BOTH the classic colon `switch` and the arrow `switch` parse under + * `switch_expression` (there is no `switch_statement` node). Its body is a + * `switch_block` (field `body`). A classic group is a + * `switch_block_statement_group` (`switch_label` + statements, FALLS THROUGH); + * an arrow rule is a `switch_rule` (`switch_label` `->` one body, does NOT + * fall through). Case tests live in a `switch_label`. + * - Comments are `line_comment` / `block_comment` (NOT `comment`). + * - An `if`/`while`/`do`/`synchronized` condition is wrapped in a + * `parenthesized_expression`; `consequence`/`alternative`/`body` are + * statements directly (no `else_clause` wrapper — an `else if` is the nested + * `if_statement` in the `alternative` field). + * - `for_statement` uses the `init` field (NOT `initializer`), plus `condition`, + * `update`, `body`. `enhanced_for_statement` uses `type`/`name`/`value`/`body`. + * - `try_with_resources_statement` carries a `resources` field + * (`resource_specification` of `resource` nodes); a plain `try_statement` has + * `body` + `catch_clause`s + an optional `finally_clause`. + * - `labeled_statement` = a leading `identifier` (the label) then the labeled + * statement (no field). `break`/`continue` carry an optional trailing + * `identifier` label. + * + * Function nodes: `method_declaration`, `constructor_declaration`, + * `compact_constructor_declaration` (a record's canonical-constructor body), and + * `lambda_expression`. + * + * Edge-kind contract (matches the TS / C# visitors — RD/CDG consume these): + * - if/else → `cond-true` / `cond-false` + * - loops (for / enhanced-for / while / do-while) → `cond-true` / `loop-back` / + * `cond-false` + * - switch → `switch-case` / `fallthrough`; a `switch_block_statement_group` + * (classic colon form) falls through to the next group when it does not + * `break`/`return`/`yield`; a `switch_rule` (arrow form) does NOT fall + * through (its single body always rejoins after the switch). + * - try/catch → `throw` (every protected-region block → the handler); a + * `try_with_resources_statement`'s auto-close runs on BOTH normal and + * exception exit (finally semantics), so a `return`/`break`/`continue` + * crossing it gets a `finally-*` completion edge too. `synchronized`'s + * monitor-release is likewise a deterministic finalizer. + * - return / throw / break / continue → the matching terminator kind; a labeled + * `break outer;` / `continue outer;` targets the labeled frame, not the + * nearest one. + * - straight-line → `seq` + * + * Classic hazards, handled explicitly (mirrors the C# / TS visitors): + * - loops allocate a dedicated loop-exit block so `break` has a target before + * the loop's successor is known; `continue` targets the header/increment. + * - `for (;;) {}` / `while (true) {}` still emit the structural `header → + * loopExit` `cond-false` escape edge so EXIT stays reverse-reachable from + * every block — the post-dominator / CDG pass silently emits zero CDG for the + * function otherwise. + * - labeled `break outer;` / `continue outer;`: the label resolves against the + * frame of the construct it names (a labeled loop/switch, or a labeled block), + * NOT the nearest enclosing frame. An UNLABELED break never targets a labeled + * block frame (control-flow-context.ts enforces this). + * - try/catch: conservative exceptional flow — EVERY block in the protected + * region edges to the handler (an exception may fire mid-block), matching the + * TS `visitTry` over-approximation. + * + * Known limitations: + * - `switch` as an EXPRESSION value (`int r = switch (x) { … };`) is left INLINE + * inside its owning statement's block — its arms are not modeled as separate + * CFG blocks (the value flows to the assignment). Only a `switch` used as a + * STATEMENT (a direct statement child) is modeled as a dispatch construct. + * This mirrors the C# `switch_expression`-in-return handling — documented gap. + * - `yield` (in a switch expression) continues to the next statement (it yields + * one value to the enclosing switch and the arm ends); the switch-expression + * state machine is not modeled, consistent with the inline-value-switch gap. + * - Exceptions thrown by a method call mid-statement are over-approximated by + * the conservative per-block throw edge inside a `try`; outside any `try` they + * are not modeled (no edge), matching TS. + * - Def/use harvest scope: see `java-harvest.ts` — field/array writes are not + * scalar defs; nested-function (lambda) bodies are opaque in both directions. + * + * Returns `undefined` (never throws) for an AST shape it cannot model, so a + * malformed function never drops the whole file's CFG group (R4). + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { + ControlFlowContext, + drainFinalizerPending, + wireJumpThroughFinalizers, +} from '../control-flow-context.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg, StatementFacts } from '../types.js'; +import { JavaHarvester } from './java-harvest.js'; + +/** Java node types that own a CFG-bearing function body. */ +const JAVA_FUNCTION_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', + 'lambda_expression', +]); + +/** Statement node types that break a basic block (everything else coalesces). */ +const CONTROL_FLOW_TYPES = new Set([ + 'if_statement', + 'while_statement', + 'do_statement', + 'for_statement', + 'enhanced_for_statement', + 'switch_expression', + 'try_statement', + 'try_with_resources_statement', + 'synchronized_statement', + 'return_statement', + 'break_statement', + 'continue_statement', + 'throw_statement', + 'labeled_statement', + 'yield_statement', + 'block', +]); + +/** Comment node types tree-sitter-java surfaces (NOT `comment`). */ +const COMMENT_TYPES = new Set(['line_comment', 'block_comment']); + +const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1; +const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1; + +const isComment = (n: SyntaxNode): boolean => COMMENT_TYPES.has(n.type); + +/** A statement sequence that produced no blocks (empty body) is "transparent". */ +type SeqResult = TraversalResult | null; + +/** A pre-built {@link StatementFacts} record, or undefined when none. */ +type StatementFactsLike = StatementFacts | undefined; + +/** + * Per-function Java walk state. One instance per function so the + * {@link ControlFlowContext}, exception-handler stack, and labeled-block frame + * bookkeeping are scoped to that function and never leak across functions. + */ +class JavaCfgWalk { + private readonly cfc = new ControlFlowContext(); + /** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */ + private readonly handlers: number[] = []; + /** Label(s) pending attachment to the NEXT pushed loop/switch frame. */ + private pendingLabels: string[] = []; + + constructor( + private readonly builder: CfgBuilder, + private readonly harvest: JavaHarvester, + ) {} + + /** Statements of a block node, ignoring comments. */ + private statementsOf(block: SyntaxNode): SyntaxNode[] { + return block.namedChildren.filter((c) => !isComment(c)); + } + + /** The `body` block of a node (field, or the first `block` child). */ + private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined { + return node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'block'); + } + + /** Strip a `parenthesized_expression` wrapper (Java `if`/`while` conditions). */ + private unwrapParen(node: SyntaxNode): SyntaxNode { + if (node.type === 'parenthesized_expression') { + const inner = node.namedChildren.find((c) => !isComment(c)); + if (inner) return inner; + } + return node; + } + + /** Visit a body that may be a `block` or a single statement. */ + private visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (node.type === 'block') return this.visitSeq(this.statementsOf(node)); + return this.visitStmt(node); + } + + /** Wire a sequence of statements, coalescing straight-line runs into blocks. */ + visitSeq(stmts: SyntaxNode[]): SeqResult { + let entry: number | undefined; + let dangling: number[] = []; + let openSimple: number | undefined; + + for (const stmt of stmts) { + // A `switch_expression` only breaks a block when it is a STATEMENT switch. + // Used as a value (inside a declaration / return) it coalesces normally. + const breaks = + CONTROL_FLOW_TYPES.has(stmt.type) && + (stmt.type !== 'switch_expression' || this.isStatementSwitch(stmt)); + if (breaks) { + openSimple = undefined; // close any open straight-line block + const res = this.visitStmt(stmt); + if (res === null) continue; // transparent (empty nested block) + if (entry === undefined) entry = res.entry; + else this.builder.connect(dangling, res.entry, 'seq'); + dangling = [...res.exits]; + } else { + if (openSimple === undefined) { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + if (entry === undefined) entry = idx; + else this.builder.connect(dangling, idx, 'seq'); + openSimple = idx; + dangling = [idx]; + } else { + this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text, this.harvest.facts(stmt)); + } + } + } + + if (entry === undefined) return null; + return { entry, exits: dangling }; + } + + /** Dispatch one statement to its handler. Non-null except for empty blocks. */ + visitStmt(stmt: SyntaxNode): SeqResult { + switch (stmt.type) { + case 'if_statement': + return this.visitIf(stmt); + case 'while_statement': + return this.visitWhile(stmt); + case 'do_statement': + return this.visitDoWhile(stmt); + case 'for_statement': + return this.visitFor(stmt); + case 'enhanced_for_statement': + return this.visitForEach(stmt); + case 'switch_expression': + return this.visitSwitch(stmt); + case 'try_statement': + case 'try_with_resources_statement': + return this.visitTry(stmt); + case 'synchronized_statement': + return this.visitSynchronized(stmt); + case 'return_statement': + return this.visitReturn(stmt); + case 'throw_statement': + return this.visitThrow(stmt); + case 'break_statement': + return this.visitBreak(stmt); + case 'continue_statement': + return this.visitContinue(stmt); + case 'labeled_statement': + return this.visitLabeled(stmt); + case 'yield_statement': + return this.visitYield(stmt); + case 'block': + return this.visitSeq(this.statementsOf(stmt)); + default: + return this.visitSimple(stmt); + } + } + + private visitSimple(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + return { entry: idx, exits: [idx] }; + } + + private visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + // A return crosses EVERY active finalizer (finally / try-with-resources / + // synchronized) before EXIT. + wireJumpThroughFinalizers( + this.builder, + idx, + this.cfc.finalizersForReturn(), + this.builder.exitIndex, + 'return', + ); + return { entry: idx, exits: [] }; + } + + private visitThrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + /** + * `yield e;` (switch-expression arm value) — yields one value to the enclosing + * switch and the arm ends; modeled as a block that continues to whatever + * follows (the switch-expression state machine is not modeled, see the visitor + * limitations). It carries the yielded value's def/use facts. + */ + private visitYield(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + return { entry: idx, exits: [idx] }; + } + + private visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveBreak(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break'); + return { entry: idx, exits: [] }; + } + + private visitContinue(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveContinue(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue'); + return { entry: idx, exits: [] }; + } + + /** The trailing label `identifier` of a `break`/`continue`, if any. */ + private jumpLabel(stmt: SyntaxNode): string | undefined { + const id = stmt.namedChildren.find((c) => c.type === 'identifier'); + return id?.text; + } + + /** + * `label: ` — the label names the construct it directly wraps. For a + * loop/switch we forward the label so its pushed frame carries it (`break + * outer;` then resolves to it); for any other labeled statement we push a + * labeled-block frame around its body so `break label;` reaches the join after. + */ + private visitLabeled(stmt: SyntaxNode): SeqResult { + const labelNode = stmt.namedChildren.find((c) => c.type === 'identifier'); + const label = labelNode?.text; + const body = stmt.namedChildren.find((c) => c.id !== labelNode?.id && !isComment(c)) ?? null; + if (!body || label === undefined) return this.visitBody(body); + + if (this.isLoopOrSwitchStatement(body)) { + // Forward the label to the loop/switch frame this statement pushes. + this.pendingLabels = [...this.pendingLabels, label]; + return this.visitStmt(body); + } + + // Labeled non-loop (`blk: { … break blk; … }`) — a break-to-label join after + // the body; an unlabeled break never matches it. + const joinBlock = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + this.cfc.pushLabeledBlock(joinBlock, [label]); + const res = this.visitBody(body); + this.cfc.pop(); + if (res) this.builder.connect(res.exits, joinBlock, 'seq'); + const entry = res?.entry ?? joinBlock; + return { entry, exits: [joinBlock] }; + } + + private isLoopOrSwitchStatement(node: SyntaxNode): boolean { + return ( + node.type === 'for_statement' || + node.type === 'enhanced_for_statement' || + node.type === 'while_statement' || + node.type === 'do_statement' || + (node.type === 'switch_expression' && this.isStatementSwitch(node)) + ); + } + + /** Take and clear the labels queued by an enclosing `labeled_statement`. */ + private takeLabels(): string[] { + const labels = this.pendingLabels; + this.pendingLabels = []; + return labels; + } + + private visitIf(stmt: SyntaxNode): TraversalResult { + const cond = this.condOf(stmt) ?? stmt; + const condBlock = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const exits: number[] = []; + + const thenRes = this.visitBody(stmt.childForFieldName('consequence')); + if (thenRes) { + this.builder.edge(condBlock, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(condBlock); // empty then — true path falls through + } + + // No `else_clause` wrapper in Java: `alternative` is the else body or the + // nested `if_statement` of an `else if` chain directly. + const elseNode = stmt.childForFieldName('alternative'); + if (elseNode) { + const elseRes = this.visitBody(elseNode); + if (elseRes) { + this.builder.edge(condBlock, elseRes.entry, 'cond-false'); + exits.push(...elseRes.exits); + } else { + exits.push(condBlock); + } + } else { + exits.push(condBlock); // no else — false path falls through to the join + } + + return { entry: condBlock, exits: [...new Set(exits)] }; + } + + /** The (paren-unwrapped) condition expression of an if/while/do/synchronized. */ + private condOf(stmt: SyntaxNode): SyntaxNode | undefined { + const cond = stmt.childForFieldName('condition'); + return cond ? this.unwrapParen(cond) : undefined; + } + + private visitWhile(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const cond = this.condOf(stmt) ?? stmt; + const header = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, labels); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-tests + } + // Always emit the structural exit edge — even `while (true)` keeps EXIT + // reverse-reachable for the post-dominator / CDG pass. + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private visitDoWhile(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const cond = this.condOf(stmt) ?? stmt; + const condBlock = this.builder.newBlock( + startLineOf(cond), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(condBlock, loopExit, labels); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + const backTarget = body ? body.entry : condBlock; + if (body) this.builder.connect(body.exits, condBlock, 'seq'); + this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again + this.builder.edge(condBlock, loopExit, 'cond-false'); + return { entry: backTarget, exits: [loopExit] }; + } + + private visitFor(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const init = stmt.childForFieldName('init'); + const cond = stmt.childForFieldName('condition'); + const incr = stmt.childForFieldName('update'); + + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? cond.text : 'for(;;)', + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + let incrBlock = header; + if (incr) { + incrBlock = this.builder.newBlock( + startLineOf(incr), + endLineOf(incr), + incr.text, + 'normal', + this.harvest.facts(incr), + ); + this.builder.edge(incrBlock, header, 'loop-back'); + } + + this.cfc.pushLoop(incrBlock, loopExit, labels); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back'); + } else { + this.builder.edge(header, incrBlock, 'cond-true'); + if (!incr) this.builder.edge(header, header, 'loop-back'); + } + // Structural exit edge — `for (;;) {}` (no condition) still keeps EXIT + // reverse-reachable so CDG is not silently skipped for the function. + this.builder.edge(header, loopExit, 'cond-false'); + + let entry = header; + if (init) { + const initBlock = this.builder.newBlock( + startLineOf(init), + endLineOf(init), + init.text, + 'normal', + this.harvest.facts(init), + ); + this.builder.edge(initBlock, header, 'seq'); + entry = initBlock; + } + return { entry, exits: [loopExit] }; + } + + private visitForEach(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + // Header text is SYNTHESIZED, so facts come from the name/value nodes + // directly (the loop variable is a def, the iterated expression a use). + const header = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + this.forEachHeaderText(stmt), + 'normal', + this.harvest.forEachHeadFacts(stmt), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, labels); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); + } + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private forEachHeaderText(stmt: SyntaxNode): string { + const name = stmt.childForFieldName('name')?.text ?? ''; + const value = stmt.childForFieldName('value')?.text ?? ''; + return name || value ? `for(${name} : ${value})` : 'for(… : …)'; + } + + /** + * `synchronized (obj) body` — the monitor release runs on BOTH normal AND + * exception exit (deterministic finalizer), exactly like a `finally`. Modeled + * as a synthesized finalizer block so a `return`/`break`/`continue` crossing it + * threads through and gets a `finally-*` completion edge. + */ + private visitSynchronized(stmt: SyntaxNode): SeqResult { + const bodyNode = this.bodyBlockOf(stmt) ?? null; + const lockExpr = this.synchronizedLock(stmt); + const releaseFacts = lockExpr ? this.harvest.facts(lockExpr) : undefined; + return this.buildProtectedSynthetic(bodyNode, stmt, 'release', releaseFacts); + } + + /** The locked expression of a `synchronized (expr) {…}` (paren-unwrapped). */ + private synchronizedLock(stmt: SyntaxNode): SyntaxNode | undefined { + const body = stmt.childForFieldName('body'); + const expr = stmt.namedChildren.find((c) => c.id !== body?.id && !isComment(c)); + return expr ? this.unwrapParen(expr) : undefined; + } + + /** Whether a `switch_expression` is used as a STATEMENT (vs an expression value). */ + private isStatementSwitch(node: SyntaxNode): boolean { + const p = node.parent; + if (!p) return false; + // A statement-position switch is a direct child of a `block`, a classic + // group, or an arrow rule body; an expression-value switch is nested under a + // declaration / return / assignment / argument and is NOT one of these. + return ( + p.type === 'block' || + p.type === 'switch_block_statement_group' || + p.type === 'switch_rule' || + p.type === 'labeled_statement' + ); + } + + private visitSwitch(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const value = this.condOf(stmt) ?? stmt; + const dispatch = this.builder.newBlock( + startLineOf(stmt), + endLineOf(value), + value.text, + 'normal', + this.harvest.facts(value), + ); + const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushSwitch(switchExit, labels); + const body = stmt.childForFieldName('body'); + // A `switch_block` holds `switch_block_statement_group`s (classic, fall + // through) OR `switch_rule`s (arrow, no fallthrough); never both. + const groups = body + ? body.namedChildren.filter( + (c) => c.type === 'switch_block_statement_group' || c.type === 'switch_rule', + ) + : []; + + // Each group's case-test expression(s) evaluate before the body runs — + // harvest their uses (and any pattern binding as a may-def) onto the dispatch + // block, one record per test, CONDITIONALLY (a later case test only runs when + // earlier cases didn't match). + for (const g of groups) { + for (const test of this.caseTests(g)) { + this.builder.attachFacts(dispatch, this.harvest.factsConditional(test)); + } + } + + const groupResults = groups.map((g) => this.visitSeq(this.caseStatements(g))); + const hasDefault = groups.some((g) => this.caseIsDefault(g)); + const arrowForm = groups.some((g) => g.type === 'switch_rule'); + + const entryOf: number[] = new Array(groups.length); + let after = switchExit; + for (let i = groups.length - 1; i >= 0; i--) { + entryOf[i] = groupResults[i]?.entry ?? after; + after = entryOf[i]; + } + + for (let i = 0; i < groups.length; i++) { + this.builder.edge(dispatch, entryOf[i], 'switch-case'); + } + if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path + + // Classic colon groups FALL THROUGH to the next group when not break/return- + // terminated; arrow rules NEVER fall through (each body rejoins after). + for (let i = 0; i < groups.length; i++) { + const res = groupResults[i]; + if (!res) continue; + if (groups[i].type === 'switch_rule') { + this.builder.connect(res.exits, switchExit, 'seq'); + } else { + const fallTarget = i + 1 < groups.length ? entryOf[i + 1] : switchExit; + this.builder.connect(res.exits, fallTarget, 'fallthrough'); + } + } + void arrowForm; + + this.cfc.pop(); + return { entry: dispatch, exits: [switchExit] }; + } + + /** A switch group/rule's body statements (everything but its `switch_label`). */ + private caseStatements(group: SyntaxNode): SyntaxNode[] { + return group.namedChildren.filter((c) => c.type !== 'switch_label' && !isComment(c)); + } + + /** The case-test value expressions of a group (a `switch_label`'s values). */ + private caseTests(group: SyntaxNode): SyntaxNode[] { + const label = group.namedChildren.find((c) => c.type === 'switch_label'); + if (!label) return []; + return label.namedChildren.filter((c) => !isComment(c)); + } + + /** A `default:` group has a `switch_label` with no value children. */ + private caseIsDefault(group: SyntaxNode): boolean { + const label = group.namedChildren.find((c) => c.type === 'switch_label'); + if (!label) return false; + return label.namedChildren.filter((c) => !isComment(c)).length === 0; + } + + /** + * try / catch / finally / try-with-resources. The `resources` of a + * try-with-resources auto-close on BOTH normal and exception exit — exactly + * `finally`-shaped — so we synthesize a close finalizer that wraps an explicit + * `finally` (if any). + */ + private visitTry(stmt: SyntaxNode): SeqResult { + const bodyNode = stmt.childForFieldName('body'); + const catchClauses: SyntaxNode[] = []; + let finallyClause: SyntaxNode | undefined; + for (let i = 0; i < stmt.namedChildCount; i++) { + const c = stmt.namedChild(i); + if (c?.type === 'catch_clause') catchClauses.push(c); + else if (c?.type === 'finally_clause') finallyClause = c; + } + const finallyBody = finallyClause?.namedChildren.find((c) => c.type === 'block'); + + // try-with-resources: the resource close is a deterministic finalizer that + // runs after the body and (if present) before the explicit finally. Model it + // as a synthesized close block prepended to the finalizer chain. + const resources = this.tryResources(stmt); + return this.buildProtected( + bodyNode ?? null, + catchClauses, + finallyBody ?? null, + resources, + stmt, + ); + } + + /** The `resource` nodes of a try-with-resources, or [] for a plain try. */ + private tryResources(stmt: SyntaxNode): SyntaxNode[] { + const spec = stmt.childForFieldName('resources'); + if (!spec) return []; + return spec.namedChildren.filter((c) => c.type === 'resource'); + } + + /** + * Shared try/catch/finally builder. `catchClauses` may be empty; `finallyBody` + * is the explicit finally's `block` (or null); `resources` are try-with- + * resources resources (auto-close = an implicit finally that runs first). + * + * Models the TS/C# `visitTry` semantics: normal completion of try AND catch + * flow through the finalizers; a throw in the protected region routes to the + * handler; early exits crossing a finalizer thread through it (`finally-*` + * completion edges). The resource close finalizer (synthesized) and the + * explicit finally are chained so a crossing jump threads BOTH. + */ + private buildProtected( + bodyNode: SyntaxNode | null, + catchClauses: SyntaxNode[], + finallyBody: SyntaxNode | null, + resources: SyntaxNode[], + span: SyntaxNode, + ): SeqResult { + // Innermost finalizer first on the active stack = outermost lexically. The + // resource close runs BEFORE the explicit finally, so push the explicit + // finally first (deeper) then the close (shallower / nearer the jump). + const explicitFinRes = finallyBody ? this.visitSeq(this.statementsOf(finallyBody)) : null; + const explicitFrame = explicitFinRes ? this.cfc.pushFinalizer(explicitFinRes.entry) : null; + + // Synthesized resource-close finalizer (a single block; close()/AutoCloseable + // has no AST node), carrying the resource bindings' facts as uses. + let closeRes: TraversalResult | null = null; + let closeFrame: ReturnType | null = null; + if (resources.length > 0) { + const closeFacts = this.harvest.resourceCloseFacts(resources); + const closeBlock = this.builder.newBlock( + endLineOf(span), + endLineOf(span), + 'close', + 'normal', + closeFacts, + ); + closeRes = { entry: closeBlock, exits: [closeBlock] }; + // The close's normal exit flows into the explicit finally (if any). + if (explicitFinRes) this.builder.edge(closeBlock, explicitFinRes.entry, 'seq'); + closeFrame = this.cfc.pushFinalizer(closeRes.entry); + } + + // The entry of the finalizer chain (what normal/exception completion runs + // first): close (if any) else explicit finally. + const finalizerEntry = closeRes?.entry ?? explicitFinRes?.entry; + // The set of normal exits AFTER the whole finalizer chain runs. + const finalizerExits = explicitFinRes?.exits ?? closeRes?.exits ?? null; + + // Build each catch handler. + const catchEntries: number[] = []; + const catchExits: number[] = []; + let firstCatchEntry: number | undefined; + for (const clause of catchClauses) { + const clauseBody = clause.childForFieldName('body'); + if (finalizerEntry !== undefined) this.handlers.push(finalizerEntry); + let res: SeqResult = clauseBody ? this.visitSeq(this.statementsOf(clauseBody)) : null; + if (finalizerEntry !== undefined) this.handlers.pop(); + if (res === null) { + // Empty `catch {}` still catches — synthesize one block so exception flow + // lands somewhere and the post-try code stays reachable. + const idx = this.builder.newBlock(startLineOf(clause), endLineOf(clause), ''); + res = { entry: idx, exits: [idx] }; + } + const paramFacts = this.harvest.catchParamFacts(clause); + if (paramFacts) { + const paramBlock = this.builder.newBlock( + startLineOf(clause), + startLineOf(clause), + '', + 'normal', + paramFacts, + ); + this.builder.edge(paramBlock, res.entry, 'seq'); + res = { entry: paramBlock, exits: res.exits }; + } + catchEntries.push(res.entry); + catchExits.push(...res.exits); + if (firstCatchEntry === undefined) firstCatchEntry = res.entry; + } + + // Handler for the try body: first catch if present, else the finalizer + // chain, else the outer handler. + const tryHandler = firstCatchEntry ?? finalizerEntry ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = bodyNode + ? bodyNode.type === 'block' + ? this.visitSeq(this.statementsOf(bodyNode)) + : this.visitStmt(bodyNode) + : null; + this.handlers.pop(); + + if (catchClauses.length > 0 || finalizerEntry !== undefined) { + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, tryHandler, 'throw'); + } + } + + // Pop the finalizer frames (inner→outer) and drain their pending legs. + if (closeFrame && closeRes) { + this.cfc.pop(); + drainFinalizerPending(this.builder, closeFrame, closeRes.exits); + } + if (explicitFrame && explicitFinRes) { + this.cfc.pop(); + drainFinalizerPending(this.builder, explicitFrame, explicitFinRes.exits); + } + + const exits: number[] = []; + if (finalizerEntry !== undefined) { + if (bodyRes) this.builder.connect(bodyRes.exits, finalizerEntry, 'seq'); + for (const e of catchExits) this.builder.edge(e, finalizerEntry, 'seq'); + if (finalizerExits) exits.push(...finalizerExits); + // No catch → an exception re-propagates out after the finalizers run. + if (catchClauses.length === 0 && finalizerExits) { + this.builder.connect(finalizerExits, this.currentHandler(), 'throw'); + } + } else { + if (bodyRes) exits.push(...bodyRes.exits); + exits.push(...catchExits); + } + + const entry = bodyRes?.entry ?? finalizerEntry ?? catchEntries[0]; + if (entry === undefined) { + void span; + return null; + } + return { entry, exits: [...new Set(exits)] }; + } + + /** + * `synchronized`: a protected body whose finalizer is a SYNTHESIZED single + * block (Monitor exit has no AST node). The finalizer runs on both normal and + * exception exit, and crossing jumps thread through it. + */ + private buildProtectedSynthetic( + bodyNode: SyntaxNode | null, + span: SyntaxNode, + text: string, + finalizerFacts: StatementFactsLike, + ): SeqResult { + const finalizerBlock = this.builder.newBlock( + endLineOf(span), + endLineOf(span), + text, + 'normal', + finalizerFacts ?? undefined, + ); + const finRes: TraversalResult = { entry: finalizerBlock, exits: [finalizerBlock] }; + const finFrame = this.cfc.pushFinalizer(finRes.entry); + + const protectedStart = this.builder.blockCount; + // The finalizer IS the handler — an exception in the body still runs the + // release, which then re-propagates to the outer handler. + this.handlers.push(finRes.entry); + const bodyRes = bodyNode + ? bodyNode.type === 'block' + ? this.visitSeq(this.statementsOf(bodyNode)) + : this.visitStmt(bodyNode) + : null; + this.handlers.pop(); + + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, finRes.entry, 'throw'); + } + + this.cfc.pop(); + drainFinalizerPending(this.builder, finFrame, finRes.exits); + + // Normal completion of the body flows through the finalizer; the finalizer's + // exit re-propagates an exception to the outer handler (it had no catch). + if (bodyRes) this.builder.connect(bodyRes.exits, finRes.entry, 'seq'); + this.builder.connect(finRes.exits, this.currentHandler(), 'throw'); + + const entry = bodyRes?.entry ?? finRes.entry; + return { entry, exits: [...finRes.exits] }; + } + + /** Nearest enclosing exception handler, or the function EXIT. */ + private currentHandler(): number { + return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex; + } +} + +/** Build the CFG for one Java function node, or `undefined` if not modelable. */ +function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { + try { + if (!JAVA_FUNCTION_TYPES.has(fnNode.type)) return undefined; + const startLine = startLineOf(fnNode); + const endLine = endLineOf(fnNode); + const startColumn = fnNode.startPosition.column; + + // The body is a `block` (field `body`) OR an expression (single-expression + // lambda `x -> expr`). + const body = + fnNode.childForFieldName('body') ?? fnNode.namedChildren.find((c) => c.type === 'block'); + if (!body) return undefined; // abstract / interface method — no body + + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new JavaHarvester(fnNode); + + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + + if (body.type !== 'block') { + // Single-expression lambda (`x -> expr`): one block whose value is returned. + const blk = builder.newBlock( + startLineOf(body), + endLineOf(body), + body.text, + 'normal', + harvest.facts(body), + ); + builder.edge(builder.entryIndex, blk, 'seq'); + builder.edge(blk, builder.exitIndex, 'return'); + return builder.finish(harvest.table()); + } + + const walk = new JavaCfgWalk(builder, harvest); + const res = walk.visitSeq(body.namedChildren.filter((c) => !isComment(c))); + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(harvest.table()); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + return builder.finish(harvest.table()); + } catch (err) { + // Never throw out of buildFunctionCfg — a malformed AST shape must skip only + // this one function's CFG, never drop the whole file's language group (R4). + // eslint-disable-next-line no-console + console.warn(`[cfg] Java buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`); + return undefined; + } +} + +/** Whether a node is a Java function this visitor builds a CFG for. */ +function isFunction(node: SyntaxNode): boolean { + return JAVA_FUNCTION_TYPES.has(node.type); +} + +/** The Java CFG visitor. */ +export function createJavaCfgVisitor(): CfgVisitor { + return { buildFunctionCfg, isFunction }; +} + +export { JAVA_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index f7ca23de3..22b9a48a3 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -26,6 +26,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; +import { createJavaCfgVisitor } from '../cfg/visitors/java.js'; import type { SymbolDefinition } from 'gitnexus-shared'; import { emitJavaScopeCaptures, @@ -118,6 +119,9 @@ export const javaProvider = defineLanguage({ // ── RFC #909 Ring 3: scope-based resolution hooks ── emitScopeCaptures: emitJavaScopeCaptures, + + // ── PDG: per-function CFG + def/use harvest (#2195 U4) ── + cfgVisitor: createJavaCfgVisitor(), interpretImport: interpretJavaImport, interpretTypeBinding: interpretJavaTypeBinding, bindingScopeFor: javaBindingScopeFor, diff --git a/gitnexus/test/integration/cfg/fixtures/java-hazards.java b/gitnexus/test/integration/cfg/fixtures/java-hazards.java new file mode 100644 index 000000000..6ad285032 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/java-hazards.java @@ -0,0 +1,149 @@ +// Java CFG hazard fixture (#2195 U4). Each method exercises one control-flow +// construct the Java CfgVisitor models; the worker-mode pipeline + snapshot +// tests assert non-trivial BasicBlock / CFG / REACHING_DEF / CDG output here. +package fixtures; + +import java.io.IOException; + +class Hazards { + + // if / else — cond-true / cond-false to both arms, joining after. + int branch(int x) { + int r; + if (x > 0) { + r = 1; + } else { + r = 2; + } + return r; + } + + // classic C-style for — init once, condition header, back-edge through update. + int classicFor(int n) { + int sum = 0; + for (int i = 0; i < n; i++) { + sum += i; + } + return sum; + } + + // enhanced for (for-each) — loop var def, iterated value use. + int forEach(int[] xs) { + int total = 0; + for (int v : xs) { + total += v; + } + return total; + } + + // while — bottom-of-header re-test, back-edge. + int whileLoop(int x) { + while (x > 0) { + x = step(x); + } + return x; + } + + // do-while — body runs before the test. + int doWhile(int x) { + do { + x = step(x); + } while (x > 0); + return x; + } + + // try-with-resources — the resource closes on BOTH normal and exception exit + // (finally semantics); a return inside crosses the close (finally-return). + int withResources(String path) throws IOException { + try (var r = open(path)) { + return read(r); + } catch (IOException e) { + return handle(e); + } finally { + cleanup(); + } + } + + // labeled break — `break outer;` from a nested loop targets the labeled frame. + int labeledBreak(int[][] grid, int needle) { + int found = -1; + outer: + for (int i = 0; i < grid.length; i++) { + for (int j = 0; j < grid[i].length; j++) { + if (grid[i][j] == needle) { + found = i; + break outer; + } + } + } + return found; + } + + // classic colon switch — fallthrough between break-less cases; the case test + // is recorded as a may-def on the dispatch block. + int classicSwitch(int x) { + int r = 0; + switch (x) { + case 1: + case 2: + r = 10; + break; + case 3: + r = 30; + default: + r = -1; + } + return r; + } + + // arrow switch — each rule rejoins after the switch (no fallthrough). + int arrowSwitch(int x) { + int r = 0; + switch (x) { + case 1 -> r = 1; + case 2, 3 -> r = 2; + default -> r = -1; + } + return r; + } + + // switch expression with yield — yields a value to the enclosing switch. + int yieldSwitch(int x) { + int r = switch (x) { + case 1 -> 10; + default -> { + yield 20; + } + }; + return r; + } + + // synchronized — the monitor release is a deterministic finalizer. + void sync(Object lock) { + synchronized (lock) { + touch(); + } + after(); + } + + // a server-style non-terminating loop — EXIT must stay reverse-reachable so + // CDG is not silently skipped for the method. + void serve() { + while (true) { + if (ready()) { + handle(); + } + } + } + + // helpers (no bodies of interest) + int step(int x) { return x - 1; } + Object open(String p) { return null; } + int read(Object r) { return 0; } + int handle(IOException e) { return 0; } + void cleanup() {} + void touch() {} + void after() {} + boolean ready() { return false; } + void handle() {} +} diff --git a/gitnexus/test/unit/cfg/java-visitor.test.ts b/gitnexus/test/unit/cfg/java-visitor.test.ts new file mode 100644 index 000000000..72d9dbb6c --- /dev/null +++ b/gitnexus/test/unit/cfg/java-visitor.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'node:module'; +import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; + +// U4 — the Java CfgVisitor, one hazard per test (KTD5: real-parser regression, +// NOT snapshot-pinning). Each fixture's distinctive statement text (step(), +// done(), handle(e), …) lets us locate the block for a region by text and assert +// the control-flow topology around it. + +const javaGrammar = createRequire(import.meta.url)('tree-sitter-java') as Parameters< + typeof makeCfgHarness +>[0]; + +const java: CfgHarness = makeCfgHarness(javaGrammar, createJavaCfgVisitor(), 'fixture.java'); + +const block = (cfg: FunctionCfg, substr: string): number => { + const b = cfg.blocks.find((bl) => bl.text.includes(substr)); + if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`); + return b.index; +}; + +const edgeKinds = (cfg: FunctionCfg): Set => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +const hasDef = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx))); +const hasUse = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx))); +const hasMayDef = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx))); + +const wrap = (body: string): string => `class C { void m(int x) { ${body} } }`; + +describe('Java CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT (seq)', () => { + const cfg = java.cfgOf(`class C { void m() { a(); b(); c(); } }`); + expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: body, kind: 'seq' }); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty body: ENTRY → EXIT', () => { + const cfg = java.cfgOf(`class C { void m() {} }`); + expect(cfg.blocks).toHaveLength(2); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('constructor, compact constructor, and lambda are CFG-bearing functions', () => { + const cfgs = java.cfgsOf( + `class C { C(int a) { this.x = a; } void outer() { Runnable r = () -> { go(); }; r.run(); } }`, + ); + // constructor C, outer, and the lambda = 3 CFGs. + expect(cfgs.length).toBeGreaterThanOrEqual(3); + for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('record compact constructor is CFG-bearing', () => { + const cfgs = java.cfgsOf(`record R(int a) { R { if (a < 0) throw new RuntimeException(); } }`); + expect(cfgs.length).toBeGreaterThanOrEqual(1); + for (const cfg of cfgs) expect(reachable(cfg, cfg.exitIndex)).toBe(true); + }); + + it('single-expression lambda: one block returns its value', () => { + const cfgs = java.cfgsOf( + `class C { void m() { java.util.function.Function f = z -> z * 2; f.apply(1); } }`, + ); + // The lambda's OWN cfg has a single body block whose text IS exactly the + // expression (the enclosing method's block contains the whole lambda source). + const lambda = cfgs.find((c) => c.blocks.some((b) => b.text === 'z * 2')); + expect(lambda).toBeDefined(); + const body = lambda!.blocks.find((b) => b.text === 'z * 2')!.index; + expect(lambda!.edges).toContainEqual({ from: body, to: lambda!.exitIndex, kind: 'return' }); + }); + + it('abstract method (no body) → graceful undefined, no throw', () => { + const root = java.parse(`abstract class C { abstract void m(); }`); + const fns = java.collectFunctions(root); + for (const fn of fns) { + expect(() => createJavaCfgVisitor().buildFunctionCfg(fn, 'f.java')).not.toThrow(); + } + }); +}); + +describe('Java CfgVisitor — branching', () => { + it('if/else: cond-true to then, cond-false to else, both reach the join', () => { + const cfg = java.cfgOf(wrap(`if (x > 0) { a(); } else { b(); } c();`)); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const join = block(cfg, 'c();'); + expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true); + }); + + it('else if chains through the nested alternative (no else_clause wrapper)', () => { + const cfg = java.cfgOf( + `class C { void m(int x) { if (x > 0) { a(); } else if (x < 0) { b(); } else { c(); } } }`, + ); + expect(reaches(cfg, block(cfg, 'a();'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'c();'), cfg.exitIndex)).toBe(true); + }); +}); + +describe('Java CfgVisitor — loops', () => { + it('while loop: header + back-edge + exit', () => { + const cfg = java.cfgOf(`class C { void m(int x) { while (x > 0) { step(); } done(); } }`); + const header = block(cfg, 'x > 0'); + const body = block(cfg, 'step();'); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + }); + + it('do-while runs the body BEFORE testing, then loops back from the bottom', () => { + const cfg = java.cfgOf(`class C { void m(int x) { do { step(); } while (x > 0); done(); } }`); + const body = block(cfg, 'step();'); + const cond = block(cfg, 'x > 0'); + expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first + expect(reaches(cfg, body, cond)).toBe(true); + expect(cfg.edges).toContainEqual({ from: cond, to: body, kind: 'loop-back' }); + expect(reaches(cfg, cond, block(cfg, 'done();'))).toBe(true); + }); + + it('classic for: init once, condition header, back-edge through update', () => { + const cfg = java.cfgOf( + `class C { void m(int n) { for (int i = 0; i < n; i++) { step(); } done(); } }`, + ); + const init = block(cfg, 'int i = 0'); + const header = block(cfg, 'i < n'); + const incr = block(cfg, 'i++'); + const body = block(cfg, 'step();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: init, kind: 'seq' }); + expect(reaches(cfg, body, incr)).toBe(true); + expect(cfg.edges).toContainEqual({ from: incr, to: header, kind: 'loop-back' }); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + }); + + it('enhanced for (for-each): header + body + loop-back + exit; loop var is a def', () => { + const cfg = java.cfgOf( + `class C { void m(int[] xs) { for (int v : xs) { use(v); } done(); } }`, + ); + const body = block(cfg, 'use(v);'); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to; + expect(header).toBeDefined(); + expect(reaches(cfg, header!, block(cfg, 'done();'))).toBe(true); + const v = bindingIdx(cfg, 'v'); + expect(hasDef(cfg, v)).toBe(true); + }); + + it('while (true) {} keeps EXIT reverse-reachable (structural exit-escape edge)', () => { + const cfg = java.cfgOf(`class C { void m() { while (true) { work(); } } }`); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('for (;;) {} keeps EXIT reverse-reachable', () => { + const cfg = java.cfgOf(`class C { void m() { for (;;) { work(); } } }`); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); +}); + +describe('Java CfgVisitor — labeled break/continue', () => { + it('break outer; from a nested loop targets the labeled (outer) frame', () => { + const cfg = java.cfgOf(`class C { void m() { + outer: + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + if (hit()) break outer; + inner(); + } + afterInner(); + } + done(); + } }`); + expect(edgeKinds(cfg).has('break')).toBe(true); + const brk = block(cfg, 'break outer;'); + // The labeled break skips past BOTH loops to the post-outer-loop continuation + // (done()), not just the inner loop's exit (afterInner()). + expect(reaches(cfg, brk, block(cfg, 'done();'))).toBe(true); + // It must NOT route back into the outer loop body's afterInner(). + expect(reaches(cfg, brk, block(cfg, 'afterInner();'))).toBe(false); + }); + + it('continue outer; from a nested loop continues the labeled (outer) loop', () => { + const cfg = java.cfgOf(`class C { void m() { + outer: + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + if (skip()) continue outer; + inner(); + } + } + done(); + } }`); + expect(edgeKinds(cfg).has('continue')).toBe(true); + expect(reaches(cfg, block(cfg, 'continue outer;'), block(cfg, 'i < 3'))).toBe(true); + }); + + it('unlabeled break does NOT match a labeled BLOCK frame', () => { + // `blk:` labels a plain block; an UNLABELED break here is a compile error in + // real Java, but the CFG must still route it to EXIT (no labeled-block match), + // and a labeled `break blk;` must reach the post-block join. + const cfg = java.cfgOf(`class C { void m() { + blk: { + if (cond()) break blk; + work(); + } + done(); + } }`); + const brk = block(cfg, 'break blk;'); + // labeled break reaches the join after the labeled block (done()). + expect(reaches(cfg, brk, block(cfg, 'done();'))).toBe(true); + // work() after the break is still reachable (false arm of the if). + expect(reachable(cfg, block(cfg, 'work();'))).toBe(true); + }); +}); + +describe('Java CfgVisitor — switch', () => { + it('classic colon switch: break-terminated case rejoins, fallthrough on break-less', () => { + const cfg = java.cfgOf(`class C { void m(int x) { + switch (x) { + case 1: one(); break; + case 2: two(); break; + default: other(); + } + after(); + } }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'after();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'two();'), block(cfg, 'after();'))).toBe(true); + // break-terminated case 1 does not fall into case 2. + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false); + }); + + it('empty colon case falls through to the next case (fallthrough edge)', () => { + const cfg = java.cfgOf(`class C { void m(int x) { + switch (x) { case 1: case 2: shared(); break; default: d(); } + after(); + } }`); + expect(reaches(cfg, block(cfg, 'shared();'), block(cfg, 'after();'))).toBe(true); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // a break-less default flows to after(). + expect(reaches(cfg, block(cfg, 'd();'), block(cfg, 'after();'))).toBe(true); + }); + + it('a non-break case FALLS THROUGH to the next case (classic semantics)', () => { + const cfg = java.cfgOf(`class C { void m(int x) { + switch (x) { case 1: one(); case 2: two(); break; default: d(); } + after(); + } }`); + // case 1 has no break → it falls into case 2's body. + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(true); + expect(edgeKinds(cfg).has('fallthrough')).toBe(true); + }); + + it('arrow switch: each rule rejoins after the switch, no fallthrough', () => { + const cfg = java.cfgOf(`class C { void m(int x) { + switch (x) { + case 1 -> a(); + case 2, 3 -> b(); + default -> c(); + } + after(); + } }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'after();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), block(cfg, 'after();'))).toBe(true); + // arrow rule a() does NOT fall into b(). + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'b();'))).toBe(false); + expect(edgeKinds(cfg).has('fallthrough')).toBe(false); + }); + + it('switch case-test is recorded as a may-def/use on the dispatch block', () => { + const cfg = java.cfgOf(`class C { void m(int x, int k) { + switch (x) { case 1: a(); break; default: b(); } + } }`); + // The dispatch block (switch value `x`) uses x; the case-test constants carry + // no bindings, but a value-bearing dispatch records the discriminant use. + const x = bindingIdx(cfg, 'x'); + expect(hasUse(cfg, x)).toBe(true); + }); + + it('switch EXPRESSION value with yield stays inline; method has a single-exit CFG', () => { + const cfg = java.cfgOf(`class C { int m(int x) { + int r = switch (x) { case 1 -> 10; default -> { yield 20; } }; + return r; + } }`); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + }); + + it('statement switch with a yield arm builds a dispatch with a yield block', () => { + const cfg = java.cfgOf(`class C { int m(int x) { + switch (x) { + case 1: yield 10; + default: yield 20; + } + } }`); + // statement-position switch breaks a block → switch-case dispatch edges. + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + }); +}); + +describe('Java CfgVisitor — try / catch / finally / try-with-resources', () => { + it('try/catch: a throw edge runs from each protected block to the handler', () => { + const cfg = java.cfgOf(`class C { void m() { + try { risky(); deeper(); } catch (java.lang.Exception e) { handle(e); } + after(); + } }`); + expect(edgeKinds(cfg).has('throw')).toBe(true); + const handler = block(cfg, 'handle(e);'); + expect(reaches(cfg, block(cfg, 'risky();'), handler)).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('finally runs on normal completion of the try', () => { + const cfg = java.cfgOf(`class C { void m() { + try { work(); } finally { cleanup(); } + after(); + } }`); + const body = block(cfg, 'work();'); + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, body, fin)).toBe(true); + expect(reaches(cfg, fin, block(cfg, 'after();'))).toBe(true); + }); + + it('return crossing a finally emits a finally-return completion edge', () => { + const cfg = java.cfgOf(`class C { int m() { + try { return compute(); } finally { cleanup(); } + } }`); + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + }); + + it('break crossing a finally emits a finally-break completion edge', () => { + const cfg = java.cfgOf(`class C { void m(int n) { + for (int i = 0; i < n; i++) { + try { if (done()) break; } finally { tick(); } + } + after(); + } }`); + expect(edgeKinds(cfg).has('finally-break')).toBe(true); + }); + + it('try-with-resources closes resources on the NORMAL exit path', () => { + const cfg = java.cfgOf(`class C { void m() { + try (var r = open()) { read(r); } + after(); + } }`); + const body = block(cfg, 'read(r);'); + const close = block(cfg, 'close'); + // body → close → after() (normal completion threads through the close). + expect(reaches(cfg, body, close)).toBe(true); + expect(reaches(cfg, close, block(cfg, 'after();'))).toBe(true); + }); + + it('try-with-resources closes resources on the EXCEPTION path too', () => { + const cfg = java.cfgOf(`class C { void m() { + try (var r = open()) { risky(r); } + } }`); + const body = block(cfg, 'risky(r);'); + const close = block(cfg, 'close'); + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(reaches(cfg, body, close)).toBe(true); + }); + + it('a return inside try-with-resources crosses the close (finally-return)', () => { + const cfg = java.cfgOf(`class C { int m() { + try (var r = open()) { return read(r); } + } }`); + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + }); + + it('multi-catch (A | B) binds the exception name as a def in the handler', () => { + const cfg = java.cfgOf(`class C { void m() { + try { x(); } catch (java.io.IOException | java.lang.RuntimeException e) { handle(e); } + } }`); + expect(edgeKinds(cfg).has('throw')).toBe(true); + const e = bindingIdx(cfg, 'e'); + expect(hasDef(cfg, e)).toBe(true); + }); +}); + +describe('Java CfgVisitor — synchronized (deterministic finalizer)', () => { + it('synchronized body runs then releases the monitor on the normal path', () => { + const cfg = java.cfgOf(`class C { void m(Object lock) { + synchronized (lock) { touch(); } + after(); + } }`); + const body = block(cfg, 'touch();'); + const release = block(cfg, 'release'); + expect(reaches(cfg, body, release)).toBe(true); + expect(reaches(cfg, release, block(cfg, 'after();'))).toBe(true); + }); + + it('synchronized releases the monitor on the exception path (throw → release)', () => { + const cfg = java.cfgOf(`class C { void m(Object lock) { synchronized (lock) { risky(); } } }`); + expect(edgeKinds(cfg).has('throw')).toBe(true); + const body = block(cfg, 'risky();'); + const release = block(cfg, 'release'); + expect(reaches(cfg, body, release)).toBe(true); + }); + + it('a return inside synchronized crosses the release (finally-return)', () => { + const cfg = java.cfgOf(`class C { int m(Object lock) { synchronized (lock) { return get(); } } }`); + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + }); +}); + +describe('Java CfgVisitor — def/use harvest', () => { + it('local declaration: int x = a + b; use(x); → def of x + use of x', () => { + const cfg = java.cfgOf(`class C { void m(int a, int b) { int x = a + b; use(x); } }`); + const x = bindingIdx(cfg, 'x'); + expect(hasDef(cfg, x)).toBe(true); + expect(hasUse(cfg, x)).toBe(true); + }); + + it('bare uninitialized local is NOT a def until assigned', () => { + const cfg = java.cfgOf(`class C { void m() { int x; x = 5; use(x); } }`); + const x = bindingIdx(cfg, 'x'); + // the assignment defines x; the declaration alone does not. + expect(hasDef(cfg, x)).toBe(true); + expect(hasUse(cfg, x)).toBe(true); + }); + + it('a && (x = g()) records x as a may-def inside the short-circuit', () => { + const cfg = java.cfgOf(`class C { void m(boolean a) { int x = 0; if (a && (x = g()) > 0) h(x); } }`); + const x = bindingIdx(cfg, 'x'); + expect(hasMayDef(cfg, x)).toBe(true); + }); + + it('ternary arms record their writes as may-defs', () => { + const cfg = java.cfgOf(`class C { void m(int a) { int x = 0; int y = a > 0 ? (x = 1) : (x = 2); use(y); } }`); + const x = bindingIdx(cfg, 'x'); + expect(hasMayDef(cfg, x)).toBe(true); + }); + + it('compound assignment reads AND writes the lvalue', () => { + const cfg = java.cfgOf(`class C { void m() { int z = 1; z += 3; } }`); + const z = bindingIdx(cfg, 'z'); + expect(hasDef(cfg, z)).toBe(true); + expect(hasUse(cfg, z)).toBe(true); + }); + + it('field/array writes are NOT scalar defs (their roots are uses)', () => { + const cfg = java.cfgOf(`class C { void m(int[] a) { a[0] = 1; this.f = 2; } }`); + // `a` is a parameter; the array write `a[0] = 1` reads a (subscript root), + // it does not define `a`. No `this.f` scalar binding is created. + const a = bindingIdx(cfg, 'a'); + expect(hasUse(cfg, a)).toBe(true); + }); +}); + +describe('Java CfgVisitor — functionStartColumn', () => { + it('two same-line methods get distinct functionStartColumn', () => { + const cfgs = java.cfgsOf(`class C { int a() { return 1; } int b() { return 2; } }`); + expect(cfgs).toHaveLength(2); + expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine); // same line + expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn); // distinct column + }); +}); + +describe('Java CfgVisitor — does not throw on exotic shapes', () => { + it('nested lambdas / anonymous-class methods each build their own CFGs', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const cfgs = java.cfgsOf(`class C { void m() { + Runnable r = () -> { if (cond()) { go(); } }; + java.util.function.Function f = z -> z + 1; + r.run(); + } }`); + expect(cfgs.length).toBeGreaterThanOrEqual(3); // m, lambda block, lambda expr + for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + } finally { + warn.mockRestore(); + } + }); +});