From b302c63544f690308d43b5b9e8429054a86bc66c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 13:53:59 +0000 Subject: [PATCH] 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) --- .../ingestion/cfg/visitors/kotlin-harvest.ts | 570 ++++++++++++ .../src/core/ingestion/cfg/visitors/kotlin.ts | 868 ++++++++++++++++++ .../src/core/ingestion/languages/kotlin.ts | 3 + .../cfg/fixtures/kotlin-hazards.kt | 115 +++ gitnexus/test/unit/cfg/kotlin-visitor.test.ts | 353 +++++++ 5 files changed, 1909 insertions(+) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt create mode 100644 gitnexus/test/unit/cfg/kotlin-visitor.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts new file mode 100644 index 000000000..52359a8dc --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts @@ -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(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + + constructor(private readonly line: number) {} + + addDef(idx: number): void { + if (this.defSeen.has(idx)) return; + this.defSeen.add(idx); + this.defs.push(idx); + } + + /** 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(); + private readonly synthetic = new Map(); + 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; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts b/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts new file mode 100644 index 000000000..8cce7a5b6 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts @@ -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_` 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 { + return { buildFunctionCfg, isFunction }; +} + +export { KOTLIN_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 4cf87a76f..d4999d022 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -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`, diff --git a/gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt b/gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt new file mode 100644 index 000000000..98d877e54 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt @@ -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) { + 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, 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, ys: List) { + 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) { + xs.forEach { x -> + if (x < 0) return@forEach + use(x) + } +} + +// expression-body function +fun square(n: Int) = n * n diff --git a/gitnexus/test/unit/cfg/kotlin-visitor.test.ts b/gitnexus/test/unit/cfg/kotlin-visitor.test.ts new file mode 100644 index 000000000..35a086d8c --- /dev/null +++ b/gitnexus/test/unit/cfg/kotlin-visitor.test.ts @@ -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 => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +const 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) { 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, ys: List) { + 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, ys: List) { + 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) { + 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) { 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 + }); +});