This commit is contained in:
ChunxueLi 2026-08-27 01:16:28 +00:00 committed by GitHub
commit 92c039ed08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 940 additions and 0 deletions

View 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;
}

View file

@ -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);

View 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);
}
});
});
});