feat(cfg): Java CFG visitor + def/use harvest (#2195 U4)

Add createJavaCfgVisitor + java-harvest over the shared CfgBuilder /
ControlFlowContext: if/else, classic for, enhanced-for, while, do-while,
classic-vs-arrow switch (switch_block_statement_group fallthrough vs
switch_rule no-fallthrough), try/catch/finally + try-with-resources
(auto-close synthesized as a finalizer, closes on normal AND exception
exit) + synchronized (monitor-release finalizer), labeled break/continue
to the labeled frame, yield, return/throw/break/continue. Wire into
javaProvider.

Every literal validated against tree-sitter-java via the probe
(switch_expression covers both switch forms, generic_type, line_comment,
for init field). Edge kinds match the contract; functionStartColumn
populated; while(true)/for(;;) keep EXIT reverse-reachable (production
CDG probe: 3 edges; hazard fixture: 34 CDG edges). buildFunctionCfg
returns undefined rather than throwing.

43 real-parser regression tests; grammar-literal gate green; no
regression (cfg unit suite 304, tsc clean). Documented gaps: switch-as-
expression-value inline, yield state machine, async/field-write defs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 11:21:28 +00:00
parent aad3a702b9
commit 6dbe7373ff
5 changed files with 2175 additions and 0 deletions

View file

@ -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<string, number>;
}
export class JavaHarvester {
private readonly bindings: BindingEntry[] = [];
private readonly scopeByNode = new Map<number, Scope>();
private readonly root: Scope = { parent: null, table: new Map() };
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */
private readonly nearestScopeCache = new Map<number, Scope>();
/** >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<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
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 } : {}),
};
}
}

View file

@ -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_<node_type>` 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: <statement>` — 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<ControlFlowContext['pushFinalizer']> | 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<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { JAVA_FUNCTION_TYPES };

View file

@ -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,

View file

@ -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() {}
}

View file

@ -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<string> => new Set(cfg.edges.map((e) => e.kind));
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
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<Integer,Integer> 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<Integer,Integer> 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();
}
});
});