diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..c092cd027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- Jetpack Compose functions are now indexed as UI components: any Kotlin function or method annotated `@Composable` is recorded with the `component` kind, so `codegraph_search` with `kind=component` finds your Compose UI the same way it already finds React components. + +- Kotlin annotations are now recorded on the symbol they annotate and shown by `codegraph_node`, so your agent can see that a class is `@HiltViewModel` or `@Entity`, or that a function is `@Composable`, without opening the file. Framework annotations come from libraries outside your project, so previously they left no trace on the graph at all. + +### Fixes + +- Kotlin annotations written with arguments (`@Preview(showBackground = true)`, `@Entity(tableName = "users")`) were ignored entirely — only bare annotations like `@Override` were picked up. Both forms are now captured, as is Kotlin's bracket form (`@[Suppress("x") JvmStatic]`). + +- Annotations on Kotlin interfaces and enums were dropped, so a Room `@Dao` interface or a `@Serializable` enum showed nothing. Both are now recorded. + +- Kotlin functions referenced as values (`register(::Header)`) kept their reference edge when the target is a Compose composable, instead of dropping it. + ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index 48323f6fd..d34b09319 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. - [Key Features](#key-features) - [Framework-aware Routes](#framework-aware-routes) - [Mixed iOS / React Native / Expo bridging](#mixed-ios--react-native--expo-bridging) +- [Android: Jetpack Compose, Hilt, and Room](#android-jetpack-compose-hilt-and-room) - [Quick Start](#quick-start) - [How It Works](#how-it-works) - [CLI Reference](#cli-reference) @@ -367,6 +368,24 @@ Each bridge emits edges tagged `provenance:'heuristic'` with `metadata.synthesiz --- +## Android: Jetpack Compose, Hilt, and Room + +Modern Android leans on annotations for structure, and those annotations come from libraries that aren't part of your project — so on their own they leave nothing behind to search. CodeGraph records them on the symbol they annotate: + +- **`@Composable` functions are indexed as UI components**, so `codegraph_search` with `kind=component` finds your Compose UI the same way it finds React components. +- **Annotations are shown by `codegraph_node`**, so your agent can see that a class is `@HiltViewModel` or `@Entity`, or a function is `@Composable`, without opening the file. + +**Measured on real Android codebases:** + +| Repo | Kotlin files | Compose components | Annotated symbols | Most common annotations | +|---|---|---|---|---| +| [Now in Android](https://github.com/android/nowinandroid) (Compose + Room + Hilt) | 310 | 151 | 617 | `@Composable` 151 · `@Binds` 27 · `@Provides` 23 · `@Module` 22 | +| [compose-samples](https://github.com/android/compose-samples) | 355 | 545 | 750 | `@Composable` 545 · `@Preview` 89 · `@Query` 19 | + +Annotations written with arguments — `@Preview(showBackground = true)`, `@Entity(tableName = "users")`, `@Query("SELECT …")` — used to be skipped entirely, as were annotations on interfaces and enums, which is where Room puts `@Dao`. All of them are now captured, which is most of what Room and Hilt are made of. On non-Compose Kotlin libraries the same fix recovers annotation coverage without inventing components: [OkHttp](https://github.com/square/okhttp) picks up 3,895 annotated symbols and [okio](https://github.com/square/okio) 1,957, with zero component nodes, because neither uses Compose. + +--- + ## Quick Start ### 1. Run the Installer diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index 130611c81..657d321ce 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -265,3 +265,50 @@ fun labeledLambda() { } fun whereClause(): Int where Int : Comparable = 1 + +// --- annotation extraction (@Composable component classification) ------------- +// Both arms must agree on: the `component` kind, the persisted `decorators` +// list AND its order, and the decorates refs. The arg-bearing form used to be +// dropped entirely (constructor_invocation was not unwrapped). +@Composable +fun AnnoComposable() { + AnnoQualified() +} + +@Preview(showBackground = true, name = "dark") +@Composable +fun AnnoPreviewComposable() {} + +@androidx.compose.runtime.Composable +fun AnnoQualified() {} + +@[Suppress("unused") JvmStatic] +fun annoBracketed() {} + +@Deprecated("gone", ReplaceWith("annoBracketed")) +fun annoNestedArgs() {} + +@receiver:Fancy +fun String.annoUseSite(): String = this.uppercase() + +@HiltViewModel +class AnnoAnnotatedClass { + @Composable + fun AnnoMember() {} +} + +fun annoHolder(content: @Composable () -> Unit) { + content() +} + +@JvmStatic +expect fun annoExpectPlatform(): String + +@Dao +interface AnnoDao { + @Query("SELECT * FROM t") + fun annoGetAll(): List +} + +@Serializable +enum class AnnoSyncKind { FULL, DELTA } diff --git a/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts index 4e4540882..6de9437cc 100644 --- a/__tests__/kernel-kotlin-parity.test.ts +++ b/__tests__/kernel-kotlin-parity.test.ts @@ -10,7 +10,8 @@ * fallback, expect/actual → node DECORATORS (the KMP synthesizer feed), * the bodiless-vs-bodied class header asymmetry, comment-glued * import/package extents, KDoc dropped-and-chain-breaking docstrings, - * `@Marker` decorates vs `@Anno(args)` nothing, zero type-annotation refs, + * `@Marker` and `@Anno(args)` both emitting decorates and a persisted node + * decorator name, zero type-annotation refs, * zero instantiates, the #750 capitalized-chain re-encode, paren-then- * lambda garbage callees, `${X}`-reads-vs-`$X`-non-reads value refs and the * packaged-file target drop) plus a `.kts` script fixture (file-attributed diff --git a/__tests__/kotlin-annotations.test.ts b/__tests__/kotlin-annotations.test.ts new file mode 100644 index 000000000..d7a2abbec --- /dev/null +++ b/__tests__/kotlin-annotations.test.ts @@ -0,0 +1,612 @@ +/** + * Kotlin annotation capture + @Composable component classification. + * + * Framework annotations (@Composable, @HiltViewModel, @Inject, …) are declared + * in external libraries outside the index, so their `decorates` references never + * resolve. These tests assert the two extraction-level guarantees that make + * annotation facts queryable anyway: + * 1. annotation simple names are persisted on the node's `decorators` list + * 2. @Composable functions/methods are classified as `component` + * + * Both are opt-in per language via `LanguageExtractor.extendedAnnotations` + * (Kotlin only today) and are implemented TWICE — in the wasm walker + * (src/extraction/tree-sitter.ts) and in the native Rust walker + * (codegraph-kernel/src/kotlin.rs). + * + * Every guarantee is therefore asserted on BOTH arms. This matters more than it + * looks: Kotlin is in the kernel's DEFAULT_ROUTED set, so a bare + * `extractFromSource` call exercises the KERNEL and says nothing about the wasm + * fallback — and `kernel-kotlin-parity.test.ts` only proves the two arms agree + * with each other, not that either is correct. See `arms()`. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { CodeGraph } from '../src'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelBuilt = fs.existsSync(KERNEL_PATH); + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; +let savedEnv: Record; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['kotlin', 'typescript', 'python', 'java']); +}); + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); +}); + +/** Extract through the wasm walker, with kernel routing forced off. */ +function viaWasm(file: string, code: string): ExtractionResult { + process.env.CODEGRAPH_KERNEL = '0'; + resetKernelForTests(); + try { + return extractFromSource(file, code, 'kotlin'); + } finally { + delete process.env.CODEGRAPH_KERNEL; + resetKernelForTests(); + } +} + +/** Extract through the native kernel. Null when no binary is staged. */ +function viaKernel(file: string, code: string): ExtractionResult | null { + if (!kernelBuilt) return null; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + delete process.env.CODEGRAPH_KERNEL; + resetKernelForTests(); + return tryKernelExtract(file, code, 'kotlin'); +} + +/** + * Every available arm, labeled. The kernel entry is absent when the .node isn't + * staged (a from-source checkout without `scripts/build-kernel.sh`), so the + * suite still runs — but on CI and any machine that built the kernel, both arms + * are checked. + */ +function arms(file: string, code: string): Array<[string, ExtractionResult]> { + const out: Array<[string, ExtractionResult]> = [['wasm', viaWasm(file, code)]]; + const k = viaKernel(file, code); + if (k) out.push(['kernel', k]); + return out; +} + +describe('Kotlin annotation capture', () => { + it('captures a simple annotation on a top-level function', () => { + const code = ` +@Composable +fun ProfileCard(name: String) { + Text(name) +} +`; + for (const [arm, result] of arms('ProfileCard.kt', code)) { + const node = result.nodes.find((n) => n.name === 'ProfileCard'); + expect(node, arm).toBeDefined(); + expect(node?.decorators, arm).toContain('Composable'); + } + }); + + it('captures stacked annotations', () => { + const code = ` +@Preview +@Composable +fun CardPreview() { + ProfileCard("x") +} +`; + for (const [arm, result] of arms('CardPreview.kt', code)) { + const node = result.nodes.find((n) => n.name === 'CardPreview'); + expect(node?.decorators, arm).toContain('Preview'); + expect(node?.decorators, arm).toContain('Composable'); + } + }); + + it('captures annotations with arguments (constructor_invocation form)', () => { + // The upstream bug this fixes: an arg-bearing annotation parses as + // `constructor_invocation`, which neither arm unwrapped, so `@Preview(...)` + // emitted nothing at all — no decorates ref, no name on the node. + const code = ` +@Preview(showBackground = true, name = "dark") +@Composable +fun ArgPreview() {} +`; + for (const [arm, result] of arms('ArgPreview.kt', code)) { + const node = result.nodes.find((n) => n.name === 'ArgPreview'); + expect(node?.decorators, arm).toContain('Preview'); + expect(node?.decorators, arm).toContain('Composable'); + const decorates = (result.unresolvedReferences ?? []) + .filter((r) => r.referenceKind === 'decorates') + .map((r) => r.referenceName); + expect(decorates, arm).toContain('Preview'); + } + }); + + it('does not harvest nested annotation arguments as annotation names', () => { + // `ReplaceWith(...)` is an argument to @Deprecated, not an annotation on the + // declaration. Collecting every target under one annotation node (needed for + // Kotlin's bracket form) must not reach into the argument list. + const code = ` +@Deprecated("gone", ReplaceWith("newThing")) +fun oldThing() {} +`; + for (const [arm, result] of arms('Deprecated.kt', code)) { + const node = result.nodes.find((n) => n.name === 'oldThing'); + expect(node?.decorators, arm).toContain('Deprecated'); + expect(node?.decorators ?? [], arm).not.toContain('ReplaceWith'); + } + }); + + it('captures class annotations (Hilt / Room)', () => { + const code = ` +@HiltViewModel +class MyViewModel : ViewModel() {} + +@Entity(tableName = "users") +data class UserEntity(val id: String) +`; + for (const [arm, result] of arms('MyViewModel.kt', code)) { + const vm = result.nodes.find((n) => n.name === 'MyViewModel'); + expect(vm?.kind, arm).toBe('class'); + expect(vm?.decorators, arm).toContain('HiltViewModel'); + + const entity = result.nodes.find((n) => n.name === 'UserEntity'); + expect(entity?.decorators, arm).toContain('Entity'); + } + }); + + it('captures annotations on an interface (Room @Dao) and an enum', () => { + // Room puts @Dao on an INTERFACE and Kotlin serialization puts + // @Serializable on enums; both take a different extraction path than + // classes and emitted nothing before. The path is gated on the language + // opt-in because it previously emitted no decorates refs at all, so calling + // it unconditionally would move every other decorator-using language. + const code = ` +@Dao +interface NewsDao { + @Query("SELECT * FROM news") + fun getAll(): List +} + +@Serializable +enum class SyncKind { FULL, DELTA } +`; + for (const [arm, result] of arms('NewsDao.kt', code)) { + const dao = result.nodes.find((n) => n.name === 'NewsDao'); + expect(dao?.kind, arm).toBe('interface'); + expect(dao?.decorators, arm).toContain('Dao'); + + const getAll = result.nodes.find((n) => n.name === 'getAll'); + expect(getAll?.decorators, arm).toContain('Query'); + + const kind = result.nodes.find((n) => n.name === 'SyncKind'); + expect(kind?.kind, arm).toBe('enum'); + expect(kind?.decorators, arm).toContain('Serializable'); + } + }); + + it('leaves a non-opted language\'s interfaces untouched', () => { + // The interface/enum path is new, so it must stay inert for Java — whose + // Rust walker was not changed and whose parity gate would fail otherwise. + const java = extractFromSource( + 'Foo.java', + ` +@FunctionalInterface +public interface Foo { void a(); } +` + ); + const foo = java.nodes.find((n) => n.name === 'Foo'); + expect(foo?.kind).toBe('interface'); + expect(foo?.decorators ?? []).not.toContain('FunctionalInterface'); + }); + + it('resolves qualified annotations to the simple name', () => { + const code = ` +@androidx.compose.runtime.Composable +fun Qualified() {} +`; + for (const [arm, result] of arms('Qualified.kt', code)) { + const node = result.nodes.find((n) => n.name === 'Qualified'); + expect(node?.decorators, arm).toContain('Composable'); + expect(node?.decorators, arm).not.toContain('androidx'); + } + }); + + it('captures every entry of bracketed multi-annotations', () => { + const code = ` +@[Suppress("unused") JvmStatic] +fun bracketAnnotated() {} +`; + for (const [arm, result] of arms('Bracket.kt', code)) { + const node = result.nodes.find((n) => n.name === 'bracketAnnotated'); + expect(node?.decorators, arm).toContain('Suppress'); + expect(node?.decorators, arm).toContain('JvmStatic'); + } + }); + + it('captures use-site-targeted annotations, skipping the target prefix', () => { + const code = ` +@receiver:Fancy +fun String.shout(): String = this.uppercase() +`; + for (const [arm, result] of arms('Ext.kt', code)) { + const ext = result.nodes.find((n) => n.name === 'shout'); + expect(ext?.decorators, arm).toContain('Fancy'); + expect(ext?.decorators, arm).not.toContain('receiver'); + } + }); + + it('does NOT attribute parameter annotations to the function', () => { + const code = ` +fun plain(@Suppress("x") arg: String) {} +`; + for (const [arm, result] of arms('Plain.kt', code)) { + const node = result.nodes.find((n) => n.name === 'plain'); + expect(node?.decorators ?? [], arm).not.toContain('Suppress'); + } + }); + + it('keeps expect/actual markers alongside annotation names', () => { + // `decorators` already carried the KMP platform modifiers (the expect/actual + // synthesizer reads them); annotation names append after, without + // displacing them. + const code = ` +@JvmStatic +expect fun platformName(): String +`; + for (const [arm, result] of arms('Platform.kt', code)) { + const node = result.nodes.find((n) => n.name === 'platformName'); + expect(node?.decorators, arm).toContain('expect'); + expect(node?.decorators, arm).toContain('JvmStatic'); + } + }); +}); + +describe('Kotlin @Composable component classification', () => { + it('classifies a @Composable top-level function as component', () => { + const code = ` +@Composable +fun ProfileCard(name: String) { + Text(name) +} +`; + for (const [arm, result] of arms('ProfileCard.kt', code)) { + const node = result.nodes.find((n) => n.name === 'ProfileCard'); + expect(node?.kind, arm).toBe('component'); + } + }); + + it('classifies a @Composable method inside a class as component', () => { + const code = ` +class CardRenderer { + @Composable + fun Render(name: String) { + Text(name) + } +} +`; + for (const [arm, result] of arms('CardRenderer.kt', code)) { + const node = result.nodes.find((n) => n.name === 'Render'); + expect(node?.kind, arm).toBe('component'); + } + }); + + it('classifies a @Preview composable as component with Preview decorator', () => { + const code = ` +@Preview +@Composable +fun CardPreview() {} +`; + for (const [arm, result] of arms('CardPreview.kt', code)) { + const node = result.nodes.find((n) => n.name === 'CardPreview'); + expect(node?.kind, arm).toBe('component'); + expect(node?.decorators, arm).toContain('Preview'); + } + }); + + it('does not reclassify @Composable in a type position', () => { + // `@Composable () -> Unit` annotates a function TYPE, not a declaration. + // `content` is a parameter and `holder` stays a plain function. + const code = ` +fun holder(content: @Composable () -> Unit) { + content() +} +`; + for (const [arm, result] of arms('Holder.kt', code)) { + const node = result.nodes.find((n) => n.name === 'holder'); + expect(node?.kind, arm).toBe('function'); + expect(node?.decorators ?? [], arm).not.toContain('Composable'); + } + }); + + it('does not reclassify an annotated class', () => { + // The kind map applies to functions and methods only; a @Composable-adjacent + // annotation on a class must not turn it into a component. + const code = ` +@HiltViewModel +class Screen : ViewModel() {} +`; + for (const [arm, result] of arms('Screen.kt', code)) { + const node = result.nodes.find((n) => n.name === 'Screen'); + expect(node?.kind, arm).toBe('class'); + } + }); + + it('keeps plain functions as function (no regression)', () => { + const code = ` +fun calculateTotal(items: List): Double { + return items.sumOf { it.price } +} +`; + for (const [arm, result] of arms('utils.kt', code)) { + const node = result.nodes.find((n) => n.name === 'calculateTotal'); + expect(node?.kind, arm).toBe('function'); + expect(node?.decorators ?? [], arm).toHaveLength(0); + } + }); + + it('keeps non-Composable annotated functions as function', () => { + const code = ` +@JvmStatic +fun helper() {} +`; + for (const [arm, result] of arms('Helper.kt', code)) { + const node = result.nodes.find((n) => n.name === 'helper'); + expect(node?.kind, arm).toBe('function'); + expect(node?.decorators, arm).toContain('JvmStatic'); + } + }); + + it('decorator-name persistence is opt-in per language, not universal', () => { + // The engine has ONE shared collector, but persisting names onto nodes is + // gated on `LanguageExtractor.extendedAnnotations` and only Kotlin opts in. + // Two reasons, both load-bearing: + // 1. every language routed to the native kernel must emit the SAME names + // from its Rust walker or the kernel<->wasm parity gate fails, and + // each of the 13 walkers carries its own decorator logic; + // 2. collecting past the first target is Kotlin-specific — Swift carries + // argument expressions in the attribute node + // (`@Siblings(through: Pivot.self, from: \\.$left)`), so collecting on + // would harvest `self` and `$left` as annotation names. + // Flipping a language on means porting its Rust walker first, then updating + // this test. Until then non-opted languages stay byte-identical. + const ts = extractFromSource( + 'service.ts', + ` +@Injectable() +export class AuthService { + login(): void {} +} +` + ); + const tsClass = ts.nodes.find((n) => n.name === 'AuthService'); + expect(tsClass).toBeDefined(); + expect(tsClass?.decorators ?? []).not.toContain('Injectable'); + + const java = extractFromSource( + 'UserController.java', + ` +@RestController +public class UserController { + @Deprecated + public void old() {} +} +` + ); + const javaClass = java.nodes.find((n) => n.name === 'UserController'); + expect(javaClass).toBeDefined(); + expect(javaClass?.decorators ?? []).not.toContain('RestController'); + + // The `decorates` REFERENCE is still emitted for every language — only the + // persisted name is gated. That behavior is unchanged by this work. + const tsDecorates = (ts.unresolvedReferences ?? []) + .filter((r) => r.referenceKind === 'decorates') + .map((r) => r.referenceName); + expect(tsDecorates).toContain('Injectable'); + }); +}); + +/** + * The reclassification's blast radius. A node created as `component` instead of + * `function` drops out of every engine gate that keys on function/method, so + * each of these pins one gate that had to be widened alongside it. Without them + * the feature regresses the graph silently — nothing fails, the edges just go + * missing. + */ +describe('@Composable reclassification does not break kind-gated behavior', () => { + let tempDir: string; + let cg: CodeGraph | undefined; + + afterEach(async () => { + if (cg) { + await cg.close(); + cg = undefined; + } + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('spans a @Composable body so its callees are captured', () => { + // createNode's endLine extension gates on function|method, and 'component' + // was added there for consistency. Guard, not a pin: Kotlin's grammar nests + // the body inside the declaration, so the extension is a documented no-op + // for this language and the span is right either way. The assertion that + // matters is the callee one — the callee trail and the callback + // synthesizer's body scan both read a node's span. + const code = ` +@Composable +fun Screen() { + Header() + Footer() +} +`; + for (const [arm, result] of arms('Screen.kt', code)) { + const screen = result.nodes.find((n) => n.name === 'Screen'); + expect(screen?.kind, arm).toBe('component'); + expect((screen!.endLine ?? screen!.startLine) - screen!.startLine, arm).toBeGreaterThan(1); + const callNames = (result.unresolvedReferences ?? []) + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + expect(callNames, arm).toContain('Header'); + expect(callNames, arm).toContain('Footer'); + } + }); + + it('keeps a function-ref edge to a @Composable used as a value', () => { + // flushFnRefCandidates / defined_fn_names gate candidate names on + // function|method before emitting a function_ref. `::Header` is a plain + // function reference even though Header is now a component. + const code = ` +@Composable +fun Header() {} + +fun install() { + register(::Header) +} +`; + for (const [arm, result] of arms('Ui.kt', code)) { + const fnRefs = (result.unresolvedReferences ?? []).filter( + (r) => r.referenceKind === 'function_ref' && r.referenceName === 'Header' + ); + expect(fnRefs.length, arm).toBeGreaterThan(0); + } + }); + + it('resolves a call from one composable into another', async () => { + // End-to-end through resolution: the `calls` kind-preference and the + // callable-candidate filters must accept `component` on both ends. + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kt-calls-')); + fs.writeFileSync( + path.join(tempDir, 'Screen.kt'), + `package p + +@Composable +fun Header() {} + +@Composable +fun Screen() { + Header() +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + + const header = cg + .searchNodes('Header', { limit: 20 }) + .map((r) => r.node) + .find((n) => n.kind === 'component'); + expect(header).toBeDefined(); + const callers = cg.getCallers(header!.id).map((c) => c.node.name); + expect(callers).toContain('Screen'); + }); + + it('keeps receiver inference scoped to each composable', async () => { + // enclosingScopeStartLine bounds receiver inference to the enclosing + // function/method; `component` had to be added or the backward scan widens + // to the whole file. Guard, not a pin: Kotlin's scan already finds the + // nearest preceding declaration, so this holds before the fix too — it + // exists so a future change to the bound can't silently cross composables. + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kt-scope-')); + fs.writeFileSync( + path.join(tempDir, 'Screen.kt'), + `package p + +class Alpha { fun run() {} } +class Beta { fun run() {} } + +@Composable +fun First() { + val svc = Alpha() + svc.run() +} + +@Composable +fun Second() { + val svc = Beta() + svc.run() +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + + const byOwner = (owner: string) => + cg! + .searchNodes('run', { limit: 50 }) + .map((r) => r.node) + .find((n) => n.kind === 'method' && n.qualifiedName?.includes(owner)); + + const alphaRun = byOwner('Alpha'); + const betaRun = byOwner('Beta'); + expect(alphaRun).toBeDefined(); + expect(betaRun).toBeDefined(); + + expect(cg.getCallers(alphaRun!.id).map((c) => c.node.name)).toEqual(['First']); + expect(cg.getCallers(betaRun!.id).map((c) => c.node.name)).toEqual(['Second']); + }); +}); + +/** + * The annotation names have to be reachable by an agent, not just present in the + * database — `decorates` edges to a library annotation never resolve, so + * `codegraph_node`'s Annotations line is the only place the fact surfaces. + */ +describe('annotations are visible through the MCP node tool', () => { + let tempDir: string; + let cg: CodeGraph | undefined; + + afterEach(async () => { + if (cg) { + await cg.close(); + cg = undefined; + } + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('lists a symbol\'s annotations in codegraph_node output', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kt-mcp-')); + fs.writeFileSync( + path.join(tempDir, 'Vm.kt'), + `package p + +@HiltViewModel +class ProfileViewModel : ViewModel() {} + +@Composable +fun ProfileCard() {} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + + const { ToolHandler } = await import('../src/mcp/tools'); + const handler = new ToolHandler(cg); + + const vm = await handler.execute('codegraph_node', { symbol: 'ProfileViewModel' }); + const vmText = JSON.stringify(vm); + expect(vmText).toContain('@HiltViewModel'); + + const card = await handler.execute('codegraph_node', { symbol: 'ProfileCard' }); + expect(JSON.stringify(card)).toContain('@Composable'); + }); +}); diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 58fde4285..f62a322f0 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -13,7 +13,8 @@ //! initializers emitting nothing, the bodiless-class header re-walk asymmetry, //! enum-entry bodies being invisible, KDoc (`multiline_comment`) never being //! a docstring AND chain-breaking, comment-gluing into import/package extents, -//! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero +//! `@Anno(args)` and `@Anno` both emitting decorates + a persisted node +//! decorator name (see ANNOTATION_KINDS / collect_decorator_entries), zero //! instantiates refs (constructors are capitalized `calls`), the qualified- //! receiver `com::qext` bug, the paren-then-lambda `trailing()` garbage //! callee, and the packaged-file value-ref target drop (namespace parents are @@ -110,6 +111,36 @@ struct Extra { /// composeReceiverQualifiedName override (extension methods) — the id /// still hashes the bare NAME; only the qualifiedName column changes. qualified_override: Option, + /// Annotation simple names collected by the caller BEFORE create_node, so + /// they can land in the node's `decorators` list (and, for functions and + /// methods, decide the node kind). See `collect_decorator_entries`. + annotations: Vec, +} + +/// One annotation applied to a declaration. Collected once per declaration and +/// then read by all three consumers — the kind decision, the node's +/// `decorators` list, and the `decorates` refs — so they cannot drift apart. +struct DecoEntry { + name: String, + line: u32, + col: u32, +} + +/// Declarative annotation -> NodeKind map; the Rust mirror of the Kotlin +/// extractor's `annotationKinds` (src/extraction/languages/kotlin.ts). +/// `@Composable` functions are Jetpack Compose UI components — the Kotlin +/// analogue of the function-level `component` nodes the React resolver creates +/// for JSX-returning functions. +const ANNOTATION_KINDS: &[(&'static str, &'static str)] = &[("Composable", "component")]; + +/// First annotation on the declaration that maps to a NodeKind, if any. +fn annotation_kind_for(entries: &[DecoEntry]) -> Option<&'static str> { + entries.iter().find_map(|e| { + ANNOTATION_KINDS + .iter() + .find(|(anno, _)| e.name == *anno) + .map(|(_, kind)| *kind) + }) } struct ValueScope<'t> { @@ -328,7 +359,10 @@ impl<'t> Walker<'t> { // kinds (in-range for this grammar, so practically a no-op — but the // hook is part of the contract). let mut end_line = node.end_position().row as u32 + 1; - if kind == "function" || kind == "method" { + // createNode's endLine extension (tree-sitter.ts:1381) — 'component' + // included: ANNOTATION_KINDS can reclassify a function or method + // (@Composable) and those nodes still have bodies to span. + if kind == "function" || kind == "method" || kind == "component" { if let Some(body) = self.resolve_body(node) { let be = body.end_position().row as u32 + 1; if be > end_line { @@ -364,11 +398,18 @@ impl<'t> Walker<'t> { } // extractModifiers merge (tree-sitter.ts:1355) — runs for EVERY // created node: expect/actual platform modifiers → decorators. - let mods = self.extract_modifiers(node); - let dec_ref: StrRef = match &mods { - Some(list) if !list.is_empty() => self.arena.put_list(list), - _ => NONE_STR, - }; + let mut deco: Vec = self.extract_modifiers(node).unwrap_or_default(); + // Annotation names append after the platform modifiers, deduped — + // mirroring extractDecoratorsFor's merge on the wasm arm. Framework + // annotations (@Composable, @HiltViewModel, @Inject) are declared in + // external libraries outside the index, so their `decorates` refs never + // resolve; the name on the node is the only queryable trace that lasts. + for a in &extra.annotations { + if !deco.contains(a) { + deco.push(a.clone()); + } + } + let dec_ref: StrRef = if deco.is_empty() { NONE_STR } else { self.arena.put_list(&deco) }; let name_ref = self.arena.put(name); let qn_ref = self.arena.put(&qualified); let id_ref = self.arena.put(&id); @@ -409,7 +450,10 @@ impl<'t> Walker<'t> { target_id_str: NONE_STR, }); - if kind == "function" || kind == "method" { + // flushFnRefCandidates' definedHere gate (tree-sitter.ts:684) — + // 'component' included: a @Composable is referenced as a plain function + // value (`::Header`), so it must pass the fn-ref gate. + if kind == "function" || kind == "method" || kind == "component" { self.defined_fn_names.insert(name.to_string()); } // captureValueRefScope — namespace parents are NOT accepted, so @@ -429,7 +473,11 @@ impl<'t> Walker<'t> { *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1; } } - if matches!(kind, "function" | "method" | "constant" | "variable") { + // captureValueRefScope (tree-sitter.ts:808) — 'component' included: + // ANNOTATION_KINDS reclassifies @Composable functions and methods, and + // their bodies still bound a value-ref scope. Both arms must agree here + // or the parity gate fails. + if matches!(kind, "function" | "method" | "constant" | "variable" | "component") { self.value_scopes.push(ValueScope { row, node, name: name.to_string() }); } Some(row) @@ -793,6 +841,10 @@ impl<'t> Walker<'t> { } return; } + // Annotations are collected BEFORE create_node: they decide the node + // kind (@Composable -> component) and ride along into `decorators`. + let entries = self.collect_decorator_entries(node); + let kind = annotation_kind_for(&entries).unwrap_or("function"); let extra = Extra { docstring: preceding_docstring(node, self.src), signature: None, // dead hook (zero fields) @@ -800,13 +852,14 @@ impl<'t> Walker<'t> { is_async: Some(self.is_async(node)), is_static: Some(false), // kotlin isStatic is always false return_type: self.return_type_of(node), + annotations: entries.iter().map(|e| e.name.clone()).collect(), ..Extra::default() }; - let Some(row) = self.create_node("function", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; // extractTypeAnnotations: the generic path's field lookups all miss // (zero fields) — kotlin emits ZERO type-annotation refs. - self.extract_decorators_for(node, row); - self.stack.push(Scope { row, kind: "function", name }); + self.emit_decorator_refs(&entries, row); + self.stack.push(Scope { row, kind, name }); if let Some(body) = self.resolve_body(node) { self.visit_function_body(body); } @@ -818,6 +871,8 @@ impl<'t> Walker<'t> { let receiver = self.receiver_type_of(node); let name = self.extract_name(node); let qualified_override = receiver.as_ref().map(|r| format!("{r}::{name}")); + let entries = self.collect_decorator_entries(node); + let kind = annotation_kind_for(&entries).unwrap_or("method"); let extra = Extra { docstring: preceding_docstring(node, self.src), signature: None, @@ -826,8 +881,9 @@ impl<'t> Walker<'t> { is_static: Some(false), return_type: self.return_type_of(node), qualified_override, + annotations: entries.iter().map(|e| e.name.clone()).collect(), }; - let Some(row) = self.create_node("method", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; // Owner-contains fallback (1799): receiver present, not class-like → // the FIRST same-file node named like the receiver with kind ∈ // {struct, class, enum, trait} (interface EXCLUDED; source-order @@ -857,8 +913,8 @@ impl<'t> Walker<'t> { } } // Type annotations: dead. Decorators: live. - self.extract_decorators_for(node, row); - self.stack.push(Scope { row, kind: "method", name }); + self.emit_decorator_refs(&entries, row); + self.stack.push(Scope { row, kind, name }); if let Some(body) = self.resolve_body(node) { self.visit_function_body(body); } @@ -869,15 +925,20 @@ impl<'t> Walker<'t> { stack_guard!(); let resolved_body = self.resolve_body(node); let name = self.extract_name(node); + // Classes persist annotation names (@HiltViewModel, @Entity, …) but are + // NOT reclassified by ANNOTATION_KINDS — the wasm arm applies the kind + // map to functions and methods only. + let entries = self.collect_decorator_entries(node); let extra = Extra { docstring: preceding_docstring(node, self.src), visibility: Some(self.visibility_of(node)), + annotations: entries.iter().map(|e| e.name.clone()).collect(), ..Extra::default() }; let Some(row) = self.create_node("class", &name, node, extra) else { return }; self.extract_inheritance(node, row); // primaryCtor refs: csharp-gated no-op. - self.extract_decorators_for(node, row); + self.emit_decorator_refs(&entries, row); self.stack.push(Scope { row, kind: "class", name }); // Bodied: ONLY class_body children (primary-ctor properties/defaults // invisible). Bodiless: the class node itself → header children @@ -895,11 +956,16 @@ impl<'t> Walker<'t> { fn extract_interface(&mut self, node: Node<'t>) { stack_guard!(); let name = self.extract_name(node); + // extractInterface's gated decorator call (tree-sitter.ts:1909) — Room's + // `@Dao interface`. Interfaces are NOT reclassified by ANNOTATION_KINDS. + let entries = self.collect_decorator_entries(node); let extra = Extra { docstring: preceding_docstring(node, self.src), + annotations: entries.iter().map(|e| e.name.clone()).collect(), ..Extra::default() // NO visibility }; let Some(row) = self.create_node("interface", &name, node, extra) else { return }; + self.emit_decorator_refs(&entries, row); self.extract_inheritance(node, row); self.stack.push(Scope { row, kind: "interface", name }); let body = self.resolve_body(node).unwrap_or(node); @@ -915,12 +981,17 @@ impl<'t> Walker<'t> { stack_guard!(); let Some(body) = self.resolve_body(node) else { return }; let name = self.extract_name(node); + // extractEnum's gated decorator call (tree-sitter.ts:2013) — + // `@Serializable enum class`. Same gating rationale as interfaces. + let entries = self.collect_decorator_entries(node); let extra = Extra { docstring: preceding_docstring(node, self.src), visibility: Some(self.visibility_of(node)), + annotations: entries.iter().map(|e| e.name.clone()).collect(), ..Extra::default() }; let Some(row) = self.create_node("enum", &name, node, extra) else { return }; + self.emit_decorator_refs(&entries, row); self.extract_inheritance(node, row); self.stack.push(Scope { row, kind: "enum", name }); for i in 0..body.named_child_count() { @@ -1159,23 +1230,31 @@ impl<'t> Walker<'t> { } } - /// extractDecoratorsFor — kotlin annotations inside `modifiers`: - /// `@Marker` (user_type child) → decorates ref; `@Anno(args)` - /// (constructor_invocation) → NOTHING. Runs for functions/methods/classes - /// only (hook properties never call it). - fn extract_decorators_for(&mut self, decl: Node<'t>, decorated_row: u32) { + /// extractDecoratorsFor — gather kotlin annotations from `modifiers` and + /// from decorator-position preceding siblings. PURE, so the kind decision, + /// the node's `decorators` list and the `decorates` refs all read the same + /// entries. Runs for functions/methods/classes only (hook properties never + /// call it). + /// + /// Mirrors the wasm arm's `extendedAnnotations` opt-in (kotlin.ts): + /// `constructor_invocation` is unwrapped so `@Preview(showBackground = + /// true)` is no longer silently dropped, and EVERY target under one + /// annotation node is collected, for bracket syntax + /// (`@[Suppress("x") JvmStatic]`). + fn collect_decorator_entries(&self, decl: Node<'t>) -> Vec { + let mut out: Vec = Vec::new(); for i in 0..decl.named_child_count() { let Some(child) = decl.named_child(i) else { continue }; - self.consider_decorator(child, decorated_row); + self.consider_decorator_into(child, &mut out); if child.kind() == "modifiers" { for j in 0..child.named_child_count() { if let Some(m) = child.named_child(j) { - self.consider_decorator(m, decorated_row); + self.consider_decorator_into(m, &mut out); } } } } - let Some(parent) = decl.parent() else { return }; + let Some(parent) = decl.parent() else { return out }; let decl_start = decl.start_byte(); let mut decl_idx: isize = -1; for i in 0..parent.named_child_count() { @@ -1196,40 +1275,53 @@ impl<'t> Walker<'t> { if !matches!(sib.kind(), "decorator" | "annotation" | "marker_annotation") { break; } - self.consider_decorator(sib, decorated_row); + self.consider_decorator_into(sib, &mut out); j -= 1; } } + out } - fn consider_decorator(&mut self, n: Node<'t>, decorated_row: u32) { + /// Emit one `decorates` ref per collected entry, from the node the + /// annotations were collected for. + fn emit_decorator_refs(&mut self, entries: &[DecoEntry], decorated_row: u32) { + let code = edge_kind_index("decorates").unwrap(); + for e in entries { + let (name, line, col) = (e.name.clone(), e.line, e.col); + self.push_ref(decorated_row, &name, code, line, col); + } + } + + fn consider_decorator_into(&self, n: Node<'t>, out: &mut Vec) { if !matches!(n.kind(), "decorator" | "annotation" | "marker_annotation" | "attribute") { return; } - let mut target: Option = None; + let line = self.line_of(n); + let col = self.col_of(n); for i in 0..n.named_child_count() { let Some(child) = n.named_child(i) else { continue }; - if child.kind() == "call_expression" { - target = child.child_by_field_name("function").or_else(|| child.named_child(0)); - if target.is_some() { - break; - } - } - if matches!( + let target: Option = if child.kind() == "call_expression" { + child.child_by_field_name("function").or_else(|| child.named_child(0)) + } else if child.kind() == "constructor_invocation" { + (0..child.named_child_count()) + .filter_map(|j| child.named_child(j)) + .find(|c| c.kind() == "user_type") + } else if matches!( child.kind(), "identifier" | "member_expression" | "scoped_identifier" | "navigation_expression" | "user_type" | "type_identifier" ) { - target = Some(child); - break; + Some(child) + } else { + None + }; + let Some(target) = target else { continue }; + let name = strip_generic_and_qualifier(self.text(target)); + if name.is_empty() { + continue; } + out.push(DecoEntry { name, line, col }); } - let Some(target) = target else { return }; - let name = strip_generic_and_qualifier(self.text(target)); - if name.is_empty() { - return; - } - self.push_ref_at(decorated_row, &name, edge_kind_index("decorates").unwrap(), n); } // --- function-as-value refs (KOTLIN_SPEC, function-ref.ts:240) ------------------ diff --git a/docs/design/kotlin-annotation-extraction.md b/docs/design/kotlin-annotation-extraction.md new file mode 100644 index 000000000..076079f8c --- /dev/null +++ b/docs/design/kotlin-annotation-extraction.md @@ -0,0 +1,278 @@ +# CodeGraph — Android / Kotlin Improvement Design + +> Status: **implemented, Kotlin-scoped.** Shipped in both extraction arms — the +> wasm walker (`src/extraction/tree-sitter.ts`) and the native Rust walker +> (`codegraph-kernel/src/kotlin.rs`), which is the arm Kotlin actually uses. +> Author context: opened from a real Getcontact Android codebase where +> annotation-driven facts (Compose / Hilt / Room) were unqueryable. +> +> **How the shipped form differs from the v2 design below.** The design proposed +> persisting annotation names for ALL languages from one shared collector. That +> is not what shipped, for two reasons found while implementing it: +> +> 1. **Each of the 13 native kernel walkers owns its own decorator logic.** +> Changing only the shared TS engine breaks the kernel↔wasm parity gate for +> every routed language. Behavior is therefore opt-in per language via +> `LanguageExtractor.extendedAnnotations`, and only Kotlin sets it — its Rust +> walker was ported in lockstep. +> 2. **Collecting every target under one annotation node is Kotlin-specific.** +> Swift carries argument expressions inside the attribute node, so +> `@Siblings(through: Pivot.self, from: \.$left)` would harvest `self` and +> `$left` as annotation names. Other languages keep stop-at-first-target. +> +> Two further corrections to the design as written: the classification hook is a +> declarative `annotationKinds?: Record` map, not the +> `classifyFunctionNode?(node)` callback described in §4/§Finding C; and +> annotation-name persistence covers functions, methods and classes only — +> properties and fields are excluded (Kotlin handles `property_declaration` in +> its own `visitNode` hook and never reaches `extractProperty`, and the kernel +> walkers document the same restriction), so `@field:Inject` yields a `decorates` +> ref but no queryable name. +> +> **Reclassifying to `component` has a blast radius.** A node created as +> `component` instead of `function` silently falls out of every gate that keys on +> those kinds. The ones that had to be widened alongside it: +> `flushFnRefCandidates`/`defined_fn_names` (function-ref edges — a real bug, +> pinned by a test), `captureValueRefScope`/`value_scopes`, +> `enclosingScopeStartLine`, `matchFuzzy`'s `callableKinds`, the function-ref +> candidate filter, `findDeadCode`'s default kinds, and `kmpKindsCompatible` +> (an asymmetrically-annotated expect/actual pair). Anything else keyed on +> node kind is a candidate for the same problem. +> +> **Not yet done** (§7's numbers were measured on a private repo and are not +> reproducible here): the full-repo kernel↔wasm sweep via +> `scripts/kernel-parity.mjs` over the Kotlin gate repos (okio, okhttp, +> kotlinx.coroutines), which `src/extraction/kernel/index.ts` records as the +> evidence base for routing Kotlin to the kernel; and a no-regression run on a +> non-Kotlin control repo. The checked-in parity fixture +> (`__tests__/fixtures/kernel-parity/torture.kt`) was extended to cover the new +> annotation shapes. + +## 0. Validation record (run 2026-08-31) + +Kernel↔wasm parity sweep after changing `codegraph-kernel/src/kotlin.rs`, plus +the annotation/component extraction gain. **Before** is a `git worktree` at clean +`main` (`6a056ec`) with its own `dist/` and its own kernel built from that tree's +`kotlin.rs`; **after** is this branch. Same repo checkouts (fresh `--depth 1`) for +both arms, so every delta is attributable to the change. + +`node scripts/kernel-parity.mjs --lang kotlin` + +| Repo | Files swept | Byte-parity | **Diffs** | Deferred before → after | Nodes before → after | +|---|---|---|---|---|---| +| square/okio | 327 | 303 | **0** | 24 → 24 | 6,764 → 6,764 | +| square/okhttp | 617 | 566 | **0** | 51 → 51 | 16,396 → 16,396 | +| Kotlin/kotlinx.coroutines | 1,082 | 1,031 | **0** | 51 → 51 | 15,379 → 15,379 | +| android/nowinandroid | 350 | 339 | **0** | 11 → 11 | 5,603 → 5,603 | +| android/compose-samples | 380 | 364 | **0** | 16 → 16 | 8,836 → 8,836 | + +- **0 diffs on 2,756 files.** Both arms agree everywhere, so the Rust port and the + wasm arm implement the same behavior on real code, not just on the fixture. +- **Deferral unchanged in every repo.** The gate repos' recorded baseline is + 23/49/51 (`src/extraction/kernel/index.ts`); the +1/+2 seen here is repo drift + since that 2026-07 sweep, not this change — the before-tree reproduces the same + 24/51/51 on these checkouts. No walker-breakage signal (the bar is a jump past + ~10%). +- **Node count identical in every repo** — the no-explosion check. Reclassifying + `@Composable` changes a node's kind, not the node set. + +Extraction gain, same checkouts (`decorates` refs and `decorators` names; the +`expect`/`actual` platform markers are excluded from the annotated-symbol count so +it reflects annotations only): + +| Repo | Components before → after | Annotated symbols | `decorates` refs before → after | +|---|---|---|---| +| square/okio | 0 → 0 | 0 → 1,957 | 1,534 → 2,014 | +| square/okhttp | 0 → 0 | 0 → 3,895 | 3,343 → 4,310 | +| Kotlin/kotlinx.coroutines | 0 → 0 | 0 → 3,775 | 3,252 → 4,111 | +| android/nowinandroid | 0 → **151** | 0 → 617 | 599 → 772 | +| android/compose-samples | 0 → **545** | 0 → 750 | 783 → 1,065 | + +The `decorates` gain (+2,742 across the five) is the arg-bearing-annotation bug +(`@Preview(...)`, `@Entity(tableName = …)`, `@Query(…)` previously emitted nothing) +plus the interface/enum paths, which emitted no decorator refs at all — that is +where Room's `@Dao` lives. +The three non-Compose libraries correctly produce **zero** component nodes while +still gaining annotation coverage — the kind map only fires on `@Composable`. + +**Still not run:** an agent A/B on an Android repo. The author's earlier eval on a +private Compose codebase found that census questions ("how many `@Composable`…") +do not make the agent reach for codegraph — 3/3 with-arm runs chose grep — which +is the low-salience wall described in CLAUDE.md's "Adapt the tool to the agent". +The `component` kind and the `codegraph_node` annotation line are the parts that +ride tools the agent already calls; neither has a measured retrieval win yet. + +--- + +## 1. Motivating problem + +On a 120k-node Kotlin index, none of these can be answered: + +- "How many `@Composable` functions are in package X?" +- "Which classes are `@HiltViewModel` / `@Entity` / `@Serializable`?" +- "Find every symbol annotated `@Inject`." + +`codegraph_search kind=component` returns **zero** results, and there is no +annotation-based query surface at all. Annotations are the backbone of modern +Android, so this is a large blind spot for any Compose/Hilt/Room project. + +--- + +## 2. Root cause (verified against source) + +The Kotlin support is a single declarative extractor: +`src/extraction/languages/kotlin.ts` (267 lines), driven by the generic engine +`src/extraction/tree-sitter.ts`. + +### Finding A — annotations ARE walked, then evaporate +`extractDecoratorsFor` (`tree-sitter.ts:2670`) **does** descend into the Kotlin +`modifiers` child (lines 2734–2738, explicitly added for "Java/Kotlin/C#"). +So `@Composable` is seen — but it is emitted as an **unresolved `decorates` +reference** (`tree-sitter.ts:2716`) pointing at a symbol named `Composable`. + +That reference is then resolved against in-repo symbols. Framework annotations +(`@Composable`, `@Inject`, `@HiltViewModel`, `@Entity`) are declared in +**external libraries that are not in the index**, so the reference stays +**unresolved → never persisted → invisible**. + +### Finding B — annotation names are not stored on the symbol node +The `decorators` column exists in the schema (`src/db/schema.sql:38`, JSON array) +and is round-tripped by `src/db/queries.ts:282/356`. But for Kotlin it is +populated **only** by `extractModifiers` = `expect`/`actual` markers +(`tree-sitter.ts:630–632`). There is no "this symbol is annotated `@X`" fact +anywhere queryable. + +### Finding C — no per-function classification hook +`@Composable` functions stay `kind: 'function'`. The `LanguageExtractor` +interface (`src/extraction/tree-sitter-types.ts`) exposes `classifyClassNode` +and `resolveTypeAliasKind`, but **nothing for functions/methods**. The +`component` node kind is currently emitted only by whole-file framework +extractors (Vue / Razor / Svelte). + +--- + +## 3. Core idea + +Stop relying on *resolving* annotation references to external symbols. Instead: + +1. **Store annotation names directly on the symbol node** as a structured + attribute (rides the existing `decorators` pipeline — no new storage). +2. **Reclassify** symbols whose annotations are semantically significant + (`@Composable` → `component`), behind a new optional extractor hook. + +--- + +## 4. Recommended scope (PR #1 = items #1 + #2) + +### Change 1 — capture annotation names onto the node +- **File:** `src/extraction/languages/kotlin.ts` +- Extend `extractModifiers` (or add a sibling reader) to walk + `node → modifiers → annotation → user_type/constructor_invocation → type_identifier` + and collect names: `Composable`, `Preview`, `HiltViewModel`, `Inject`, + `Entity`, `Serializable`, etc. +- These merge into `node.decorators` at `tree-sitter.ts:630–632` and are already + persisted. **Zero engine changes** for capture. +- ⚠️ The field is named `decorators` but would now hold annotations + expect/actual. + Acceptable (the line-628 comment anticipates this); note it in the PR. + +### Change 2 — reclassify `@Composable` → `component` +- **New hook:** `classifyFunctionNode?(node): NodeKind | undefined` on + `LanguageExtractor` (mirrors `classifyClassNode`). +- **Engine:** call it in `extractFunction` (`tree-sitter.ts:815`) and + `extractMethod` (`:944`) to override the default kind. +- **Kotlin impl:** annotation set contains `Composable` → return `'component'`. +- `@Preview` composables remain `component` but are tagged via `decorators`, so + search can separate the ~real UI composables from preview harnesses. + +### Files touched +| File | Change | Approx | +|---|---|---| +| `src/extraction/languages/kotlin.ts` | annotation reader + `classifyFunctionNode` | ~40 LOC | +| `src/extraction/tree-sitter-types.ts` | add `classifyFunctionNode?` | ~6 LOC | +| `src/extraction/tree-sitter.ts` | invoke hook in extractFunction/extractMethod | ~6 LOC | +| `__tests__/` (new `kotlin-annotations.test.ts`) | fixtures | ~60 LOC | + +### Test plan +Repo uses **vitest** with inline-source fixtures (see `__tests__/extraction.test.ts`, +`frameworks.test.ts`). Add cases: +- `@Composable fun X()` → node kind `component` +- `@Preview @Composable fun XPreview()` → `component`, `Preview` in decorators +- `@HiltViewModel class` / `@Inject` → decorators populated +- plain `fun` → still `function` (no regression) + +No fixture files needed — tests build source strings and assert on nodes. + +--- + +## 5. Deferred (separate follow-up PRs) +- **#3 Room/Hilt edges** — DI provision graph (`@Provides`/`@Binds`/`@Module`), + `@Entity`/`@Dao`/`@Database` tagging. Resolution-layer work; additive once + annotations are on nodes. +- **#4 Search filter** — `codegraph_search ... annotatedWith="Composable"` + (`src/search`, `src/mcp`). Natural follow-up once data exists. + +--- + +## 6. Risks / unknowns to resolve before coding +1. **AST shape** — confirm tree-sitter-kotlin annotation structure against a real + parse, incl. use-site targets (`@field:Foo`) and multi-annotation `@[A B]`. + A throwaway parse harness settles this. +2. **`component` semantics upstream** — maintainer may treat `component` as a + *file-level framework unit* (Vue/Svelte are whole-file), not a function. + Float an issue first. Fallback: keep `kind: function` + an annotation-driven + flag instead of reclassifying — less invasive, likelier to merge. +3. **Index size/noise** — every annotated symbol gains a decorators array; + negligible but measurable at 120k nodes. + +--- + +## 7. Validation evals (per CLAUDE.md methodology, run 2026-06-11) + +Repo under test: Getcontact Android (Kotlin/Compose). Tiers indexed with the +patched dist: small = newsfeed (220 kt), medium = app module (2,535 kt), +large = full repo (7,537 kt). + +**Deterministic probes** +- `probe-explore` (small): flow query `NewsFeedAdViewScreen NewsFeedAdViewViewModel` + connects composable→viewmodel, headers show `(component)` kinds. PASS +- Component census: small 150 / medium 1,224 (300 preview) / large 2,670 + (613 preview); decorated nodes large: 7,070. PASS +- Node explosion: small re-index 3,729 → 3,729 (stable); large 119,321 vs + 120,548 on the pre-change binary (comparable file set). PASS +- Precision: 8/8 random `component` nodes verified `@Composable` in source; + 0 components lack the `Composable` decorator. PASS + +**Agent A/B** (headless `claude -p`, opus, `--strict-mcp-config`, medium tier, +flow Q: "How does NewsFeedAdViewScreen get the ad content it displays?") + +| Arm | Runs | Duration | Read | Bash | explore | +|---|---|---|---|---|---| +| with (medium) | 2 | 75s / 81s | 1 / 1 | 5 / 5 | 3 / 4 | +| without (medium) | 2 | 137s / 160s | 11 / 14 | 34 / 35 | — | +| with (large/full repo) | 1 | 63s | **0** | 1 | 3 | + +Both arms reached the same correct conclusion (ad content via external +AdManager SDK vs ad settings via repo chain). Medium-tier with-arm greps were +chasing modules absent from that tier's copy; on the full-repo index the run +hits the pass bar exactly (0 Read, 1 Bash). PASS (~2× faster, ~0 Read/Grep). + +**Finding for upstream:** census questions ("how many @Composable…") do NOT +trigger codegraph spontaneously — 3/3 with-arm runs chose grep, despite +`codegraph_status`/`search kind=component` answering instantly when invoked +(verified). `server-instructions.ts` should add a census/count routing line +(e.g. "how many X / list all X → codegraph_search with kind/annotation"). + +**Harness note:** the eval harness MCP config needs an `env` block on Node 25 +hosts (`CODEGRAPH_ALLOW_UNSAFE_NODE`) — `serve --mcp` exits silently at the +version gate and claude reports the server "pending" forever. `parse-run.mjs` +"tools exposed: 0" reads the t=0 snapshot and is misleading in that state. + +**Not yet run:** control-repo A/B on a non-Kotlin repo (regression there is +covered so far by the unit suite: 1,163 tests incl. TS/Python/Java decorator +persistence; full suite green). + +## 8. Recommendation +Lead with **Change 1** — nearly free (reuses `decorators` storage, no engine +change) and unblocks everything. **Change 2** delivers the `kind=component` fix +but carries the upstream-acceptance risk in §6.2, so open a discussion issue +before sending the PR. diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index 5e3c4e2fa..183546f70 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -239,6 +239,11 @@ export const kotlinExtractor: LanguageExtractor = { } return null; }, + // `@Composable` functions are Jetpack Compose UI components — the Kotlin + // analogue of the function-level 'component' nodes the React resolver + // creates for JSX-returning functions. + annotationKinds: { Composable: 'component' }, + extendedAnnotations: true, classifyClassNode: (node) => { // Kotlin reuses class_declaration for classes, interfaces, and enums. // Detect by checking for keyword children: diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 6808895e1..dda45223c 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -225,6 +225,46 @@ export interface LanguageExtractor { */ classifyMethodNode?: (node: SyntaxNode) => 'method' | 'property'; + /** + * Declarative decorator/annotation → NodeKind map for function/method + * declarations. When a declaration carries a decorator whose simple name is + * a key here, the node is created with the mapped kind instead of the + * default 'function'/'method' (e.g. Kotlin `{ Composable: 'component' }` + * classifies Jetpack Compose UI components). Names are matched against the + * same decorator entries the engine already extracts for `decorates` edges, + * so a language adds classification with data only — no parsing logic. + */ + annotationKinds?: Record; + + /** + * Opt in to extended annotation extraction. Off by default, and deliberately + * per-language: it changes extraction output, so the language's native kernel + * walker must mirror it exactly or the kernel<->wasm parity gate fails. + * + * Three behaviors, all needed for Kotlin annotations: + * 1. persist annotation simple names onto each node's `decorators` list — + * framework annotations live in external libraries outside the index, so + * their `decorates` refs never resolve and the name on the node is the + * only queryable trace that survives; + * 2. unwrap `constructor_invocation` so arg-bearing annotations + * (`@Preview(showBackground = true)`) are not silently dropped; + * 3. collect EVERY target under one annotation node, for Kotlin's bracket + * syntax (`@[Suppress("x") JvmStatic]`). + * + * (3) is why this is opt-in rather than universal: Swift attributes carry + * argument expressions in the same node (`@Siblings(from: \.$left)`), so + * collecting past the first target harvests `self`/`$left` as annotation + * names. Languages that don't opt in keep the stop-at-first behavior. + * + * Scope: functions, methods and classes. Properties and fields deliberately + * do NOT persist names — Kotlin handles `property_declaration` in its own + * `visitNode` hook and never reaches `extractProperty`/`extractField`, and the + * kernel walkers document the same restriction, so wiring it on this arm alone + * would break parity. `@field:Inject` on a property is therefore captured as a + * `decorates` ref but not as a queryable name; closing that needs both arms. + */ + extendedAnnotations?: boolean; + /** * Resolve the body node for a function/method/class when it's not a child field. * (e.g. Dart puts function_body as a sibling, not a child.) diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c34dc4716..5668ff6d2 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -388,6 +388,19 @@ const LITERAL_RECEIVER_TYPES = new Set([ 'dictionary', 'dict_literal', 'object', 'tuple', 'set', ]); +/** + * One decorator/annotation/attribute applied to a declaration, as gathered by + * `collectDecoratorEntries`. `kind` is the EdgeKind the entry becomes: normal + * annotations emit `decorates`, while Solidity `modifier_invocation` emits + * `calls` (its body really does execute around the function). + */ +interface DecoratorEntry { + name: string; + kind: 'decorates' | 'calls'; + line: number; + column: number; +} + export class TreeSitterExtractor { private filePath: string; private language: Language; @@ -407,6 +420,19 @@ export class TreeSitterExtractor { private valueRefScopes: Array<{ id: string; node: SyntaxNode; name: string }> = []; private errors: ExtractionError[] = []; private extractor: LanguageExtractor | null = null; + /** + * True when this language's `annotationKinds` map produces 'component' nodes + * (Kotlin @Composable). Such a node was going to be a function or method, so + * every engine gate that keys on those kinds must accept it too — otherwise + * reclassifying silently drops its function-ref edges and its value-ref + * scope. Derived, not a separate flag, so a language opting into + * `annotationKinds` can't forget to set it. + * + * Scoped to opted-in languages on purpose: the same gates exist in all 13 + * native kernel walkers, so widening them for a language whose Rust walker + * was not changed in lockstep would break the kernel<->wasm parity gate. + */ + private componentIsFunctionLike = false; private nodeStack: string[] = []; // Stack of parent node IDs // C/C++ enclosing `namespace ns { … }` names, prepended to every contained // symbol's qualifiedName (see visitNode). Prefix-only by design — no @@ -445,6 +471,9 @@ export class TreeSitterExtractor { this.source = source; this.language = language || detectLanguage(filePath, source); this.extractor = EXTRACTORS[this.language] || null; + this.componentIsFunctionLike = Object.values(this.extractor?.annotationKinds ?? {}).includes( + 'component' + ); this.fnRefSpec = FN_REF_SPECS[this.language]; this.sourceIsPreParsed = options?.sourceIsPreParsed === true; } @@ -650,6 +679,9 @@ export class TreeSitterExtractor { const definedHere = new Set(); for (const n of this.nodes) { if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name); + // Kotlin @Composable functions are 'component' but are referenced as + // plain function values (`::Header`), so they must pass the gate too. + else if (this.componentIsFunctionLike && n.kind === 'component') definedHere.add(n.name); // Python only (#1478): class-as-value is a first-class idiom (DRF // get_serializer_class, Meta.model, registry dicts), so same-file CLASS // names pass the gate too. Other languages keep the function/method @@ -768,7 +800,13 @@ export class TreeSitterExtractor { this.fileScopeValueCounts.set(name, (this.fileScopeValueCounts.get(name) ?? 0) + 1); } } - if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable') { + if ( + kind === 'function' || + kind === 'method' || + kind === 'constant' || + kind === 'variable' || + (this.componentIsFunctionLike && kind === 'component') + ) { this.valueRefScopes.push({ id, node, name }); } } @@ -1338,7 +1376,9 @@ export class TreeSitterExtractor { // (callees, the callback synthesizer's body scan, context slices). Guarded to // only ever extend: for child-body grammars the body is within range (no-op). let endLine = node.endPosition.row + 1; - if (kind === 'function' || kind === 'method') { + // 'component' included: annotationKinds can reclassify a function/method + // (e.g. Kotlin @Composable) and those nodes still have bodies to span. + if (kind === 'function' || kind === 'method' || kind === 'component') { const body = this.extractor?.resolveBody?.(node, this.extractor.bodyField); if (body && body.endPosition.row + 1 > endLine) { endLine = body.endPosition.row + 1; @@ -1592,7 +1632,8 @@ export class TreeSitterExtractor { const isStatic = this.extractor.isStatic?.(node); const returnType = this.extractor.getReturnType?.(node, this.source); - const funcNode = this.createNode('function', name, node, { + const funcKind = this.annotationKindFor(node) ?? 'function'; + const funcNode = this.createNode(funcKind, name, node, { docstring, signature, visibility, @@ -1609,7 +1650,7 @@ export class TreeSitterExtractor { // Extract decorators applied to the function (rare in JS/TS but // present in Python `@decorator def f():` and Java/Kotlin // annotations on free functions). - this.extractDecoratorsFor(node, funcNode.id); + this.extractDecoratorsFor(node, funcNode.id, funcNode); // Push to stack and visit body this.nodeStack.push(funcNode.id); @@ -1720,7 +1761,7 @@ export class TreeSitterExtractor { this.extractCsharpPrimaryCtorParamRefs(node, classNode.id); // Extract decorators applied to the class (`@Foo class X {}`). - this.extractDecoratorsFor(node, classNode.id); + this.extractDecoratorsFor(node, classNode.id, classNode); // Push to stack and visit body this.nodeStack.push(classNode.id); @@ -1804,7 +1845,8 @@ export class TreeSitterExtractor { extraProps.qualifiedName = this.composeReceiverQualifiedName(receiverType, name); } - const methodNode = this.createNode('method', name, node, extraProps); + const methodKind = this.annotationKindFor(node) ?? 'method'; + const methodNode = this.createNode(methodKind, name, node, extraProps); if (!methodNode) return; // For methods with a receiver type but no class-like parent on the stack @@ -1829,7 +1871,7 @@ export class TreeSitterExtractor { this.extractTypeAnnotations(node, methodNode.id); // Extract decorators (`@Get('/list') list() {}`). - this.extractDecoratorsFor(node, methodNode.id); + this.extractDecoratorsFor(node, methodNode.id, methodNode); // Push to stack and visit body this.nodeStack.push(methodNode.id); @@ -1859,6 +1901,15 @@ export class TreeSitterExtractor { }); if (!interfaceNode) return; + // Annotations on the declaration (Room's `@Dao interface`, `@Serializable + // enum class`). Gated on the language opt-in: these two paths emitted NO + // decorates refs before, so calling this unconditionally would change + // output for every other decorator-using language and break its kernel + // parity gate. + if (this.extractor.extendedAnnotations) { + this.extractDecoratorsFor(node, interfaceNode.id, interfaceNode); + } + // Extract extends (interface inheritance) this.extractInheritance(node, interfaceNode.id); @@ -1954,6 +2005,15 @@ export class TreeSitterExtractor { }); if (!enumNode) return; + // Annotations on the declaration (Room's `@Dao interface`, `@Serializable + // enum class`). Gated on the language opt-in: these two paths emitted NO + // decorates refs before, so calling this unconditionally would change + // output for every other decorator-using language and break its kernel + // parity gate. + if (this.extractor.extendedAnnotations) { + this.extractDecoratorsFor(node, enumNode.id, enumNode); + } + // Extract inheritance (e.g. Swift: enum AFError: Error) this.extractInheritance(node, enumNode.id); @@ -4947,9 +5007,11 @@ export class TreeSitterExtractor { } /** - * Scan `declNode` and its preceding siblings (within the parent's - * named children) for decorator nodes, emitting a `decorates` - * reference from `decoratedId` to each decorator's function name. + * Gather every decorator/annotation/attribute applied to `declNode` as + * (simple name, position) entries. Shared by `extractDecoratorsFor` + * (decorates edges + persisting names onto the node) and + * `annotationKindFor` (declarative kind classification), so the + * grammar-specific shapes are parsed exactly once, in one place. * * Why preceding siblings: in TypeScript, `@Foo class Bar {}` parses * as an `export_statement` (or top-level wrapper) with the @@ -4959,9 +5021,35 @@ export class TreeSitterExtractor { * so we also scan declNode.namedChildren. * * Idempotent across grammars: if neither location yields decorators - * (most non-decorator-using languages), the function is a no-op. + * (most non-decorator-using languages), the result is empty. */ - private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string): void { + private collectDecoratorEntries(declNode: SyntaxNode): DecoratorEntry[] { + const entries: DecoratorEntry[] = []; + + const pushTarget = ( + target: SyntaxNode, + anno: SyntaxNode, + kind: DecoratorEntry['kind'] = 'decorates' + ): void => { + let name = getNodeText(target, this.source); + const lt = name.indexOf('<'); // strip generic args: `@Argument` → `Argument` + if (lt > 0) name = name.slice(0, lt); + const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::')); + if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.]/, ''); + name = name.trim(); + if (!name) return; + entries.push({ + name, + kind, + line: anno.startPosition.row + 1, + column: anno.startPosition.column, + }); + }; + + // Gated: see LanguageExtractor.extendedAnnotations. Without the opt-in this + // function reproduces the stop-at-first-target behavior exactly. + const extended = this.extractor?.extendedAnnotations === true; + const consider = (n: SyntaxNode | null): void => { if (!n) return; // Solidity `modifier_invocation` (unique to that grammar) sits @@ -4974,16 +5062,7 @@ export class TreeSitterExtractor { // traversal rides it. if (n.type === 'modifier_invocation') { const target = n.namedChild(0); - const name = target?.type === 'identifier' ? getNodeText(target, this.source) : undefined; - if (name) { - this.unresolvedReferences.push({ - fromNodeId: decoratedId, - referenceName: name, - referenceKind: 'calls', - line: n.startPosition.row + 1, - column: n.startPosition.column, - }); - } + if (target?.type === 'identifier') pushTarget(target, n, 'calls'); return; } // `marker_annotation` is Java's grammar for arg-less annotations @@ -4998,16 +5077,29 @@ export class TreeSitterExtractor { ) { return; } - // Find the leading identifier: skip the `@` punct, unwrap - // a call_expression if the decorator is invoked with args. - let target: SyntaxNode | null = null; + // Find the leading identifier(s): skip the `@` punct, unwrap a + // call_expression / constructor_invocation if the decorator is invoked + // with args. Kotlin bracket syntax (`@[Suppress("x") JvmStatic]`) puts + // SEVERAL entries under one annotation node, so collect every match + // instead of stopping at the first. for (let i = 0; i < n.namedChildCount; i++) { const child = n.namedChild(i); if (!child) continue; if (child.type === 'call_expression') { const fn = getChildByField(child, 'function') ?? child.namedChild(0); - if (fn) target = fn; - if (target) break; + if (fn) { + pushTarget(fn, n); + if (!extended) return; + } + continue; + } + // Kotlin arg-bearing annotations (`@Preview(showBackground = true)`) + // wrap the type in a constructor_invocation — without unwrapping it the + // annotation is silently dropped. + if (extended && child.type === 'constructor_invocation') { + const ut = child.namedChildren.find((c: SyntaxNode) => c.type === 'user_type'); + if (ut) pushTarget(ut, n); + continue; } if ( child.type === 'identifier' || @@ -5017,25 +5109,12 @@ export class TreeSitterExtractor { child.type === 'user_type' || // swift attribute → user_type (`@Argument`) child.type === 'type_identifier' ) { - target = child; - break; + pushTarget(child, n); + // Kotlin bracket syntax puts SEVERAL entries under one annotation + // node; every other language stops at the first target. + if (!extended) return; } } - if (!target) return; - let name = getNodeText(target, this.source); - const lt = name.indexOf('<'); // strip generic args: `@Argument` → `Argument` - if (lt > 0) name = name.slice(0, lt); - const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::')); - if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.]/, ''); - name = name.trim(); - if (!name) return; - this.unresolvedReferences.push({ - fromNodeId: decoratedId, - referenceName: name, - referenceKind: 'decorates', - line: n.startPosition.row + 1, - column: n.startPosition.column, - }); }; // 1. Decorators that are direct children of the declaration @@ -5088,6 +5167,60 @@ export class TreeSitterExtractor { } } } + + return entries; + } + + /** + * Emit a `decorates` reference from `decoratedId` to each decorator applied + * to `declNode`. When `intoNode` is provided, the decorator simple names are + * also persisted onto that node's `decorators` list — framework decorators + * (`@Composable`, `@Inject`, `@app.route`, …) are declared in external + * libraries outside the index, so their `decorates` references never resolve; + * the name on the node is the only queryable trace that survives. + * (`intoNode` is omitted where the edges are attributed to a DIFFERENT node + * than the decorated declaration, e.g. Swift property wrappers attributed to + * the enclosing type.) + */ + private extractDecoratorsFor(declNode: SyntaxNode, decoratedId: string, intoNode?: Node): void { + const entries = this.collectDecoratorEntries(declNode); + if (entries.length === 0) return; + for (const entry of entries) { + this.unresolvedReferences.push({ + fromNodeId: decoratedId, + referenceName: entry.name, + referenceKind: entry.kind, + line: entry.line, + column: entry.column, + }); + } + if (intoNode && this.extractor?.extendedAnnotations) { + const merged = [...(intoNode.decorators ?? [])]; + for (const entry of entries) { + // Solidity `modifier_invocation` entries are call-flow hops, not + // annotations — they must not show up as decorators on the node. + if (entry.kind !== 'decorates') continue; + if (!merged.includes(entry.name)) merged.push(entry.name); + } + if (merged.length > 0) intoNode.decorators = merged; + } + } + + /** + * Resolve a declaration's NodeKind from the language's declarative + * `annotationKinds` map (e.g. Kotlin `@Composable` → 'component'). + * Returns undefined when the language declares no map or no decorator + * on the declaration matches. + */ + private annotationKindFor(declNode: SyntaxNode): NodeKind | undefined { + const map = this.extractor?.annotationKinds; + if (!map) return undefined; + for (const entry of this.collectDecoratorEntries(declNode)) { + if (entry.kind !== 'decorates') continue; + const kind = map[entry.name]; + if (kind) return kind; + } + return undefined; } /** diff --git a/src/graph/queries.ts b/src/graph/queries.ts index e2af59335..96b46a830 100644 --- a/src/graph/queries.ts +++ b/src/graph/queries.ts @@ -303,7 +303,7 @@ export class GraphQueryManager { * @returns Array of unreferenced nodes */ findDeadCode(kinds?: Node['kind'][]): Node[] { - const targetKinds = kinds || ['function', 'method', 'class']; + const targetKinds = kinds || ['function', 'method', 'class', 'component']; const deadCode: Node[] = []; for (const kind of targetKinds) { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5c23f675d..a617b4b93 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -6305,6 +6305,14 @@ export class ToolHandler { if (node.signature) { lines.push(`**Signature:** \`${node.signature}\``); } + // Annotations/decorators the symbol carries. Framework annotations + // (@Composable, @HiltViewModel, @Inject, @app.route) are declared in + // libraries outside the index, so their `decorates` edges never resolve — + // the name recorded on the node is the only trace, and it answers + // "is this injected / a Compose UI / an entity" without reading the file. + if (node.decorators && node.decorators.length > 0) { + lines.push(`**Annotations:** ${node.decorators.map((d) => `\`@${d}\``).join(' ')}`); + } lines.push(''); let embedded = false; if (includeCode) { @@ -7021,6 +7029,14 @@ export class ToolHandler { if (node.signature) { lines.push(`**Signature:** \`${node.signature}\``); } + // Annotations/decorators the symbol carries. Framework annotations + // (@Composable, @HiltViewModel, @Inject, @app.route) are declared in + // libraries outside the index, so their `decorates` edges never resolve — + // the name recorded on the node is the only trace, and it answers + // "is this injected / a Compose UI / an entity" without reading the file. + if (node.decorators && node.decorators.length > 0) { + lines.push(`**Annotations:** ${node.decorators.map((d) => `\`@${d}\``).join(' ')}`); + } // Only include docstring if it's short and useful if (node.docstring && node.docstring.length < 200) { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0d53829b7..f0a2223dc 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -995,8 +995,18 @@ async function goCrossFileMethodContainsEdges(queries: QueryBuilder, onYield: Ma // `actual` marker already gates out unrelated symbols, so widening to the // type-like kinds is safe. const KMP_TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'type_alias']); +// A @Composable expect/actual pair can be annotated asymmetrically — the actual +// carries @Composable, the expect declaration doesn't (or vice versa) — which +// makes one side 'component' and the other 'function'/'method'. Same-FQN plus the +// `actual` marker already gates out unrelated symbols, so treat the callable +// kinds as interchangeable rather than dropping the edge. +const KMP_CALLABLE_KINDS = new Set(['function', 'method', 'component']); function kmpKindsCompatible(a: string, b: string): boolean { - return a === b || (KMP_TYPE_KINDS.has(a) && KMP_TYPE_KINDS.has(b)); + return ( + a === b || + (KMP_TYPE_KINDS.has(a) && KMP_TYPE_KINDS.has(b)) || + (KMP_CALLABLE_KINDS.has(a) && KMP_CALLABLE_KINDS.has(b)) + ); } async function kotlinExpectActualEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c74d8f272..9f23de7a2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -275,6 +275,7 @@ export function matchFunctionRef( .filter( (n) => (n.kind === 'function' || + n.kind === 'component' || // function-level components are callable (!bareFnOnly && n.kind === 'method') || (bareClassOk && n.kind === 'class')) && sameLanguageFamily(n.language, ref.language) && @@ -1469,7 +1470,12 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] function enclosingScopeStartLine(ref: UnresolvedRef, context: ResolutionContext): number { let start = 1; for (const n of context.getNodesInFile(ref.filePath)) { - if (n.kind !== 'function' && n.kind !== 'method') continue; + // 'component' counts: it is a function-level kind (Kotlin @Composable, + // React HOC-wrapped) whose body bounds a scope exactly like a function's, + // so omitting it would widen the scan to the whole file. Consistency, not a + // observed defect — Kotlin's scan takes the nearest preceding declaration, + // so no fixture we could build resolves differently either way. + if (n.kind !== 'function' && n.kind !== 'method' && n.kind !== 'component') continue; if (n.language !== ref.language) continue; const end = n.endLine ?? n.startLine; if (n.startLine <= ref.line && end >= ref.line && n.startLine >= start) { @@ -2346,9 +2352,16 @@ function findBestMatch( score -= 80; } - // For call references, prefer functions/methods + // For call references, prefer functions/methods. 'component' is included + // because function-level components (Kotlin @Composable) are invoked as + // plain calls — without it, every call edge into a composable loses the + // bonus and can be outscored by a same-named non-callable. if (ref.referenceKind === 'calls') { - if (candidate.kind === 'function' || candidate.kind === 'method') { + if ( + candidate.kind === 'function' || + candidate.kind === 'method' || + candidate.kind === 'component' + ) { score += 25; } } @@ -2411,7 +2424,7 @@ export function matchFuzzy( const candidates = context.getNodesByLowerName(lowerName); // Filter to callable kinds only (function, method, class) - const callableKinds = new Set(['function', 'method', 'class']); + const callableKinds = new Set(['function', 'method', 'class', 'component']); const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref); // Prefer same-language matches