mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-20 16:05:00 +08:00
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:
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user