mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
0e137cce27
commit
b302c63544
5 changed files with 1909 additions and 0 deletions
570
gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts
Normal file
570
gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
868
gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts
Normal file
868
gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts
Normal 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 };
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
115
gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt
Normal file
115
gitnexus/test/integration/cfg/fixtures/kotlin-hazards.kt
Normal 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
|
||||
353
gitnexus/test/unit/cfg/kotlin-visitor.test.ts
Normal file
353
gitnexus/test/unit/cfg/kotlin-visitor.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue