mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge 4163f2968f into ac68f5254c
This commit is contained in:
commit
92c039ed08
3 changed files with 940 additions and 0 deletions
482
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
482
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* Lombok method synthesizer for Java.
|
||||
*
|
||||
* Lombok generates accessor methods (getters/setters) at compile time via
|
||||
* annotation processors. These methods are absent from the AST — every call
|
||||
* to `obj.getOrderId()` on a `@Data` class is an unresolved call edge in the
|
||||
* static graph.
|
||||
*
|
||||
* This module walks the tree-sitter Java AST and, for each class annotated
|
||||
* with `@Getter`, `@Setter`, or `@Data`, synthesizes virtual Method graph
|
||||
* nodes for the accessor methods Lombok would generate. The output mirrors
|
||||
* the shape of real Method symbols/nodes/relationships so the rest of the
|
||||
* ingestion pipeline treats them identically.
|
||||
*
|
||||
* Synthesized methods are:
|
||||
* - **Public** (`visibility: 'public'`): Lombok's default access level.
|
||||
* - **Non-static**: only instance fields get accessors.
|
||||
* - **Skipped when a hand-written method of the same name already exists.**
|
||||
* - **Skipped for final fields' setters** (Lombok never emits those).
|
||||
* - **Skipped for fields explicitly suppressed** via `@Getter/@Setter(AccessLevel.NONE)`.
|
||||
*
|
||||
* Naming follows the JavaBeans convention Lombok uses:
|
||||
* - `String name` → `getName()` / `setName(String)`
|
||||
* - `boolean active` → `isActive()` / `setActive(boolean)`
|
||||
* - `Boolean active` → `getActive()` (boxed → getXxx, per Lombok)
|
||||
*
|
||||
* ## Identity model (root-cause fix for name ambiguity)
|
||||
*
|
||||
* A class is identified by its class_declaration AST node, not by its simple
|
||||
* name — simple names are ambiguous across files and among nested classes
|
||||
* with the same tail (bot review: cross-file collision + `Outer.A` vs
|
||||
* `Other.A` overwriting each other in a name-keyed map). The caller keys the
|
||||
* owner map by tree-sitter node id (`SyntaxNode.id`, a stable per-tree
|
||||
* integer), which is unique by construction for every declaration in the
|
||||
* file, so both collisions are impossible rather than filtered out.
|
||||
*
|
||||
* The synthesized Method node id follows the SAME convention real methods
|
||||
* use in parse-worker: `${filePath}:${idMethodName}#${arity}` where
|
||||
* `idMethodName` is qualified by the IMMEDIATE enclosing class simple name
|
||||
* only (`Outer.method`, never the full `Top.Outer.method` chain) — matching
|
||||
* `findEnclosingClassInfo().className` + `nodeName`, which keys real Method
|
||||
* ids for languages without `qualifiedNodeId` (Java among them).
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A field extracted from the AST for Lombok synthesis. */
|
||||
interface LombokField {
|
||||
name: string;
|
||||
type: string;
|
||||
isStatic: boolean;
|
||||
isFinal: boolean;
|
||||
/** True when @Getter(AccessLevel.NONE) suppresses the getter for this field. */
|
||||
suppressGetter: boolean;
|
||||
/** True when @Setter(AccessLevel.NONE) suppresses the setter for this field. */
|
||||
suppressSetter: boolean;
|
||||
}
|
||||
|
||||
/** A class eligible for Lombok accessor synthesis. */
|
||||
interface LombokClass {
|
||||
/** Tree-sitter node of the class_declaration — the class's identity. */
|
||||
node: Parser.SyntaxNode;
|
||||
/** Class simple name. */
|
||||
name: string;
|
||||
/** 'getter' and/or 'setter' depending on which annotations are present. */
|
||||
generateGetters: boolean;
|
||||
generateSetters: boolean;
|
||||
fields: LombokField[];
|
||||
/** Names of methods already declared in this class body (collision guard). */
|
||||
existingMethods: Set<string>;
|
||||
}
|
||||
|
||||
/** Synthetic symbol entry — mirrors the shape pushed to `result.symbols`. */
|
||||
export interface SyntheticSymbol {
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: 'Method';
|
||||
ownerId: string;
|
||||
parameterCount: number;
|
||||
requiredParameterCount: number;
|
||||
parameterTypes: string[];
|
||||
returnType: string;
|
||||
visibility: string;
|
||||
isStatic: boolean;
|
||||
isAbstract: boolean;
|
||||
isFinal: boolean;
|
||||
isLombok: true;
|
||||
}
|
||||
|
||||
/** Synthetic node entry — mirrors the shape pushed to `result.nodes`. */
|
||||
export interface SyntheticNode {
|
||||
id: string;
|
||||
label: 'Method';
|
||||
properties: Record<string, unknown> & {
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
language: string;
|
||||
isExported: boolean;
|
||||
synthetic: 'lombok';
|
||||
visibility: string;
|
||||
isStatic: boolean;
|
||||
returnType: string;
|
||||
parameterTypes: string[];
|
||||
parameterCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Synthetic relationship entry — mirrors the shape pushed to `result.relationships`. */
|
||||
export interface SyntheticRelationship {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: 'HAS_METHOD';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface LombokSynthesisResult {
|
||||
symbols: SyntheticSymbol[];
|
||||
nodes: SyntheticNode[];
|
||||
relationships: SyntheticRelationship[];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Capitalize the first letter of a string. */
|
||||
function capitalize(s: string): string {
|
||||
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/** Generate the Lombok getter method name for a field. */
|
||||
function getterName(fieldName: string, fieldType: string): string {
|
||||
// Primitive boolean → isXxx(); everything else → getXxx()
|
||||
if (fieldType === 'boolean') {
|
||||
return `is${capitalize(fieldName)}`;
|
||||
}
|
||||
return `get${capitalize(fieldName)}`;
|
||||
}
|
||||
|
||||
/** Generate the Lombok setter method name for a field. */
|
||||
function setterName(fieldName: string): string {
|
||||
return `set${capitalize(fieldName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract annotation simple names from a tree-sitter `modifiers` node.
|
||||
* Handles both `marker_annotation` (`@Data`) and `annotation` (`@Getter(...)`)
|
||||
* and their fully-qualified forms (`@lombok.Data`).
|
||||
*/
|
||||
function extractAnnotationNames(modifiersNode: Parser.SyntaxNode | null): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!modifiersNode) return names;
|
||||
|
||||
for (const child of modifiersNode.children) {
|
||||
if (child.type !== 'marker_annotation' && child.type !== 'annotation') continue;
|
||||
// The name child is a named field 'name' within marker_annotation/annotation
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const text = nameNode?.text ?? '';
|
||||
// Normalize to simple name: `lombok.Data` → `Data`
|
||||
const simpleName = text.split('.').pop() ?? text;
|
||||
if (simpleName) names.add(simpleName);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a field's Lombok accessor is suppressed.
|
||||
*
|
||||
* `@Getter(AccessLevel.NONE)` or `@Setter(AccessLevel.NONE)` on a field
|
||||
* disables that specific accessor. We check for the string `NONE` in the
|
||||
* annotation text as a lightweight heuristic — the annotation argument is
|
||||
* always an enum constant, so `NONE` uniquely identifies suppression.
|
||||
*/
|
||||
function isAccessorSuppressed(
|
||||
fieldNode: Parser.SyntaxNode,
|
||||
accessorType: 'Getter' | 'Setter',
|
||||
): boolean {
|
||||
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers');
|
||||
if (!modifiers) return false;
|
||||
for (const child of modifiers.children) {
|
||||
if (child.type !== 'annotation') continue;
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const simpleName = nameNode?.text.split('.').pop();
|
||||
if (simpleName === accessorType && child.text.includes('NONE')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a field declaration node to extract field name(s) and type.
|
||||
*
|
||||
* `private String name;` → [{ name: 'name', type: 'String', isStatic: false, isFinal: false }]
|
||||
* `private final Long id = 0L;` → [{ name: 'id', type: 'Long', isStatic: false, isFinal: true }]
|
||||
* `private int x, y;` → [{ name: 'x', ... }, { name: 'y', ... }]
|
||||
*/
|
||||
function parseFieldDeclaration(
|
||||
fieldNode: Parser.SyntaxNode,
|
||||
): { name: string; type: string; isStatic: boolean; isFinal: boolean; suppressGetter: boolean; suppressSetter: boolean }[] {
|
||||
const results: { name: string; type: string; isStatic: boolean; isFinal: boolean; suppressGetter: boolean; suppressSetter: boolean }[] = [];
|
||||
|
||||
// Type is in the `type` field
|
||||
const typeNode = fieldNode.childForFieldName('type');
|
||||
const fieldType = typeNode?.text ?? 'Object';
|
||||
|
||||
// Check static/final — tree-sitter-java uses the keyword itself as the node type
|
||||
// (e.g. `static`, `final`), not a wrapper `modifier` node.
|
||||
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers');
|
||||
let isStatic = false;
|
||||
let isFinal = false;
|
||||
if (modifiers) {
|
||||
for (const mod of modifiers.children) {
|
||||
if (mod.text === 'static') {
|
||||
isStatic = true;
|
||||
} else if (mod.text === 'final') {
|
||||
isFinal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all variable declarators — handles both single (`int x;`) and
|
||||
// multi-variable (`int x, y;`) declarations. The `declarator` field name
|
||||
// returns only the first one; the rest are unnamed children.
|
||||
const declarators: Parser.SyntaxNode[] = [];
|
||||
const declaratorField = fieldNode.childForFieldName('declarator');
|
||||
if (declaratorField) {
|
||||
declarators.push(declaratorField);
|
||||
}
|
||||
// Also collect unnamed variable_declarator children (multi-variable case)
|
||||
for (const child of fieldNode.children) {
|
||||
if (child.type === 'variable_declarator' && child !== declaratorField) {
|
||||
declarators.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (const declaratorNode of declarators) {
|
||||
const nameNode = declaratorNode.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
results.push({
|
||||
name: nameNode.text,
|
||||
type: fieldType,
|
||||
isStatic,
|
||||
isFinal,
|
||||
suppressGetter: false,
|
||||
suppressSetter: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect method names from a class body (for collision detection).
|
||||
* Walks direct children of the class body for `method_declaration` nodes.
|
||||
*/
|
||||
function collectExistingMethodNames(classBody: Parser.SyntaxNode | null): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!classBody) return names;
|
||||
for (const child of classBody.children) {
|
||||
if (child.type === 'method_declaration') {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (nameNode) names.add(nameNode.text);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Walk the tree for class_declaration nodes eligible for Lombok synthesis. */
|
||||
function findLombokClasses(root: Parser.SyntaxNode): LombokClass[] {
|
||||
const classes: LombokClass[] = [];
|
||||
|
||||
function walk(node: Parser.SyntaxNode): void {
|
||||
if (node.type === 'class_declaration') {
|
||||
const modifiers = node.children.find((c) => c.type === 'modifiers');
|
||||
const annotations = extractAnnotationNames(modifiers);
|
||||
|
||||
const hasGetter = annotations.has('Getter') || annotations.has('Data');
|
||||
const hasSetter = annotations.has('Setter') || annotations.has('Data');
|
||||
|
||||
if (hasGetter || hasSetter) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const className = nameNode?.text ?? '';
|
||||
if (className) {
|
||||
// Find class body
|
||||
const body = node.children.find((c) => c.type === 'class_body');
|
||||
const existingMethods = collectExistingMethodNames(body ?? null);
|
||||
|
||||
// Collect fields
|
||||
const fields: LombokField[] = [];
|
||||
if (body) {
|
||||
for (const child of body.children) {
|
||||
if (child.type !== 'field_declaration') continue;
|
||||
for (const f of parseFieldDeclaration(child)) {
|
||||
// Skip static fields (Lombok doesn't generate instance accessors for static fields)
|
||||
if (f.isStatic) continue;
|
||||
// Mark accessors suppressed when @Getter/@Setter(AccessLevel.NONE) is on the field
|
||||
if (hasGetter && isAccessorSuppressed(child, 'Getter')) {
|
||||
f.suppressGetter = true;
|
||||
}
|
||||
if (hasSetter && isAccessorSuppressed(child, 'Setter')) {
|
||||
f.suppressSetter = true;
|
||||
}
|
||||
fields.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
classes.push({
|
||||
node,
|
||||
name: className,
|
||||
generateGetters: hasGetter,
|
||||
generateSetters: hasSetter,
|
||||
fields,
|
||||
existingMethods,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children for nested classes
|
||||
for (const child of node.children) walk(child);
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return classes;
|
||||
}
|
||||
|
||||
// ── Main API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synthesize Lombok accessor methods for a Java file.
|
||||
*
|
||||
* Call this after the normal AST capture loop in parse-worker, for Java files
|
||||
* only. The returned symbols/nodes/relationships should be pushed into the
|
||||
* worker result so they flow through the rest of the pipeline unchanged.
|
||||
*
|
||||
* @param tree The parsed tree-sitter Java AST.
|
||||
* @param filePath Absolute file path.
|
||||
* @param classOwnersById Map from tree-sitter node id (SyntaxNode.id) of the
|
||||
* class_declaration → graph node id of that class.
|
||||
* Keyed by AST node identity, so simple-name collisions
|
||||
* (across files or among same-tailed nested classes)
|
||||
* cannot resolve to the wrong class.
|
||||
* @returns Synthesis result, or empty if no Lombok classes found.
|
||||
*/
|
||||
export function synthesizeLombokAccessors(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: Map<number, string>,
|
||||
): LombokSynthesisResult {
|
||||
const result: LombokSynthesisResult = {
|
||||
symbols: [],
|
||||
nodes: [],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const lombokClasses = findLombokClasses(tree.rootNode);
|
||||
|
||||
for (const cls of lombokClasses) {
|
||||
const ownerId = classOwnersById.get(cls.node.id);
|
||||
if (!ownerId) continue; // Class not in the graph — skip
|
||||
|
||||
// Synthesized method ids are keyed by the class's own simple name only
|
||||
// (`Inner.method`), matching how real member ids are keyed for nested
|
||||
// classes — `findEnclosingClassInfo().className` is the IMMEDIATE parent
|
||||
// simple name, so a real method in `Outer.Inner` keys as `Inner.method`,
|
||||
// never `Outer.Inner.method` (Java has no qualifiedNodeId).
|
||||
const idMethodNamePrefix = cls.name;
|
||||
|
||||
for (const field of cls.fields) {
|
||||
// Getter (skip if suppressed by @Getter(AccessLevel.NONE))
|
||||
if (cls.generateGetters && !field.suppressGetter) {
|
||||
const gName = getterName(field.name, field.type);
|
||||
if (!cls.existingMethods.has(gName)) {
|
||||
const nodeId = `Method:${filePath}:${idMethodNamePrefix}.${gName}#0`;
|
||||
result.nodes.push({
|
||||
id: nodeId,
|
||||
label: 'Method',
|
||||
properties: {
|
||||
name: gName,
|
||||
filePath,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
language: 'java',
|
||||
isExported: false,
|
||||
synthetic: 'lombok',
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
returnType: field.type,
|
||||
parameterTypes: [],
|
||||
parameterCount: 0,
|
||||
},
|
||||
});
|
||||
result.symbols.push({
|
||||
filePath,
|
||||
name: gName,
|
||||
nodeId,
|
||||
type: 'Method',
|
||||
ownerId,
|
||||
parameterCount: 0,
|
||||
requiredParameterCount: 0,
|
||||
parameterTypes: [],
|
||||
returnType: field.type,
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
isFinal: false,
|
||||
isLombok: true,
|
||||
});
|
||||
result.relationships.push({
|
||||
id: `HAS_METHOD:${ownerId}->${nodeId}`,
|
||||
sourceId: ownerId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
confidence: 1.0,
|
||||
reason: 'lombok-getter',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setter — skipped when suppressed by @Setter(AccessLevel.NONE) or when
|
||||
// the field is final (Lombok never generates setters for final fields).
|
||||
if (cls.generateSetters && !field.suppressSetter && !field.isFinal) {
|
||||
const sName = setterName(field.name);
|
||||
if (!cls.existingMethods.has(sName)) {
|
||||
const nodeId = `Method:${filePath}:${idMethodNamePrefix}.${sName}#1`;
|
||||
result.nodes.push({
|
||||
id: nodeId,
|
||||
label: 'Method',
|
||||
properties: {
|
||||
name: sName,
|
||||
filePath,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
language: 'java',
|
||||
isExported: false,
|
||||
synthetic: 'lombok',
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
returnType: 'void',
|
||||
parameterTypes: [field.type],
|
||||
parameterCount: 1,
|
||||
},
|
||||
});
|
||||
result.symbols.push({
|
||||
filePath,
|
||||
name: sName,
|
||||
nodeId,
|
||||
type: 'Method',
|
||||
ownerId,
|
||||
parameterCount: 1,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: [field.type],
|
||||
returnType: 'void',
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
isFinal: false,
|
||||
isLombok: true,
|
||||
});
|
||||
result.relationships.push({
|
||||
id: `HAS_METHOD:${ownerId}->${nodeId}`,
|
||||
sourceId: ownerId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
confidence: 1.0,
|
||||
reason: 'lombok-setter',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -119,6 +119,7 @@ import type { ConstructorBinding } from '../type-env.js';
|
|||
import { detectFrameworkFromAST } from '../framework-detection.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { defaultExportNameCollides } from '../languages/typescript/cjs-export-assignment.js';
|
||||
import { synthesizeLombokAccessors } from '../languages/java/lombok-synthesizer.js';
|
||||
import {
|
||||
extractVueScript,
|
||||
extractTemplateComponents,
|
||||
|
|
@ -1572,6 +1573,15 @@ const processFileGroup = (
|
|||
}
|
||||
const provider = getProvider(language);
|
||||
|
||||
// Lombok synthesis owner map: class_declaration AST node id → graph node
|
||||
// id for the classes THIS file's capture loop materialized. Keyed by AST
|
||||
// node identity (SyntaxNode.id is a stable per-tree integer), never by
|
||||
// simple name — simple names collide across files (the map is per-file but
|
||||
// the capture loop's symbols share `result.symbols` with the whole batch)
|
||||
// and among same-tailed nested classes. Filled in the capture loop below;
|
||||
// consumed by synthesizeLombokAccessors after it (Java only).
|
||||
const classOwnersByNodeId = new Map<number, string>();
|
||||
|
||||
// #2687: ONE pass over `matches` yields both suppression sets — the
|
||||
// definition-name claims by rank (callable > Property > value), so the dedup
|
||||
// below cannot depend on tree-sitter's match order, and the concrete-typedef
|
||||
|
|
@ -2976,6 +2986,18 @@ const processFileGroup = (
|
|||
: {}),
|
||||
});
|
||||
|
||||
// Class-like definitions register their AST node id → graph node id for
|
||||
// the Lombok synthesis pass below. The definition node IS the class
|
||||
// declaration for these labels (see getDefinitionNodeFromCaptures), so
|
||||
// the key is the same node findLombokClasses will walk to.
|
||||
if (
|
||||
isClassLikeLabel &&
|
||||
definitionNode &&
|
||||
provider.classExtractor?.isTypeDeclaration(definitionNode)
|
||||
) {
|
||||
classOwnersByNodeId.set(definitionNode.id, nodeId);
|
||||
}
|
||||
|
||||
// Object-literal callables remain file definitions as well as members of
|
||||
// their exported binding. Class members still use HAS_METHOD alone.
|
||||
const isTopLevelObjectCallable =
|
||||
|
|
@ -3081,6 +3103,18 @@ const processFileGroup = (
|
|||
if (springTypes.length > 0) (result.springTypes ??= []).push(...springTypes);
|
||||
}
|
||||
|
||||
// Lombok accessor synthesis: generate virtual Method nodes for getter/setter
|
||||
// methods that Lombok generates at compile time (@Data/@Getter/@Setter).
|
||||
// Java only — the synthesizer walks the AST for annotated classes.
|
||||
if (language === SupportedLanguages.Java) {
|
||||
const lombok = synthesizeLombokAccessors(tree, file.path, classOwnersByNodeId);
|
||||
if (lombok.symbols.length > 0) {
|
||||
for (const node of lombok.nodes) result.nodes.push(node as never);
|
||||
for (const sym of lombok.symbols) result.symbols.push(sym as never);
|
||||
for (const rel of lombok.relationships) result.relationships.push(rel as never);
|
||||
}
|
||||
}
|
||||
|
||||
// Vue: emit CALLS edges for components used in <template>
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
|
|
|
|||
424
gitnexus/test/unit/lombok-synthesizer.test.ts
Normal file
424
gitnexus/test/unit/lombok-synthesizer.test.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/**
|
||||
* Unit test: Lombok accessor method synthesis.
|
||||
*
|
||||
* Tests the lombok-synthesizer module directly (no worker pool needed).
|
||||
* Verifies that @Data/@Getter/@Setter annotated classes produce the correct
|
||||
* synthetic getter/setter methods, with proper naming conventions, collision
|
||||
* guards, and AccessLevel.NONE suppression.
|
||||
*
|
||||
* The owner map is keyed by class_declaration AST node id (SyntaxNode.id) —
|
||||
* mirroring how parse-worker fills it from the capture loop — so tests build
|
||||
* it by walking the parsed tree for class declarations.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.js';
|
||||
|
||||
function parse(code: string): Parser.Tree {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Java);
|
||||
return parser.parse(code);
|
||||
}
|
||||
|
||||
const FILE_PATH = '/test/Order.java';
|
||||
|
||||
/**
|
||||
* Build the AST-node-id → graph-node-id map the way parse-worker does:
|
||||
* `Class:<file>:<Name>` for top-level classes and `Class:<file>:<Outer>.<Name>`
|
||||
* for nested ones (the capture loop keys a class id by its simple name when
|
||||
* top-level, and by `<immediateParentSimpleName>.<name>` when nested — see
|
||||
* findEnclosingClassInfo). Only the KEY is load-bearing for the synthesizer
|
||||
* (the AST node id); values mirror the real graph ids so edge assertions
|
||||
* below observe production shapes.
|
||||
*/
|
||||
function ownerMapBySimpleName(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
const CLASS_LIKE = new Set([
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'enum_declaration',
|
||||
'record_declaration',
|
||||
]);
|
||||
const immediateParentName = (node: Parser.SyntaxNode): string | null => {
|
||||
for (let current = node.parent; current; current = current.parent) {
|
||||
if (CLASS_LIKE.has(current.type)) {
|
||||
const nameNode = current.childForFieldName('name');
|
||||
if (nameNode) return nameNode.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'class_declaration') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const parent = immediateParentName(node);
|
||||
const key = parent ? `${parent}.${nameNode.text}` : nameNode.text;
|
||||
map.set(node.id, `Class:${filePath}:${key}`);
|
||||
}
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('synthesizeLombokAccessors', () => {
|
||||
describe('@Data annotation', () => {
|
||||
it('generates both getter and setter for each field', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
private Long amount;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// 2 fields × 2 (getter + setter) = 4 synthetic methods
|
||||
expect(result.symbols).toHaveLength(4);
|
||||
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['getAmount', 'getOrderId', 'setAmount', 'setOrderId']);
|
||||
});
|
||||
|
||||
it('sets correct return types and parameter types', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
const getter = result.symbols.find((s) => s.name === 'getOrderId')!;
|
||||
expect(getter).toBeDefined();
|
||||
expect(getter.returnType).toBe('String');
|
||||
expect(getter.parameterTypes).toEqual([]);
|
||||
expect(getter.parameterCount).toBe(0);
|
||||
|
||||
const setter = result.symbols.find((s) => s.name === 'setOrderId')!;
|
||||
expect(setter).toBeDefined();
|
||||
expect(setter.returnType).toBe('void');
|
||||
expect(setter.parameterTypes).toEqual(['String']);
|
||||
expect(setter.parameterCount).toBe(1);
|
||||
});
|
||||
|
||||
it('creates HAS_METHOD relationships linking to the class', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// At least one getter AND one setter edge — the loop below must not be
|
||||
// vacuous (an empty result would pass it trivially).
|
||||
expect(result.relationships.length).toBeGreaterThanOrEqual(2);
|
||||
const expectedOwner = classNodeIds.get(
|
||||
[...classNodeIds.keys()][0],
|
||||
) as string;
|
||||
for (const rel of result.relationships) {
|
||||
expect(rel.type).toBe('HAS_METHOD');
|
||||
expect(rel.sourceId).toBe(expectedOwner);
|
||||
expect(rel.confidence).toBe(1.0);
|
||||
}
|
||||
expect(result.relationships.every((r) => r.reason.startsWith('lombok-'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean naming', () => {
|
||||
it('uses isXxx() for primitive boolean fields', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Config {
|
||||
private boolean enabled;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['isEnabled', 'setEnabled']);
|
||||
});
|
||||
|
||||
it('uses getXxx() for Boolean (boxed) fields', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Config {
|
||||
private Boolean enabled;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['getEnabled', 'setEnabled']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotation variants', () => {
|
||||
it('only generates getters with @Getter', () => {
|
||||
const tree = parse(`
|
||||
@Getter
|
||||
public class ReadOnly {
|
||||
private String value;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0].name).toBe('getValue');
|
||||
});
|
||||
|
||||
it('only generates setters with @Setter', () => {
|
||||
const tree = parse(`
|
||||
@Setter
|
||||
public class WriteOnly {
|
||||
private String value;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0].name).toBe('setValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collision and suppression guards', () => {
|
||||
it('skips getter when a hand-written method of the same name exists', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
|
||||
public String getOrderId() { return orderId; }
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// Getter is hand-written → only the setter is synthesized
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0].name).toBe('setOrderId');
|
||||
});
|
||||
|
||||
it('does not generate accessors for static fields', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Constants {
|
||||
private static String VERSION = "1.0";
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not generate setters for final fields (Lombok never does)', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private final String id;
|
||||
private String mutable;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
// final field: getter only; mutable field: getter + setter
|
||||
expect(names).toEqual(['getId', 'getMutable', 'setMutable']);
|
||||
});
|
||||
|
||||
it('skips getter when @Getter(AccessLevel.NONE) is on a field', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
@Getter(AccessLevel.NONE)
|
||||
private String secret;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0].name).toBe('setSecret');
|
||||
});
|
||||
|
||||
it('skips setter when @Setter(AccessLevel.NONE) is on a field', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
@Setter(AccessLevel.NONE)
|
||||
private String pinned;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0].name).toBe('getPinned');
|
||||
});
|
||||
|
||||
it('returns empty result for classes without Lombok annotations', () => {
|
||||
const tree = parse(`
|
||||
public class PlainClass {
|
||||
private String value;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
expect(result.relationships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips classes not present in the owner map', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = new Map<number, string>(); // empty
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested classes', () => {
|
||||
it('handles nested @Data classes', () => {
|
||||
const tree = parse(`
|
||||
public class Outer {
|
||||
@Data
|
||||
public static class Inner {
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// Only Inner has @Data
|
||||
expect(result.symbols).toHaveLength(2);
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['getValue', 'setValue']);
|
||||
});
|
||||
|
||||
it('keys synthesized ids by the immediate enclosing class (Outer.method, like real nested member ids)', () => {
|
||||
const tree = parse(`
|
||||
public class Outer {
|
||||
@Data
|
||||
public static class Inner {
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
const getter = result.symbols.find((s) => s.name === 'getValue')!;
|
||||
expect(getter).toBeDefined();
|
||||
// Real nested-class method ids are keyed `Inner.method` (immediate
|
||||
// parent simple name) — the synthesized id must agree so call
|
||||
// resolution can hit it.
|
||||
expect(getter.nodeId).toBe('Method:/test/Order.java:Inner.getValue#0');
|
||||
});
|
||||
|
||||
it('gives same-tailed nested classes DISTINCT owners (bot: name-keyed map overwrite)', () => {
|
||||
const tree = parse(`
|
||||
public class First {
|
||||
@Data
|
||||
public static class Item {
|
||||
private String a;
|
||||
}
|
||||
}
|
||||
|
||||
public class Second {
|
||||
@Data
|
||||
public static class Item {
|
||||
private String b;
|
||||
}
|
||||
}
|
||||
`);
|
||||
// Same-tail `Item` twice in one file — a name-keyed map could only keep
|
||||
// one. The AST-node-id key keeps both.
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
expect(classNodeIds.size).toBe(4); // First, First.Item, Second, Second.Item
|
||||
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// Both Items synthesize — a name collision would drop one side.
|
||||
// Method ids follow the real convention (keyed by the class's own
|
||||
// simple name `Item.method`, matching how real nested-class members
|
||||
// key), so the two Items' methods share id shapes but anchor on
|
||||
// DIFFERENT class nodes via HAS_METHOD.
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['getA', 'getB', 'setA', 'setB']);
|
||||
|
||||
// And each HAS_METHOD edge anchors on its own class — the owner
|
||||
// resolution is what the AST-node-id key fixed.
|
||||
const edges = result.relationships.map((r) => `${r.sourceId} -> ${r.targetId}`).sort();
|
||||
expect(edges).toEqual([
|
||||
'Class:/test/Order.java:First.Item -> Method:/test/Order.java:Item.getA#0',
|
||||
'Class:/test/Order.java:First.Item -> Method:/test/Order.java:Item.setA#1',
|
||||
'Class:/test/Order.java:Second.Item -> Method:/test/Order.java:Item.getB#0',
|
||||
'Class:/test/Order.java:Second.Item -> Method:/test/Order.java:Item.setB#1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-variable declarations', () => {
|
||||
it('handles `int x, y;` style declarations', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Point {
|
||||
private int x, y;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// 2 fields × 2 accessors = 4 methods
|
||||
expect(result.symbols).toHaveLength(4);
|
||||
const names = result.symbols.map((s) => s.name).sort();
|
||||
expect(names).toEqual(['getX', 'getY', 'setX', 'setY']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node properties', () => {
|
||||
it('marks synthetic methods with synthetic: lombok (non-vacuous)', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
|
||||
// The loop below must iterate real nodes — assert the counts first so
|
||||
// `for..of` over an empty array cannot pass vacuously.
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
for (const node of result.nodes) {
|
||||
expect(node.properties.synthetic).toBe('lombok');
|
||||
expect(node.properties.visibility).toBe('public');
|
||||
expect(node.properties.isStatic).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue