GitNexus/gitnexus/test/unit/lombok-synthesizer.test.ts
ChunxueLi 4163f2968f fix(lombok): resolve class identity by AST node id, not simple name
Root-cause fix for the bot review's name-ambiguity findings:

1. Cross-file collision: the owner map was rebuilt per file from
   result.symbols, which accumulates across the whole language group —
   a later Java file with the same simple class name resolved to the
   earlier file's class node. The map is now filled INSIDE the capture
   loop (per-file scope) and keyed by the class_declaration AST node
   id (SyntaxNode.id), which is unique by construction.

2. Same-tail nested classes (Outer.A vs Other.A): a name-keyed map
   overwrote one with the other; AST-node-id keys cannot collide.

3. Synthesized method ids now follow the SAME convention real nested
   member ids use (keyed by the class's own simple name, matching
   findEnclosingClassInfo().className), so call resolution can hit
   synthesized accessors exactly like hand-written ones.

4. Lombok semantics: setters are no longer generated for final fields
   (Lombok never emits those) and @Setter(AccessLevel.NONE) now
   suppresses setters, symmetric to the existing getter suppression.

Also tightens two vacuous test loops flagged by the bot (empty-array
for..of passed trivially): counts are asserted before property loops,
and a new regression test pins distinct owners for same-tailed nested
classes plus the real id convention for nested accessors.
2026-08-27 09:07:44 +08:00

424 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
});
});
});