From ad77e5fc8e4105448036a9edd3068dfaf32f7534 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 19:26:19 +0000 Subject: [PATCH] fix(java,kotlin): extract enum constants' own bodies (#2194) A Java enum constant or Kotlin enum entry with a class body - `PLUS { int apply(...) { ... } }`, `NewBuffer { override fun pipe() ... }` - was recorded as a bare enum_member: its body was never visited, so its methods were missing and every call in them was dropped. jsoup's tokenizer (`TokeniserState`, one `read` per state) and tree builder (`HtmlTreeBuilderState`) are written this way. The body is now visited under the member (QN `Op::PLUS::apply`), which counts as class-like so its functions are methods and its properties fields. Only a `class_body` child is visited, so Rust's struct-like variants (a `body` field of field declarations) are untouched. Mirrored in the kernel's Java and Kotlin walkers; kernel parity is byte-identical on the fixtures (Torture.java gains a constant-with-body enum; torture.kt already had one) and on gson/koin/okio. jsoup +2156 edges, okio +138 -23, okhttp +106 -11, gson +97, halo +29, commons-lang +16; koin, Exposed, mall unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/enum-constant-bodies.test.ts | 107 ++++++++++++++++++ __tests__/fixtures/kernel-parity/Torture.java | 21 ++++ codegraph-kernel/src/java.rs | 29 +++-- codegraph-kernel/src/kotlin.rs | 31 ++++- src/extraction/tree-sitter.ts | 29 +++-- 6 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 __tests__/enum-constant-bodies.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 377aea2f5..403458b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Java enum constants and Kotlin enum entries with a body of their own now have their methods in the graph. Examples are `PLUS { int apply(…) { … } }` and `NewBuffer { override fun pipe() … }`. Before, those methods were missing and every call inside them was dropped. jsoup's HTML tokenizer and tree builder are written this way, so over 2,000 of their calls were invisible. Re-index to pick this up. - Scala calls no longer land on another test's local value. A `val f` written inside one `test("…") { }` block was being picked for a lambda parameter `f` in another suite, and even for `f(…)` in cats' core library. A package import like `import cats.syntax.all._` no longer puts every class's members in scope. Members that really are in scope now resolve: those of an imported package object's parent types (`import sttp.client4._` gives `SttpApi`'s `multipart`), and of the file's own package object. - A barrel's `export * as core from './core'` now exports only `core`, not everything in `./core`. In zod, `z.string()` and `z.number()` had been landing on same-named regex constants deep in its core module instead of the schema functions. Calls through `import * as ns` also find members a barrel forwards by name (`export { safeParse } from '../core'`). They follow a namespace path like `z.coerce.number()` too. - JavaScript calls through CommonJS `require` bindings now reach the right function. `var express = require('../')` follows `module.exports` through an `index.js` to the function it exports, and every binding in a `var a = require('x'), b = require('y')` list is read. `require('./utils').setCharset` means that module's `setCharset` rather than its first export, and a call to a package from outside the project (`supertest`, `node:assert`) is no longer an edge to the local variable. On express, over 700 `express()` calls now reach `createApplication`. diff --git a/__tests__/enum-constant-bodies.test.ts b/__tests__/enum-constant-bodies.test.ts new file mode 100644 index 000000000..035648aec --- /dev/null +++ b/__tests__/enum-constant-bodies.test.ts @@ -0,0 +1,107 @@ +/** + * A Java enum constant or Kotlin enum entry with a body of its own + * (`PLUS { int apply(…) { … } }`, `NewBuffer { override fun pipe() … }`) + * declares members of its own, and the calls in them are calls. Neither + * extractor visited those bodies: jsoup's tokenizer — an enum whose every + * state implements `read` — was absent from the graph, 2,000 calls in all. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-enum-bodies-')); + const files: Record = { + 'src/main/java/app/Op.java': `package app; + +public enum Op { + PLUS { + @Override + int apply(int a, int b) { + return helper(a) + b; + } + }, + MINUS { + @Override + int apply(int a, int b) { return a - b; } + }; + + abstract int apply(int a, int b); + + static int helper(int x) { return x; } +} +`, + 'src/main/kotlin/app/Factory.kt': `package app + +enum class Factory { + NewBuffer { + override val isOneByteAtATime: Boolean + get() = false + + override fun pipe(): Int { + val buffer = 1 + return helper(buffer) + } + }, + Other { + override val isOneByteAtATime: Boolean = true + override fun pipe(): Int = 2 + }; + + abstract val isOneByteAtATime: Boolean + abstract fun pipe(): Int + + fun helper(x: Int): Int = x +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const methods = (file: string) => + cg.getNodesInFile(file).filter((n) => n.kind === 'method').map((n) => n.qualifiedName).sort(); + +const calls = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => `${cg.getNode(e.source)!.qualifiedName} -> ${cg.getNode(e.target)!.qualifiedName}`) + .sort(); +}; + +describe('enum constants with bodies', () => { + it('Java: each constant’s methods are extracted and their calls resolved', () => { + expect(methods('src/main/java/app/Op.java')).toEqual([ + 'app::Op::MINUS::apply', + 'app::Op::PLUS::apply', + 'app::Op::apply', + 'app::Op::helper', + ]); + expect(calls('src/main/java/app/Op.java')).toEqual(['app::Op::PLUS::apply -> app::Op::helper']); + }); + + it('Kotlin: each entry’s methods are extracted and their calls resolved', () => { + expect(methods('src/main/kotlin/app/Factory.kt')).toEqual([ + 'app::Factory::NewBuffer::pipe', + 'app::Factory::Other::pipe', + 'app::Factory::helper', + 'app::Factory::pipe', + ]); + expect(calls('src/main/kotlin/app/Factory.kt')).toEqual(['app::Factory::NewBuffer::pipe -> app::Factory::helper']); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index 9ecb8d823..d1e71cbd2 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -79,6 +79,27 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab } } + enum Step { + NEXT(1) { + @Override + int apply(int at) { + return clamp(at + size); + } + }, + BACK(-1) { + @Override + int apply(int at) { return at + size; } + }; + + final int size; + + Step(int size) { this.size = size; } + + abstract int apply(int at); + + static int clamp(int at) { return Math.max(0, at); } + } + interface Listener { void onChange(TortureService svc); } diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index d5947ac52..daaf49de0 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -267,7 +267,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module" | "enum_member")) .unwrap_or(false) } @@ -695,7 +695,8 @@ impl<'t> Walker<'t> { for i in 0..body.named_child_count() { let Some(child) = body.named_child(i) else { continue }; if child.kind() == "enum_constant" { - self.extract_enum_members(child); + let member = self.extract_enum_members(child); + self.visit_enum_constant_body(child, member); } else { self.visit_node(child); } @@ -703,12 +704,26 @@ impl<'t> Walker<'t> { self.stack.pop(); } - fn extract_enum_members(&mut self, node: Node<'t>) { - if let Some(name_node) = node.child_by_field_name("name") { - let name = self.text(name_node).to_string(); - self.create_node("enum_member", &name, node, Extra::default()); - } + fn extract_enum_members(&mut self, node: Node<'t>) -> Option<(u32, String)> { + let name_node = node.child_by_field_name("name")?; + let name = self.text(name_node).to_string(); + let row = self.create_node("enum_member", &name, node, Extra::default())?; // (identifier-children / leaf fallbacks are other grammars' shapes) + Some((row, name)) + } + + /// `PLUS { int apply(…) { … } }`: a constant's own class_body declares + /// members of its own, scoped under the constant. + fn visit_enum_constant_body(&mut self, node: Node<'t>, member: Option<(u32, String)>) { + let Some((row, name)) = member else { return }; + let Some(body) = (0..node.named_child_count()).filter_map(|i| node.named_child(i)).find(|c| c.kind() == "class_body") else { return }; + self.stack.push(Scope { row, kind: "enum_member", name }); + for i in 0..body.named_child_count() { + if let Some(c) = body.named_child(i) { + self.visit_node(c); + } + } + self.stack.pop(); } /// extractField — each declarator becomes a field/constant node. diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 159e478c3..a224cb726 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -330,7 +330,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module" | "enum_member")) .unwrap_or(false) } @@ -1089,7 +1089,8 @@ impl<'t> Walker<'t> { for i in 0..body.named_child_count() { let Some(child) = body.named_child(i) else { continue }; if child.kind() == "enum_entry" { - self.extract_enum_members(child); + let member = self.extract_enum_members(child); + self.visit_enum_entry_body(child, member); } else { self.visit_node(child); } @@ -1097,18 +1098,36 @@ impl<'t> Walker<'t> { self.stack.pop(); } - fn extract_enum_members(&mut self, node: Node<'t>) { + fn extract_enum_members(&mut self, node: Node<'t>) -> Option<(u32, String)> { // name field → null (zero fields) → the identifier-children scan: one // enum_member per direct simple_identifier, positioned AT the - // identifier. Entry value_arguments and entry class_bodies (override - // methods!) are never visited — invisible (quirk). + // identifier. Entry value_arguments are never visited. + let mut first: Option<(u32, String)> = None; for i in 0..node.named_child_count() { let Some(child) = node.named_child(i) else { continue }; if matches!(child.kind(), "simple_identifier" | "identifier" | "property_identifier") { let name = self.text(child).to_string(); - self.create_node("enum_member", &name, child, Extra::default()); + let row = self.create_node("enum_member", &name, child, Extra::default()); + if first.is_none() { + first = row.map(|r| (r, name)); + } } } + first + } + + /// `NewBuffer { override fun pipe() … }`: an entry's own class_body + /// declares members of its own, scoped under the entry. + fn visit_enum_entry_body(&mut self, node: Node<'t>, member: Option<(u32, String)>) { + let Some((row, name)) = member else { return }; + let Some(body) = (0..node.named_child_count()).filter_map(|i| node.named_child(i)).find(|c| c.kind() == "class_body") else { return }; + self.stack.push(Scope { row, kind: "enum_member", name }); + for i in 0..body.named_child_count() { + if let Some(c) = body.named_child(i) { + self.visit_node(c); + } + } + self.stack.pop(); } /// extractTypeAlias — plain node; the alias-value ref walk reads the diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 474323864..51086c3bf 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1668,7 +1668,9 @@ export class TreeSitterExtractor { parentNode.kind === 'interface' || parentNode.kind === 'trait' || parentNode.kind === 'enum' || - parentNode.kind === 'module' + parentNode.kind === 'module' || + // A Java / Kotlin enum constant with a body of its own (see extractEnum). + parentNode.kind === 'enum_member' ); } @@ -2152,7 +2154,18 @@ export class TreeSitterExtractor { if (!child) continue; if (memberTypes?.includes(child.type)) { - this.extractEnumMembers(child); + const member = this.extractEnumMembers(child); + // Java's `PLUS { int apply(…) { … } }`, Kotlin's `NewBuffer { override + // fun pipe() … }`: the constant's own body declares members of its own. + const entryBody = member ? child.namedChildren.find((c) => c.type === 'class_body') : undefined; + if (member && entryBody) { + this.nodeStack.push(member.id); + for (let j = 0; j < entryBody.namedChildCount; j++) { + const inner = entryBody.namedChild(j); + if (inner) this.visitNode(inner); + } + this.nodeStack.pop(); + } } else { this.visitNode(child); } @@ -2164,28 +2177,30 @@ export class TreeSitterExtractor { * Extract enum member names from an enum member node. * Handles multi-case declarations (Swift: `case put, delete`) and single-case patterns. */ - private extractEnumMembers(node: SyntaxNode): void { + private extractEnumMembers(node: SyntaxNode): Node | null { // Try field-based name first (e.g. Rust enum_variant has a 'name' field) const nameNode = getChildByField(node, 'name'); if (nameNode) { - this.createNode('enum_member', getNodeText(nameNode, this.source), node); - return; + return this.createNode('enum_member', getNodeText(nameNode, this.source), node); } // Check for identifier-like children (Swift: simple_identifier, TS: property_identifier) + let first: Node | null = null; let found = false; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child && (child.type === 'simple_identifier' || child.type === 'identifier' || child.type === 'property_identifier')) { - this.createNode('enum_member', getNodeText(child, this.source), child); + const created = this.createNode('enum_member', getNodeText(child, this.source), child); + first ??= created; found = true; } } // If the node itself IS the identifier (e.g. TS property_identifier directly in enum body) if (!found && node.namedChildCount === 0) { - this.createNode('enum_member', getNodeText(node, this.source), node); + return this.createNode('enum_member', getNodeText(node, this.source), node); } + return first; } /**