mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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>
This commit is contained in:
parent
e04e1ecc65
commit
4aa6bddd0a
25 changed files with 3416 additions and 8 deletions
14
.github/workflows/ci-tests.yml
vendored
14
.github/workflows/ci-tests.yml
vendored
|
|
@ -509,6 +509,20 @@ jobs:
|
|||
run: node --import tsx bench/callable-value-flow/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Java Lombok accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-Lombok vs Lombok-heavy corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/java-lombok-synthesis/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin JVM accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-property vs data-class corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Re-export closure scaling guards (#2864)
|
||||
# Build-free: asserts buildReexportClosures stays linear in chain depth
|
||||
# and within an absolute ceiling on a wide package corpus. #2864 changed
|
||||
|
|
|
|||
8
gitnexus/bench/java-lombok-synthesis/baselines.json
Normal file
8
gitnexus/bench/java-lombok-synthesis/baselines.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/java-lombok-synthesis/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the lombok_large corpus (800 @Data entities × 4 fields × 2 accessors = 6400 methods). no_lombok arm must emit 0 methods. Budgets are timing gates with CI headroom.",
|
||||
"fingerprint": "b935d6894d32de7594d5887bb62af6ade2b66b19d6846700a05ef3baf1ed1eb1",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the lombok arm. Measured ~1.01.",
|
||||
"widening_overhead_budget": 2.5,
|
||||
"_widening_overhead_note": "lombok_large_ms / no_lombok_large_ms using an unannotated, shape-equivalent four-field control. Measured about 1.24; budget guards against a pathological feature-arm regression."
|
||||
}
|
||||
124
gitnexus/bench/java-lombok-synthesis/measure.mjs
Normal file
124
gitnexus/bench/java-lombok-synthesis/measure.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for Java Lombok accessor synthesis.
|
||||
*
|
||||
* Arms:
|
||||
* - no_lombok: unannotated fields (shape-equivalent control) — synthesizer no-ops
|
||||
* - lombok_heavy: @Data classes (feature path)
|
||||
*
|
||||
* Times synthesizeLombokAccessors over N separate files (not one giant buffer).
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/java-lombok-synthesis/measure.mjs
|
||||
* node --import tsx bench/java-lombok-synthesis/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSample,
|
||||
runBaselineCheck,
|
||||
runMethodCountCheck,
|
||||
} from '../lib/identity-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
function entitySource(i, mode) {
|
||||
if (mode === 'lombok') {
|
||||
return `import lombok.Data;
|
||||
@Data
|
||||
public class Entity${i} {
|
||||
private String id;
|
||||
private String name;
|
||||
private boolean active;
|
||||
private Long amount;
|
||||
}
|
||||
`;
|
||||
}
|
||||
return `public class Entity${i} {
|
||||
private String id;
|
||||
private String name;
|
||||
private boolean active;
|
||||
private Long amount;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function ownerMap(tree, filePath) {
|
||||
const map = new Map();
|
||||
const walk = (node) => {
|
||||
if (node.type === 'class_declaration') {
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
if (name) map.set(node.id, `Class:${filePath}:${name}`);
|
||||
}
|
||||
for (const c of node.children) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Java);
|
||||
const filePath = `bench/${mode}/Entity${i}.java`;
|
||||
const tree = parser.parse(entitySource(i, mode));
|
||||
files.push({ tree, filePath, owners: ownerMap(tree, filePath) });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function runAll(files) {
|
||||
const nodes = [];
|
||||
for (const f of files) {
|
||||
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
|
||||
for (const n of result.nodes) nodes.push(n.id);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const files = prepare(mode, fileCount);
|
||||
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
methods: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
no_lombok_small: measure('bare', SMALL),
|
||||
no_lombok_large: measure('bare', LARGE),
|
||||
lombok_small: measure('lombok', SMALL),
|
||||
lombok_large: measure('lombok', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.lombok_large.ms / report.lombok_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.lombok_large.ms / Math.max(report.no_lombok_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.fingerprint = report.lombok_large.fingerprint;
|
||||
|
||||
runMethodCountCheck(report, {
|
||||
no_lombok_large: 0,
|
||||
lombok_large: 6400,
|
||||
});
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
8
gitnexus/bench/kotlin-jvm-accessors/baselines.json
Normal file
8
gitnexus/bench/kotlin-jvm-accessors/baselines.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/kotlin-jvm-accessors/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the data_large corpus (800 data classes × 4 vars × 2 accessors = 6400 methods). no_props arm uses @JvmField so kotlinc and the synthesizer emit 0 accessor methods. Budgets are timing gates with CI headroom.",
|
||||
"fingerprint": "18e4f295a437a747c486699e8ec5d310d9bde54437d9a96356a1b1bf8442b0ef",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the data-class arm. Measured ~1.02.",
|
||||
"widening_overhead_budget": 2.5,
|
||||
"_widening_overhead_note": "data_large_ms / no_props_large_ms. The @JvmField control preserves four property declarations without accessors; budget guards against a pathological synthesis-arm regression."
|
||||
}
|
||||
121
gitnexus/bench/kotlin-jvm-accessors/measure.mjs
Normal file
121
gitnexus/bench/kotlin-jvm-accessors/measure.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for Kotlin JVM accessor synthesis.
|
||||
*
|
||||
* Arms:
|
||||
* - no_props: @JvmField properties with no JVM accessors (control)
|
||||
* - data_class: data class constructor properties (feature path)
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs
|
||||
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Parser from 'tree-sitter';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
|
||||
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/kotlin/lombok-synthesizer.ts';
|
||||
import {
|
||||
fingerprintIds,
|
||||
minSample,
|
||||
runBaselineCheck,
|
||||
runMethodCountCheck,
|
||||
} from '../lib/identity-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
function entitySource(i, mode) {
|
||||
if (mode === 'data') {
|
||||
return `data class Entity${i}(var id: String, var name: String, var active: Boolean, var amount: Long)
|
||||
`;
|
||||
}
|
||||
// @JvmField suppresses accessors in kotlinc and in the synthesizer while
|
||||
// retaining the same four property declarations as the feature arm.
|
||||
return `class Entity${i} {
|
||||
@JvmField var id: String = ""
|
||||
@JvmField var name: String = ""
|
||||
@JvmField var active: Boolean = false
|
||||
@JvmField var amount: Long = 0
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function ownerMap(tree, filePath) {
|
||||
const map = new Map();
|
||||
const walk = (node) => {
|
||||
if (node.type === 'class_declaration' || node.type === 'object_declaration') {
|
||||
const name =
|
||||
node.childForFieldName('name')?.text ??
|
||||
node.namedChildren.find((c) => c.type === 'type_identifier')?.text;
|
||||
if (name) map.set(node.id, `Class:${filePath}:${name}`);
|
||||
}
|
||||
for (const c of node.children) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
function prepare(mode, fileCount) {
|
||||
const files = [];
|
||||
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(lang);
|
||||
const filePath = `bench/${mode}/Entity${i}.kt`;
|
||||
const tree = parser.parse(entitySource(i, mode));
|
||||
files.push({ tree, filePath, owners: ownerMap(tree, filePath), parser });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function runAll(files) {
|
||||
const nodes = [];
|
||||
for (const f of files) {
|
||||
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
|
||||
for (const n of result.nodes) nodes.push(n.id);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function measure(mode, fileCount) {
|
||||
const files = prepare(mode, fileCount);
|
||||
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
|
||||
return {
|
||||
files: fileCount,
|
||||
ms,
|
||||
methods: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
no_props_small: measure('hand', SMALL),
|
||||
no_props_large: measure('hand', LARGE),
|
||||
data_small: measure('data', SMALL),
|
||||
data_large: measure('data', LARGE),
|
||||
};
|
||||
report.scaling_ratio = Number(
|
||||
(report.data_large.ms / report.data_small.ms / (LARGE / SMALL)).toFixed(3),
|
||||
);
|
||||
report.widening_overhead = Number(
|
||||
(report.data_large.ms / Math.max(report.no_props_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.fingerprint = report.data_large.fingerprint;
|
||||
|
||||
runMethodCountCheck(report, {
|
||||
no_props_large: 0,
|
||||
data_large: 6400,
|
||||
});
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
62
gitnexus/bench/lib/identity-guard.mjs
Normal file
62
gitnexus/bench/lib/identity-guard.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Shared fingerprint + --check for JVM accessor synthesis benches.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export function fingerprintIds(ids) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update([...ids].sort().join('\n'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function minSample(run, warmup, reps) {
|
||||
for (let w = 0; w < warmup; w++) run();
|
||||
const samples = [];
|
||||
let last;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
last = run();
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
return { last, ms: Math.min(...samples) };
|
||||
}
|
||||
|
||||
export function runMethodCountCheck(report, expectedCounts) {
|
||||
const errors = [];
|
||||
for (const [arm, expected] of Object.entries(expectedCounts)) {
|
||||
const actual = report[arm]?.methods;
|
||||
if (actual !== expected) {
|
||||
errors.push(`${arm}.methods ${String(actual)} != ${expected}`);
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error(JSON.stringify({ report, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runBaselineCheck(report, baselinePath) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
||||
const errors = [];
|
||||
if (report.fingerprint !== baseline.fingerprint) {
|
||||
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
|
||||
}
|
||||
if (
|
||||
baseline.widening_overhead_budget !== undefined &&
|
||||
report.widening_overhead > baseline.widening_overhead_budget
|
||||
) {
|
||||
errors.push(
|
||||
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
|
||||
);
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error(JSON.stringify({ report, errors }, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, report }, null, 2));
|
||||
}
|
||||
|
|
@ -209,8 +209,10 @@
|
|||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832",
|
||||
"fingerprint": "aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
|
||||
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
|
|
@ -223,9 +225,9 @@
|
|||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
|
||||
"_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.",
|
||||
"capture_groups_small": 4753,
|
||||
"capture_groups_large": 15203,
|
||||
"capture_groups_fp": 2367,
|
||||
"capture_groups_small": 5753,
|
||||
"capture_groups_large": 18403,
|
||||
"capture_groups_fp": 2563,
|
||||
"fixture_count": 141
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,6 +402,54 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => SharedSpringType[];
|
||||
|
||||
/**
|
||||
* Optional post-capture emission of synthetic structure members (nodes,
|
||||
* symbols, ownership edges) that have no AST method node — e.g. Lombok
|
||||
* accessors. Called once per file after the capture loop, at the same
|
||||
* post-capture site as {@link extractDecoratorRoutes}.
|
||||
*
|
||||
* `classOwnersByNodeId` maps in-memory tree-sitter node ids of type
|
||||
* declarations materialized in THIS file's capture loop to their graph
|
||||
* node ids. Keys are never persisted; they exist only for the duration
|
||||
* of the worker pass.
|
||||
*
|
||||
* Default: undefined (no synthetic structure members).
|
||||
*/
|
||||
readonly synthesizeStructureMembers?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersByNodeId: ReadonlyMap<number, string>,
|
||||
) => {
|
||||
nodes: ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
symbols: ReadonlyArray<{
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: string;
|
||||
ownerId?: string;
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
returnType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isAbstract?: boolean;
|
||||
isFinal?: boolean;
|
||||
}>;
|
||||
relationships: ReadonlyArray<{
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Harvest this file's module-level string constants (#2391 core, #2980 Java
|
||||
* parity) into the language-agnostic {@link ModuleConstants} shape, so the
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
javaRecordMethodExtractor,
|
||||
shouldSkipJavaRecordComponentDefinition,
|
||||
} from './java/record-components.js';
|
||||
import { synthesizeLombokAccessors } from './java/lombok-synthesizer.js';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
interpretJavaImport,
|
||||
|
|
@ -222,6 +223,8 @@ export const javaProvider = defineLanguage({
|
|||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
extractRouteInheritanceTypes: extractSpringTypes,
|
||||
|
||||
synthesizeStructureMembers: synthesizeLombokAccessors,
|
||||
|
||||
// ── #2980: constant harvest + qualified-ref fold for non-literal mapping
|
||||
// paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so
|
||||
// the shared ingestion layers stay language-agnostic. The heuristic is
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
type JavaSpringNonHttpHandlerFact,
|
||||
} from './spring-non-http-handlers.js';
|
||||
import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js';
|
||||
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
|
@ -422,6 +423,7 @@ export function emitJavaScopeCaptures(
|
|||
...synthesizeJavaExplicitConstructorReferences(tree.rootNode),
|
||||
...synthesizeJavaAnonymousClassDeclarations(tree.rootNode),
|
||||
...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode),
|
||||
...synthesizeLombokAccessorCaptures(tree.rootNode),
|
||||
...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
539
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
539
gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
/**
|
||||
* Lombok accessor synthesizer for Java.
|
||||
*
|
||||
* Lombok generates getters/setters at compile time. They are absent from the
|
||||
* AST, so calls like `obj.getOrderId()` on a `@Data` class would otherwise
|
||||
* leave unresolved CALLS edges. This module walks the tree-sitter Java AST
|
||||
* and synthesizes Method graph members for the accessors Lombok would emit
|
||||
* under the supported subset.
|
||||
*
|
||||
* ## Supported subset (v1)
|
||||
* - Proven `lombok.Data` / `lombok.Getter` / `lombok.Setter` (FQN or import).
|
||||
* - Class- or field-level enable; `AccessLevel.NONE` disables.
|
||||
* - Default JavaBeans naming; primitive `boolean isX` → `isX` / `setX`.
|
||||
* - Access levels PUBLIC/PROTECTED/PRIVATE/PACKAGE.
|
||||
* - `@Accessors(chain=true)` modeled as setter return = declaring type.
|
||||
* - `@Accessors(fluent=true)` / `prefix=…`: omit affected accessors (names
|
||||
* cannot be proven without full Lombok config).
|
||||
* - External `lombok.config`: unsupported (may change semantics invisibly).
|
||||
*
|
||||
* ## Identity
|
||||
* Owner lookup uses in-memory AST node ids only. Method ids are derived from
|
||||
* the stable declaring-owner graph key (the Class node id's name segment),
|
||||
* never from persisted tree-sitter node ids.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { jvmGetterName, jvmSetterName } from '../jvm/beanspec.js';
|
||||
import {
|
||||
createExistingMethodIndex,
|
||||
createJvmAccessorSynthesis,
|
||||
hasExistingMethod,
|
||||
rememberExistingMethodRange,
|
||||
type ExistingMethodIndex,
|
||||
type PlannedJvmAccessor,
|
||||
type PlannedJvmAccessorOwner,
|
||||
type SyntheticAccessorResult,
|
||||
type SyntheticVisibility,
|
||||
} from '../jvm/accessor-synthesis.js';
|
||||
|
||||
const JAVA_TYPE_DECLS = new Set([
|
||||
'class_declaration',
|
||||
'enum_declaration',
|
||||
'interface_declaration',
|
||||
'record_declaration',
|
||||
]);
|
||||
|
||||
// ── Public result types (ParsedSymbol / ParsedNode compatible) ────────────
|
||||
|
||||
export type LombokVisibility = SyntheticVisibility;
|
||||
export type SyntheticSymbol = SyntheticAccessorResult['symbols'][number];
|
||||
export type SyntheticNode = SyntheticAccessorResult['nodes'][number];
|
||||
export type SyntheticRelationship = SyntheticAccessorResult['relationships'][number];
|
||||
export type LombokSynthesisResult = SyntheticAccessorResult;
|
||||
export type PlannedLombokAccessor = PlannedJvmAccessor;
|
||||
|
||||
export interface AccessorConfig {
|
||||
enabled: boolean;
|
||||
visibility: LombokVisibility;
|
||||
}
|
||||
|
||||
interface AccessorsOptions {
|
||||
/** When true, JavaBeans get/set/is prefixes are not used — omit (unsupported). */
|
||||
fluent: boolean;
|
||||
/** When true, field prefixes alter base names — omit (unsupported). */
|
||||
hasPrefix: boolean;
|
||||
/** When true, setters return the declaring type instead of void. */
|
||||
chain: boolean;
|
||||
}
|
||||
|
||||
interface LombokField {
|
||||
name: string;
|
||||
type: string;
|
||||
isStatic: boolean;
|
||||
isFinal: boolean;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
fieldGetter: AccessorConfig | null;
|
||||
fieldSetter: AccessorConfig | null;
|
||||
accessors: AccessorsOptions;
|
||||
accessorsPresent: boolean;
|
||||
}
|
||||
|
||||
interface LombokClass {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
classGetter: AccessorConfig | null;
|
||||
classSetter: AccessorConfig | null;
|
||||
classAccessors: AccessorsOptions;
|
||||
fields: LombokField[];
|
||||
existingMethods: ExistingMethodIndex;
|
||||
}
|
||||
|
||||
const LOMBOK_ANNOTATION_PACKAGE = new Map<string, string>([
|
||||
['Data', 'lombok'],
|
||||
['Getter', 'lombok'],
|
||||
['Setter', 'lombok'],
|
||||
['Accessors', 'lombok.experimental'],
|
||||
['Tolerate', 'lombok.experimental'],
|
||||
]);
|
||||
|
||||
export function getterName(fieldName: string, fieldType: string): string {
|
||||
return jvmGetterName(fieldName, fieldType === 'boolean');
|
||||
}
|
||||
|
||||
export function setterName(fieldName: string, fieldType: string): string {
|
||||
return jvmSetterName(fieldName, fieldType === 'boolean');
|
||||
}
|
||||
|
||||
// ── Provenance / imports ──────────────────────────────────────────────────
|
||||
|
||||
function annotationSimpleName(nameText: string): string {
|
||||
return nameText.split('.').pop() ?? nameText;
|
||||
}
|
||||
|
||||
interface LombokImportIndex {
|
||||
bySimple: Map<string, string>;
|
||||
starPackages: Set<string>;
|
||||
shadowedSimpleNames: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compilation-unit imports only — Java `import` is never nested in a type body.
|
||||
*/
|
||||
function collectLombokImports(root: Parser.SyntaxNode): LombokImportIndex {
|
||||
const bySimple = new Map<string, string>();
|
||||
const starPackages = new Set<string>();
|
||||
const shadowedSimpleNames = new Set<string>();
|
||||
for (const child of root.children) {
|
||||
if (!JAVA_TYPE_DECLS.has(child.type) && child.type !== 'annotation_type_declaration') continue;
|
||||
const name = child.childForFieldName('name')?.text;
|
||||
if (name) shadowedSimpleNames.add(name);
|
||||
}
|
||||
for (const child of root.children) {
|
||||
if (child.type !== 'import_declaration') continue;
|
||||
if (/^import\s+static\b/.test(child.text)) continue;
|
||||
const text = child.text
|
||||
.replace(/^import\s+/, '')
|
||||
.replace(/;\s*$/, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim();
|
||||
if (text === 'lombok.*') {
|
||||
starPackages.add('lombok');
|
||||
} else if (text === 'lombok.experimental.*') {
|
||||
starPackages.add('lombok.experimental');
|
||||
} else if (!text.endsWith('.*')) {
|
||||
bySimple.set(annotationSimpleName(text), text);
|
||||
}
|
||||
}
|
||||
return { bySimple, starPackages, shadowedSimpleNames };
|
||||
}
|
||||
|
||||
function isProvenLombokAnnotation(nameText: string, imports: LombokImportIndex): boolean {
|
||||
const simple = annotationSimpleName(nameText);
|
||||
const packageName = LOMBOK_ANNOTATION_PACKAGE.get(simple);
|
||||
if (packageName === undefined) return false;
|
||||
if (nameText.includes('.')) return nameText === `${packageName}.${simple}`;
|
||||
const imported = imports.bySimple.get(simple);
|
||||
if (imported !== undefined) return imported === `${packageName}.${simple}`;
|
||||
if (imports.shadowedSimpleNames.has(simple)) return false;
|
||||
return imports.starPackages.has(packageName);
|
||||
}
|
||||
|
||||
// ── AccessLevel / Accessors structural parse ──────────────────────────────
|
||||
|
||||
function parseAccessLevelToken(text: string): LombokVisibility | 'none' | null {
|
||||
const simple = annotationSimpleName(text.trim());
|
||||
switch (simple) {
|
||||
case 'PUBLIC':
|
||||
return 'public';
|
||||
case 'PROTECTED':
|
||||
return 'protected';
|
||||
case 'PRIVATE':
|
||||
return 'private';
|
||||
case 'PACKAGE':
|
||||
case 'MODULE': // treated as package-private for graph metadata
|
||||
return 'package';
|
||||
case 'NONE':
|
||||
return 'none';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findAccessLevelInAnnotation(ann: Parser.SyntaxNode): LombokVisibility | 'none' | null {
|
||||
// Positional: @Getter(AccessLevel.PROTECTED) or @Getter(lombok.AccessLevel.NONE)
|
||||
// Named: @Getter(value = AccessLevel.PRIVATE)
|
||||
const stack: Parser.SyntaxNode[] = [...ann.children];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop();
|
||||
if (!n) break;
|
||||
if (n.type === 'field_access' || n.type === 'identifier') {
|
||||
const level = parseAccessLevelToken(n.text);
|
||||
if (level !== null) return level;
|
||||
}
|
||||
for (const c of n.children) stack.push(c);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultAccessors(): AccessorsOptions {
|
||||
return { fluent: false, hasPrefix: false, chain: false };
|
||||
}
|
||||
|
||||
function parseAccessorsAnnotation(ann: Parser.SyntaxNode): AccessorsOptions {
|
||||
const opts = defaultAccessors();
|
||||
const stack: Parser.SyntaxNode[] = [...ann.children];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop();
|
||||
if (!n) break;
|
||||
if (n.type === 'element_value_pair') {
|
||||
const key =
|
||||
n.childForFieldName('key')?.text ?? n.children.find((c) => c.type === 'identifier')?.text;
|
||||
const valueNode =
|
||||
n.childForFieldName('value') ??
|
||||
n.children.find(
|
||||
(c) =>
|
||||
c.type === 'true' || c.type === 'false' || c.type === 'element_value_array_initializer',
|
||||
);
|
||||
if (key === 'fluent' && (valueNode?.type === 'true' || valueNode?.type === 'false')) {
|
||||
opts.fluent = valueNode.type === 'true';
|
||||
}
|
||||
if (key === 'chain' && (valueNode?.type === 'true' || valueNode?.type === 'false')) {
|
||||
opts.chain = valueNode.type === 'true';
|
||||
}
|
||||
if (key === 'prefix') opts.hasPrefix = true;
|
||||
}
|
||||
for (const c of n.children) stack.push(c);
|
||||
}
|
||||
const text = ann.text;
|
||||
if (/\bprefix\s*=/.test(text)) opts.hasPrefix = true;
|
||||
if (/\bfluent\s*=\s*true\b/.test(text)) opts.fluent = true;
|
||||
if (/\bfluent\s*=\s*false\b/.test(text)) opts.fluent = false;
|
||||
if (/\bchain\s*=\s*true\b/.test(text)) opts.chain = true;
|
||||
if (/\bchain\s*=\s*false\b/.test(text)) opts.chain = false;
|
||||
return opts;
|
||||
}
|
||||
|
||||
interface ParsedAnnotations {
|
||||
getter: AccessorConfig | null;
|
||||
setter: AccessorConfig | null;
|
||||
accessors: AccessorsOptions;
|
||||
accessorsPresent: boolean;
|
||||
tolerate: boolean;
|
||||
}
|
||||
|
||||
function parseModifierAnnotations(
|
||||
modifiersNode: Parser.SyntaxNode | null,
|
||||
imports: LombokImportIndex,
|
||||
): ParsedAnnotations {
|
||||
const result: ParsedAnnotations = {
|
||||
getter: null,
|
||||
setter: null,
|
||||
accessors: defaultAccessors(),
|
||||
accessorsPresent: false,
|
||||
tolerate: false,
|
||||
};
|
||||
if (!modifiersNode) return result;
|
||||
|
||||
for (const child of modifiersNode.children) {
|
||||
if (child.type !== 'marker_annotation' && child.type !== 'annotation') continue;
|
||||
const nameNode = child.childForFieldName('name');
|
||||
const nameText = nameNode?.text ?? '';
|
||||
if (!isProvenLombokAnnotation(nameText, imports)) continue;
|
||||
const simple = annotationSimpleName(nameText);
|
||||
|
||||
if (simple === 'Tolerate') {
|
||||
result.tolerate = true;
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Accessors') {
|
||||
result.accessors = parseAccessorsAnnotation(child);
|
||||
result.accessorsPresent = true;
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Data') {
|
||||
result.getter ??= { enabled: true, visibility: 'public' };
|
||||
result.setter ??= { enabled: true, visibility: 'public' };
|
||||
continue;
|
||||
}
|
||||
if (simple === 'Getter' || simple === 'Setter') {
|
||||
const level = child.type === 'annotation' ? findAccessLevelInAnnotation(child) : null;
|
||||
const cfg: AccessorConfig =
|
||||
level === 'none'
|
||||
? { enabled: false, visibility: 'public' }
|
||||
: { enabled: true, visibility: level ?? 'public' };
|
||||
if (simple === 'Getter') result.getter = cfg;
|
||||
else result.setter = cfg;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeAccessors(
|
||||
classOpts: AccessorsOptions,
|
||||
fieldOpts: AccessorsOptions,
|
||||
fieldAccessorsPresent: boolean,
|
||||
): AccessorsOptions {
|
||||
return fieldAccessorsPresent ? fieldOpts : classOpts;
|
||||
}
|
||||
|
||||
function effectiveAccessor(
|
||||
classCfg: AccessorConfig | null,
|
||||
fieldCfg: AccessorConfig | null,
|
||||
): AccessorConfig | null {
|
||||
if (fieldCfg !== null) return fieldCfg;
|
||||
return classCfg;
|
||||
}
|
||||
|
||||
// ── Field / method collection ─────────────────────────────────────────────
|
||||
|
||||
function parseFieldDeclaration(
|
||||
fieldNode: Parser.SyntaxNode,
|
||||
imports: LombokImportIndex,
|
||||
): LombokField[] {
|
||||
const typeNode = fieldNode.childForFieldName('type');
|
||||
const fieldType = typeNode?.text ?? 'Object';
|
||||
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
const fieldAnn = parseModifierAnnotations(modifiers, imports);
|
||||
|
||||
const declarators: Parser.SyntaxNode[] = [];
|
||||
const declaratorField = fieldNode.childForFieldName('declarator');
|
||||
if (declaratorField) declarators.push(declaratorField);
|
||||
for (const child of fieldNode.children) {
|
||||
if (child.type === 'variable_declarator' && child !== declaratorField) {
|
||||
declarators.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const startLine = fieldNode.startPosition.row + 1;
|
||||
const endLine = fieldNode.endPosition.row + 1;
|
||||
const out: LombokField[] = [];
|
||||
for (const declaratorNode of declarators) {
|
||||
const nameNode = declaratorNode.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
out.push({
|
||||
name: nameNode.text,
|
||||
type: fieldType,
|
||||
isStatic,
|
||||
isFinal,
|
||||
startLine,
|
||||
endLine,
|
||||
declaratorNode,
|
||||
fieldGetter: fieldAnn.getter,
|
||||
fieldSetter: fieldAnn.setter,
|
||||
accessors: fieldAnn.accessors,
|
||||
accessorsPresent: fieldAnn.accessorsPresent,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function methodArityRange(methodNode: Parser.SyntaxNode): { min: number; max: number } {
|
||||
const params = methodNode.childForFieldName('parameters');
|
||||
if (!params) return { min: 0, max: 0 };
|
||||
let count = 0;
|
||||
for (const child of params.namedChildren) {
|
||||
if (child.type === 'spread_parameter') return { min: count, max: Number.POSITIVE_INFINITY };
|
||||
if (child.type === 'formal_parameter') count += 1;
|
||||
}
|
||||
return { min: count, max: count };
|
||||
}
|
||||
|
||||
function collectExistingMethods(
|
||||
classBody: Parser.SyntaxNode | null,
|
||||
imports: LombokImportIndex,
|
||||
): ExistingMethodIndex {
|
||||
const index = createExistingMethodIndex('case-folded');
|
||||
if (!classBody) return index;
|
||||
const scan = (container: Parser.SyntaxNode): void => {
|
||||
for (const child of container.children) {
|
||||
if (child.type === 'enum_body_declarations') {
|
||||
scan(child);
|
||||
continue;
|
||||
}
|
||||
if (child.type !== 'method_declaration') continue;
|
||||
const mods = child.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
const ann = parseModifierAnnotations(mods, imports);
|
||||
if (ann.tolerate) continue;
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const arity = methodArityRange(child);
|
||||
rememberExistingMethodRange(index, nameNode.text, arity.min, arity.max);
|
||||
}
|
||||
};
|
||||
scan(classBody);
|
||||
return index;
|
||||
}
|
||||
|
||||
const TYPE_BODIES = new Set(['class_body', 'enum_body']);
|
||||
|
||||
function findTypeBody(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
return node.children.find((c) => TYPE_BODIES.has(c.type)) ?? null;
|
||||
}
|
||||
|
||||
function findLombokClasses(root: Parser.SyntaxNode, imports: LombokImportIndex): LombokClass[] {
|
||||
const classes: LombokClass[] = [];
|
||||
|
||||
function walk(node: Parser.SyntaxNode): void {
|
||||
if (node.type === 'class_declaration' || node.type === 'enum_declaration') {
|
||||
const modifiers = node.children.find((c) => c.type === 'modifiers') ?? null;
|
||||
const classAnn = parseModifierAnnotations(modifiers, imports);
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const className = nameNode?.text ?? '';
|
||||
if (className) {
|
||||
const body = findTypeBody(node);
|
||||
const fields: LombokField[] = [];
|
||||
if (body) {
|
||||
const collectFields = (container: Parser.SyntaxNode): void => {
|
||||
for (const child of container.children) {
|
||||
if (child.type === 'field_declaration') {
|
||||
for (const f of parseFieldDeclaration(child, imports)) {
|
||||
if (f.isStatic) continue;
|
||||
fields.push(f);
|
||||
}
|
||||
} else if (child.type === 'enum_body_declarations') {
|
||||
collectFields(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectFields(body);
|
||||
}
|
||||
|
||||
const anyFieldEnable = fields.some(
|
||||
(f) => f.fieldGetter?.enabled === true || f.fieldSetter?.enabled === true,
|
||||
);
|
||||
const classEnable = classAnn.getter?.enabled === true || classAnn.setter?.enabled === true;
|
||||
|
||||
// Class-level NONE alone is not enable — getter/setter configs may be disabled
|
||||
if (classEnable || anyFieldEnable) {
|
||||
classes.push({
|
||||
node,
|
||||
name: className,
|
||||
classGetter: classAnn.getter,
|
||||
classSetter: classAnn.setter,
|
||||
classAccessors: classAnn.accessors,
|
||||
fields,
|
||||
existingMethods: collectExistingMethods(body, imports),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return classes;
|
||||
}
|
||||
|
||||
function planAccessors(cls: LombokClass): PlannedLombokAccessor[] {
|
||||
const planned: PlannedLombokAccessor[] = [];
|
||||
for (const field of cls.fields) {
|
||||
const accessors = mergeAccessors(cls.classAccessors, field.accessors, field.accessorsPresent);
|
||||
// fluent/prefix change names — omit rather than invent wrong names
|
||||
if (accessors.fluent || accessors.hasPrefix) continue;
|
||||
|
||||
const getterCfg = effectiveAccessor(cls.classGetter, field.fieldGetter);
|
||||
const setterCfg = effectiveAccessor(cls.classSetter, field.fieldSetter);
|
||||
|
||||
if (getterCfg?.enabled) {
|
||||
const gName = getterName(field.name, field.type);
|
||||
if (!hasExistingMethod(cls.existingMethods, gName, 0)) {
|
||||
planned.push({
|
||||
kind: 'getter',
|
||||
name: gName,
|
||||
returnType: field.type,
|
||||
parameterTypes: [],
|
||||
visibility: getterCfg.visibility,
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
startLine: field.startLine,
|
||||
endLine: field.endLine,
|
||||
declaratorNode: field.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (setterCfg?.enabled && !field.isFinal) {
|
||||
const sName = setterName(field.name, field.type);
|
||||
if (!hasExistingMethod(cls.existingMethods, sName, 1)) {
|
||||
// chain=true → setter returns declaring type; never emit void in that case
|
||||
const returnType = accessors.chain ? cls.name : 'void';
|
||||
planned.push({
|
||||
kind: 'setter',
|
||||
name: sName,
|
||||
returnType,
|
||||
parameterTypes: [field.type],
|
||||
visibility: setterCfg.visibility,
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
startLine: field.startLine,
|
||||
endLine: field.endLine,
|
||||
declaratorNode: field.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function planLombokAccessorOwners(root: Parser.SyntaxNode): PlannedJvmAccessorOwner[] {
|
||||
const imports = collectLombokImports(root);
|
||||
return findLombokClasses(root, imports).map((cls) => ({
|
||||
node: cls.node,
|
||||
name: cls.name,
|
||||
accessors: planAccessors(cls),
|
||||
}));
|
||||
}
|
||||
|
||||
const lombokAccessorSynthesis = createJvmAccessorSynthesis({
|
||||
language: 'java',
|
||||
synthetic: 'lombok',
|
||||
planOwners: planLombokAccessorOwners,
|
||||
});
|
||||
|
||||
// ── Main API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function synthesizeLombokAccessors(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): LombokSynthesisResult {
|
||||
return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById);
|
||||
}
|
||||
|
||||
/** Scope captures for Lombok accessors (dual-path parity with record components). */
|
||||
export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] {
|
||||
return lombokAccessorSynthesis.captures(rootNode);
|
||||
}
|
||||
316
gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts
Normal file
316
gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Shared planning orchestration and emission for synthetic JVM accessors.
|
||||
*
|
||||
* Language adapters discover accessor plans. This module owns method-collision
|
||||
* policy, graph emission, and scope captures without naming any language.
|
||||
*/
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { toZeroBasedLine } from '../../utils/line-base.js';
|
||||
|
||||
export type SyntheticVisibility = 'public' | 'protected' | 'private' | 'package';
|
||||
export type MethodNameMatching = 'exact' | 'case-folded';
|
||||
|
||||
export interface ExistingMethodIndex {
|
||||
readonly matching: MethodNameMatching;
|
||||
readonly aritiesByName: Map<string, Set<number>>;
|
||||
readonly arityRangesByName: Map<string, Array<{ min: number; max: number }>>;
|
||||
}
|
||||
|
||||
export function createExistingMethodIndex(matching: MethodNameMatching): ExistingMethodIndex {
|
||||
return { matching, aritiesByName: new Map(), arityRangesByName: new Map() };
|
||||
}
|
||||
|
||||
function methodKey(index: ExistingMethodIndex, name: string): string {
|
||||
return index.matching === 'case-folded' ? name.toLowerCase() : name;
|
||||
}
|
||||
|
||||
export function rememberExistingMethod(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
arity: number,
|
||||
): void {
|
||||
const key = methodKey(index, name);
|
||||
let arities = index.aritiesByName.get(key);
|
||||
if (!arities) {
|
||||
arities = new Set();
|
||||
index.aritiesByName.set(key, arities);
|
||||
}
|
||||
arities.add(arity);
|
||||
}
|
||||
|
||||
export function rememberExistingMethodRange(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): void {
|
||||
if (min === max) {
|
||||
rememberExistingMethod(index, name, min);
|
||||
return;
|
||||
}
|
||||
const key = methodKey(index, name);
|
||||
const ranges = index.arityRangesByName.get(key) ?? [];
|
||||
ranges.push({ min, max });
|
||||
index.arityRangesByName.set(key, ranges);
|
||||
}
|
||||
|
||||
export function hasExistingMethod(
|
||||
index: ExistingMethodIndex,
|
||||
name: string,
|
||||
arity: number,
|
||||
): boolean {
|
||||
const key = methodKey(index, name);
|
||||
if (index.aritiesByName.get(key)?.has(arity) === true) return true;
|
||||
return (
|
||||
index.arityRangesByName.get(key)?.some((range) => range.min <= arity && arity <= range.max) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorSymbol {
|
||||
filePath: string;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
type: 'Method';
|
||||
ownerId: string;
|
||||
qualifiedName: string;
|
||||
parameterCount: number;
|
||||
requiredParameterCount: number;
|
||||
parameterTypes: string[];
|
||||
returnType: string;
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
isAbstract: boolean;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorNode {
|
||||
id: string;
|
||||
label: 'Method';
|
||||
properties: {
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
language: string;
|
||||
isExported: boolean;
|
||||
synthetic: string;
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
returnType: string;
|
||||
parameterTypes: string[];
|
||||
parameterCount: number;
|
||||
qualifiedName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorRelationship {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: 'HAS_METHOD';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SyntheticAccessorResult {
|
||||
symbols: SyntheticAccessorSymbol[];
|
||||
nodes: SyntheticAccessorNode[];
|
||||
relationships: SyntheticAccessorRelationship[];
|
||||
}
|
||||
|
||||
export interface PlannedJvmAccessor {
|
||||
kind: 'getter' | 'setter';
|
||||
name: string;
|
||||
returnType: string;
|
||||
parameterTypes: string[];
|
||||
visibility: SyntheticVisibility;
|
||||
isStatic: boolean;
|
||||
isAbstract: boolean;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
}
|
||||
|
||||
export interface PlannedJvmAccessorOwner {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
accessors: readonly PlannedJvmAccessor[];
|
||||
}
|
||||
|
||||
interface JvmAccessorSynthesisConfig {
|
||||
language: string;
|
||||
synthetic: string;
|
||||
planOwners(rootNode: Parser.SyntaxNode): readonly PlannedJvmAccessorOwner[];
|
||||
}
|
||||
|
||||
export interface JvmAccessorSynthesis {
|
||||
synthesize(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): SyntheticAccessorResult;
|
||||
captures(rootNode: Parser.SyntaxNode): CaptureMatch[];
|
||||
}
|
||||
|
||||
export function createJvmAccessorSynthesis(
|
||||
config: JvmAccessorSynthesisConfig,
|
||||
): JvmAccessorSynthesis {
|
||||
return {
|
||||
synthesize(tree, filePath, classOwnersById) {
|
||||
const result = emptySyntheticAccessorResult();
|
||||
for (const owner of config.planOwners(tree.rootNode)) {
|
||||
const ownerId = classOwnersById.get(owner.node.id);
|
||||
if (!ownerId) continue;
|
||||
emitPlannedAccessors({
|
||||
planned: owner.accessors,
|
||||
filePath,
|
||||
ownerId,
|
||||
idPrefix: ownerIdNamePrefix(ownerId, filePath, owner.name),
|
||||
language: config.language,
|
||||
synthetic: config.synthetic,
|
||||
result,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
captures(rootNode) {
|
||||
return capturesForPlannedAccessors(config.planOwners(rootNode));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptySyntheticAccessorResult(): SyntheticAccessorResult {
|
||||
return { symbols: [], nodes: [], relationships: [] };
|
||||
}
|
||||
|
||||
function ownerIdNamePrefix(ownerId: string, filePath: string, fallback: string): string {
|
||||
const needle = `Class:${filePath}:`;
|
||||
if (ownerId.startsWith(needle)) return ownerId.slice(needle.length);
|
||||
const enumNeedle = `Enum:${filePath}:`;
|
||||
if (ownerId.startsWith(enumNeedle)) return ownerId.slice(enumNeedle.length);
|
||||
const ifaceNeedle = `Interface:${filePath}:`;
|
||||
if (ownerId.startsWith(ifaceNeedle)) return ownerId.slice(ifaceNeedle.length);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function jvmTypeSimpleName(node: Parser.SyntaxNode): string | undefined {
|
||||
const named = node.childForFieldName('name')?.text;
|
||||
if (named) return named;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'type_identifier' || child.type === 'simple_identifier') return child.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emitPlannedAccessors(args: {
|
||||
planned: readonly PlannedJvmAccessor[];
|
||||
filePath: string;
|
||||
ownerId: string;
|
||||
idPrefix: string;
|
||||
language: string;
|
||||
synthetic: string;
|
||||
result: SyntheticAccessorResult;
|
||||
}): void {
|
||||
const emittedIds = new Set<string>();
|
||||
for (const acc of args.planned) {
|
||||
const arity = acc.parameterTypes.length;
|
||||
const qualifiedName = `${args.idPrefix}.${acc.name}`;
|
||||
const nodeId = `Method:${args.filePath}:${qualifiedName}#${arity}`;
|
||||
if (emittedIds.has(nodeId)) continue;
|
||||
emittedIds.add(nodeId);
|
||||
args.result.nodes.push({
|
||||
id: nodeId,
|
||||
label: 'Method',
|
||||
properties: {
|
||||
name: acc.name,
|
||||
filePath: args.filePath,
|
||||
startLine: toZeroBasedLine(acc.startLine),
|
||||
endLine: toZeroBasedLine(acc.endLine),
|
||||
language: args.language,
|
||||
isExported: false,
|
||||
synthetic: args.synthetic,
|
||||
visibility: acc.visibility,
|
||||
isStatic: acc.isStatic,
|
||||
returnType: acc.returnType,
|
||||
parameterTypes: acc.parameterTypes,
|
||||
parameterCount: arity,
|
||||
qualifiedName,
|
||||
},
|
||||
});
|
||||
args.result.symbols.push({
|
||||
filePath: args.filePath,
|
||||
name: acc.name,
|
||||
nodeId,
|
||||
type: 'Method',
|
||||
ownerId: args.ownerId,
|
||||
qualifiedName,
|
||||
parameterCount: arity,
|
||||
requiredParameterCount: arity,
|
||||
parameterTypes: acc.parameterTypes,
|
||||
returnType: acc.returnType,
|
||||
visibility: acc.visibility,
|
||||
isStatic: acc.isStatic,
|
||||
isAbstract: acc.isAbstract,
|
||||
isFinal: false,
|
||||
});
|
||||
args.result.relationships.push({
|
||||
id: `HAS_METHOD:${args.ownerId}->${nodeId}`,
|
||||
sourceId: args.ownerId,
|
||||
targetId: nodeId,
|
||||
type: 'HAS_METHOD',
|
||||
confidence: 1.0,
|
||||
reason: acc.kind === 'getter' ? `${args.synthetic}-getter` : `${args.synthetic}-setter`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function accessorCapture(name: string, acc: PlannedJvmAccessor, text: string): Capture {
|
||||
const node = acc.declaratorNode;
|
||||
const startLine = node.startPosition.row + 1;
|
||||
const startCol = node.startPosition.column;
|
||||
const endLine = node.endPosition.row + 1;
|
||||
const endCol = acc.kind === 'getter' ? node.endPosition.column : startCol;
|
||||
return { name, range: { startLine, startCol, endLine, endCol }, text };
|
||||
}
|
||||
|
||||
function capturesForPlannedAccessors(owners: readonly PlannedJvmAccessorOwner[]): CaptureMatch[] {
|
||||
const captures: CaptureMatch[] = [];
|
||||
for (const owner of owners) {
|
||||
const enclosing = owner.name;
|
||||
const emitted = new Set<string>();
|
||||
for (const acc of owner.accessors) {
|
||||
const arity = String(acc.parameterTypes.length);
|
||||
const qualifiedName = `${enclosing}.${acc.name}`;
|
||||
const identity = `${qualifiedName}#${arity}`;
|
||||
if (emitted.has(identity)) continue;
|
||||
emitted.add(identity);
|
||||
captures.push({
|
||||
'@scope.function': accessorCapture('@scope.function', acc, acc.name),
|
||||
});
|
||||
captures.push({
|
||||
'@declaration.method': accessorCapture('@declaration.method', acc, acc.name),
|
||||
'@declaration.name': accessorCapture('@declaration.name', acc, acc.name),
|
||||
'@declaration.qualified_name': accessorCapture(
|
||||
'@declaration.qualified_name',
|
||||
acc,
|
||||
qualifiedName,
|
||||
),
|
||||
'@declaration.parameter-count': accessorCapture('@declaration.parameter-count', acc, arity),
|
||||
'@declaration.required-parameter-count': accessorCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
acc,
|
||||
arity,
|
||||
),
|
||||
'@declaration.return-type': accessorCapture(
|
||||
'@declaration.return-type',
|
||||
acc,
|
||||
acc.returnType,
|
||||
),
|
||||
'@declaration.is-synthetic': accessorCapture('@declaration.is-synthetic', acc, 'true'),
|
||||
});
|
||||
}
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
49
gitnexus/src/core/ingestion/languages/jvm/beanspec.ts
Normal file
49
gitnexus/src/core/ingestion/languages/jvm/beanspec.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Language-neutral JVM JavaBeans naming primitives.
|
||||
*
|
||||
* Language adapters choose whether to invent/preserve an `is` prefix and
|
||||
* which single-character capitalization policy their compiler uses.
|
||||
*/
|
||||
|
||||
export function capitalizeBeanName(s: string): string {
|
||||
if (s.length === 0) return s;
|
||||
const first = s.charAt(0);
|
||||
const upper = first.toUpperCase();
|
||||
// Java Character case conversion is one UTF-16 code unit. JavaScript
|
||||
// full-case conversion may expand one unit (`ß` → `SS`), which would invent
|
||||
// a method name no JVM compiler emits.
|
||||
return (upper.length === 1 ? upper : first) + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitive-boolean / Kotlin `is`-prefix fields whose name already starts with
|
||||
* `is` plus a non-lowercase character keep that name for the getter and drop
|
||||
* the `is` prefix for the setter base (`isEnabled` → `isEnabled()` /
|
||||
* `setEnabled(...)`, `is1` → `is1()` / `set1(...)`). Digits and punctuation
|
||||
* count as non-lowercase, matching Lombok `!Character.isLowerCase` and kotlinc.
|
||||
*/
|
||||
export function booleanIsPrefixBase(fieldName: string, useIsPrefix: boolean): string | null {
|
||||
if (!useIsPrefix || !fieldName.startsWith('is') || fieldName.length < 3) return null;
|
||||
const third = fieldName.charAt(2);
|
||||
return third === third.toUpperCase() ? fieldName.slice(2) : null;
|
||||
}
|
||||
|
||||
export function jvmGetterName(
|
||||
fieldName: string,
|
||||
useIsPrefix: boolean,
|
||||
capitalize: (name: string) => string = capitalizeBeanName,
|
||||
): string {
|
||||
if (booleanIsPrefixBase(fieldName, useIsPrefix) !== null) return fieldName;
|
||||
if (useIsPrefix) return `is${capitalize(fieldName)}`;
|
||||
return `get${capitalize(fieldName)}`;
|
||||
}
|
||||
|
||||
export function jvmSetterName(
|
||||
fieldName: string,
|
||||
useIsPrefix: boolean,
|
||||
capitalize: (name: string) => string = capitalizeBeanName,
|
||||
): string {
|
||||
const stripped = booleanIsPrefixBase(fieldName, useIsPrefix);
|
||||
if (stripped !== null) return `set${stripped}`;
|
||||
return `set${capitalize(fieldName)}`;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import {
|
|||
kotlinMergeBindings,
|
||||
kotlinReceiverBinding,
|
||||
} from './kotlin/index.js';
|
||||
import { synthesizeLombokAccessors } from './kotlin/lombok-synthesizer.js';
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
|
|
@ -202,4 +203,5 @@ export const kotlinProvider = defineLanguage({
|
|||
mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings),
|
||||
receiverBinding: kotlinReceiverBinding,
|
||||
arityCompatibility: kotlinArityCompatibility,
|
||||
synthesizeStructureMembers: synthesizeLombokAccessors,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from './capture-side-channel.js';
|
||||
import { captureKotlinPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
|
||||
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
|
||||
import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js';
|
||||
import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
|
||||
|
|
@ -371,6 +372,7 @@ export function emitKotlinScopeCaptures(
|
|||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||
setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
|
||||
setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
||||
out.push(...synthesizeLombokAccessorCaptures(tree.rootNode));
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,512 @@
|
|||
/**
|
||||
* Kotlin accessor synthesizer (same provider-hook role as Java Lombok).
|
||||
*
|
||||
* kotlinc emits JavaBeans getters/setters for `val`/`var` properties. Those
|
||||
* methods are absent from the tree-sitter AST, so Java (and Kotlin) calls
|
||||
* like `user.getName()` miss CALLS edges. Planning is Kotlin-specific;
|
||||
* naming and Method emission share `jvm/beanspec` + `jvm/accessor-synthesis`.
|
||||
*
|
||||
* ## Supported subset (v1)
|
||||
* - Class / data class / object / companion / interface `val`/`var` properties
|
||||
* (interface accessors without a custom body are abstract JVM methods).
|
||||
* - Primary-constructor `val`/`var` class parameters.
|
||||
* - Names beginning with `is` + a non-lowercase character keep that getter name; all other
|
||||
* properties, including `Boolean`, use `get`.
|
||||
* - Custom `get()`/`set()` bodies still emit their JVM accessor Methods.
|
||||
* - Explicit `fun getX` / `@JvmField` / `const` skip synthesis.
|
||||
* - `@JvmName`-renamed accessors are suppressed until custom-name emission lands.
|
||||
* Unsupported: `@JvmStatic` renaming, file-facade top-level properties.
|
||||
*/
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { booleanIsPrefixBase, jvmGetterName, jvmSetterName } from '../jvm/beanspec.js';
|
||||
import {
|
||||
createExistingMethodIndex,
|
||||
createJvmAccessorSynthesis,
|
||||
hasExistingMethod,
|
||||
jvmTypeSimpleName,
|
||||
rememberExistingMethod,
|
||||
type ExistingMethodIndex,
|
||||
type PlannedJvmAccessor,
|
||||
type PlannedJvmAccessorOwner,
|
||||
type SyntheticAccessorResult,
|
||||
type SyntheticVisibility,
|
||||
} from '../jvm/accessor-synthesis.js';
|
||||
|
||||
const KOTLIN_TYPE_DECLS = new Set(['class_declaration', 'object_declaration', 'companion_object']);
|
||||
|
||||
function capitalizeAscii(name: string): string {
|
||||
const first = name.charAt(0);
|
||||
return first >= 'a' && first <= 'z'
|
||||
? String.fromCharCode(first.charCodeAt(0) - 32) + name.slice(1)
|
||||
: name;
|
||||
}
|
||||
|
||||
export function kotlinGetterName(propertyName: string): string {
|
||||
return jvmGetterName(
|
||||
propertyName,
|
||||
booleanIsPrefixBase(propertyName, true) !== null,
|
||||
capitalizeAscii,
|
||||
);
|
||||
}
|
||||
|
||||
export function kotlinSetterName(propertyName: string): string {
|
||||
return jvmSetterName(propertyName, true, capitalizeAscii);
|
||||
}
|
||||
|
||||
interface KtProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
isVar: boolean;
|
||||
skipGetter: boolean;
|
||||
skipSetter: boolean;
|
||||
getterVisibility: SyntheticVisibility;
|
||||
setterVisibility: SyntheticVisibility;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
propertyNode: Parser.SyntaxNode;
|
||||
declaratorNode: Parser.SyntaxNode;
|
||||
}
|
||||
|
||||
interface KtClass {
|
||||
node: Parser.SyntaxNode;
|
||||
name: string;
|
||||
isStatic: boolean;
|
||||
isInterface: boolean;
|
||||
wasHoisted: boolean;
|
||||
properties: KtProperty[];
|
||||
existingMethods: ExistingMethodIndex;
|
||||
}
|
||||
|
||||
interface KotlinImportIndex {
|
||||
byLocalName: Map<string, string>;
|
||||
shadowedSimpleNames: Set<string>;
|
||||
}
|
||||
|
||||
function collectKotlinImports(root: Parser.SyntaxNode): KotlinImportIndex {
|
||||
const byLocalName = new Map<string, string>();
|
||||
const shadowedSimpleNames = new Set<string>();
|
||||
for (const child of root.children) {
|
||||
if (child.type !== 'class_declaration') continue;
|
||||
const name = jvmTypeSimpleName(child);
|
||||
if (name) shadowedSimpleNames.add(name);
|
||||
}
|
||||
const importList = root.children.find((child) => child.type === 'import_list');
|
||||
for (const child of importList?.children ?? []) {
|
||||
if (child.type !== 'import_header') continue;
|
||||
const text = child.text
|
||||
.replace(/^import\s+/, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
const [pathText, aliasText] = text.split(/\s+as\s+/, 2);
|
||||
const importPath = pathText?.replace(/\s+/g, '');
|
||||
if (!importPath || importPath.endsWith('.*')) continue;
|
||||
const localName = aliasText?.trim() || importPath.split('.').pop();
|
||||
if (localName) byLocalName.set(localName, importPath);
|
||||
}
|
||||
return { byLocalName, shadowedSimpleNames };
|
||||
}
|
||||
|
||||
function annotationUserTypeText(annotation: Parser.SyntaxNode): string {
|
||||
const constructor = annotation.namedChildren.find((c) => c.type === 'constructor_invocation');
|
||||
const userType =
|
||||
constructor?.namedChildren.find((c) => c.type === 'user_type') ??
|
||||
annotation.namedChildren.find((c) => c.type === 'user_type');
|
||||
return userType?.text ?? '';
|
||||
}
|
||||
|
||||
function isKotlinJvmAnnotation(
|
||||
annotation: Parser.SyntaxNode,
|
||||
name: string,
|
||||
imports: KotlinImportIndex,
|
||||
): boolean {
|
||||
const typeText = annotationUserTypeText(annotation);
|
||||
const canonical = `kotlin.jvm.${name}`;
|
||||
if (typeText.includes('.')) return typeText === canonical;
|
||||
const imported = imports.byLocalName.get(typeText);
|
||||
if (imported !== undefined) return imported === canonical;
|
||||
if (imports.shadowedSimpleNames.has(typeText)) return false;
|
||||
return typeText === name;
|
||||
}
|
||||
|
||||
function kotlinVisibility(modifiers: Parser.SyntaxNode | undefined): SyntheticVisibility {
|
||||
if (!modifiers) return 'public';
|
||||
for (const child of modifiers.namedChildren) {
|
||||
if (child.type !== 'visibility_modifier') continue;
|
||||
if (child.text === 'private') return 'private';
|
||||
if (child.text === 'protected') return 'protected';
|
||||
if (child.text === 'internal') return 'package';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function hasJvmField(node: Parser.SyntaxNode, imports: KotlinImportIndex): boolean {
|
||||
const mods = node.children.find((c) => c.type === 'modifiers');
|
||||
return (
|
||||
mods?.namedChildren.some(
|
||||
(child) => child.type === 'annotation' && isKotlinJvmAnnotation(child, 'JvmField', imports),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
function hasConst(node: Parser.SyntaxNode): boolean {
|
||||
const mods = node.children.find((c) => c.type === 'modifiers');
|
||||
if (
|
||||
mods?.namedChildren.some(
|
||||
(child) => child.type === 'property_modifier' && child.text === 'const',
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return node.namedChildren.some((child) => child.type === 'const');
|
||||
}
|
||||
|
||||
function isVarBinding(node: Parser.SyntaxNode): boolean | null {
|
||||
const kind = node.children.find((c) => c.type === 'binding_pattern_kind');
|
||||
const text = kind?.text;
|
||||
if (text === 'var') return true;
|
||||
if (text === 'val') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferredInitializerType(node: Parser.SyntaxNode): string | undefined {
|
||||
switch (node.type) {
|
||||
case 'string_literal':
|
||||
case 'line_string_literal':
|
||||
case 'multi_line_string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'Char';
|
||||
case 'boolean_literal':
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'Boolean';
|
||||
case 'long_literal':
|
||||
return 'Long';
|
||||
case 'unsigned_literal':
|
||||
return /l$/i.test(node.text) ? 'ULong' : 'UInt';
|
||||
case 'integer_literal':
|
||||
case 'decimal_integer_literal':
|
||||
case 'hex_integer_literal':
|
||||
case 'octal_integer_literal':
|
||||
case 'binary_integer_literal':
|
||||
return 'Int';
|
||||
case 'real_literal':
|
||||
case 'decimal_floating_point_literal':
|
||||
return /f$/i.test(node.text) ? 'Float' : 'Double';
|
||||
case 'prefix_expression': {
|
||||
const operand = node.namedChildren.at(-1);
|
||||
return operand ? inferredInitializerType(operand) : undefined;
|
||||
}
|
||||
case 'call_expression': {
|
||||
const callee = node.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (!callee) return undefined;
|
||||
const first = callee.text.charAt(0);
|
||||
return first !== '' && first === first.toUpperCase() ? callee.text : undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function propertyTypeText(node: Parser.SyntaxNode): string {
|
||||
const declarator =
|
||||
node.type === 'class_parameter'
|
||||
? node
|
||||
: (node.children.find((c) => c.type === 'variable_declaration') ?? node);
|
||||
const colon = declarator.children.find((c) => c.type === ':');
|
||||
let typeNode = colon?.nextNamedSibling ?? null;
|
||||
while (typeNode?.type === 'type_modifiers') typeNode = typeNode.nextNamedSibling;
|
||||
if (typeNode) return typeNode.text;
|
||||
const initializer = node.namedChildren.find(
|
||||
(child) =>
|
||||
child.id !== declarator.id &&
|
||||
child.type !== 'binding_pattern_kind' &&
|
||||
child.type !== 'modifiers',
|
||||
);
|
||||
return initializer ? (inferredInitializerType(initializer) ?? 'unknown') : 'unknown';
|
||||
}
|
||||
|
||||
function propertyNameNode(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
if (node.type === 'class_parameter') {
|
||||
return node.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
const decl = node.children.find((c) => c.type === 'variable_declaration');
|
||||
if (decl) {
|
||||
return decl.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
return node.children.find((c) => c.type === 'simple_identifier') ?? null;
|
||||
}
|
||||
|
||||
function accessorMetadata(
|
||||
prop: Parser.SyntaxNode,
|
||||
propertyVisibility: SyntheticVisibility,
|
||||
imports: KotlinImportIndex,
|
||||
): {
|
||||
getterVisibility: SyntheticVisibility;
|
||||
setterVisibility: SyntheticVisibility;
|
||||
skipGetter: boolean;
|
||||
skipSetter: boolean;
|
||||
} {
|
||||
let getter = propertyVisibility;
|
||||
let setter = propertyVisibility;
|
||||
let skipGetter = false;
|
||||
let skipSetter = false;
|
||||
const propertyModifiers = prop.children.find((c) => c.type === 'modifiers');
|
||||
for (const annotation of propertyModifiers?.namedChildren ?? []) {
|
||||
if (
|
||||
annotation.type !== 'annotation' ||
|
||||
!isKotlinJvmAnnotation(annotation, 'JvmName', imports)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = annotation.children.find((c) => c.type === 'use_site_target')?.text;
|
||||
if (target === 'get:') skipGetter = true;
|
||||
if (target === 'set:') skipSetter = true;
|
||||
}
|
||||
const apply = (node: Parser.SyntaxNode): void => {
|
||||
const modifiers = node.children.find((c) => c.type === 'modifiers');
|
||||
if (!modifiers) return;
|
||||
if (node.type === 'getter') getter = kotlinVisibility(modifiers);
|
||||
if (node.type === 'setter') setter = kotlinVisibility(modifiers);
|
||||
if (
|
||||
modifiers.namedChildren.some((annotation) =>
|
||||
isKotlinJvmAnnotation(annotation, 'JvmName', imports),
|
||||
)
|
||||
) {
|
||||
if (node.type === 'getter') skipGetter = true;
|
||||
if (node.type === 'setter') skipSetter = true;
|
||||
}
|
||||
};
|
||||
for (const child of prop.children) {
|
||||
if (child.type === 'getter' || child.type === 'setter') apply(child);
|
||||
}
|
||||
let sib: Parser.SyntaxNode | null = prop.nextNamedSibling;
|
||||
while (sib && (sib.type === 'getter' || sib.type === 'setter')) {
|
||||
apply(sib);
|
||||
sib = sib.nextNamedSibling;
|
||||
}
|
||||
return {
|
||||
getterVisibility: getter,
|
||||
setterVisibility: setter,
|
||||
skipGetter,
|
||||
skipSetter,
|
||||
};
|
||||
}
|
||||
|
||||
function hasKotlinAccessorBody(prop: Parser.SyntaxNode, kind: 'getter' | 'setter'): boolean {
|
||||
const hasBody = (node: Parser.SyntaxNode): boolean =>
|
||||
node.type === kind && node.children.some((child) => child.type === 'function_body');
|
||||
if (prop.children.some(hasBody)) return true;
|
||||
let sib: Parser.SyntaxNode | null = prop.nextNamedSibling;
|
||||
while (sib && (sib.type === 'getter' || sib.type === 'setter')) {
|
||||
if (hasBody(sib)) return true;
|
||||
sib = sib.nextNamedSibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function functionName(node: Parser.SyntaxNode): string | undefined {
|
||||
return node.children.find((c) => c.type === 'simple_identifier')?.text;
|
||||
}
|
||||
|
||||
function functionArity(node: Parser.SyntaxNode): number {
|
||||
const params = node.children.find((c) => c.type === 'function_value_parameters');
|
||||
let arity =
|
||||
node.childForFieldName('receiver') !== null ||
|
||||
node.namedChildren.some((child) => child.type === 'receiver_type')
|
||||
? 1
|
||||
: 0;
|
||||
const modifiers = node.children.find((child) => child.type === 'modifiers');
|
||||
if (
|
||||
modifiers?.namedChildren.some(
|
||||
(child) => child.type === 'function_modifier' && child.text === 'suspend',
|
||||
)
|
||||
) {
|
||||
arity += 1;
|
||||
}
|
||||
for (const child of params?.namedChildren ?? []) {
|
||||
if (child.type === 'parameter' || child.type === 'parameter_with_optional_type') arity += 1;
|
||||
}
|
||||
return arity;
|
||||
}
|
||||
|
||||
function collectExistingMethods(...bodies: Array<Parser.SyntaxNode | null>): ExistingMethodIndex {
|
||||
const index = createExistingMethodIndex('exact');
|
||||
for (const body of bodies) {
|
||||
if (!body) continue;
|
||||
for (const child of body.children) {
|
||||
if (child.type !== 'function_declaration') continue;
|
||||
const name = functionName(child);
|
||||
if (!name) continue;
|
||||
rememberExistingMethod(index, name, functionArity(child));
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function toKtProperty(child: Parser.SyntaxNode, imports: KotlinImportIndex): KtProperty | null {
|
||||
const isVar = isVarBinding(child);
|
||||
if (isVar === null) return null;
|
||||
if (hasJvmField(child, imports) || hasConst(child)) return null;
|
||||
const nameNode = propertyNameNode(child);
|
||||
if (!nameNode) return null;
|
||||
const mods = child.children.find((c) => c.type === 'modifiers');
|
||||
const visibility = kotlinVisibility(mods);
|
||||
const accessor = accessorMetadata(child, visibility, imports);
|
||||
return {
|
||||
name: nameNode.text,
|
||||
type: propertyTypeText(child),
|
||||
isVar,
|
||||
skipGetter: accessor.skipGetter,
|
||||
skipSetter: accessor.skipSetter,
|
||||
getterVisibility: accessor.getterVisibility,
|
||||
setterVisibility: accessor.setterVisibility,
|
||||
startLine: child.startPosition.row + 1,
|
||||
endLine: child.endPosition.row + 1,
|
||||
propertyNode: child,
|
||||
declaratorNode: nameNode,
|
||||
};
|
||||
}
|
||||
|
||||
function collectTypedProperties(
|
||||
parent: Parser.SyntaxNode | null,
|
||||
type: 'class_parameter' | 'property_declaration',
|
||||
imports: KotlinImportIndex,
|
||||
): KtProperty[] {
|
||||
if (!parent) return [];
|
||||
const out: KtProperty[] = [];
|
||||
for (const child of parent.namedChildren) {
|
||||
if (child.type !== type) continue;
|
||||
const prop = toKtProperty(child, imports);
|
||||
if (prop) out.push(prop);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findKtClasses(root: Parser.SyntaxNode, imports: KotlinImportIndex): KtClass[] {
|
||||
const classes: KtClass[] = [];
|
||||
const graphOwnerNode = (node: Parser.SyntaxNode): Parser.SyntaxNode => {
|
||||
if (node.type !== 'companion_object') return node;
|
||||
if (jvmTypeSimpleName(node)) return node;
|
||||
let current = node.parent;
|
||||
while (current && !KOTLIN_TYPE_DECLS.has(current.type)) current = current.parent;
|
||||
return current ?? node;
|
||||
};
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
if (KOTLIN_TYPE_DECLS.has(node.type)) {
|
||||
const ownerNode = graphOwnerNode(node);
|
||||
const name = jvmTypeSimpleName(ownerNode) ?? '';
|
||||
const ctor = node.children.find((c) => c.type === 'primary_constructor') ?? null;
|
||||
const body = node.children.find((c) => c.type === 'class_body') ?? null;
|
||||
if (name) {
|
||||
const properties = [
|
||||
...collectTypedProperties(ctor, 'class_parameter', imports),
|
||||
...collectTypedProperties(body, 'property_declaration', imports),
|
||||
];
|
||||
if (properties.length > 0) {
|
||||
const ownerBody =
|
||||
ownerNode.id === node.id
|
||||
? null
|
||||
: (ownerNode.children.find((child) => child.type === 'class_body') ?? null);
|
||||
classes.push({
|
||||
node: ownerNode,
|
||||
name,
|
||||
isStatic: node.type === 'companion_object',
|
||||
isInterface: node.children.some((child) => child.type === 'interface'),
|
||||
wasHoisted: ownerNode.id !== node.id,
|
||||
properties,
|
||||
existingMethods: collectExistingMethods(body, ownerBody),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (body) {
|
||||
for (const child of body.namedChildren) {
|
||||
if (KOTLIN_TYPE_DECLS.has(child.type)) walk(child);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of node.namedChildren) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return classes;
|
||||
}
|
||||
|
||||
function planAccessors(cls: KtClass): PlannedJvmAccessor[] {
|
||||
const planned: PlannedJvmAccessor[] = [];
|
||||
for (const prop of cls.properties) {
|
||||
const gName = kotlinGetterName(prop.name);
|
||||
if (!prop.skipGetter && !hasExistingMethod(cls.existingMethods, gName, 0)) {
|
||||
planned.push({
|
||||
kind: 'getter',
|
||||
name: gName,
|
||||
returnType: prop.type,
|
||||
parameterTypes: [],
|
||||
visibility: prop.getterVisibility,
|
||||
isStatic: cls.isStatic,
|
||||
isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'getter'),
|
||||
startLine: prop.startLine,
|
||||
endLine: prop.endLine,
|
||||
declaratorNode: prop.declaratorNode,
|
||||
});
|
||||
}
|
||||
if (prop.isVar && !prop.skipSetter) {
|
||||
const sName = kotlinSetterName(prop.name);
|
||||
if (!hasExistingMethod(cls.existingMethods, sName, 1)) {
|
||||
planned.push({
|
||||
kind: 'setter',
|
||||
name: sName,
|
||||
returnType: 'void',
|
||||
parameterTypes: [prop.type],
|
||||
visibility: prop.setterVisibility,
|
||||
isStatic: cls.isStatic,
|
||||
isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'setter'),
|
||||
startLine: prop.startLine,
|
||||
endLine: prop.endLine,
|
||||
declaratorNode: prop.declaratorNode,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function planKotlinAccessorOwners(rootNode: Parser.SyntaxNode): PlannedJvmAccessorOwner[] {
|
||||
const owners: PlannedJvmAccessorOwner[] = [];
|
||||
const imports = collectKotlinImports(rootNode);
|
||||
for (const cls of findKtClasses(rootNode, imports)) {
|
||||
const accessors = planAccessors(cls);
|
||||
const existingIndex = cls.wasHoisted
|
||||
? owners.findIndex((owner) => owner.node.id === cls.node.id)
|
||||
: -1;
|
||||
const existing = existingIndex >= 0 ? owners[existingIndex] : undefined;
|
||||
if (existing) {
|
||||
owners[existingIndex] = {
|
||||
...existing,
|
||||
accessors: [...existing.accessors, ...accessors],
|
||||
};
|
||||
} else {
|
||||
owners.push({ node: cls.node, name: cls.name, accessors });
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
const lombokAccessorSynthesis = createJvmAccessorSynthesis({
|
||||
language: 'kotlin',
|
||||
synthetic: 'kotlin-jvm',
|
||||
planOwners: planKotlinAccessorOwners,
|
||||
});
|
||||
|
||||
export function synthesizeLombokAccessors(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
classOwnersById: ReadonlyMap<number, string>,
|
||||
): SyntheticAccessorResult {
|
||||
return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById);
|
||||
}
|
||||
|
||||
export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] {
|
||||
return lombokAccessorSynthesis.captures(rootNode);
|
||||
}
|
||||
|
|
@ -1775,6 +1775,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@scope.lexical-names',
|
||||
'@declaration.name',
|
||||
'@declaration.qualified_name',
|
||||
'@declaration.is-synthetic',
|
||||
'@import.name',
|
||||
'@import.source',
|
||||
'@import.alias',
|
||||
|
|
|
|||
|
|
@ -1578,6 +1578,11 @@ const processFileGroup = (
|
|||
}
|
||||
const provider = getProvider(language);
|
||||
|
||||
// Owner map for provider.synthesizeStructureMembers: type-declaration AST
|
||||
// node id → graph node id for classes THIS file's capture loop materialized.
|
||||
// Keyed by in-memory AST identity (never persisted); filled below.
|
||||
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
|
||||
|
|
@ -2987,6 +2992,17 @@ const processFileGroup = (
|
|||
: {}),
|
||||
});
|
||||
|
||||
// Class-like definitions register their AST node id → graph node id for
|
||||
// provider.synthesizeStructureMembers. The definition node is the same
|
||||
// type-declaration AST node that the provider-specific planner receives.
|
||||
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 =
|
||||
|
|
@ -3092,6 +3108,19 @@ const processFileGroup = (
|
|||
if (springTypes.length > 0) (result.springTypes ??= []).push(...springTypes);
|
||||
}
|
||||
|
||||
if (provider.synthesizeStructureMembers) {
|
||||
const synthetic = provider.synthesizeStructureMembers(tree, file.path, classOwnersByNodeId);
|
||||
for (const node of synthetic.nodes) {
|
||||
result.nodes.push(node as ParsedNode);
|
||||
}
|
||||
for (const sym of synthetic.symbols) {
|
||||
result.symbols.push(sym as ParsedSymbol);
|
||||
}
|
||||
for (const rel of synthetic.relationships) {
|
||||
result.relationships.push(rel as ParsedRelationship);
|
||||
}
|
||||
}
|
||||
|
||||
// Vue: emit CALLS edges for components used in <template>
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
|
|
|
|||
|
|
@ -672,7 +672,20 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// programmatic Spring lookup facts. A warm v81 cache has no such facts, so it
|
||||
// would skip workers and silently omit the new INJECTS edges. origin/main at
|
||||
// allocation is 81.
|
||||
const SCHEMA_BUMP = 82;
|
||||
// 82 -> 83: Java Lombok @Data/@Getter/@Setter accessor synthesis emits
|
||||
// synthetic Method nodes, HAS_METHOD edges, and matching scope captures into
|
||||
// ParseWorkerResult / ParsedFile. A warm v82 cache replays pre-Lombok worker
|
||||
// output and silently omits those callables. origin/main at allocation is 82.
|
||||
// 83 -> 84: Kotlin val/var properties synthesize JVM get/set Method nodes
|
||||
// (same provider hook as Java Lombok). A warm v83 cache omits those callables.
|
||||
// origin/main at allocation is 83 (Java Lombok on this branch).
|
||||
// 84 -> 85: JVM synthetic accessor captures now use the declaration
|
||||
// qualified_name key consumed by scope extraction, and Kotlin accessor planning
|
||||
// follows JvmAbi naming plus conservative @JvmName suppression. A warm v84
|
||||
// cache can replay stale names and declaration metadata.
|
||||
// 85 -> 86: Kotlin interface property accessors now record isAbstract on the
|
||||
// synthetic Method. A warm v85 cache replays them as concrete.
|
||||
const SCHEMA_BUMP = 86;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
258
gitnexus/test/integration/resolvers/java-lombok.test.ts
Normal file
258
gitnexus/test/integration/resolvers/java-lombok.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
121
gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts
Normal file
121
gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Production-path Kotlin JVM accessor synthesis.
|
||||
*/
|
||||
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';
|
||||
|
||||
describe('Kotlin JVM accessor synthesis (pipeline)', () => {
|
||||
it('emits generated and custom getters and resolves same-language calls', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-jvm-acc-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'User.kt': `data class User(val name: String) {
|
||||
val display: String get() = name
|
||||
}
|
||||
fun read(user: User): String = user.getName()
|
||||
fun readDisplay(user: User): String = user.getDisplay()
|
||||
`,
|
||||
});
|
||||
const linked = await runPipelineFromRepo(root, () => {});
|
||||
const getName = getNodesByLabelFull(linked, 'Method').find(
|
||||
(m) => m.name === 'getName' && m.properties.filePath.endsWith('User.kt'),
|
||||
);
|
||||
expect(getName).toBeDefined();
|
||||
expect(getName?.properties).toMatchObject({
|
||||
parameterCount: 0,
|
||||
returnType: 'String',
|
||||
synthetic: 'kotlin-jvm',
|
||||
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 === 'read' && e.target === 'getName',
|
||||
);
|
||||
expect(calls.some((e) => e.targetFilePath.endsWith('User.kt'))).toBe(true);
|
||||
const getDisplay = getNodesByLabelFull(linked, 'Method').find(
|
||||
(m) => m.name === 'getDisplay' && m.properties.filePath.endsWith('User.kt'),
|
||||
);
|
||||
expect(getDisplay?.properties).toMatchObject({
|
||||
returnType: 'String',
|
||||
synthetic: 'kotlin-jvm',
|
||||
qualifiedName: 'User.getDisplay',
|
||||
});
|
||||
const displayCalls = getRelationships(linked, 'CALLS').filter(
|
||||
(e) => e.source === 'readDisplay' && e.target === 'getDisplay',
|
||||
);
|
||||
expect(displayCalls.some((e) => e.targetFilePath.endsWith('User.kt'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('resolves same-named synthetic accessors to the receiver class', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kt-jvm-owner-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'Models.kt': `class Alpha(val name: String)
|
||||
class Beta(val name: String)
|
||||
fun readAlpha(alpha: Alpha): String = alpha.getName()
|
||||
fun readBeta(beta: Beta): String = beta.getName()
|
||||
`,
|
||||
});
|
||||
const linked = await runPipelineFromRepo(root, () => {});
|
||||
const ownerByMethod = new Map(
|
||||
getRelationships(linked, 'HAS_METHOD').map((edge) => [edge.rel.targetId, edge.source]),
|
||||
);
|
||||
const calls = getRelationships(linked, 'CALLS').filter((edge) => edge.target === 'getName');
|
||||
const alphaCall = calls.find((edge) => edge.source === 'readAlpha');
|
||||
const betaCall = calls.find((edge) => edge.source === 'readBeta');
|
||||
expect(alphaCall).toBeDefined();
|
||||
expect(betaCall).toBeDefined();
|
||||
expect(ownerByMethod.get(alphaCall?.rel.targetId ?? '')).toBe('Alpha');
|
||||
expect(ownerByMethod.get(betaCall?.rel.targetId ?? '')).toBe('Beta');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('emits Kotlin getName while Java-to-Kotlin member CALLS remains unsupported', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-kt-acc-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'models/User.kt': `package models
|
||||
data class User(val name: String)
|
||||
`,
|
||||
'app/Consumer.java': `package app;
|
||||
import models.User;
|
||||
class Consumer {
|
||||
String consume(User user) {
|
||||
return user.getName();
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
const linked = await runPipelineFromRepo(root, () => {});
|
||||
const getName = getNodesByLabelFull(linked, 'Method').find(
|
||||
(m) => m.name === 'getName' && String(m.properties.filePath).endsWith('User.kt'),
|
||||
);
|
||||
expect(getName).toBeDefined();
|
||||
const calls = getRelationships(linked, 'CALLS').filter(
|
||||
(e) => e.source === 'consume' && e.target === 'getName',
|
||||
);
|
||||
// Pre-existing cross-language resolver gap: this feature adds the target
|
||||
// Method but does not claim Java→Kotlin member CALLS support.
|
||||
expect(getName?.properties.synthetic).toBe('kotlin-jvm');
|
||||
expect(calls).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
|
@ -30,9 +30,10 @@ describe('Kotlin heritage resolution', () => {
|
|||
expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable', 'Validatable']);
|
||||
});
|
||||
|
||||
it('detects 6 class/interface methods (processUser is inside UserService)', () => {
|
||||
it('detects 6 declared methods plus the data-class JVM getter', () => {
|
||||
expect(getNodesByLabel(result, 'Function')).toEqual([]);
|
||||
expect(getNodesByLabel(result, 'Method')).toEqual([
|
||||
'getName',
|
||||
'processUser',
|
||||
'save',
|
||||
'serialize',
|
||||
|
|
@ -2963,6 +2964,32 @@ describe('F52 — Kotlin companion-object properties', () => {
|
|||
// The companion's `create` function is a Method, never a Property/field.
|
||||
expect(getNodesByLabel(result, 'Property')).not.toContain('create');
|
||||
});
|
||||
|
||||
it('synthesizes companion property accessors on the enclosing dispatch class', () => {
|
||||
const accessors = getNodesByLabelFull(result, 'Method')
|
||||
.filter(
|
||||
(node) =>
|
||||
node.properties.synthetic === 'kotlin-jvm' &&
|
||||
(node.name === 'getInstances' || node.name === 'getCfgX'),
|
||||
)
|
||||
.map((node) => ({
|
||||
name: node.name,
|
||||
qualifiedName: node.properties.qualifiedName,
|
||||
returnType: node.properties.returnType,
|
||||
isStatic: node.properties.isStatic,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
expect(accessors).toEqual([
|
||||
{ name: 'getCfgX', qualifiedName: 'Factory.getCfgX', returnType: 'Int', isStatic: true },
|
||||
{ name: 'getInstances', qualifiedName: 'C.getInstances', returnType: 'Int', isStatic: true },
|
||||
]);
|
||||
|
||||
const ownership = getRelationships(result, 'HAS_METHOD')
|
||||
.filter((edge) => edge.target === 'getInstances' || edge.target === 'getCfgX')
|
||||
.map((edge) => `${edge.source}.${edge.target}`)
|
||||
.sort();
|
||||
expect(ownership).toEqual(['C.getInstances', 'Factory.getCfgX']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -244,11 +244,12 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// collided, because each re-checked once and neither re-checked after the
|
||||
// other moved — which is why the rule is re-applied AT MERGE, not when the
|
||||
// number is picked.
|
||||
it('pins SCHEMA_BUMP to 82 so concurrent bumps cannot silently collide (#2766, #3015, #3088)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(82);
|
||||
it('pins SCHEMA_BUMP to 86 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(86);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
for (const taken of [
|
||||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
420
gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts
Normal file
420
gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
/**
|
||||
* Kotlin JVM accessor synthesis (data class / val / var properties).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getKotlinParser } from '../../src/core/ingestion/languages/kotlin/query.js';
|
||||
import {
|
||||
kotlinGetterName,
|
||||
kotlinSetterName,
|
||||
synthesizeLombokAccessors,
|
||||
synthesizeLombokAccessorCaptures,
|
||||
} from '../../src/core/ingestion/languages/kotlin/lombok-synthesizer.js';
|
||||
|
||||
const FILE_PATH = '/test/User.kt';
|
||||
|
||||
function parse(code: string) {
|
||||
return getKotlinParser().parse(code);
|
||||
}
|
||||
|
||||
function ownerMap(tree: ReturnType<typeof parse>, filePath: string): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
const TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']);
|
||||
const walk = (node: (typeof tree)['rootNode']): void => {
|
||||
if (TYPES.has(node.type)) {
|
||||
const name =
|
||||
node.childForFieldName('name')?.text ??
|
||||
node.namedChildren.find(
|
||||
(c) => c.type === 'type_identifier' || c.type === 'simple_identifier',
|
||||
)?.text;
|
||||
if (name) map.set(node.id, `Class:${filePath}:${name}`);
|
||||
}
|
||||
for (const c of node.children) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('Kotlin JVM accessor naming', () => {
|
||||
it('preserves is-prefixed names and otherwise uses get, including for Boolean', () => {
|
||||
expect(kotlinGetterName('active')).toBe('getActive');
|
||||
expect(kotlinGetterName('flag')).toBe('getFlag');
|
||||
expect(kotlinGetterName('isReady')).toBe('isReady');
|
||||
expect(kotlinGetterName('is1')).toBe('is1');
|
||||
expect(kotlinSetterName('is1')).toBe('set1');
|
||||
expect(kotlinGetterName('ßeta')).toBe('getßeta');
|
||||
expect(kotlinGetterName('élan')).toBe('getélan');
|
||||
expect(kotlinSetterName('élan')).toBe('setélan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('synthesizeLombokAccessors', () => {
|
||||
it('emits get/set for data class var/val constructor properties', () => {
|
||||
const tree = parse(`
|
||||
data class User(val name: String, var age: Int)
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getAge', 'getName', 'setAge']);
|
||||
});
|
||||
|
||||
it('uses Kotlin property-name rules for Boolean and is-prefixed properties', () => {
|
||||
const tree = parse(`
|
||||
data class Flags(var active: Boolean, var isReady: Boolean, var isLabel: String, var is1: Boolean)
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual([
|
||||
'getActive',
|
||||
'is1',
|
||||
'isLabel',
|
||||
'isReady',
|
||||
'set1',
|
||||
'setActive',
|
||||
'setLabel',
|
||||
'setReady',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips getter when an explicit fun getName exists', () => {
|
||||
const tree = parse(`
|
||||
data class User(val name: String) {
|
||||
fun getName(): String = name
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still emits getName when only a differently-cased GetName exists', () => {
|
||||
const tree = parse(`
|
||||
data class User(val name: String) {
|
||||
fun GetName(): String = name
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getName']);
|
||||
});
|
||||
|
||||
it('does not treat a member extension receiver as a zero-arity accessor collision', () => {
|
||||
const tree = parse(`
|
||||
class User(val name: String) {
|
||||
fun String.getName(): String = this
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getName']);
|
||||
});
|
||||
|
||||
it('does not treat a suspend continuation as a zero-arity accessor collision', () => {
|
||||
const tree = parse(`
|
||||
class User(var name: String) {
|
||||
suspend fun getName(): String = name
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getName', 'setName']);
|
||||
});
|
||||
|
||||
it('emits custom get/set JVM methods but skips @JvmField', () => {
|
||||
const tree = parse(`
|
||||
class User {
|
||||
var extra: String = "x"
|
||||
get() = field
|
||||
set(v) { field = v }
|
||||
@JvmField val raw: Int = 1
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getExtra', 'setExtra']);
|
||||
});
|
||||
|
||||
it('does not treat a custom qualified JvmField annotation as kotlin.jvm.JvmField', () => {
|
||||
const tree = parse(`
|
||||
class User {
|
||||
@com.acme.JvmField val name: String = "x"
|
||||
@kotlin.jvm.JvmField val raw: Int = 1
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getName']);
|
||||
});
|
||||
|
||||
it('resolves Kotlin JVM annotation aliases and explicit shadows', () => {
|
||||
const alias = parse(`
|
||||
import kotlin.jvm.JvmField as Field
|
||||
class User {
|
||||
@Field val name: String = "x"
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(alias, FILE_PATH, ownerMap(alias, FILE_PATH)).symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const shadow = parse(`
|
||||
import com.acme.JvmField
|
||||
class User {
|
||||
@JvmField val name: String = "x"
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(shadow, FILE_PATH, ownerMap(shadow, FILE_PATH)).symbols.map(
|
||||
(symbol) => symbol.name,
|
||||
),
|
||||
).toEqual(['getName']);
|
||||
|
||||
const renamed = parse(`
|
||||
import kotlin.jvm.JvmName as Rename
|
||||
class User {
|
||||
@get:Rename("fetchName") val name: String = "x"
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(renamed, FILE_PATH, ownerMap(renamed, FILE_PATH)).symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const localShadow = parse(`
|
||||
annotation class JvmField
|
||||
annotation class JvmName
|
||||
class User {
|
||||
@JvmField val field: String = "x"
|
||||
@get:JvmName("fetchName") val name: String = "x"
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
localShadow,
|
||||
FILE_PATH,
|
||||
ownerMap(localShadow, FILE_PATH),
|
||||
).symbols.map((symbol) => symbol.name),
|
||||
).toEqual(['getField', 'getName']);
|
||||
});
|
||||
|
||||
it('suppresses accessors renamed with @JvmName until custom names are modeled', () => {
|
||||
const tree = parse(`
|
||||
class User {
|
||||
@get:JvmName("fetchName") @set:JvmName("putName")
|
||||
var name: String = ""
|
||||
var display: String = ""
|
||||
@JvmName("readDisplay") get() = field
|
||||
@JvmName("writeDisplay") set(v) { field = v }
|
||||
@NotJvmName var ordinary: String = ""
|
||||
@com.acme.JvmName("custom") var qualified: String = ""
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual([
|
||||
'getOrdinary',
|
||||
'getQualified',
|
||||
'setOrdinary',
|
||||
'setQualified',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts the declared type past annotations and honors accessor visibility', () => {
|
||||
const tree = parse(`
|
||||
class User {
|
||||
@Deprecated var annotated: String = "x"
|
||||
private set
|
||||
var typeAnnotated: @Deprecated String? = null
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
const getter = result.symbols.find((s) => s.name === 'getAnnotated');
|
||||
const setter = result.symbols.find((s) => s.name === 'setAnnotated');
|
||||
expect(getter).toMatchObject({
|
||||
returnType: 'String',
|
||||
parameterTypes: [],
|
||||
visibility: 'public',
|
||||
});
|
||||
expect(setter).toMatchObject({
|
||||
returnType: 'void',
|
||||
parameterTypes: ['String'],
|
||||
visibility: 'private',
|
||||
});
|
||||
expect(result.symbols.find((s) => s.name === 'getTypeAnnotated')).toMatchObject({
|
||||
returnType: 'String?',
|
||||
});
|
||||
expect(result.symbols.find((s) => s.name === 'setTypeAnnotated')).toMatchObject({
|
||||
parameterTypes: ['String?'],
|
||||
});
|
||||
});
|
||||
|
||||
it('infers common initializer types without claiming Any', () => {
|
||||
const tree = parse(`
|
||||
class User {
|
||||
val name = "x"
|
||||
val count = 1
|
||||
val total = 1L
|
||||
val unsigned = 1u
|
||||
val unsignedTotal = 1UL
|
||||
val ratio = 1.0
|
||||
val enabled = true
|
||||
val initial = 'x'
|
||||
val nested = User()
|
||||
val unresolved = compute()
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
const returnTypes = Object.fromEntries(
|
||||
result.symbols.map((symbol) => [symbol.name, symbol.returnType]),
|
||||
);
|
||||
expect(returnTypes).toMatchObject({
|
||||
getName: 'String',
|
||||
getCount: 'Int',
|
||||
getTotal: 'Long',
|
||||
getUnsigned: 'UInt',
|
||||
getUnsignedTotal: 'ULong',
|
||||
getRatio: 'Double',
|
||||
getEnabled: 'Boolean',
|
||||
getInitial: 'Char',
|
||||
getNested: 'User',
|
||||
getUnresolved: 'unknown',
|
||||
});
|
||||
expect(Object.values(returnTypes)).not.toContain('Any');
|
||||
});
|
||||
|
||||
it('attaches object and companion accessors to their dispatch owners', () => {
|
||||
const tree = parse(`
|
||||
object Config {
|
||||
var enabled: Boolean = true
|
||||
}
|
||||
class Outer {
|
||||
companion object {
|
||||
var tag: String = "x"
|
||||
}
|
||||
}
|
||||
class Named {
|
||||
companion object Factory {
|
||||
val code: Int = 1
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(
|
||||
result.symbols.map((symbol) => `${symbol.ownerId}:${symbol.qualifiedName}`).sort(),
|
||||
).toEqual([
|
||||
`Class:${FILE_PATH}:Config:Config.getEnabled`,
|
||||
`Class:${FILE_PATH}:Config:Config.setEnabled`,
|
||||
`Class:${FILE_PATH}:Factory:Factory.getCode`,
|
||||
`Class:${FILE_PATH}:Outer:Outer.getTag`,
|
||||
`Class:${FILE_PATH}:Outer:Outer.setTag`,
|
||||
]);
|
||||
expect(
|
||||
result.symbols.map((symbol) => `${symbol.qualifiedName}:${String(symbol.isStatic)}`).sort(),
|
||||
).toEqual([
|
||||
'Config.getEnabled:false',
|
||||
'Config.setEnabled:false',
|
||||
'Factory.getCode:true',
|
||||
'Outer.getTag:true',
|
||||
'Outer.setTag:true',
|
||||
]);
|
||||
expect(
|
||||
synthesizeLombokAccessorCaptures(tree.rootNode)
|
||||
.map((match) => match['@declaration.qualified_name']?.text)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
'Config.getEnabled',
|
||||
'Config.setEnabled',
|
||||
'Factory.getCode',
|
||||
'Outer.getTag',
|
||||
'Outer.setTag',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not replace a real enclosing method with an unnamed companion accessor', () => {
|
||||
const tree = parse(`
|
||||
class Outer {
|
||||
fun getTag(): String = "real"
|
||||
companion object {
|
||||
var tag: String = "x"
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((symbol) => symbol.name)).toEqual(['setTag']);
|
||||
expect(result.symbols[0]?.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps capture and graph identities deduplicated for one dispatch owner', () => {
|
||||
const tree = parse(`
|
||||
class Collision(var tag: String) {
|
||||
companion object {
|
||||
var tag: Int = 1
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((symbol) => symbol.qualifiedName).sort()).toEqual([
|
||||
'Collision.getTag',
|
||||
'Collision.setTag',
|
||||
]);
|
||||
expect(
|
||||
synthesizeLombokAccessorCaptures(tree.rootNode)
|
||||
.map((match) => match['@declaration.qualified_name']?.text)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
.sort(),
|
||||
).toEqual(['Collision.getTag', 'Collision.setTag']);
|
||||
});
|
||||
|
||||
it('emits nested class accessors and skips function-local classes', () => {
|
||||
const tree = parse(`
|
||||
class Outer {
|
||||
fun skip() {
|
||||
class Local(val hidden: String)
|
||||
}
|
||||
class Inner(val name: String)
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getName']);
|
||||
});
|
||||
|
||||
it('uses unique scope ranges for getter and setter of the same property', () => {
|
||||
const tree = parse(`
|
||||
class Pair(var first: Int, var second: Int)
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, ownerMap(tree, FILE_PATH));
|
||||
expect(result.symbols).toHaveLength(4);
|
||||
const captures = synthesizeLombokAccessorCaptures(tree.rootNode);
|
||||
const scopes = captures
|
||||
.map((m) => m['@scope.function'])
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined)
|
||||
.map((c) => `${c.range.startLine}:${c.range.startCol}-${c.range.endLine}:${c.range.endCol}`);
|
||||
expect(scopes).toHaveLength(4);
|
||||
expect(new Set(scopes).size).toBe(4);
|
||||
expect(
|
||||
captures
|
||||
.map((m) => m['@declaration.qualified_name']?.text)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
.sort(),
|
||||
).toEqual(['Pair.getFirst', 'Pair.getSecond', 'Pair.setFirst', 'Pair.setSecond']);
|
||||
expect(captures.some((m) => '@declaration.qualified-name' in m)).toBe(false);
|
||||
});
|
||||
|
||||
it('marks interface property accessors abstract unless they have a body', () => {
|
||||
const abstractTree = parse(`
|
||||
interface Named { val name: String; var age: Int }
|
||||
`);
|
||||
const abstractResult = synthesizeLombokAccessors(
|
||||
abstractTree,
|
||||
FILE_PATH,
|
||||
ownerMap(abstractTree, FILE_PATH),
|
||||
);
|
||||
expect(abstractResult.symbols.map((s) => `${s.name}:${s.isAbstract}`).sort()).toEqual([
|
||||
'getAge:true',
|
||||
'getName:true',
|
||||
'setAge:true',
|
||||
]);
|
||||
|
||||
const defaultTree = parse(`
|
||||
interface Named { val name: String get() = "x" }
|
||||
`);
|
||||
const defaultResult = synthesizeLombokAccessors(
|
||||
defaultTree,
|
||||
FILE_PATH,
|
||||
ownerMap(defaultTree, FILE_PATH),
|
||||
);
|
||||
expect(defaultResult.symbols.map((s) => `${s.name}:${s.isAbstract}`)).toEqual([
|
||||
'getName:false',
|
||||
]);
|
||||
});
|
||||
});
|
||||
726
gitnexus/test/unit/lombok-synthesizer.test.ts
Normal file
726
gitnexus/test/unit/lombok-synthesizer.test.ts
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
/**
|
||||
* Unit test: Lombok accessor method synthesis.
|
||||
*
|
||||
* Tests the lombok-synthesizer module directly (no worker pool needed).
|
||||
* Fixtures use proven lombok imports / FQNs — bare `@Data` without provenance
|
||||
* must not synthesize.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import {
|
||||
getterName,
|
||||
setterName,
|
||||
synthesizeLombokAccessors,
|
||||
synthesizeLombokAccessorCaptures,
|
||||
} 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';
|
||||
|
||||
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' || node.type === 'enum_declaration') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
const parent = immediateParentName(node);
|
||||
const key = parent ? `${parent}.${nameNode.text}` : nameNode.text;
|
||||
const label = node.type === 'enum_declaration' ? 'Enum' : 'Class';
|
||||
map.set(node.id, `${label}:${filePath}:${key}`);
|
||||
}
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('Lombok naming helpers', () => {
|
||||
it('uses isPrefix for primitive boolean isX without double-is', () => {
|
||||
expect(getterName('isEnabled', 'boolean')).toBe('isEnabled');
|
||||
expect(setterName('isEnabled', 'boolean')).toBe('setEnabled');
|
||||
expect(getterName('active', 'boolean')).toBe('isActive');
|
||||
expect(setterName('active', 'boolean')).toBe('setActive');
|
||||
expect(getterName('active', 'Boolean')).toBe('getActive');
|
||||
expect(setterName('active', 'Boolean')).toBe('setActive');
|
||||
expect(getterName('is1', 'boolean')).toBe('is1');
|
||||
expect(setterName('is1', 'boolean')).toBe('set1');
|
||||
expect(getterName('ßeta', 'String')).toBe('getßeta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('synthesizeLombokAccessors', () => {
|
||||
describe('@Data annotation', () => {
|
||||
it('generates both getter and setter for each field', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
private Long amount;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
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(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
const getter = result.symbols.find((s) => s.name === 'getOrderId')!;
|
||||
expect(getter.returnType).toBe('String');
|
||||
expect(getter.parameterTypes).toEqual([]);
|
||||
const setter = result.symbols.find((s) => s.name === 'setOrderId')!;
|
||||
expect(setter.returnType).toBe('void');
|
||||
expect(setter.parameterTypes).toEqual(['String']);
|
||||
});
|
||||
|
||||
it('uses unique scope ranges for multi-declarator getters and setters', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Pair {
|
||||
private int first, second;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual([
|
||||
'getFirst',
|
||||
'getSecond',
|
||||
'setFirst',
|
||||
'setSecond',
|
||||
]);
|
||||
const scopes = synthesizeLombokAccessorCaptures(tree.rootNode)
|
||||
.map((m) => m['@scope.function'])
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined)
|
||||
.map(
|
||||
(c) => `${c.range.startLine}:${c.range.startCol}-${c.range.endLine}:${c.range.endCol}`,
|
||||
);
|
||||
expect(scopes).toHaveLength(4);
|
||||
expect(new Set(scopes).size).toBe(4);
|
||||
});
|
||||
|
||||
it('creates HAS_METHOD relationships linking to the class', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const classNodeIds = ownerMapBySimpleName(tree, FILE_PATH);
|
||||
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
|
||||
expect(result.relationships.length).toBeGreaterThan(0);
|
||||
for (const rel of result.relationships) {
|
||||
expect(rel.type).toBe('HAS_METHOD');
|
||||
expect(rel.sourceId).toBe(`Class:${FILE_PATH}:Order`);
|
||||
expect(rel.targetId.startsWith('Method:')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips unproven bare @Data without lombok import', () => {
|
||||
const tree = parse(`
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips lombok subpackages that are not lombok or lombok.experimental', () => {
|
||||
const nestedFqn = parse(`
|
||||
@lombok.foo.Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(nestedFqn, FILE_PATH, ownerMapBySimpleName(nestedFqn, FILE_PATH))
|
||||
.symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const nestedImport = parse(`
|
||||
import lombok.foo.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
nestedImport,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(nestedImport, FILE_PATH),
|
||||
).symbols,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps lombok and lombok.experimental star imports package-specific', () => {
|
||||
const experimentalOnly = parse(`
|
||||
import lombok.experimental.*;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
experimentalOnly,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(experimentalOnly, FILE_PATH),
|
||||
).symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const coreOnly = parse(`
|
||||
import lombok.*;
|
||||
@Data
|
||||
@Accessors(fluent = true)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(coreOnly, FILE_PATH, ownerMapBySimpleName(coreOnly, FILE_PATH))
|
||||
.symbols.map((symbol) => symbol.name)
|
||||
.sort(),
|
||||
).toEqual(['getOrderId', 'setOrderId']);
|
||||
|
||||
const splitPackages = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.*;
|
||||
@Data
|
||||
@Accessors(fluent = true)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
splitPackages,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(splitPackages, FILE_PATH),
|
||||
).symbols,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lets explicit imports and local declarations shadow star imports', () => {
|
||||
const explicitShadow = parse(`
|
||||
import lombok.*;
|
||||
import com.acme.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
explicitShadow,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(explicitShadow, FILE_PATH),
|
||||
).symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const localShadow = parse(`
|
||||
import lombok.*;
|
||||
@interface Data {}
|
||||
@Data
|
||||
class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
localShadow,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(localShadow, FILE_PATH),
|
||||
).symbols,
|
||||
).toHaveLength(0);
|
||||
|
||||
const staticImport = parse(`
|
||||
import static com.acme.Constants.Data;
|
||||
import lombok.*;
|
||||
@Data
|
||||
class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
expect(
|
||||
synthesizeLombokAccessors(
|
||||
staticImport,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(staticImport, FILE_PATH),
|
||||
).symbols.map((symbol) => symbol.name),
|
||||
).toEqual(['getOrderId', 'setOrderId']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean naming', () => {
|
||||
it('uses isActive for primitive boolean active', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Flag {
|
||||
private boolean active;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['isActive', 'setActive']);
|
||||
});
|
||||
|
||||
it('does not double-prefix primitive boolean isEnabled', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Flag {
|
||||
private boolean isEnabled;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['isEnabled', 'setEnabled']);
|
||||
expect(result.symbols.find((s) => s.name === 'isIsEnabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses get/set for boxed Boolean', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Flag {
|
||||
private Boolean active;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getActive', 'setActive']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('field-level and NONE', () => {
|
||||
it('enables field-only @Getter without class annotation', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
public class Order {
|
||||
@Getter private String orderId;
|
||||
private String ignored;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getOrderId']);
|
||||
});
|
||||
|
||||
it('class @Getter(AccessLevel.NONE) does not enable getters', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
import lombok.AccessLevel;
|
||||
@Getter(AccessLevel.NONE)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps class @Getter(NONE) when @Data follows it', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.AccessLevel;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['setOrderId']);
|
||||
});
|
||||
|
||||
it('field @Setter(AccessLevel.NONE) suppresses setter under @Data', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.Setter;
|
||||
import lombok.AccessLevel;
|
||||
@Data
|
||||
public class Order {
|
||||
@Setter(AccessLevel.NONE)
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getOrderId']);
|
||||
});
|
||||
|
||||
it('honors @Getter(AccessLevel.PROTECTED)', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
import lombok.AccessLevel;
|
||||
@Getter(AccessLevel.PROTECTED)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0]!.visibility).toBe('protected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collision and final', () => {
|
||||
it('skips setter for final fields', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private final String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getOrderId']);
|
||||
});
|
||||
|
||||
it('does not suppress zero-arg getter when getX(int) exists', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
public String getOrderId(int unused) { return orderId; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getOrderId', 'setOrderId']);
|
||||
expect(result.symbols.find((s) => s.name === 'getOrderId')!.parameterCount).toBe(0);
|
||||
});
|
||||
|
||||
it('suppresses when same name and arity exist (case-insensitive)', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
public class Order {
|
||||
private String orderId;
|
||||
public String getorderId() { return orderId; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['setOrderId']);
|
||||
});
|
||||
|
||||
it('ignores proven @Tolerate methods when checking accessor collisions', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Tolerate;
|
||||
public class Order {
|
||||
@Setter private java.sql.Date date;
|
||||
@Tolerate public void setDate(String date) {}
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(1);
|
||||
expect(result.symbols[0]).toMatchObject({
|
||||
name: 'setDate',
|
||||
parameterTypes: ['java.sql.Date'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let an unproven @Tolerate unlock setter synthesis', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Setter;
|
||||
import lombok.*;
|
||||
public class Order {
|
||||
@Setter private java.sql.Date date;
|
||||
@Tolerate public void setDate(String date) {}
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats varargs as an arity range for collision checks', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
public class Order {
|
||||
@Getter private String name;
|
||||
public String getName(int... ignored) { return name; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('finds existing methods nested under enum body declarations', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
@Getter
|
||||
public enum Kind {
|
||||
A, B;
|
||||
private final String code = "";
|
||||
public String getCode() { return code; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@Accessors', () => {
|
||||
it('ignores unproven experimental Accessors and still emits beanspec', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
@Data
|
||||
@Accessors(fluent = true)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getOrderId', 'setOrderId']);
|
||||
});
|
||||
|
||||
it('omits when proven Accessors fluent=true', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@Data
|
||||
@Accessors(fluent = true)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('models chain=true setter return as declaring type; still emits getter', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class Order {
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
const getter = result.symbols.find((s) => s.name === 'getOrderId')!;
|
||||
const setter = result.symbols.find((s) => s.name === 'setOrderId')!;
|
||||
expect(getter.returnType).toBe('String');
|
||||
expect(setter.returnType).toBe('Order');
|
||||
expect(setter.returnType).not.toBe('void');
|
||||
});
|
||||
|
||||
it('omits when prefix is configured', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@Data
|
||||
@Accessors(prefix = "m")
|
||||
public class Order {
|
||||
private String mOrderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lets field @Accessors(fluent=false) restore beanspec under class fluent=true', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@Data
|
||||
@Accessors(fluent = true)
|
||||
public class Order {
|
||||
@Accessors(fluent = false)
|
||||
private String orderId;
|
||||
private String other;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name).sort()).toEqual(['getOrderId', 'setOrderId']);
|
||||
});
|
||||
|
||||
it('lets field @Accessors(chain=false) restore void setters under class chain=true', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class Order {
|
||||
@Accessors(chain = false)
|
||||
private String orderId;
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.find((s) => s.name === 'setOrderId')!.returnType).toBe('void');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested identity', () => {
|
||||
it('keeps distinct method ids for same-tailed nested classes with same field', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Data;
|
||||
public class Outer {
|
||||
@Data class Item { private String value; }
|
||||
}
|
||||
class Other {
|
||||
@Data class Item { private String value; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
const getters = result.nodes.filter((n) => n.properties.name === 'getValue');
|
||||
expect(getters.length).toBe(2);
|
||||
const ids = new Set(getters.map((n) => n.id));
|
||||
expect(ids.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum', () => {
|
||||
it('synthesizes getters on enum with proven @Getter', () => {
|
||||
const tree = parse(`
|
||||
import lombok.Getter;
|
||||
@Getter
|
||||
public enum Kind {
|
||||
A, B;
|
||||
private final String code;
|
||||
Kind(String code) { this.code = code; }
|
||||
}
|
||||
`);
|
||||
const result = synthesizeLombokAccessors(
|
||||
tree,
|
||||
FILE_PATH,
|
||||
ownerMapBySimpleName(tree, FILE_PATH),
|
||||
);
|
||||
expect(result.symbols.map((s) => s.name)).toEqual(['getCode']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue