From 4aa6bddd0a78135136d29d8440eb29613097f616 Mon Sep 17 00:00:00 2001 From: ChunxueLi <54129170+ChunxueLi@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:55:52 +0800 Subject: [PATCH] feat(jvm): synthesize Lombok and Kotlin JVM accessor methods (#2885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: ChunxueLi Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 14 + .../java-lombok-synthesis/baselines.json | 8 + .../bench/java-lombok-synthesis/measure.mjs | 124 +++ .../bench/kotlin-jvm-accessors/baselines.json | 8 + .../bench/kotlin-jvm-accessors/measure.mjs | 121 +++ gitnexus/bench/lib/identity-guard.mjs | 62 ++ gitnexus/bench/scope-capture/baselines.json | 10 +- .../src/core/ingestion/language-provider.ts | 48 ++ gitnexus/src/core/ingestion/languages/java.ts | 3 + .../core/ingestion/languages/java/captures.ts | 2 + .../languages/java/lombok-synthesizer.ts | 539 +++++++++++++ .../languages/jvm/accessor-synthesis.ts | 316 ++++++++ .../core/ingestion/languages/jvm/beanspec.ts | 49 ++ .../src/core/ingestion/languages/kotlin.ts | 2 + .../ingestion/languages/kotlin/captures.ts | 2 + .../languages/kotlin/lombok-synthesizer.ts | 512 ++++++++++++ .../src/core/ingestion/scope-extractor.ts | 1 + .../core/ingestion/workers/parse-worker.ts | 29 + gitnexus/src/storage/parse-cache.ts | 15 +- .../integration/resolvers/java-lombok.test.ts | 258 +++++++ .../resolvers/kotlin-jvm-accessors.test.ts | 121 +++ .../test/integration/resolvers/kotlin.test.ts | 29 +- .../test/unit/incremental-parse-cache.test.ts | 5 +- .../unit/kotlin-lombok-synthesizer.test.ts | 420 ++++++++++ gitnexus/test/unit/lombok-synthesizer.test.ts | 726 ++++++++++++++++++ 25 files changed, 3416 insertions(+), 8 deletions(-) create mode 100644 gitnexus/bench/java-lombok-synthesis/baselines.json create mode 100644 gitnexus/bench/java-lombok-synthesis/measure.mjs create mode 100644 gitnexus/bench/kotlin-jvm-accessors/baselines.json create mode 100644 gitnexus/bench/kotlin-jvm-accessors/measure.mjs create mode 100644 gitnexus/bench/lib/identity-guard.mjs create mode 100644 gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts create mode 100644 gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts create mode 100644 gitnexus/src/core/ingestion/languages/jvm/beanspec.ts create mode 100644 gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts create mode 100644 gitnexus/test/integration/resolvers/java-lombok.test.ts create mode 100644 gitnexus/test/integration/resolvers/kotlin-jvm-accessors.test.ts create mode 100644 gitnexus/test/unit/kotlin-lombok-synthesizer.test.ts create mode 100644 gitnexus/test/unit/lombok-synthesizer.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 5bdcf3560..9739f9028 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/gitnexus/bench/java-lombok-synthesis/baselines.json b/gitnexus/bench/java-lombok-synthesis/baselines.json new file mode 100644 index 000000000..9e14f8f3c --- /dev/null +++ b/gitnexus/bench/java-lombok-synthesis/baselines.json @@ -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." +} diff --git a/gitnexus/bench/java-lombok-synthesis/measure.mjs b/gitnexus/bench/java-lombok-synthesis/measure.mjs new file mode 100644 index 000000000..512af1e12 --- /dev/null +++ b/gitnexus/bench/java-lombok-synthesis/measure.mjs @@ -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); diff --git a/gitnexus/bench/kotlin-jvm-accessors/baselines.json b/gitnexus/bench/kotlin-jvm-accessors/baselines.json new file mode 100644 index 000000000..6ef877719 --- /dev/null +++ b/gitnexus/bench/kotlin-jvm-accessors/baselines.json @@ -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." +} diff --git a/gitnexus/bench/kotlin-jvm-accessors/measure.mjs b/gitnexus/bench/kotlin-jvm-accessors/measure.mjs new file mode 100644 index 000000000..0edbfb1e4 --- /dev/null +++ b/gitnexus/bench/kotlin-jvm-accessors/measure.mjs @@ -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); diff --git a/gitnexus/bench/lib/identity-guard.mjs b/gitnexus/bench/lib/identity-guard.mjs new file mode 100644 index 000000000..b73a1a241 --- /dev/null +++ b/gitnexus/bench/lib/identity-guard.mjs @@ -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)); +} diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d8831f822..7dcd85c15 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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 } } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 2775cf07b..a835c2105 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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, + ) => { + nodes: ReadonlyArray<{ + id: string; + label: string; + properties: Record; + }>; + 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 diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 0fddb6657..25b1daa18 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 22083e6e5..2c3c2c281 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -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), ]; } diff --git a/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts b/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts new file mode 100644 index 000000000..3f2eeab9a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts @@ -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([ + ['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; + starPackages: Set; + shadowedSimpleNames: Set; +} + +/** + * Compilation-unit imports only — Java `import` is never nested in a type body. + */ +function collectLombokImports(root: Parser.SyntaxNode): LombokImportIndex { + const bySimple = new Map(); + const starPackages = new Set(); + const shadowedSimpleNames = new Set(); + 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, +): 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); +} diff --git a/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts new file mode 100644 index 000000000..e1cb89253 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts @@ -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>; + readonly arityRangesByName: Map>; +} + +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, + ): 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(); + 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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts b/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts new file mode 100644 index 000000000..0a14209f5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts @@ -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)}`; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 18d4fd9c1..4107d5430 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index f3b00db03..6c8d6f6c3 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts b/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts new file mode 100644 index 000000000..bb0c3d5b7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts @@ -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; + shadowedSimpleNames: Set; +} + +function collectKotlinImports(root: Parser.SyntaxNode): KotlinImportIndex { + const byLocalName = new Map(); + const shadowedSimpleNames = new Set(); + 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): 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, +): SyntheticAccessorResult { + return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById); +} + +export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] { + return lombokAccessorSynthesis.captures(rootNode); +} diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 39b8667ae..fde3c7b26 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1775,6 +1775,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@scope.lexical-names', '@declaration.name', '@declaration.qualified_name', + '@declaration.is-synthetic', '@import.name', '@import.source', '@import.alias', diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ab27cf7ac..3f3f70395 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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(); + // #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