feat(cfg): Kotlin CFG visitor + def/use harvest (#2195 U13)

Add createKotlinCfgVisitor + kotlin-harvest (vendored tree-sitter-kotlin):
if/else, when (subject + subjectless, no fallthrough), for/while/do-while,
try/catch/finally, jump_expression (return/return@/break/break@/continue/
continue@/throw), labeled loops, control_structure_body unwrapping,
expression-body functions. The grammar is field-less for control flow, so
the visitor navigates by child type+position. Wire into kotlinProvider.

Every literal validated against the vendored grammar via the probe
(line_comment/multiline_comment, not comment). while (true) keeps EXIT
reverse-reachable (production CDG probe: 3 edges; worker-mode fixture:
BB=82, CDG=41). 28 real-parser tests; comprehensive sweep green (571).
Gaps: value-position if/when/try inline, inline-fun non-local return,
getters/setters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 13:53:59 +00:00
parent 0e137cce27
commit b302c63544
5 changed files with 1909 additions and 0 deletions

View file

@ -0,0 +1,570 @@
/**
* Kotlin def/use harvester (#2195) — the Kotlin analogue of
* {@link import('./swift-harvest.js').SwiftHarvester} and the C-family / Go /
* Rust / Python harvesters. Like the Swift / Python / Rust harvesters it harvests
* NO call-site `sites[]` (the call-site taint substrate is a later step): it emits
* only the per-function binding table ({@link BindingEntry}[]) plus
* {@link StatementFacts} (defs / uses / mayDefs) via a local
* {@link FactAccumulator} with no site machinery, so the produced facts never
* carry a `sites` key.
*
* Runs in the parse worker next to the Kotlin CFG visitor. Output is the binding
* table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG,
* plus the per-block def/use facts the reaching-defs / CDG solvers consume.
*
* Every node-type literal below was grammar-validated against the VENDORED
* tree-sitter-kotlin via the introspection probe before use (mandatory pre-step).
* The grammar is FIELD-LESS for the constructs harvested here (no
* `childForFieldName` fields on `parameter` / `property_declaration` /
* `for_statement` / etc.), so this harvester navigates by child TYPE and position.
* Kotlin shapes pre-empted (verified by a real parse):
* - functions: `function_declaration` (`fun` `simple_identifier`
* `function_value_parameters` `function_body`), `anonymous_function`
* (`fun function_value_parameters function_body`), `lambda_literal`
* (`{ lambda_parameters? -> statements }`).
* - parameters: `function_value_parameters` → `parameter`
* (`simple_identifier : user_type`). A lambda's params live in
* `lambda_parameters` → `variable_declaration` (each a `simple_identifier`,
* optionally `: user_type`).
* - `property_declaration` — `binding_pattern_kind` (`val`/`var`), then a
* `variable_declaration` (`simple_identifier`) OR a `multi_variable_declaration`
* (`( variable_declaration, … )` for `val (a, b) = p`), then `= value`.
* - `for_statement` — pattern is a `variable_declaration` / `multi_variable_declaration`
* after `(`; the iterated collection is the expression after `in`.
* - `catch_block` — `catch ( simple_identifier : user_type ) { statements }`; the
* bound error is the `simple_identifier`.
* - `when_subject` — `( expr )` or `( val variable_declaration = expr )`.
* - reads: `simple_identifier`, `navigation_expression` (`a.b` / `a?.b`),
* `call_expression`, `assignment` (`directly_assignable_expression` lvalue +
* operator + value), `elvis_expression` (`a ?: b`).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Swift / Rust / Go
* harvesters): the CFG walk is NOT source-order (`do … while` builds the condition
* after 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,
* declaring every bound name into ONE function table; phase 2 resolves defs/uses
* against that finished table from any walk order. Kotlin DOES have block scope +
* shadowing, but a single function table is the documented v1 simplification used
* by the Swift / Python / Rust harvesters — distinct shadowing redeclarations of
* the same name collapse onto one binding (an over-approximation that can falsely
* kill across a shadow, the sound direction for taint).
*
* v1 def-semantics scope:
* - `property_declaration` (`val`/`var PAT = …`) — each `simple_identifier` leaf
* of the `variable_declaration` / `multi_variable_declaration` is a def; the
* value is walked for uses.
* - `assignment` plain `=` — a plain-identifier lvalue is a def; a
* `navigation_expression` / subscript target (`this.x = …`, `a[i] = …`) is NOT
* a scalar def (its root is a use). A compound `+=`/`-=`/… target def-AND-uses
* the lvalue.
* - `for (x in xs)` — the loop pattern's leaves are defs, the collection a use.
* - a `when (val r = e)` subject binds `r`.
* - `catch_block`'s error identifier binds.
* - parameters (incl. lambda params) are `param`-kind defs.
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / subscript writes
* (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their root identifiers are uses
* only. Nested-function bodies (`lambda_literal`, nested `anonymous_function` /
* `function_declaration`) are opaque in BOTH directions.
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` short-circuit, the elvis (`?:`) right operand and a
* safe-call (`?.`) chain, and a `when`-entry case test — is a may-def (gen WITHOUT
* kill), so the not-taken path's prior def is not falsely killed.
*
* Identifiers with no in-function declaration (top-level functions, types,
* properties) 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([
'function_declaration',
'anonymous_function',
'lambda_literal',
]);
const COMMENT_TYPES = new Set(['line_comment', 'multiline_comment', 'shebang_line']);
/**
* Minimal ordered, deduplicating def/use collector for one statement record.
* Deliberately NOT the shared {@link import('./call-site-harvest.js')
* CallSiteFactAccumulator} — this unit harvests NO call sites (taint substrate is
* a later step), so a local accumulator with only the def/use/may-def machinery
* keeps `kotlin-harvest.ts` free of site logic and guarantees the emitted facts
* carry no `sites` key (mirrors the Swift / Python / Rust harvesters).
*/
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);
}
/** A def that may not execute (conditional context) — gen without kill. */
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;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
// Stay absent when empty — keeps the serialized side-channel payload lean.
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}
export class KotlinHarvester {
private readonly bindings: BindingEntry[] = [];
/** Single function-scope name → binding index (v1: no block scope). */
private readonly table = new Map<string, number>();
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body) this.prescan(body);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
bindingTable(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/lambda body subtree to pre-scan (`statements` or `function_body`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
if (fnNode.type === 'lambda_literal') {
return fnNode.namedChildren.find((c) => c.type === 'statements');
}
return fnNode.namedChildren.find((c) => c.type === 'function_body');
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void {
const name = nameNode.text;
if (!name || name === '_' || this.table.has(name)) return;
this.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Declare every parameter binder of a fn / anonymous fn / lambda. */
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.namedChildren.find((c) => c.type === 'function_value_parameters');
if (params) {
for (const p of params.namedChildren) {
if (p.type !== 'parameter') continue;
const name = p.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.declare(name, 'param');
}
}
// Lambda params: `lambda_parameters` → `variable_declaration`(s).
const lambdaParams = fnNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (lambdaParams) {
for (const vd of lambdaParams.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, 'param');
else if (vd.type === 'multi_variable_declaration') {
for (const inner of vd.namedChildren) {
if (inner.type === 'variable_declaration') this.declareVariableDeclaration(inner, 'param');
}
}
}
}
}
/** Declare the `simple_identifier` of a `variable_declaration`. */
private declareVariableDeclaration(vd: SyntaxNode, kind: BindingEntry['kind']): void {
const id = vd.namedChildren.find((c) => c.type === 'simple_identifier') ?? vd;
if (id.type === 'simple_identifier') this.declare(id, kind);
}
/**
* Pre-scan the function body once, declaring every bound name. Recurses into
* compound expressions but NOT into nested function/lambda bodies (opaque).
*/
private prescan(node: SyntaxNode): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return;
switch (t) {
case 'property_declaration':
this.declarePropertyPattern(node, 'let');
break;
case 'for_statement':
this.declareForPattern(node);
break;
case 'catch_block':
this.declareCatchParam(node);
break;
case 'when_subject':
this.declareWhenSubject(node);
break;
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c);
}
}
/** Declare every binder of a `property_declaration`'s pattern (single or multi). */
private declarePropertyPattern(node: SyntaxNode, kind: BindingEntry['kind']): void {
const single = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.declareVariableDeclaration(single, kind);
return;
}
const multi = node.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, kind);
}
}
}
/** Declare a `for` loop variable (`variable_declaration` / `multi_variable_declaration`). */
private declareForPattern(node: SyntaxNode): void {
const single = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.declareVariableDeclaration(single, 'let');
return;
}
const multi = node.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.declareVariableDeclaration(vd, 'let');
}
}
}
/** Declare a `catch (e: T)` error name. */
private declareCatchParam(node: SyntaxNode): void {
const id = node.namedChildren.find((c) => c.type === 'simple_identifier');
if (id) this.declare(id, 'catch');
}
/** Declare a `when (val r = e)` subject binding. */
private declareWhenSubject(node: SyntaxNode): void {
const vd = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (vd) this.declareVariableDeclaration(vd, 'let');
}
// ── 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 ( PAT in COLLECTION )` head: the loop pattern's leaves are
* defs, the iterated collection a use.
*/
forHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const collection = this.forCollection(stmt);
if (collection) this.walkValue(collection, acc);
this.defForPattern(stmt, acc);
return acc.finish();
}
/** Facts for a `when` subject: a `val r = e` binds `r` (def); the expr's uses. */
whenSubjectFacts(subject: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(subject.startPosition.row + 1);
const vd = subject.namedChildren.find((c) => c.type === 'variable_declaration');
// Walk the subject's value expression(s) for uses; bind the `val` name.
for (const c of subject.namedChildren) {
if (c.type === 'variable_declaration') continue;
this.walkValue(c, acc);
}
if (vd) this.defVariableDeclaration(vd, acc);
return acc.finish();
}
/** ENTRY-block facts for the parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const params = this.fnNode.namedChildren.find((c) => c.type === 'function_value_parameters');
if (params) {
for (const p of params.namedChildren) {
if (p.type !== 'parameter') continue;
const name = p.namedChildren.find((c) => c.type === 'simple_identifier');
if (name) this.def(name, acc);
}
}
const lambdaParams = this.fnNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (lambdaParams) {
for (const vd of lambdaParams.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
else if (vd.type === 'multi_variable_declaration') {
for (const inner of vd.namedChildren) {
if (inner.type === 'variable_declaration') this.defVariableDeclaration(inner, acc);
}
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (e: T)` error name — prepend to the handler entry block. */
catchParamFacts(catchBlock: SyntaxNode): StatementFacts | undefined {
const id = catchBlock.namedChildren.find((c) => c.type === 'simple_identifier');
if (!id) return undefined;
const acc = new FactAccumulator(catchBlock.startPosition.row + 1);
this.def(id, acc);
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const idx = this.table.get(name);
if (idx !== undefined) return idx;
let syn = this.synthetic.get(name);
if (syn === undefined) {
syn = this.bindings.length;
this.synthetic.set(name, syn);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return syn;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank target defines nothing
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return;
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--;
}
}
/** Def the `simple_identifier` of a `variable_declaration`. */
private defVariableDeclaration(vd: SyntaxNode, acc: FactAccumulator): void {
const id = vd.namedChildren.find((c) => c.type === 'simple_identifier');
if (id) this.def(id, acc);
}
/** Def every binder of a `for` loop pattern. */
private defForPattern(stmt: SyntaxNode, acc: FactAccumulator): void {
const single = stmt.namedChildren.find((c) => c.type === 'variable_declaration');
if (single) {
this.defVariableDeclaration(single, acc);
return;
}
const multi = stmt.namedChildren.find((c) => c.type === 'multi_variable_declaration');
if (multi) {
for (const vd of multi.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
}
}
}
/** The iterated collection of a `for_statement` — the named child after `in`. */
private forCollection(stmt: SyntaxNode): SyntaxNode | undefined {
let sawIn = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (c.type === 'in') {
sawIn = true;
continue;
}
if (c.type === ')') return undefined;
if (sawIn && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** Value-position walk: collect uses; route def positions to the pattern handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque
switch (t) {
case 'simple_identifier':
this.use(node, acc);
return;
case 'property_declaration': {
// Walk the value for uses, then def each pattern binder.
const binder = node.namedChildren.find(
(c) => c.type === 'variable_declaration' || c.type === 'multi_variable_declaration',
);
const value = this.propertyValue(node);
if (value) this.walkValue(value, acc);
if (binder?.type === 'variable_declaration') this.defVariableDeclaration(binder, acc);
else if (binder?.type === 'multi_variable_declaration') {
for (const vd of binder.namedChildren) {
if (vd.type === 'variable_declaration') this.defVariableDeclaration(vd, acc);
}
}
return;
}
case 'assignment': {
const lvalue = node.namedChildren.find((c) => c.type === 'directly_assignable_expression');
const op = this.assignmentOperator(node);
const value = this.assignmentValue(node);
if (value) this.walkValue(value, acc);
if (lvalue) {
const lv = this.unwrapAssignable(lvalue);
if (lv.type === 'simple_identifier') {
this.def(lv, acc);
if (op !== '=') this.use(lv, acc); // compound assign reads too
} else {
// `this.x = …`, `a[i] = …` — root is a use only (not a scalar def).
this.walkValue(lv, acc);
}
}
return;
}
case 'navigation_expression': {
// `a.b` / `a?.b` — value read of the chain root only; the suffix name is
// not a scalar binding.
const target = node.namedChild(0);
if (target) this.walkValue(target, acc);
return;
}
case 'conjunction_expression':
case 'disjunction_expression': {
// `a && b` / `a || b` — the right operand is conditionally evaluated.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'elvis_expression': {
// `a ?: b` — the right operand only evaluates when the left is null.
const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type));
if (operands.length > 0) this.walkValue(operands[0], acc);
for (let i = 1; i < operands.length; i++) {
const rhs = operands[i];
this.conditional(() => this.walkValue(rhs, acc));
}
return;
}
case 'when_subject':
case 'user_type':
case 'type_identifier':
case 'binding_pattern_kind':
// Binding keyword / type position — no scalar value uses.
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/** The `= value` expression of a `property_declaration` (the child after `=`). */
private propertyValue(node: SyntaxNode): SyntaxNode | undefined {
let sawEq = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (c.type === '=') {
sawEq = true;
continue;
}
if (sawEq && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** The assignment operator text (`=` / `+=` / …) of an `assignment`. */
private assignmentOperator(node: SyntaxNode): string {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && /^[+\-*/%]?=$/.test(c.type)) return c.type;
}
return '=';
}
/** The right-hand value of an `assignment` (the named child after the operator). */
private assignmentValue(node: SyntaxNode): SyntaxNode | undefined {
let sawOp = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (!c.isNamed && /^[+\-*/%]?=$/.test(c.type)) {
sawOp = true;
continue;
}
if (sawOp && c.isNamed && !COMMENT_TYPES.has(c.type)) return c;
}
return undefined;
}
/** Strip a `directly_assignable_expression` wrapper around an lvalue. */
private unwrapAssignable(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 4;
while (n.type === 'directly_assignable_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
}

View file

@ -0,0 +1,868 @@
/**
* Kotlin CfgVisitor (#2195) — the VENDORED-GRAMMAR JVM/brace-family CFG target.
*
* Kotlin's tree-sitter grammar (vendored, NOT an npm package — loaded via
* `requireVendoredGrammar('tree-sitter-kotlin')`, exactly like tree-sitter-swift)
* is field-less for control flow: NONE of the control-flow nodes expose
* `childForFieldName` fields (verified by a real parse — every `fieldNameForChild`
* came back null), so this visitor navigates purely by child TYPE and position.
* Every node-type literal below was grammar-validated against the vendored
* tree-sitter-kotlin via the introspection probe before use (mandatory pre-step —
* the grammar-literal CI gate maps `kotlin.ts → Kotlin` and fails on a wrong
* literal).
*
* Structured like the Java / C# visitors — a `visit_<node_type>` dispatch over the
* statement taxonomy driving a per-function {@link ControlFlowContext} — because
* Kotlin shares JVM `finally` semantics (try/catch/finally + labeled
* break/continue), which the finalizer-frame + labeled-frame machinery in
* `control-flow-context.ts` models.
*
* Kotlin's defining quirk: `if` / `when` / `try` are EXPRESSIONS. The visitor
* treats each as a control-flow construct when it appears in STATEMENT position
* (a direct child of a `statements` list) and as opaque straight-line value when
* nested inside an expression (e.g. `val y = if (x) 1 else 2`) — exactly the
* Java statement-vs-value-switch split.
*
* Kotlin shapes pre-empted (verified by a real parse):
* - functions: `function_declaration` (name `simple_identifier`,
* `function_value_parameters`, optional `: user_type`, `function_body`),
* `anonymous_function` (`fun (...) function_body`), and `lambda_literal`
* (`{ lambda_parameters? -> statements }`). A `function_body` is either
* `{ statements }` OR an expression body `= expr` (no `statements` wrapper).
* - `if_expression`: `if ( COND ) control_structure_body [ else
* (control_structure_body | if_expression) ]`. No `else_clause` wrapper — an
* `else if` is the nested `if_expression` after the `else` keyword.
* - `when_expression`: `when when_subject? { when_entry* }`. A `when_subject` is
* `( expr )` or `( val v = expr )`. A `when_entry` is `when_condition*` (comma
* separated, OR an `else` keyword) `-> control_structure_body`. Arms do NOT
* fall through. A `when_condition` may wrap a `range_test` (`in 1..10`),
* `type_test` (`is T` / `!is T`), or a plain expression.
* - `for_statement`: `for ( (variable_declaration | multi_variable_declaration)
* in COLLECTION ) control_structure_body`.
* - `while_statement`: `while ( COND ) control_structure_body`.
* - `do_while_statement` — BOTTOM-TEST: `do control_structure_body while ( COND )`.
* - `try_expression`: `try { statements } catch_block* finally_block?`. A
* `catch_block` is `catch ( simple_identifier : user_type ) { statements }`;
* a `finally_block` is `finally { statements }`.
* - `jump_expression` — `return [expr]` / `return@label` / `break` / `break@label`
* / `continue` / `continue@label` / `throw expr`. The leading anonymous keyword
* child (`return` / `return@` / `break` / `break@` / `continue` / `continue@` /
* `throw`) decides; a labeled jump carries a `label` named child.
* - `label` — a labeled loop is preceded by a SIBLING `label` (`outer@`) in the
* same `statements`, NOT a wrapper; the jump's target label child is `outer`.
* - `control_structure_body` wraps either `{ statements }` or a single bare
* statement (`if (c) a()`).
*
* Edge-kind contract (matches the existing visitors — RD/CDG consume these):
* - if / else → `cond-true` / `cond-false`
* - `when` dispatch → `switch-case` (NO fallthrough — each arm rejoins after)
* - `for` / `while` → `cond-true` / `loop-back` / `cond-false`
* - `do … while` → bottom-test: body runs first, condition `loop-back` (true) /
* `cond-false` (exit)
* - try/catch → `throw` (every protected-region block → the handler); a
* `finally` runs on BOTH normal and exception exit, so a `return`/`break`/
* `continue` crossing it threads through it (`finally-*` completion edges).
* - return(@label) / throw / break(@label) / continue(@label) → the matching
* terminator kind; a labeled jump targets the labeled loop frame.
* - straight-line → `seq`
*
* Classic hazards, handled explicitly (mirrors Java / C# / Swift):
* - loops allocate a dedicated loop-exit block so `break` has a target before the
* loop's successor is known; `continue` targets the header.
* - `while (true) {}` / `do {} 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 otherwise. This
* is the single highest-risk correctness property.
* - labeled `break@outer` / `continue@outer`: the label resolves against the
* labeled loop frame, NOT the nearest one.
* - try/catch: conservative exceptional flow — EVERY block in the protected
* region edges to the handler (an exception may fire mid-block), matching the
* Java/C#/TS over-approximation.
*
* Kotlin-specific modeling decisions (documented approximations):
* - `if` / `when` / `try` used as an EXPRESSION VALUE (assigned, returned inline,
* passed as an argument) is left INLINE inside its owning statement's block —
* its arms are not modeled as separate CFG blocks (the value flows to the
* consumer). Only a STATEMENT-position construct (a direct `statements` child)
* becomes a dispatch/branch construct. This mirrors the Java inline-value-switch
* gap — documented, not faked.
* - a `lambda_literal` / nested `anonymous_function` / nested
* `function_declaration` is collected as its OWN function by `isFunction`, so
* its body gets a standalone CFG; in the ENCLOSING function it is an opaque
* straight-line value (its body is not followed inline). A `return@label`
* inside a lambda routes to the lambda's OWN EXIT (the lambda is its own CFG).
* - a `throw` with no enclosing `try`/`catch` routes to EXIT (the function
* propagates the exception to its caller), matching Java.
*
* Known limitations:
* - `?.` safe-call and `?:` elvis short-circuit are NOT modeled as branches —
* their conditional sub-evaluation is a HARVEST may-def concern (see
* kotlin-harvest.ts), not a CFG split (consistent with the TS `&&`/`??`
* treatment, which also stays in one block).
* - secondary-constructor `constructor(...)` bodies and property getters/setters
* are NOT function nodes in this grammar's CFG-bearing set; v1 does not build a
* CFG for them (documented gap).
* - `inline fun` non-local returns: a `return` inside an inline-lambda argument
* can return from the ENCLOSING function in real Kotlin; the lambda is modeled
* as its own CFG (the conservative, sound-for-RD direction), so that non-local
* return is not threaded into the enclosing function — a documented v1 gap.
*
* 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 } from '../types.js';
import { KotlinHarvester } from './kotlin-harvest.js';
/** Kotlin node types that own a CFG-bearing function body. */
const KOTLIN_FUNCTION_TYPES = new Set([
'function_declaration',
'anonymous_function',
'lambda_literal',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_expression',
'when_expression',
'for_statement',
'while_statement',
'do_while_statement',
'try_expression',
'jump_expression',
'label',
]);
/** Comment / non-code node types tree-sitter-kotlin surfaces (NOT `comment`). */
const COMMENT_TYPES = new Set(['line_comment', 'multiline_comment', 'shebang_line']);
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;
/**
* Per-function Kotlin walk state. One instance per function so the
* {@link ControlFlowContext}, exception-handler stack, and labeled-frame
* bookkeeping are scoped to that function and never leak across functions.
*/
class KotlinCfgWalk {
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 frame. */
private pendingLabels: string[] = [];
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: KotlinHarvester,
) {}
/** Named statements of a `statements` node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => !isComment(c));
}
/**
* Unwrap a `control_structure_body`: a `{ statements }` block yields its
* `statements` node; a bare single statement (`if (c) a()`) yields itself.
*/
private bodyOf(csb: SyntaxNode | undefined | null): SyntaxNode | undefined {
if (!csb) return undefined;
if (csb.type === 'control_structure_body') {
const stmts = csb.namedChildren.find((c) => c.type === 'statements');
if (stmts) return stmts;
const single = csb.namedChildren.find((c) => !isComment(c));
return single;
}
return csb;
}
/** Visit a `control_structure_body` (block or single statement). */
private visitBody(csb: SyntaxNode | undefined | null): SeqResult {
const inner = this.bodyOf(csb);
if (!inner) return null;
if (inner.type === 'statements') return this.visitSeq(this.statementsOf(inner));
return this.visitStmt(inner);
}
/** 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) {
if (this.isControlFlow(stmt)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block / label-only)
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 };
}
/**
* Whether a statement breaks the current straight-line block. `if` / `when` /
* `try` are EXPRESSIONS in Kotlin — they only break a block when used as a
* STATEMENT (a direct child of a `statements` list); nested in a value they
* coalesce (their arms are not modeled — documented gap).
*/
private isControlFlow(stmt: SyntaxNode): boolean {
if (stmt.type === 'label') return true; // queue label, emit no block
if (!CONTROL_FLOW_TYPES.has(stmt.type)) return false;
if (this.isExpressionConstruct(stmt.type)) return this.isStatementPosition(stmt);
return true;
}
private isExpressionConstruct(type: string): boolean {
return type === 'if_expression' || type === 'when_expression' || type === 'try_expression';
}
/**
* Whether an expression-construct (`if`/`when`/`try`) is in STATEMENT position
* (a direct `statements` child, or a `control_structure_body` that is itself a
* bare statement) vs an expression VALUE (nested under a declaration / jump /
* assignment / argument). Statement-position constructs are modeled as
* dispatch/branch; value-position ones stay inline.
*/
private isStatementPosition(node: SyntaxNode): boolean {
const p = node.parent;
if (!p) return false;
return p.type === 'statements' || p.type === 'control_structure_body';
}
/** Dispatch one statement to its handler. Non-null except for empty / label-only. */
visitStmt(stmt: SyntaxNode): SeqResult {
if (stmt.type === 'label') {
// A label preceding its loop — queue it; the loop construct picks it up.
const name = this.labelName(stmt);
if (name !== undefined) this.pendingLabels = [...this.pendingLabels, name];
return null; // emits no block of its own
}
switch (stmt.type) {
case 'if_expression':
return this.isStatementPosition(stmt) ? this.visitIf(stmt) : this.visitSimple(stmt);
case 'when_expression':
return this.isStatementPosition(stmt) ? this.visitWhen(stmt) : this.visitSimple(stmt);
case 'try_expression':
return this.isStatementPosition(stmt) ? this.visitTry(stmt) : this.visitSimple(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_while_statement':
return this.visitDoWhile(stmt);
case 'jump_expression':
return this.visitJump(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] };
}
// ── jump expressions (return / throw / break / continue) ──────────────────
/** The leading anonymous keyword of a `jump_expression` decides its kind. */
private jumpKeyword(stmt: SyntaxNode): string {
const first = stmt.child(0);
return first?.text ?? '';
}
private visitJump(stmt: SyntaxNode): TraversalResult {
const kw = this.jumpKeyword(stmt);
if (kw === 'return' || kw === 'return@') return this.visitReturn(stmt);
if (kw === 'throw') return this.visitThrow(stmt);
if (kw === 'break' || kw === 'break@') return this.visitBreak(stmt);
if (kw === 'continue' || kw === 'continue@') return this.visitContinue(stmt);
// Unknown jump — straight through (defensive; the grammar emits only the above).
return this.visitSimple(stmt);
}
/** `return [expr]` / `return@label` — threads through every active finalizer. */
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
/** `throw e` — routes to the nearest enclosing handler (catch/finally), else EXIT. */
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: [] };
}
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 target `label` of a `break@outer` / `continue@outer` / `return@x`, if any. */
private jumpLabel(stmt: SyntaxNode): string | undefined {
const label = stmt.namedChildren.find((c) => c.type === 'label');
return label ? this.stripLabel(label.text) : undefined;
}
/** The name of a `label` sibling (`outer@` ⇒ `outer`; jump target `outer` ⇒ `outer`). */
private labelName(label: SyntaxNode): string | undefined {
const id = label.namedChildren.find((c) => c.type === 'simple_identifier');
if (id?.text) return this.stripLabel(id.text);
return this.stripLabel(label.text) || undefined;
}
private stripLabel(text: string): string {
return text.replace(/@$/, '').replace(/^@/, '').trim();
}
/** Take and clear the labels queued by a preceding `label` sibling. */
private takeLabels(): string[] {
const labels = this.pendingLabels;
this.pendingLabels = [];
return labels;
}
// ── branches ──────────────────────────────────────────────────────────────
/**
* `if ( COND ) control_structure_body [ else (control_structure_body |
* if_expression) ]`. The else child after the `else` keyword is either the
* else body (`control_structure_body`) or a nested `if_expression` (`else if`).
*/
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = this.parenCondition(stmt);
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? `if (${cond.text})` : 'if',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const bodies = stmt.namedChildren.filter((c) => c.type === 'control_structure_body');
const elseNode = this.elseNodeOf(stmt);
const exits: number[] = [];
const thenRes = this.visitBody(bodies[0]);
if (thenRes) {
this.builder.edge(header, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(header); // empty then — true path falls through
}
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(header, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(header);
}
} else {
exits.push(header); // no else — false path falls through to the join
}
return { entry: header, exits: [...new Set(exits)] };
}
/**
* The node after the `else` keyword: a nested `if_expression` (`else if`) or the
* else-body `control_structure_body`.
*/
private elseNodeOf(stmt: SyntaxNode): SyntaxNode | undefined {
let sawElse = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (sawElse && c.isNamed) return c;
if (c.type === 'else') sawElse = true;
}
return undefined;
}
/** The condition expression of an `if`/`while` (the named child between `(` and `)`). */
private parenCondition(stmt: SyntaxNode): SyntaxNode | undefined {
let sawOpen = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (c.type === '(') {
sawOpen = true;
continue;
}
if (c.type === ')') return undefined;
if (sawOpen && c.isNamed && !isComment(c)) return c;
}
return undefined;
}
// ── when (no fallthrough) ──────────────────────────────────────────────────
/**
* `when when_subject? { when_entry* }`. Arms do NOT fall through — each
* `when_entry` body rejoins after the `when`. The subject (and each entry's
* `when_condition` tests) evaluate before the body; their uses are harvested
* onto the dispatch block (a later entry's test runs only when earlier ones
* didn't match, so any binding there is a may-def).
*/
private visitWhen(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const subject = stmt.namedChildren.find((c) => c.type === 'when_subject');
const dispatch = this.builder.newBlock(
startLineOf(stmt),
subject ? endLineOf(subject) : startLineOf(stmt),
subject ? `when ${subject.text}` : 'when',
'normal',
subject ? this.harvest.whenSubjectFacts(subject) : undefined,
);
const whenExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(whenExit, labels);
const entries = stmt.namedChildren.filter((c) => c.type === 'when_entry');
// Each entry's case tests evaluate conditionally before its body.
for (const entry of entries) {
for (const test of this.entryConditions(entry)) {
this.builder.attachFacts(dispatch, this.harvest.factsConditional(test));
}
}
const entryResults = entries.map((e) => this.visitBody(this.entryBody(e)));
const hasElse = entries.some((e) => this.entryIsElse(e));
for (const res of entryResults) {
if (!res) continue;
this.builder.edge(dispatch, res.entry, 'switch-case');
}
// A `when` with no `else` (statement position) may match no arm — the no-match
// path falls straight to the join.
if (!hasElse) this.builder.edge(dispatch, whenExit, 'switch-case');
const exits: number[] = [whenExit];
// Each arm rejoins after the when (no fallthrough); an empty-body entry's
// dispatch edge already targets whenExit via the no-match path below.
for (const res of entryResults) {
if (!res) continue;
this.builder.connect(res.exits, whenExit, 'seq');
}
this.cfc.pop();
return { entry: dispatch, exits };
}
/** The `when_condition` test(s) of a `when_entry` (empty for an `else` arm). */
private entryConditions(entry: SyntaxNode): SyntaxNode[] {
return entry.namedChildren.filter((c) => c.type === 'when_condition');
}
/** The body `control_structure_body` of a `when_entry`. */
private entryBody(entry: SyntaxNode): SyntaxNode | undefined {
return entry.namedChildren.find((c) => c.type === 'control_structure_body');
}
/** An `else ->` arm has an `else` keyword child and no `when_condition`. */
private entryIsElse(entry: SyntaxNode): boolean {
return entry.children.some((c) => c.type === 'else');
}
// ── loops ───────────────────────────────────────────────────────────────
/** `for ( PAT in COLLECTION ) control_structure_body`. */
private visitFor(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const collection = this.forCollection(stmt);
const header = this.builder.newBlock(
startLineOf(stmt),
collection ? endLineOf(collection) : startLineOf(stmt),
this.forHeaderText(stmt),
'normal',
this.harvest.forHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.loopBody(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-iterates
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
/** The iterated collection of a `for` — the named child after `in` before `)`. */
private forCollection(stmt: SyntaxNode): SyntaxNode | undefined {
let sawIn = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (c.type === 'in') {
sawIn = true;
continue;
}
if (c.type === ')') return undefined;
if (sawIn && c.isNamed && !isComment(c)) return c;
}
return undefined;
}
private forHeaderText(stmt: SyntaxNode): string {
const pat = stmt.namedChildren.find(
(c) => c.type === 'variable_declaration' || c.type === 'multi_variable_declaration',
);
const collection = this.forCollection(stmt);
const p = pat?.text ?? '';
const col = collection?.text ?? '';
return p || col ? `for (${p} in ${col})` : 'for';
}
/** The loop body `control_structure_body` (the LAST one — for/while/do). */
private loopBody(stmt: SyntaxNode): SyntaxNode | undefined {
const all = stmt.namedChildren.filter((c) => c.type === 'control_structure_body');
return all.length ? all[all.length - 1] : undefined;
}
/** `while ( COND ) control_structure_body`. */
private visitWhile(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const cond = this.parenCondition(stmt);
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? `while (${cond.text})` : 'while',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.loopBody(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
}
// Structural exit edge — even `while (true) {}` keeps EXIT reverse-reachable.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
/**
* `do control_structure_body while ( COND )` — BOTTOM-TEST: the body runs at
* least once, THEN the condition decides whether to loop back.
*/
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const cond = this.doWhileCondition(stmt);
const condBlock = this.builder.newBlock(
cond ? startLineOf(cond) : endLineOf(stmt),
cond ? endLineOf(cond) : endLineOf(stmt),
cond ? `while (${cond.text})` : 'while',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
// `continue` re-tests the condition; `break` leaves the loop.
this.cfc.pushLoop(condBlock, loopExit, labels);
const body = this.visitBody(this.loopBody(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
// Structural exit edge — even `do {} while (true)` keeps EXIT reachable.
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
/** The condition of a `do … while ( COND )` — the named child after the trailing `while`. */
private doWhileCondition(stmt: SyntaxNode): SyntaxNode | undefined {
let sawWhile = false;
for (let i = 0; i < stmt.childCount; i++) {
const c = stmt.child(i);
if (!c) continue;
if (c.type === 'while') {
sawWhile = true;
continue;
}
if (sawWhile && c.type === ')') return undefined;
if (sawWhile && c.isNamed && !isComment(c)) return c;
}
return undefined;
}
// ── try / catch / finally ──────────────────────────────────────────────────
/**
* `try { statements } catch_block* finally_block?`. The `finally` runs on BOTH
* normal and exception exit (finally semantics) — a `return`/`break`/`continue`
* crossing it threads through and gets a `finally-*` completion edge. Mirrors
* the Java `visitTry`.
*/
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.namedChildren.find((c) => c.type === 'statements');
const catchBlocks = stmt.namedChildren.filter((c) => c.type === 'catch_block');
const finallyBlock = stmt.namedChildren.find((c) => c.type === 'finally_block');
const finallyBody = finallyBlock?.namedChildren.find((c) => c.type === 'statements');
// The explicit finally is a finalizer the whole protected region threads through.
const finallyRes = finallyBody ? this.visitSeq(this.statementsOf(finallyBody)) : null;
const finallyFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
const finalizerEntry = finallyRes?.entry;
const finalizerExits = finallyRes?.exits ?? null;
// Build each catch handler.
const catchEntries: number[] = [];
const catchExits: number[] = [];
let firstCatchEntry: number | undefined;
for (const clause of catchBlocks) {
const clauseBody = clause.namedChildren.find((c) => c.type === 'statements');
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 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 finally, else outer.
const tryHandler = firstCatchEntry ?? finalizerEntry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
if (catchBlocks.length > 0 || finalizerEntry !== undefined) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, tryHandler, 'throw');
}
}
// Pop the finalizer frame and drain its pending completion legs.
if (finallyFrame && finallyRes) {
this.cfc.pop();
drainFinalizerPending(this.builder, finallyFrame, finallyRes.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 finally runs.
if (catchBlocks.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) return null;
return { entry, exits: [...new Set(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;
}
}
/** The function/lambda body `statements`, or an expression body, or undefined. */
function bodyStatementsOf(fnNode: SyntaxNode): SyntaxNode | undefined {
if (fnNode.type === 'lambda_literal') {
return fnNode.namedChildren.find((c) => c.type === 'statements');
}
const fb = fnNode.namedChildren.find((c) => c.type === 'function_body');
if (!fb) return undefined;
// `function_body` is `{ statements }` OR an expression body `= expr`.
const stmts = fb.namedChildren.find((c) => c.type === 'statements');
if (stmts) return stmts;
// Expression body — return the body itself so the caller treats it as one block.
return fb;
}
/** Build the CFG for one Kotlin function node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!KOTLIN_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
// A `function_declaration` / `anonymous_function` needs a `function_body`; a
// `lambda_literal` carries its `statements` directly. Absence of a body
// container ⇒ an abstract / interface-member / signature-only declaration with
// nothing to model (return undefined).
const hasBody =
fnNode.type === 'lambda_literal' ||
fnNode.namedChildren.some((c) => c.type === 'function_body');
if (!hasBody) return undefined;
const body = bodyStatementsOf(fnNode);
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new KotlinHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
// Expression body (`fun f() = expr` / a `function_body` that is `= expr`): one
// block whose value is returned.
if (body && body.type === 'function_body') {
const expr = body.namedChildren.find((c) => !isComment(c) && c.type !== 'statements');
if (expr) {
const blk = builder.newBlock(
startLineOf(expr),
endLineOf(expr),
expr.text,
'normal',
harvest.facts(expr),
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.bindingTable());
}
// `function_body` with neither statements nor an expression — empty.
builder.edge(builder.entryIndex, builder.exitIndex, 'seq');
return builder.finish(harvest.bindingTable());
}
const walk = new KotlinCfgWalk(builder, harvest);
const res = body
? walk.visitSeq(body.namedChildren.filter((c) => !isComment(c)))
: null;
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.bindingTable());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.bindingTable());
} 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] Kotlin buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a Kotlin function/lambda this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return KOTLIN_FUNCTION_TYPES.has(node.type);
}
/** The Kotlin CFG visitor. */
export function createKotlinCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { KOTLIN_FUNCTION_TYPES };

View file

@ -22,6 +22,7 @@ import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
import { createKotlinCfgVisitor } from '../cfg/visitors/kotlin.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@ -177,6 +178,8 @@ export const kotlinProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitKotlinScopeCaptures,
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
cfgVisitor: createKotlinCfgVisitor(),
// Worker-side: snapshot the module-level companion-scope marks
// `emitKotlinScopeCaptures` just populated for this file (`markCompanionScope`
// → `companionScopesByFile`) into plain data on `ParsedFile.captureSideChannel`,

View file

@ -0,0 +1,115 @@
// Kotlin CFG hazard fixture (#2195) — one of every control-flow shape the Kotlin
// CfgVisitor models, so the worker-roundtrip / integration passes exercise the
// real (vendored) grammar end-to-end. Mirrors the sibling fixtures
// (swift-hazards, java-hazards, go-hazards). Kotlin's grammar is field-less for
// control flow and `if`/`when`/`try` are expressions — this fixture stresses both
// statement-position constructs and a value-position one.
package fixtures
// if / else-if / else (statement position) + a value-position `if` (stays inline)
fun branching(x: Int): Int {
if (x > 0) {
positive()
} else if (x < 0) {
negative()
} else {
zero()
}
val sign = if (x >= 0) 1 else -1 // value-position if — not modeled as a branch
return sign
}
// when — with subject (no fallthrough) + range/type tests + else
fun dispatch(x: Any) {
when (x) {
1, 2 -> small()
in 3..10 -> medium()
is String -> text(x)
else -> other()
}
after()
}
// when — without subject (guard form) + a short-circuit guard
fun guarded(x: Int, y: Int) {
when {
x > 0 && y < 5 -> both()
x > 0 -> onlyX()
else -> neither()
}
}
// for-in (binds the loop var) + while + do-while (bottom-test)
fun loops(xs: List<Int>) {
for (item in xs) {
consume(item)
}
while (running()) {
tick()
}
do {
retry()
} while (shouldRetry())
}
// destructuring + elvis (`?:`) + safe call (`?.`) — harvest may-defs, not branches
fun harvest(p: Pair<Int, Int>, s: String?): Int {
val (a, b) = p
val len = s?.length ?: 0
var total = a
total += b
return total + len
}
// try / catch / finally — the finally runs on both normal and exception exit; a
// return inside the try threads through the finally
fun protect(): Int {
try {
val r = risky()
return r
} catch (e: Exception) {
handle(e)
return -1
} finally {
cleanup()
}
}
// labeled break@outer / continue@loop escaping nested loops
fun labeled(xs: List<Int>, ys: List<Int>) {
outer@ for (i in xs) {
for (j in ys) {
if (i == j) break@outer
if (i < 0) continue@outer
pair(i, j)
}
}
done()
}
// while (true) {} — keeps EXIT reverse-reachable via the structural escape edge
fun spin(flag: Boolean) {
while (true) {
if (flag) {
step()
}
}
}
// throw with no enclosing try/catch routes to EXIT
fun maybeThrow(x: Boolean) {
if (x) throw RuntimeException("bad")
finish()
}
// a lambda is its own CFG; return@label routes to the lambda's EXIT
fun lambdas(xs: List<Int>) {
xs.forEach { x ->
if (x < 0) return@forEach
use(x)
}
}
// expression-body function
fun square(n: Int) = n * n

View file

@ -0,0 +1,353 @@
import { describe, it, expect } from 'vitest';
import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js';
import { createKotlinCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/kotlin.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
// The Kotlin CfgVisitor, one hazard per test (real-parser regression, NOT
// snapshot-pinning). Kotlin's grammar is VENDORED (not an npm package): the
// grammar loads from vendor/ via `requireVendoredGrammar('tree-sitter-kotlin')`,
// exactly like the Swift test loads the vendored tree-sitter-swift. 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 kotlinGrammar = requireVendoredGrammar('tree-sitter-kotlin') as Parameters<
typeof makeCfgHarness
>[0];
const kotlin: CfgHarness = makeCfgHarness(kotlinGrammar, createKotlinCfgVisitor(), 'fixture.kt');
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 definesBinding = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx)));
const usesBinding = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx)));
describe('Kotlin CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT (seq)', () => {
const cfg = kotlin.cfgOf(`fun f() { 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 = kotlin.cfgOf(`fun f() {}`);
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('expression-body function: ENTRY → block → EXIT', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) = x + 1`);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
expect(edgeKinds(cfg).has('return')).toBe(true);
// The parameter is bound and used.
expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true);
});
it('an unmodeled shape produces a graceful partial CFG (never throws)', () => {
// An abstract / interface method (no body) — buildFunctionCfg must return
// undefined rather than throw; a real function still builds.
const root = kotlin.parse(`interface P { fun f() }`);
const fns = kotlin.collectFunctions(root);
for (const fn of fns) {
expect(() => createKotlinCfgVisitor().buildFunctionCfg(fn, 'p.kt')).not.toThrow();
}
const cfg = kotlin.cfgOf(`fun g() { x() }`);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('a class method is a CFG-bearing function', () => {
const cfg = kotlin.cfgOf(`class C { fun m(a: Int) { g(a) } }`);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
expect(definesBinding(cfg, bindingIdx(cfg, 'a'))).toBe(true);
});
});
describe('Kotlin CfgVisitor — branching (if/else)', () => {
it('if/else: cond-true to then, cond-false to else, both reach the join', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) { 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 chain branches each condition', () => {
const cfg = kotlin.cfgOf(
`fun f(x: Int) { if (x == 1) { a() } else if (x == 2) { b() } else { c() }; d() }`,
);
const join = block(cfg, 'd()');
expect(reaches(cfg, block(cfg, 'a()'), join)).toBe(true);
expect(reaches(cfg, block(cfg, 'b()'), join)).toBe(true);
expect(reaches(cfg, block(cfg, 'c()'), join)).toBe(true);
});
it('if without braces (bare control_structure_body) still branches', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) { if (x > 0) a() else b(); c() }`);
expect(edgeKinds(cfg).has('cond-true')).toBe(true);
expect(edgeKinds(cfg).has('cond-false')).toBe(true);
expect(reaches(cfg, block(cfg, 'a()'), block(cfg, 'c()'))).toBe(true);
});
it('an if used as an expression VALUE stays inline (not modeled as a branch)', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) { val y = if (x > 0) 1 else 2; use(y) }`);
// The value-position if has no branch edges; the assignment coalesces.
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
expect(definesBinding(cfg, bindingIdx(cfg, 'y'))).toBe(true);
});
});
describe('Kotlin CfgVisitor — when (no fallthrough)', () => {
it('when with subject: each arm dispatches and rejoins after (no fallthrough)', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) {
when (x) {
1 -> one()
2 -> two()
else -> other()
}
after()
}`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
// arm 1 does NOT fall into arm 2 (Kotlin when has no fallthrough).
expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'two()'))).toBe(false);
// every arm reaches the post-when continuation.
expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'after()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'two()'), block(cfg, 'after()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'other()'), block(cfg, 'after()'))).toBe(true);
});
it('when WITHOUT subject (guard form) dispatches each condition', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) {
when {
x > 0 -> pos()
else -> nonpos()
}
after()
}`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
expect(reachable(cfg, block(cfg, 'pos()'))).toBe(true);
expect(reachable(cfg, block(cfg, 'nonpos()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'pos()'), block(cfg, 'after()'))).toBe(true);
});
it('a when with NO else lets the no-match path fall to the join', () => {
const cfg = kotlin.cfgOf(`fun f(x: Int) { when (x) { 1 -> a() }; after() }`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
// the dispatch can reach after() without entering arm 1 (no-match path).
expect(reachable(cfg, block(cfg, 'after()'))).toBe(true);
});
});
describe('Kotlin CfgVisitor — loops', () => {
it('for-in: header + body + loop-back + exit; binds the loop var', () => {
const cfg = kotlin.cfgOf(`fun f(xs: List<Int>) { for (x in xs) { step(x) }; done() }`);
const header = block(cfg, 'for (x in xs)');
const body = block(cfg, 'step(x)');
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);
expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true);
});
it('while: header tests first, body loops back', () => {
const cfg = kotlin.cfgOf(`fun f() { while (cond()) { step() }; done() }`);
const header = block(cfg, 'while (cond())');
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 = kotlin.cfgOf(`fun f() { do { step() } while (cond()); done() }`);
const body = block(cfg, 'step()');
const cond = block(cfg, 'while (cond())');
expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first
expect(reaches(cfg, body, cond)).toBe(true); // condition tests at the bottom
expect(cfg.edges).toContainEqual({ from: cond, to: body, kind: 'loop-back' });
expect(reaches(cfg, cond, block(cfg, 'done()'))).toBe(true);
});
it('while (true) {} keeps EXIT reverse-reachable (structural exit-escape edge)', () => {
const cfg = kotlin.cfgOf(`fun f() { 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('do {} while (true) keeps EXIT reverse-reachable', () => {
const cfg = kotlin.cfgOf(`fun f() { do { work() } while (true) }`);
expect(edgeKinds(cfg).has('cond-false')).toBe(true);
expect(exitReachableFromAll(cfg)).toBe(true);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
});
describe('Kotlin CfgVisitor — try/catch/finally', () => {
it('try/catch/finally: a throw edge runs to the handler; finally completion edges', () => {
const cfg = kotlin.cfgOf(`fun f() {
try { risky() } catch (e: Exception) { handle(e) } finally { cleanup() }
after()
}`);
const kinds = edgeKinds(cfg);
expect(kinds.has('throw')).toBe(true);
const handler = block(cfg, 'handle(e)');
expect(reaches(cfg, block(cfg, 'risky()'), handler)).toBe(true);
// the finally runs on both normal and exception exit.
const fin = block(cfg, 'cleanup()');
expect(reaches(cfg, block(cfg, 'risky()'), fin)).toBe(true);
expect(reaches(cfg, handler, fin)).toBe(true);
// after() is still reachable (finally completion rejoins).
expect(reachable(cfg, block(cfg, 'after()'))).toBe(true);
// the catch binds the error `e`.
expect(definesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true);
});
it('a return inside a try threads through the finally (finally-return completion)', () => {
const cfg = kotlin.cfgOf(`fun f(): Int {
try { return compute() } finally { cleanup() }
}`);
const kinds = edgeKinds(cfg);
expect(kinds.has('return')).toBe(true);
expect(kinds.has('finally-return')).toBe(true);
const fin = block(cfg, 'cleanup()');
expect(reaches(cfg, fin, cfg.exitIndex)).toBe(true);
});
it('a throw with NO enclosing try/catch routes to EXIT and ends its block', () => {
const cfg = kotlin.cfgOf(`fun f(x: Boolean) { if (x) throw RuntimeException(); done() }`);
const thr = block(cfg, 'throw RuntimeException()');
expect(cfg.edges).toContainEqual({ from: thr, to: cfg.exitIndex, kind: 'throw' });
// throw terminates its block — control does not fall into done() from it.
expect(reaches(cfg, thr, block(cfg, 'done()'))).toBe(false);
expect(reachable(cfg, block(cfg, 'done()'))).toBe(true); // via the if false branch
});
});
describe('Kotlin CfgVisitor — labeled break/continue', () => {
it('labeled break@outer escapes BOTH loops and reaches done()', () => {
const cfg = kotlin.cfgOf(`fun f(xs: List<Int>, ys: List<Int>) {
outer@ for (i in xs) {
for (j in ys) { break@outer }
}
done()
}`);
const brk = block(cfg, 'break@outer');
expect(edgeKinds(cfg).has('break')).toBe(true);
expect(reaches(cfg, brk, block(cfg, 'done()'))).toBe(true);
});
it('continue@loop targets the outer loop header', () => {
const cfg = kotlin.cfgOf(`fun f(xs: List<Int>, ys: List<Int>) {
loop@ for (i in xs) {
for (j in ys) { continue@loop }
}
done()
}`);
expect(edgeKinds(cfg).has('continue')).toBe(true);
const outer = block(cfg, 'for (i in xs)');
const cont = block(cfg, 'continue@loop');
expect(reaches(cfg, cont, outer)).toBe(true);
});
});
describe('Kotlin CfgVisitor — lambdas (own CFG)', () => {
it('a lambda is collected as its own CFG; return@label routes to the lambda EXIT', () => {
const cfgs = kotlin.cfgsOf(`fun f(xs: List<Int>) {
xs.forEach { x ->
if (x < 0) return@forEach
use(x)
}
}`);
// f and the lambda are both CFG-bearing.
expect(cfgs.length).toBeGreaterThanOrEqual(2);
for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
// The lambda's CFG (the one containing return@forEach) keeps EXIT reachable.
const lambdaCfg = cfgs.find((c) => c.blocks.some((b) => b.text.includes('return@forEach')));
expect(lambdaCfg).toBeDefined();
const ret = lambdaCfg!.blocks.find((b) => b.text.includes('return@forEach'))!.index;
expect(reaches(lambdaCfg!, ret, lambdaCfg!.exitIndex)).toBe(true);
});
});
describe('Kotlin CfgVisitor — def/use harvest', () => {
it('val x = compute(); use(x) produces a def of x and a use in the consumer', () => {
const cfg = kotlin.cfgOf(`fun f() { val x = compute(); use(x) }`);
const x = bindingIdx(cfg, 'x');
expect(definesBinding(cfg, x)).toBe(true);
expect(usesBinding(cfg, x)).toBe(true);
});
it('destructuring `val (a, b) = p` defines both names', () => {
const cfg = kotlin.cfgOf(`fun f(p: Pair<Int, Int>) { val (a, b) = p; use(a); use(b) }`);
for (const name of ['a', 'b']) {
const idx = bindingIdx(cfg, name);
expect(definesBinding(cfg, idx)).toBe(true);
}
});
it('var reassignment defines the variable again', () => {
const cfg = kotlin.cfgOf(`fun f() { var y = 0; y = compute(); use(y) }`);
const y = bindingIdx(cfg, 'y');
expect(definesBinding(cfg, y)).toBe(true);
expect(usesBinding(cfg, y)).toBe(true);
});
it('compound assign `x += 1` defines AND uses x', () => {
const cfg = kotlin.cfgOf(`fun f() { var x = 0; x += step() }`);
const x = bindingIdx(cfg, 'x');
expect(definesBinding(cfg, x)).toBe(true);
expect(usesBinding(cfg, x)).toBe(true);
});
});
describe('Kotlin CfgVisitor — functionStartColumn', () => {
it('two same-line functions get distinct functionStartColumn', () => {
const cfgs = kotlin.cfgsOf(`fun a() { x() }; fun b() { y() }`);
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
});
});