diff --git a/CHANGELOG.md b/CHANGELOG.md index 140f023..24001bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_explore` now follows **MediatR** request and notification dispatch in C#/.NET. A `_mediator.Send(command)` or `_mediator.Publish(notification)` call now links to the `Handle` method of the matching `IRequestHandler<>` / `INotificationHandler<>` — usually in a different file in a Clean Architecture layout — so "what handles this command?" traces from the controller straight into the handler instead of stopping at the mediator call. The sent type is recognized whether it's constructed inline (`Send(new GetFooQuery())`), built into a local first (`var cmd = new …; Send(cmd)`), or passed in as a parameter, and it's matched by type — so a `MessagingCenter.Send(...)` or a same-named DTO that isn't a request is never mislinked, and a project without MediatR is unaffected. - `codegraph_explore` now follows **Sidekiq** background-job dispatch in Ruby. A `DestroyUserWorker.perform_async(id)` (or `.perform_in` / `.perform_at`) call now links to that worker's `perform` method — usually in `app/workers/` away from the controller or model that enqueues it — so "what runs in the background here?" traces from the enqueue straight into the job body. Both the modern `include Sidekiq::Job` and the older `Sidekiq::Worker` are recognized, namespaced workers resolve to the right class even when several share a name (e.g. `Comments::NotifyWorker` vs `Articles::NotifyWorker`), and Rails ActiveJob's `perform_later` — a different mechanism — is intentionally left alone. - `codegraph_explore` now follows **Laravel events** in PHP. An `event(new OrderShipped($order))` call now links to every listener that handles it — each listener's `handle()` method, usually a separate `app/Listeners/` class — so "what reacts to this event?" traces from the dispatch straight into the listener bodies. Listeners are found both ways Laravel registers them: by a typed `handle(OrderShipped $event)` (auto-discovery, including a `handle(A|B $event)` union that listens for two events) and by the `protected $listen` map in your `EventServiceProvider` (which also catches a listener whose `handle()` has no type-hint). One event fans out to all its listeners, and queued jobs — dispatched via `::dispatch()` rather than `event()` — are correctly left out. +- CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912) - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking. - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off. diff --git a/__tests__/lombok.test.ts b/__tests__/lombok.test.ts new file mode 100644 index 0000000..5027607 --- /dev/null +++ b/__tests__/lombok.test.ts @@ -0,0 +1,156 @@ +/** + * Lombok-generated member synthesis (Java, #912). + * + * Lombok generates getters/setters/builder/equals/hashCode/toString and the + * `log` field at compile time, so they never appear in the source AST. Without + * synthesis they're absent from the index and any `bean.getX()` / `Bean.builder()` + * / `log.info()` call resolves to nothing — call chains break silently. We + * synthesize the mechanical ones from the annotations + fields, mark them + * (`lombok` decorator + a docstring naming the source annotation), and they then + * resolve as ordinary call targets. These tests prove the synthesis, the call + * resolution that motivated it, and the precision boundaries (static fields + * skipped, hand-written members never overridden, a non-Lombok class is clean). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('lombok synthesis', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lombok-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + type Row = { name: string; kind: string; decorators: string | null; docstring: string | null; signature: string | null }; + const load = async () => { + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const nodes: Row[] = db.prepare(`SELECT name, kind, decorators, docstring, signature FROM nodes`).all(); + const calls: { src: string; tgt: string }[] = db + .prepare( + `SELECT s.name src, t.name tgt FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'calls'` + ) + .all(); + cg.close?.(); + return { nodes, calls }; + }; + + const isLombok = (n: Row | undefined) => !!n && (n.decorators ?? '').includes('lombok'); + + it('synthesizes accessors that resolve as call targets, and the @Slf4j log field', async () => { + write('model/User.java', `package model; +import lombok.Data; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +@Data +@Builder +@Slf4j +public class User { + private String name; + private boolean active; + private static final int MAX = 10; +} +`); + write('svc/UserService.java', `package svc; +import model.User; + +class UserService { + String describe(User user) { + user.setActive(true); + return user.getName(); + } + User make() { + return User.builder(); + } +} +`); + + const { nodes, calls } = await load(); + const byName = (name: string) => nodes.find((n) => n.name === name && isLombok(n)); + + // Accessors + Data contract + builder are synthesized and marked. + for (const m of ['getName', 'setName', 'isActive', 'setActive', 'builder', 'equals', 'hashCode', 'toString']) { + expect(isLombok(byName(m)), `expected synthesized ${m}`).toBe(true); + } + expect(byName('getName')!.docstring).toMatch(/Lombok-generated/); + expect(byName('getName')!.signature).toBe('String getName()'); + expect(byName('isActive')!.signature).toBe('boolean isActive()'); // boolean → is-prefix + expect(byName('builder')!.signature).toContain('static '); + + // @Slf4j → a `log` field. + expect(isLombok(nodes.find((n) => n.name === 'log' && n.kind === 'field'))).toBe(true); + + // PRECISION: a static field gets no accessor. + expect(nodes.some((n) => n.name === 'getMAX' || n.name === 'getMax')).toBe(false); + + // THE FIX: calls to Lombok-generated methods resolve to their synthesized target. + const resolved = (src: string, tgt: string) => calls.some((c) => c.src === src && c.tgt === tgt); + expect(resolved('describe', 'getName')).toBe(true); + expect(resolved('describe', 'setActive')).toBe(true); + expect(resolved('make', 'builder')).toBe(true); + }); + + it('never overrides a hand-written accessor', async () => { + write('model/Account.java', `package model; +import lombok.Getter; + +@Getter +public class Account { + private int balance; + private String owner; + + // explicit getter — Lombok skips it, so must we + public int getBalance() { return balance < 0 ? 0 : balance; } +} +`); + const { nodes } = await load(); + const getBalance = nodes.filter((n) => n.name === 'getBalance'); + expect(getBalance.length).toBe(1); // exactly one, not duplicated + expect(isLombok(getBalance[0])).toBe(false); // the hand-written one survives + // the un-shadowed field still gets its synthesized getter + expect(isLombok(nodes.find((n) => n.name === 'getOwner'))).toBe(true); + }); + + it('field-level @Getter/@Setter and final-field rules', async () => { + write('model/Box.java', `package model; +import lombok.Getter; +import lombok.Setter; + +public class Box { + @Getter @Setter private String label; + @Getter private final long id; // final → getter only, no setter + private int hidden; // no annotation → nothing +} +`); + const { nodes } = await load(); + expect(isLombok(nodes.find((n) => n.name === 'getLabel'))).toBe(true); + expect(isLombok(nodes.find((n) => n.name === 'setLabel'))).toBe(true); + expect(isLombok(nodes.find((n) => n.name === 'getId'))).toBe(true); + expect(nodes.some((n) => n.name === 'setId')).toBe(false); // final → no setter + expect(nodes.some((n) => n.name === 'getHidden')).toBe(false); // un-annotated → nothing + }); + + it('produces no synthesized members for a plain Java class (clean control)', async () => { + write('model/Plain.java', `package model; + +public class Plain { + private int value; + public int getValue() { return value; } + public void setValue(int v) { this.value = v; } +} +`); + const { nodes } = await load(); + expect(nodes.some((n) => isLombok(n))).toBe(false); + }); +}); diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index 6224a46..5db8258 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -1,6 +1,6 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; import { getNodeText, getChildByField } from '../tree-sitter-helpers'; -import type { LanguageExtractor } from '../tree-sitter-types'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; /** * Tree-sitter-java node types for a method's `type` (return) field that can @@ -15,18 +15,16 @@ const JAVA_NON_CLASS_RETURN_NODES = new Set([ ]); /** - * A Java method's declared return type, normalized to the bare class name a - * chained `Foo.getInstance().bar()` could be called on (the #645/#608 mechanism). - * Reads the `type` field: primitives/void/arrays yield undefined (no class to - * chain on), `List` is unwrapped to its base type `List`, and a dotted - * package/outer-class qualifier (`java.util.List`) is stripped to the simple - * name. Constructors have no `type` field → undefined. + * Normalize a Java type node to the bare class name a chained + * `foo.getThing().bar()` could be called on (the #645/#608 mechanism): + * primitives/void/arrays yield undefined (no class to chain on), `List` + * is unwrapped to its base `List`, and a dotted package/outer-class qualifier + * (`java.util.List`) is stripped to the simple name. */ -function extractJavaReturnType(node: SyntaxNode, source: string): string | undefined { - const typeNode = getChildByField(node, 'type'); +function normalizeJavaType(typeNode: SyntaxNode | null, source: string): string | undefined { if (!typeNode) return undefined; if (JAVA_NON_CLASS_RETURN_NODES.has(typeNode.type)) return undefined; - // An array return (`Foo[]`) isn't a receiver you call instance methods on. + // An array (`Foo[]`) isn't a receiver you call instance methods on. if (typeNode.type === 'array_type') return undefined; // Strip type arguments (`List` → `List`) — the chain resolves on the base. const raw = getNodeText(typeNode, source).trim().replace(/<[^>]*>/g, ''); @@ -36,6 +34,222 @@ function extractJavaReturnType(node: SyntaxNode, source: string): string | undef return last; } +/** + * A Java method's declared return type. Reads the `type` field; constructors + * (no `type` field) → undefined. + */ +function extractJavaReturnType(node: SyntaxNode, source: string): string | undefined { + return normalizeJavaType(getChildByField(node, 'type'), source); +} + +// --------------------------------------------------------------------------- +// Lombok-generated member synthesis (#912) +// --------------------------------------------------------------------------- +// Lombok generates methods at compile time, so they never appear in the source +// AST and static extraction misses them — `bean.getX()`, `bean.setX()`, +// `Bean.builder()`, and `log.info(...)` calls then resolve to nothing and call +// chains break silently. We synthesize the mechanical, well-documented ones. + +/** Lombok logging annotations — all generate a field named `log` by default. */ +const LOMBOK_LOG_ANNOTATIONS = new Set([ + 'Slf4j', 'Log4j', 'Log4j2', 'Log', 'CommonsLog', 'JBossLog', 'Flogger', 'XSlf4j', 'CustomLog', +]); + +/** Simple names of every annotation in a node's `modifiers` child (`@lombok.Getter` → `Getter`). */ +function lombokAnnotationNames(node: SyntaxNode): Set { + const names = new Set(); + const modifiers = node.namedChildren.find((c: SyntaxNode) => c.type === 'modifiers'); + if (!modifiers) return names; + for (const child of modifiers.namedChildren) { + if (child.type === 'marker_annotation' || child.type === 'annotation') { + const nameNode = getChildByField(child, 'name'); + const simple = nameNode ? nameNode.text.trim().split('.').pop() : undefined; + if (simple) names.add(simple); + } + } + return names; +} + +/** Text of a declaration's `modifiers` child (keyword modifiers are anonymous, so match on text). */ +function modifierTextOf(node: SyntaxNode): string { + const modifiers = node.namedChildren.find((c: SyntaxNode) => c.type === 'modifiers'); + return modifiers ? modifiers.text : ''; +} + +function capitalizeJava(name: string): string { + return name ? name.charAt(0).toUpperCase() + name.slice(1) : name; +} + +/** Lombok getter name: `getX`, or `isX` for a primitive boolean (keeping an existing `isFoo` field name). */ +function lombokGetterName(fieldName: string, isBooleanPrimitive: boolean): string { + if (isBooleanPrimitive) { + return /^is[A-Z]/.test(fieldName) ? fieldName : 'is' + capitalizeJava(fieldName); + } + return 'get' + capitalizeJava(fieldName); +} + +/** Lombok setter name: `setX` (a primitive boolean field `isFoo` sets via `setFoo`). */ +function lombokSetterName(fieldName: string, isBooleanPrimitive: boolean): string { + const base = isBooleanPrimitive && /^is[A-Z]/.test(fieldName) ? fieldName.slice(2) : fieldName; + return 'set' + capitalizeJava(base); +} + +/** + * Synthesize the members Lombok generates at compile time. Covers the common, + * mechanical annotations: + * + * @Getter / @Setter (class- or field-level) → getX()/isX(), setX() + * @Data → getters + setters (non-final) + * + equals/hashCode/toString + * @Value → getters + equals/hashCode/toString (immutable, no setters) + * @Builder / @SuperBuilder → static builder() + * @ToString / @EqualsAndHashCode → those methods + * @Slf4j and the other @Log* annotations → the `log` field + * + * Each node is anchored on the field's (or class's) name token — a leaf, so it + * pulls in no spurious value-reference scope — carries a `lombok` decorator and + * a docstring naming the generating annotation, so it reads as generated rather + * than hand-written. Deliberately NOT synthesized: constructors (`new X()` + * already links to the class via `instantiates`, and overloaded + * @NoArgs/@AllArgs/@RequiredArgs ctors share a name → would collide on a + * synthetic node id), the fluent builder setters, and `@Accessors(fluent=true)` + * naming. A member the source already declares is never overridden. + */ +function synthesizeLombokMembers(classNode: SyntaxNode, ctx: ExtractorContext): void { + const classAnns = lombokAnnotationNames(classNode); + const classGetter = classAnns.has('Getter'); + const classSetter = classAnns.has('Setter'); + const isData = classAnns.has('Data'); + const isValue = classAnns.has('Value'); + const hasBuilder = classAnns.has('Builder') || classAnns.has('SuperBuilder'); + const hasToString = isData || isValue || classAnns.has('ToString'); + const hasEquals = isData || isValue || classAnns.has('EqualsAndHashCode'); + const logAnn = [...classAnns].find((a) => LOMBOK_LOG_ANNOTATIONS.has(a)); + + const body = getChildByField(classNode, 'body'); + if (!body) return; + const fields = body.namedChildren.filter((c: SyntaxNode) => c.type === 'field_declaration'); + + // Leave immediately when nothing Lombok is present, so a non-Lombok class + // pays nothing beyond one scan of its direct field declarations (and an + // annotated class skips even that — this hook runs for every Java class). + const classHasLombok = + classGetter || classSetter || isData || isValue || hasBuilder || hasToString || hasEquals || !!logAnn; + if (!classHasLombok && !fields.some((f: SyntaxNode) => lombokAnnotationNames(f).size > 0)) { + return; + } + + // Members already declared directly in this class. Lombok never overrides an + // explicit member, so we skip a name the source already has. Methods and + // fields are tracked separately: they're distinct namespaces in Java (a + // boolean field `isRunning` and its generated getter `isRunning()` coexist), + // and the node id is keyed by kind so they never actually collide. + const classId = ctx.nodeStack[ctx.nodeStack.length - 1]; + const classRec = ctx.nodes.find((n) => n.id === classId); + const classQN = classRec?.qualifiedName; + const takenMethods = new Set(); + const takenFields = new Set(); + if (classQN) { + for (const n of ctx.nodes) { + if (n.filePath === ctx.filePath && n.qualifiedName === `${classQN}::${n.name}`) { + if (n.kind === 'method' || n.kind === 'function') takenMethods.add(n.name); + else if (n.kind === 'field' || n.kind === 'variable' || n.kind === 'constant' || n.kind === 'property') { + takenFields.add(n.name); + } + } + } + } + + const classNameNode = getChildByField(classNode, 'name') ?? classNode; + const className = classRec?.name ?? getNodeText(classNameNode, ctx.source).trim(); + + const emitMethod = ( + name: string, + anchor: SyntaxNode, + signature: string, + fromAnnotation: string, + extra: { returnType?: string; isStatic?: boolean } = {} + ): void => { + if (!name || takenMethods.has(name)) return; + takenMethods.add(name); + ctx.createNode('method', name, anchor, { + visibility: 'public', + signature, + docstring: `Lombok-generated (${fromAnnotation})`, + decorators: ['lombok'], + isStatic: extra.isStatic, + returnType: extra.returnType, + }); + }; + + // Per-field getters/setters. + for (const fd of fields) { + const mods = modifierTextOf(fd); + if (/\bstatic\b/.test(mods)) continue; // Lombok skips static fields. + const isFinal = /\bfinal\b/.test(mods); + const fieldAnns = lombokAnnotationNames(fd); + const fieldGetter = fieldAnns.has('Getter'); + const fieldSetter = fieldAnns.has('Setter'); + + const wantGetter = classGetter || isData || isValue || fieldGetter; + const wantSetter = (classSetter || isData || fieldSetter) && !isFinal; + if (!wantGetter && !wantSetter) continue; + + const typeNode = getChildByField(fd, 'type'); + const typeText = typeNode ? getNodeText(typeNode, ctx.source).trim() : 'Object'; + const isBooleanPrimitive = typeNode?.type === 'boolean_type'; + const returnType = normalizeJavaType(typeNode, ctx.source); + + for (const vd of fd.namedChildren) { + if (vd.type !== 'variable_declarator') continue; + const nameNode = getChildByField(vd, 'name'); + if (!nameNode) continue; + const fieldName = getNodeText(nameNode, ctx.source).trim(); + if (!fieldName) continue; + + if (wantGetter) { + const g = lombokGetterName(fieldName, isBooleanPrimitive); + emitMethod(g, nameNode, `${typeText} ${g}()`, + fieldGetter ? '@Getter' : isData ? '@Data' : isValue ? '@Value' : '@Getter', + { returnType }); + } + if (wantSetter) { + const s = lombokSetterName(fieldName, isBooleanPrimitive); + emitMethod(s, nameNode, `void ${s}(${typeText} ${fieldName})`, + fieldSetter ? '@Setter' : isData ? '@Data' : '@Setter'); + } + } + } + + // Class-level synthesized methods. + if (hasBuilder) { + emitMethod('builder', classNameNode, `static ${className}.${className}Builder builder()`, + classAnns.has('SuperBuilder') ? '@SuperBuilder' : '@Builder', + { isStatic: true, returnType: `${className}Builder` }); + } + if (hasToString) { + emitMethod('toString', classNameNode, 'String toString()', + isData ? '@Data' : isValue ? '@Value' : '@ToString'); + } + if (hasEquals) { + const from = isData ? '@Data' : isValue ? '@Value' : '@EqualsAndHashCode'; + emitMethod('equals', classNameNode, 'boolean equals(Object o)', from); + emitMethod('hashCode', classNameNode, 'int hashCode()', from); + } + + // Logger field (@Slf4j and friends). + if (logAnn && !takenFields.has('log')) { + takenFields.add('log'); + ctx.createNode('field', 'log', classNameNode, { + visibility: 'private', + isStatic: true, + signature: 'Logger log', + docstring: `Lombok-generated (@${logAnn})`, + decorators: ['lombok'], + }); + } +} + export const javaExtractor: LanguageExtractor = { functionTypes: [], classTypes: ['class_declaration'], @@ -58,6 +272,7 @@ export const javaExtractor: LanguageExtractor = { paramsField: 'parameters', returnField: 'type', getReturnType: extractJavaReturnType, + synthesizeMembers: synthesizeLombokMembers, getSignature: (node, source) => { const params = getChildByField(node, 'parameters'); const returnType = getChildByField(node, 'type'); diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 28338b0..b65ee09 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -174,6 +174,20 @@ export interface LanguageExtractor { */ visitNode?: (node: SyntaxNode, ctx: ExtractorContext) => boolean; + /** + * Synthesize members that exist at compile time but not in the source AST, + * called at the end of class extraction with the class still on the scope + * stack (so `ctx.createNode` attaches containment + qualified names) and the + * class's real members already extracted (so the hook can skip a member the + * source explicitly declares). Used by Java for Lombok-generated accessors + * (`@Getter`/`@Setter`/`@Data`/`@Value`/`@Builder` → `getX`/`setX`/`builder`/ + * `equals`/`hashCode`/`toString` + the `log` field), which are otherwise + * invisible and break call-chain analysis (#912). The created nodes carry a + * `lombok` decorator + a docstring naming the generating annotation, so an + * agent can tell them apart from hand-written code. + */ + synthesizeMembers?: (classNode: SyntaxNode, ctx: ExtractorContext) => void; + /** * Classify a class_declaration node when the grammar reuses one node type * for multiple concepts (e.g. Swift uses class_declaration for classes, structs, and enums). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e8354c5..1761ef2 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1530,6 +1530,14 @@ export class TreeSitterExtractor { this.visitNode(child); } } + + // Synthesize compile-time-generated members (Lombok accessors, #912). Runs + // after the body so the hook can dedup against hand-written members, and + // while the class is still on the stack so containment/QNs attach. + if (this.extractor.synthesizeMembers) { + this.extractor.synthesizeMembers(node, this.makeExtractorContext()); + } + this.nodeStack.pop(); }