GitNexus/gitnexus/test/integration/resolvers/java-lombok.test.ts
ChunxueLi 4aa6bddd0a
feat(jvm): synthesize Lombok and Kotlin JVM accessor methods (#2885)
* feat(java): synthesize Lombok @Data/@Getter/@Setter accessor methods

* 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.

* feat(java): synthesize Lombok accessors via provider hook and scope dual-path

Replace the worker language===Java branch with LanguageProvider.synthesizeStructureMembers,
align MethodRegistry ownership through scope captures, and bump parse-cache schema to 83
so warm caches cannot replay pre-synthesis worker output.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(java): cover Lombok synthesis semantics, cache replay, and CI bench

Add unit/integration matrices (including durable cold/warm/historical parse-cache),
a permanent no-Lombok vs Lombok-heavy harness with fingerprint budgets, and a CI
--check step. Document that Kotlin→Java member CALLS remains a pre-existing gap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(lombok): drop dead state and redundant scans from accessor synthesis

Collapse Lombok import provenance into one compilation-unit scan with a cached
wildcard flag, remove unused planned-accessor fields and the duplicate @Data
enable flag, and plan scope captures without wrapping a fake Parser.Tree.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#2885)

Give each Lombok accessor a unique scope range so multi-declarator fields do not share @scope.function IDs, and type the owner map as ReadonlyMap to match the provider hook.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(bench): pin the real Lombok synthesis fingerprint (#2885)

The committed baseline held a fingerprint no revision of this branch ever
produced, so the CI guard failed on every push. Re-pin it to the value the
synthesizer deterministically emits and correct the method count the comment
claims (800 x 4 x 2 = 6400, not 12800).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(kotlin): synthesize JVM accessors using shared beanspec helpers (#2885)

Kotlin val/var properties now emit the same JavaBeans get/set Methods as Lombok, via jvm/beanspec + jvm/synthetic-accessors. SCHEMA_BUMP 84 invalidates warm caches that would omit those callables.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): match kotlinc JVM accessor ABI (#2885)

Emit custom getters, preserve is-prefix names, and convert synthetic
graph lines to 0-based so same-name accessors resolve to the owner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#2885)

Restrict Lombok provenance to lombok/experimental FQNs and match Kotlin existing methods by exact JVM name.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(jvm): consolidate accessor synthesis (#2885)

Keep language-specific discovery in Java and Kotlin adapters while centralizing owner orchestration, collision policy, graph emission, and captures.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(jvm): align accessor synthesis with compiler ABI (#2885)

Match Lombok and kotlinc provenance, companion owners, and collision
arity so mixed-JVM CALLS bind to the Methods compilers actually emit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#2885)

Mark Kotlin interface accessors abstract, pin the Lombok case-fold collision test, and document the non-lowercase is-prefix rule.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#2885)

Honor explicit Getter/Setter over @Data regardless of order, and let field @Accessors replace class-level fluent/chain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): pin Kotlin scope-capture fingerprint after interface accessors (#2885)

Invalidate warm parse cache so interface property Methods are not replayed as concrete.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:55:52 +00:00

258 lines
8.6 KiB
TypeScript

/**
* Production-path Lombok accessor synthesis: Method nodes, HAS_METHOD, and CALLS
* through the real worker/provider pipeline (not unit synthesizer alone).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import {
writeFixtureRepo,
runPipelineFromRepo,
getRelationships,
getNodesByLabelFull,
} from './helpers.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,
pruneCache,
saveParseCache,
type ParseCache,
} from '../../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../../src/storage/parsedfile-store.js';
function projectLombokMethods(result: Awaited<ReturnType<typeof runPipelineFromRepo>>) {
return [...result.graph.iterNodes()]
.filter((n) => n.label === 'Method' && n.properties.synthetic === 'lombok')
.map(
(n) =>
`${n.id}|${n.properties.qualifiedName}|${n.properties.name}|${n.properties.parameterCount}|${n.properties.returnType}|${n.properties.visibility}`,
)
.sort();
}
function projectCallsToGetName(result: Awaited<ReturnType<typeof runPipelineFromRepo>>) {
return getRelationships(result, 'CALLS')
.filter((e) => e.target === 'getName')
.map((e) => `${e.source}->${e.target}@${e.targetFilePath}`)
.sort();
}
describe('Java Lombok accessor synthesis (pipeline)', () => {
it('resolves Consumer.consume -> User.getName for @Data', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-lombok-'));
try {
writeFixtureRepo(root, {
'LombokUser.java': `import lombok.Data;
@Data
class User {
private String name;
}
class Consumer {
String consume(User user) {
return user.getName();
}
}
`,
});
const linked = await runPipelineFromRepo(root, () => {});
const methods = getNodesByLabelFull(linked, 'Method');
const getName = methods.find(
(m) => m.name === 'getName' && m.properties.filePath.endsWith('LombokUser.java'),
);
expect(getName).toBeDefined();
expect(getName?.properties).toMatchObject({
parameterCount: 0,
returnType: 'String',
synthetic: 'lombok',
visibility: 'public',
qualifiedName: 'User.getName',
});
const hasMethod = getRelationships(linked, 'HAS_METHOD').filter(
(e) => e.source === 'User' && e.target === 'getName',
);
expect(hasMethod.length).toBeGreaterThanOrEqual(1);
const calls = getRelationships(linked, 'CALLS').filter(
(e) => e.source === 'consume' && e.target === 'getName',
);
expect(calls.some((e) => e.targetFilePath.endsWith('LombokUser.java'))).toBe(true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('Kotlin caller of Java getName(): synthetic Method exists; CALLS blocked by pre-existing JVM interop', async () => {
// Control: hand-written Java getName is also unreachable from Kotlin callers
// in this pipeline (Kotlin→Kotlin member CALLS work; Kotlin→Java do not).
// Lombok must not invent a second interop feature — only prove the Method
// node Java callers already resolve is present for Kotlin to target later.
const lombokRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-lombok-'));
const handRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-hand-'));
try {
writeFixtureRepo(lombokRoot, {
'models/User.java': `package models;
import lombok.Data;
@Data
public class User {
private String name;
public void save() {}
}
`,
'models/Factory.kt': `package models
fun getUser(): User = User()
`,
'app/App.kt': `package app
import models.getUser
class App {
fun run() {
val u = getUser()
u.save()
u.getName()
}
}
`,
});
writeFixtureRepo(handRoot, {
'models/User.java': `package models;
public class User {
private String name;
public String getName() { return name; }
public void save() {}
}
`,
'models/Factory.kt': `package models
fun getUser(): User = User()
`,
'app/App.kt': `package app
import models.getUser
class App {
fun run() {
val u = getUser()
u.save()
u.getName()
}
}
`,
});
const lombok = await runPipelineFromRepo(lombokRoot, () => {});
const hand = await runPipelineFromRepo(handRoot, () => {});
const lombokGetName = getNodesByLabelFull(lombok, 'Method').filter(
(m) => m.name === 'getName' && m.properties.filePath.endsWith('User.java'),
);
expect(lombokGetName).toHaveLength(1);
expect(lombokGetName[0]?.properties.synthetic).toBe('lombok');
expect(lombokGetName[0]?.properties.qualifiedName).toBe('User.getName');
const handGetName = getNodesByLabelFull(hand, 'Method').filter(
(m) => m.name === 'getName' && m.properties.filePath.endsWith('User.java'),
);
expect(handGetName).toHaveLength(1);
expect(handGetName[0]?.properties.synthetic).toBeUndefined();
// Pre-existing gap: Kotlin resolves getUser (Kotlin) but not Java members.
expect(projectCallsToGetName(lombok)).toEqual([]);
expect(projectCallsToGetName(hand)).toEqual([]);
expect(
getRelationships(lombok, 'CALLS').some((e) => e.source === 'run' && e.target === 'getUser'),
).toBe(true);
// Document: Kotlin property read `user.name` is ACCESSES on the field
// Property, not a JavaBeans getter lowering. Not asserted as CALLS.
} finally {
fs.rmSync(lombokRoot, { recursive: true, force: true });
fs.rmSync(handRoot, { recursive: true, force: true });
}
}, 120000);
it('cold parse, warm replay (byte-identical Method ids), and incompatible historical cache miss', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-lombok-cache-repo-'));
const storage = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-lombok-cache-store-'));
try {
writeFixtureRepo(root, {
'LombokUser.java': `import lombok.Data;
@Data
class User {
private String name;
}
class Consumer {
String consume(User user) {
return user.getName();
}
}
`,
});
const coldCache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storage,
onDiskKeys: new Set(),
};
const cold = await runPipelineFromRepo(root, () => {}, {
skipGraphPhases: true,
workerPoolSize: 1,
parseCache: coldCache,
});
expect(cold.usedWorkerPool).toBe(true);
const coldMethods = projectLombokMethods(cold);
expect(coldMethods.some((m) => m.includes('|User.getName|getName|'))).toBe(true);
expect(projectCallsToGetName(cold).some((c) => c.startsWith('consume->getName@'))).toBe(true);
pruneCache(coldCache, coldCache.usedKeys);
const savedKeys = await saveParseCache(storage, coldCache);
expect(savedKeys.length).toBeGreaterThan(0);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storage),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warmCache = await loadParseCache(storage);
expect(warmCache.onDiskKeys).toEqual(new Set(savedKeys));
const warm = await runPipelineFromRepo(root, () => {}, {
skipGraphPhases: true,
workerPoolSize: 1,
parseCache: warmCache,
});
expect(warm.usedWorkerPool).toBe(false);
expect(projectLombokMethods(warm)).toEqual(coldMethods);
expect(projectCallsToGetName(warm)).toEqual(projectCallsToGetName(cold));
// Historical incompatible cache: foreign version must not replay.
const indexPath = path.join(storage, 'parse-cache', 'index.json');
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')) as { version: string };
index.version = '82+0.0.0-historical';
fs.writeFileSync(indexPath, JSON.stringify(index), 'utf8');
const historical = await loadParseCache(storage);
expect(historical.entries.size).toBe(0);
expect(historical.onDiskKeys?.size ?? 0).toBe(0);
const recoveredCache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storage,
onDiskKeys: new Set(),
};
const recovered = await runPipelineFromRepo(root, () => {}, {
skipGraphPhases: true,
workerPoolSize: 1,
parseCache: recoveredCache,
});
expect(recovered.usedWorkerPool).toBe(true);
expect(projectLombokMethods(recovered)).toEqual(coldMethods);
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(storage, { recursive: true, force: true });
}
}, 120000);
});