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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 19:26:19 +00:00
committed by GitHub
parent 8410db42a1
commit ad77e5fc8e
6 changed files with 198 additions and 20 deletions
+1
View File
@@ -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`.
+107
View File
@@ -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<string, string> = {
'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']);
});
});
@@ -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);
}
+22 -7
View File
@@ -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.
+25 -6
View File
@@ -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
+22 -7
View File
@@ -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;
}
/**