From ab96b85d600ff2c3e22d27e36c9adbde2d30a89e Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 12:53:25 +0900 Subject: [PATCH 1/7] fix(java-kotlin): preserve anonymous inheritance and resolve nested types --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 44 ++++++ __tests__/kotlin-object-literal.test.ts | 108 +++++++++++++++ __tests__/resolution.test.ts | 140 +++++++++++++++++++ codegraph-kernel/src/java.rs | 27 +++- codegraph-kernel/src/kotlin.rs | 156 ++++++++++++++++++++- src/extraction/tree-sitter.ts | 173 ++++++++++++++++++++++-- src/resolution/name-matcher.ts | 73 ++++++++-- 8 files changed, 696 insertions(+), 26 deletions(-) create mode 100644 __tests__/kotlin-object-literal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a4f9bfeb..d84aa32430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 2814a4a2c3..906627db8d 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2035,6 +2035,50 @@ class T { expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']); expect(callersOf('compute')).toEqual(['eager']); }); + + it('keeps the full qualified name on the extends reference of `new Outer.Inner() { ... }` (real AOSP AIDL shape: `new ICarPropertyEventListener.Stub() { ... }`)', () => { + // The anon class's OWN name and the enclosing `instantiates` edge are + // correctly truncated to the bare last segment ("Stub") — that matches + // how a real in-project nested class's own node is named, and how + // `instantiates` resolves (by bare class name). But the `extends` + // reference is a DIFFERENT resolution path: a real named class's + // `extends IFoo.Stub` clause is extracted verbatim (untruncated), and + // AOSP-style qualified lookups (hal.ts/aidl.ts's `IFoo`/`IFoo.%` prefix + // match) depend on that full dotted text surviving. Truncating the + // anon-class extends reference to "Stub" the same way made a real field + // initializer like `new ICarPropertyEventListener.Stub() { ... }` + // invisible to `aidl-impl` even after the anon body itself was extracted + // (found on the real platform_packages_services_car AOSP mirror, + // 2026-09-11). + const code = ` +package p; +interface ICarPropertyEventListener { + interface Stub {} +} +class CarNightService { + private final ICarPropertyEventListener mListener = + new ICarPropertyEventListener.Stub() { + public void onEvent() {} + }; +} +`; + const result = extractFromSource('CarNightService.java', code); + const anon = result.nodes.find((n) => n.kind === 'class' && /Stub\$anon@/.test(n.name)); + expect(anon, 'anonymous Stub subclass should be extracted as a class').toBeDefined(); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' && r.fromNodeId === anon!.id + ); + expect(extendsRef, 'anon class should carry an extends reference').toBeDefined(); + expect(extendsRef!.referenceName).toBe('ICarPropertyEventListener.Stub'); + + // The anon class's own cosmetic name and the instantiates edge stay + // truncated to the bare last segment — unaffected by this fix. + const instantiatesRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Stub' + ); + expect(instantiatesRef, 'enclosing field should still instantiate the bare Stub name').toBeDefined(); + }); }); describe('C# Extraction', () => { diff --git a/__tests__/kotlin-object-literal.test.ts b/__tests__/kotlin-object-literal.test.ts new file mode 100644 index 0000000000..82f5337213 --- /dev/null +++ b/__tests__/kotlin-object-literal.test.ts @@ -0,0 +1,108 @@ +/** Kotlin anonymous objects: negative shapes and traversal/ownership regressions. */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TreeSitterExtractor } from '../src/extraction/tree-sitter'; +import { initGrammars, loadGrammarsForLanguages, getParser } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['kotlin']); +}); + +function extract(source: string) { + const tree = getParser('kotlin')!.parse(source)!; + expect(tree.rootNode.hasError, tree.rootNode.toString()).toBe(false); + tree.delete(); + const result = new TreeSitterExtractor('src/Probe.kt', source, 'kotlin').extract(); + expect(result.errors).toEqual([]); + return result; +} + +const classes = (r: ReturnType) => r.nodes.filter(n => n.kind === 'class' && n.name.includes('$anon@')); +const refs = (r: ReturnType, kind: string) => r.unresolvedReferences.filter(r => r.referenceKind === kind); + +describe('Kotlin object literals', () => { + it.each(['val value = ', 'fun create() = '])('keeps constructor argument calls in the enclosing scope: %s', prefix => { + const r = extract(`${prefix}object : IFoo.Stub(computeArg()) { override fun run() { bodyCall() }; }`); + const arg = refs(r, 'calls').filter(x => x.referenceName === 'computeArg'); + expect(arg).toHaveLength(1); + expect(arg[0]!.fromNodeId).not.toBe(classes(r)[0]!.id); + const run = r.nodes.find(n => n.name === 'run')!; + expect(refs(r, 'calls').find(x => x.referenceName === 'bodyCall')?.fromNodeId).toBe(run.id); + }); + + it('extracts delegated interface identity without treating delegate calls as supertypes', () => { + const r = extract('fun create() = object : api.IFoo by (makeDelegate()), Marker {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['api.IFoo', 'Marker']); + expect(refs(r, 'instantiates')).toEqual([]); + expect(refs(r, 'calls').filter(x => x.referenceName === 'makeDelegate')).toHaveLength(1); + }); + + it('keeps generic outer and inner type segments while excluding type arguments', () => { + const r = extract('val x = object : pkg.Outer.Inner>() {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + }); + + it('preserves nested members and calls of objects without explicit supertypes', () => { + const r = extract(`fun create() = object { + fun outer() { outerCall() } + val child = object : IFoo.Stub() { + override fun inner() { innerCall() } + } + }`); + expect(classes(r)).toHaveLength(2); + for (const name of ['outer', 'inner']) { + const members = r.nodes.filter(n => n.name === name); + expect(members).toHaveLength(1); + expect(refs(r, 'calls').find(x => x.referenceName === `${name}Call`)?.fromNodeId).toBe(members[0]!.id); + expect(r.edges.filter(e => e.kind === 'contains' && e.target === members[0]!.id)).toHaveLength(1); + } + }); + + it('does not merge two same-supertype anonymous objects on one line', () => { + const r = extract('val x = object : Base() { val child = object : Base() {}; }'); + expect(classes(r)).toHaveLength(2); + expect(new Set(classes(r).map(n => n.id)).size).toBe(2); + expect(refs(r, 'extends').map(x => x.fromNodeId)).toEqual(classes(r).map(n => n.id)); + }); + + it('keeps same-line same-name members in distinct anonymous owners', () => { + const r = extract('val x = object : Base() { fun run() { outerCall() }; val child = object : Base() { fun run() { innerCall() }; }; }'); + const members = r.nodes.filter(n => n.name === 'run'); + expect(members).toHaveLength(2); + expect(new Set(members.map(n => n.id)).size).toBe(2); + expect(refs(r, 'calls').find(x => x.referenceName === 'outerCall')?.fromNodeId).toBe(members[0]!.id); + expect(refs(r, 'calls').find(x => x.referenceName === 'innerCall')?.fromNodeId).toBe(members[1]!.id); + }); + + it('visits an anonymous object in a constructor argument exactly once', () => { + const r = extract(`fun create() = object : Base(object : Callback { + override fun invoke() { callbackCall() } + }) { fun own() { ownCall() }; }`); + expect(classes(r)).toHaveLength(2); + expect(r.nodes.filter(n => n.name === 'invoke')).toHaveLength(1); + expect(refs(r, 'calls').filter(x => x.referenceName === 'callbackCall')).toHaveLength(1); + }); + + it('chooses the sole constructor after an interface and ignores generic argument decoys', () => { + const r = extract('val x = object : Marker, pkg.Real.Stub(), Other {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['Marker', 'pkg.Real.Stub', 'Other']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Real.Stub']); + }); + + it('does not classify companion or named objects as anonymous literals', () => { + const r = extract(`class Host { + companion object : Marker {} + object Named : Marker {} + val value = object : Marker { fun run() {}; } + }`); + expect(classes(r)).toHaveLength(1); + expect(r.nodes.filter(n => n.name === 'run')).toHaveLength(1); + }); + + it('does not throw for incomplete objects or syntactically accepted multiple constructors', () => { + for (const source of ['val x = object :', 'val x = object : Base( {', 'val x = object : A(), B() {}']) { + expect(() => new TreeSitterExtractor('Bad.kt', source, 'kotlin').extract()).not.toThrow(); + } + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index e20d2d997c..55f72b706b 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,27 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('accepts every supported supertype kind in exact-name inheritance matching', () => { + for (const kind of ['component', 'namespace'] as const) { + const target: Node = { + id: `${kind}:base`, kind, name: 'Base', qualifiedName: 'Base', + filePath: 'model.ts', language: 'typescript', startLine: 1, endLine: 1, + startColumn: 0, endColumn: 0, updatedAt: 0, + }; + const context = { + getNodesByName: () => [target], getNodesInFile: () => [target], + getNodesByQualifiedName: () => [], getNodesByKind: () => [], + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'class:derived', referenceName: 'Base', referenceKind: 'extends', + filePath: 'model.ts', language: 'typescript', line: 2, column: 0, + }; + expect(matchByExactName(ref, context)?.targetNodeId).toBe(target.id); + } + }); + it('should match exact name references', () => { // Create a mock context const mockNodes: Node[] = [ @@ -6260,6 +6281,54 @@ in }); }); + describe('A dotted qualified extends/implements reference resolves to a real nested type (2026-09-11)', () => { + it('resolves `extends Outer.Inner` (named class) and `new Outer.Inner() { ... }` (anonymous class) to the SAME real, indexed nested type', async () => { + // Every qualifiedName the engine builds joins scope with `::` + // (buildQualifiedName), but a Java/C# extends clause or anonymous-class + // constructor type is recorded verbatim with a dot (`Outer.Inner`). A + // real in-project nested type must still resolve — this is not an + // AOSP-specific concern (an absent AIDL Stub staying unresolved is + // correct there), it's the general case where the target genuinely + // exists in the index. + fs.writeFileSync( + path.join(tempDir, 'Host.java'), + `package p; +class Outer { static class Inner { public void run() {} } } +class Named extends Outer.Inner {} +class Host { + Object field = new Outer.Inner() { public void run() {} }; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const innerId = db + .getDb() + .prepare("select id from nodes where kind = 'class' and name = 'Inner'") + .get() as { id: string } | undefined; + expect(innerId, 'the real Outer.Inner class should be indexed').toBeDefined(); + + const extendsTargets = db + .getDb() + .prepare( + `select src.name as sourceName, dst.id as targetId + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and dst.id = ?` + ) + .all(innerId!.id) as Array<{ sourceName: string; targetId: string }>; + + const sourceNames = extendsTargets.map((r) => r.sourceName).sort(); + // `Named` (a real named class) and the anonymous class inside `Host` + // (named ``) must BOTH resolve their extends edge to + // the real `Inner` node — neither should stay in unresolved_refs. + expect(sourceNames.some((n) => n === 'Named')).toBe(true); + expect(sourceNames.some((n) => /Inner\$anon@/.test(n))).toBe(true); + }); + }); + describe('Bindings in a module that exports nothing (#1719)', () => { it('does not treat documentation headings as package imports', () => { // Inject the planned Markdown node shape without depending on its extractor. @@ -6600,4 +6669,75 @@ func (n *Node) Size() int { expect(selfLoops(cg, 'node.go')).toEqual(['Size']); }, 30000); }); + + describe('Inheritance references never use method-call resolution', () => { + it('does not resolve bare extends/implements names to same-named methods', async () => { + fs.writeFileSync( + path.join(tempDir, 'Hierarchy.java'), + `class Child extends Missing {} +class Implementer implements Missing {} +class Other { + void Missing() {} +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select src.name as sourceName, dst.kind as targetKind + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind in ('extends', 'implements') + and src.name in ('Child', 'Implementer')` + ) + .all() as Array<{ sourceName: string; targetKind: string }>; + + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + + it('does not resolve Java extends IBar.Stub to an unrelated Stub constructor', async () => { + // IBar intentionally has no in-project declaration: this mirrors generated + // AIDL Stub bases that are absent from a sparse source checkout. An explicit + // constructor is required because implicit Java constructors are not nodes. + fs.writeFileSync( + path.join(tempDir, 'AService.java'), + `package com.example; +class AService { + final class BinderService extends IBar.Stub {} +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'UiModeManagerService.java'), + `package com.example; +class UiModeManagerService { + class Stub { Stub() {} } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select dst.kind as targetKind, dst.qualified_name as targetQualifiedName, + e.metadata as metadata + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'BinderService'` + ) + .all() as Array<{ targetKind: string; targetQualifiedName: string; metadata: string }>; + + // An inheritance reference is a type reference, never a receiver.method() + // call. In particular, an absent IBar must not make `Stub` fall through to + // the sole same-named constructor elsewhere in the project. + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + }); }); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 6a7735a20b..85b2ac9ca9 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -959,13 +959,28 @@ impl<'t> Walker<'t> { .or_else(|| node.child_by_field_name("type")) .or_else(|| node.child_by_field_name("name")) .or_else(|| node.named_child(0)); - let mut type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); - type_name = strip_generic_and_qualifier(&type_name); - if type_name.is_empty() { - type_name = "Object".to_string(); + let raw_type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); + // The `extends` reference must carry the FULL dotted name (generics + // stripped, qualifier kept) — AOSP-style qualified-name lookups + // (hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on it + // surviving. Only the anon class's own cosmetic name is truncated to + // the bare last segment, matching the portable extractor. + let full_type_name = { + let mut n = raw_type_name.clone(); + if let Some(lt) = n.find('<') { + if lt > 0 { + n.truncate(lt); + } + } + let trimmed = n.trim().to_string(); + if trimmed.is_empty() { "Object".to_string() } else { trimmed } + }; + let mut short_type_name = strip_generic_and_qualifier(&raw_type_name); + if short_type_name.is_empty() { + short_type_name = "Object".to_string(); } - let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1); + let anon_name = format!("<{short_type_name}$anon@{}>", node.start_position().row + 1); let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { return; }; @@ -975,7 +990,7 @@ impl<'t> Walker<'t> { Some(t) => (t.start_position().row as u32, self.col_of(t)), None => (node.start_position().row as u32, self.col_of(node)), }; - self.push_ref(row, &type_name, edge_kind_index("extends").unwrap(), line, column); + self.push_ref(row, &full_type_name, edge_kind_index("extends").unwrap(), line, column); self.stack.push(Scope { row, kind: "class", name: anon_name }); for i in 0..body.named_child_count() { diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 5459e63c98..0e2fb10e34 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -224,6 +224,11 @@ pub struct Walker<'t> { fs_values: HashMap, fs_value_counts: HashMap, value_scopes: Vec>, + /// kotlinObjectScopeDepth (tree-sitter.ts:480) — >0 while inside an + /// `object : IFoo.Stub() { ... }` literal body, so createNode binds + /// member identity to the enclosing owner (two anon objects can share + /// both name and line). + kotlin_object_scope_depth: u32, } pub fn extract(file_path: &str, source: &str) -> Result { @@ -258,6 +263,7 @@ pub fn extract(file_path: &str, source: &str) -> Result { fs_values: HashMap::new(), fs_value_counts: HashMap::new(), value_scopes: Vec::new(), + kotlin_object_scope_depth: 0, }; let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1; @@ -407,8 +413,14 @@ impl<'t> Walker<'t> { return None; } let start_line = self.line_of(node); + // Members in different anonymous objects can share a name and line. + let identity_name = if self.kotlin_object_scope_depth > 0 && !self.stack.is_empty() { + format!("{}::{}", self.node_ids[self.top_row() as usize], name) + } else { + name.to_string() + }; let column = self.col_of(node); - let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column); + let id = self.node_id_allocator.generate(self.file_path, kind, &identity_name, start_line, column); // endLine extension via resolveBody — LIVE for kotlin function/method // kinds (in-range for this grammar, so practically a no-op — but the // hook is part of the contract). @@ -895,10 +907,15 @@ impl<'t> Walker<'t> { self.extract_call(node); } else if kind == "infix_expression" { self.extract_infix_call(node); + } else if kind == "object_literal" { + // Kotlin `object : IFoo.Stub() { ... }` — AIDL Stub implementation + // idiom, matching the portable extractor. + self.extract_kotlin_object_literal(node); + skip_children = true; } // companion_object, anonymous_initializer, secondary_constructor, - // getter/setter siblings, file_annotation, object_literal, if/when at - // top level: no branch — recursed (calls attribute to the stack top). + // getter/setter siblings, file_annotation, if/when at top level: no + // branch — recursed (calls attribute to the stack top). if !skip_children { for i in 0..node.named_child_count() { @@ -963,6 +980,10 @@ impl<'t> Walker<'t> { // object_declaration is NOT dispatched here — a body-local object's // `fun`s hit the function branch above and leak out as FUNCTIONS // under the enclosing fn; its properties mint nothing (quirk). + if kind == "object_literal" { + self.extract_kotlin_object_literal(node); + return; + } for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { @@ -1402,6 +1423,135 @@ impl<'t> Walker<'t> { } } + /// extractKotlinObjectLiteral (tree-sitter.ts:5330) — `object : IFoo.Stub() { ... }`, + /// the dominant AIDL Stub implementation idiom. NOT the same AST shape as + /// Java/C#'s object_creation_expression (repeatable delegation_specifier + /// supertypes, no single constructor/type field) — its own extraction. + fn extract_kotlin_object_literal(&mut self, node: Node<'t>) { + stack_guard!(); + let Some(body) = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .find(|c| c.kind() == "class_body") + else { + return; + }; + + let delegation_specifiers: Vec> = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .filter(|c| c.kind() == "delegation_specifier") + .collect(); + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup (excludes type_arguments). + struct SuperType<'t> { + full_name: String, + user_type: Node<'t>, + has_call: bool, + } + let mut super_types: Vec> = Vec::new(); + for &spec in &delegation_specifiers { + let mut user_type: Option> = None; + let mut has_call = false; + for i in 0..spec.named_child_count() { + let Some(child) = spec.named_child(i) else { continue }; + if matches!(child.kind(), "constructor_invocation" | "explicit_delegation") { + has_call = child.kind() == "constructor_invocation"; + for j in 0..child.named_child_count() { + if let Some(grandchild) = child.named_child(j) { + if grandchild.kind() == "user_type" { + user_type = Some(grandchild); + break; + } + } + } + break; + } + if child.kind() == "user_type" { + user_type = Some(child); + break; + } + } + let Some(user_type) = user_type else { continue }; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + let full_name = (0..user_type.named_child_count()) + .filter_map(|i| user_type.named_child(i)) + .filter(|c| c.kind() == "type_identifier") + .map(|c| self.text(c).trim().to_string()) + .collect::>() + .join("."); + if !full_name.is_empty() { + super_types.push(SuperType { full_name, user_type, has_call }); + } + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, + // delegates, and objects nested in either retain their calls/structure. + for &spec in &delegation_specifiers { + self.visit_node(spec); + } + + let enclosing_row = if self.stack.is_empty() { None } else { Some(self.top_row()) }; + if let Some(enclosing_row) = enclosing_row { + if let Some(primary_ctor) = super_types.iter().find(|s| s.has_call) { + self.push_ref( + enclosing_row, + &primary_ctor.full_name, + edge_kind_index("instantiates").unwrap(), + node.start_position().row as u32 + 1, + self.col_of(node), + ); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let mut type_name = super_types.first().map(|s| s.full_name.clone()).unwrap_or_else(|| "Object".to_string()); + if let Some(last_dot) = type_name.rfind('.') { + type_name = type_name[(last_dot + 1)..].to_string(); + } + type_name = type_name.trim().to_string(); + if type_name.is_empty() { + type_name = "Object".to_string(); + } + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + let anon_name = format!( + "<{type_name}$anon@{}:{}>", + node.start_position().row + 1, + node.start_position().column + ); + let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { + return; + }; + + // Bug-for-bug: the TS code uses `startPosition.row` (0-based) as the + // LINE here (no +1), unlike push_ref_at's usual line_of. + let extends_kind = edge_kind_index("extends").unwrap(); + for s in &super_types { + self.push_ref( + row, + &s.full_name, + extends_kind, + s.user_type.start_position().row as u32, + self.col_of(s.user_type), + ); + } + + self.stack.push(Scope { row, kind: "class", name: anon_name }); + self.kotlin_object_scope_depth += 1; + for i in 0..body.named_child_count() { + if let Some(c) = body.named_child(i) { + self.visit_node(c); + } + } + self.kotlin_object_scope_depth -= 1; + self.stack.pop(); + } + /// extractDecoratorsFor — kotlin annotations inside `modifiers`: /// `@Marker` (user_type child) → decorates ref; `@Anno(args)` /// (constructor_invocation) → NOTHING. Runs for functions/methods/classes diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 22889804ed..4b7ec8c80e 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -917,6 +917,7 @@ export class TreeSitterExtractor { private sourceAsWritten: string | null = null; // Memoized reExportAsWritten verdict (per-extractor = per-file). private reExportsAsWritten: boolean | null = null; + private kotlinObjectScopeDepth = 0; constructor( filePath: string, @@ -2008,6 +2009,14 @@ export class TreeSitterExtractor { skipChildren = true; } } + // Kotlin `object : IFoo.Stub() { ... }` — structurally distinct from + // INSTANTIATION_KINDS (multiple repeatable `delegation_specifier` + // supertypes, no single constructor/type field), so it gets its own + // dedicated extraction rather than being folded into extractInstantiation. + else if (nodeType === 'object_literal') { + this.extractKotlinObjectLiteral(node); + skipChildren = true; + } // (Decorator handling lives inside the symbol-creating extractors // — extractClass / extractFunction / extractProperty — because the // decorator node sits BEFORE the symbol in the AST and the walker @@ -2056,7 +2065,12 @@ export class TreeSitterExtractor { return null; } - const id = this.nodeIds.generate(this.filePath, kind, name, node.startPosition.row + 1, node.startPosition.column); + // Members in different Kotlin anonymous objects can share both name and + // line. Bind their identity to the enclosing owner as well. + const identityName = this.kotlinObjectScopeDepth > 0 + ? `${this.nodeStack[this.nodeStack.length - 1]}::${name}` + : name; + const id = this.nodeIds.generate(this.filePath, kind, identityName, node.startPosition.row + 1, node.startPosition.column); // Some grammars (e.g. Dart) model a function/method body as a *sibling* of // the signature node, so the declaration node's own range is just the @@ -6980,16 +6994,22 @@ export class TreeSitterExtractor { if (!this.extractor) return; // The instantiated type sits in the same field/position that - // extractInstantiation reads from. Use the same lookup so the anon - // class's `extends` target matches the `instantiates` edge. + // extractInstantiation reads from. const typeNode = getChildByField(node, 'constructor') || getChildByField(node, 'type') || getChildByField(node, 'name') || node.namedChild(0); - let typeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; - const ltIdx = typeName.indexOf('<'); - if (ltIdx > 0) typeName = typeName.slice(0, ltIdx); + let fullTypeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; + const ltIdx = fullTypeName.indexOf('<'); + if (ltIdx > 0) fullTypeName = fullTypeName.slice(0, ltIdx); + fullTypeName = fullTypeName.trim() || 'Object'; + + // The anon class's own (cosmetic) name is deliberately the short, bare + // form — matching extractInstantiation's `instantiates` truncation, since + // that edge resolves by bare class name (a real in-project nested type's + // own node is named by its last segment too). + let typeName = fullTypeName; const lastDot = Math.max(typeName.lastIndexOf('.'), typeName.lastIndexOf('::')); if (lastDot >= 0) typeName = typeName.slice(lastDot + 1).replace(/^[:.]/, ''); typeName = typeName.trim() || 'Object'; @@ -6998,14 +7018,23 @@ export class TreeSitterExtractor { const classNode = this.createNode('class', anonName, node, {}); if (!classNode) return; - // The anonymous class implicitly extends/implements the named type. + // The anonymous class implicitly extends/implements the named type. The + // `extends` reference itself must NOT be truncated to the bare last + // segment the way the anon class's own name and the `instantiates` edge + // are: a NAMED class's real `extends IFoo.Stub` clause is extracted + // verbatim (untruncated) precisely because AOSP-style qualified-name + // lookups (e.g. hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on + // the full dotted text surviving. Truncating this to "Stub" made a real + // AIDL `new ICarPropertyEventListener.Stub() { ... }` field initializer + // invisible to aidl-impl even after the anon class body itself started + // being extracted. // We can't tell at extraction time whether T is a class or an interface, // so emit `extends`. Resolution will still bind T to whatever it is, and // Phase 5.5 (which already handles both `extends` and `implements`) will // bridge T's methods to the override names found in the anon body. this.unresolvedReferences.push({ fromNodeId: classNode.id, - referenceName: typeName, + referenceName: fullTypeName, referenceKind: 'extends', line: typeNode?.startPosition.row ?? node.startPosition.row, column: typeNode?.startPosition.column ?? node.startPosition.column, @@ -7021,6 +7050,129 @@ export class TreeSitterExtractor { this.nodeStack.pop(); } + /** + * Extract a Kotlin anonymous object expression — `object : IFoo.Stub() { ... }` + * — a common AIDL Stub implementation idiom. This has a different AST shape from Java/C#'s + * `object_creation_expression`, so it cannot reuse `extractAnonymousClass`: + * + * object_literal + * delegation_specifier (one per supertype, repeatable — + * constructor_invocation Kotlin allows `object : Base(), I1, I2 { }`) + * user_type + * type_identifier ... (dotted segments, e.g. IFoo, Stub) + * -- OR, for an interface with no constructor call -- + * user_type + * type_identifier ... + * class_body + * + * Before this function existed, `object_literal` was not in + * INSTANTIATION_KINDS and had no anonymous-class handling at all, so this + * idiom produced neither an `instantiates` nor an `extends` reference — + * the interface→impl synthesizer (Phase 5.5) and aidl.ts's unresolved_refs + * lookup never saw these implementations. + */ + private extractKotlinObjectLiteral(node: SyntaxNode): void { + if (!this.extractor) return; + const body = this.findAnonymousClassBody(node); + if (!body) return; + + const delegationSpecifiers: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'delegation_specifier') delegationSpecifiers.push(child); + } + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup, excluding type arguments. + const superTypes: { fullName: string; userType: SyntaxNode; hasCall: boolean }[] = []; + for (const spec of delegationSpecifiers) { + let userType: SyntaxNode | null = null; + let hasCall = false; + for (let i = 0; i < spec.namedChildCount; i++) { + const child = spec.namedChild(i); + if (!child) continue; + if (child.type === 'constructor_invocation' || child.type === 'explicit_delegation') { + hasCall = child.type === 'constructor_invocation'; + for (let j = 0; j < child.namedChildCount; j++) { + const grandchild = child.namedChild(j); + if (grandchild && grandchild.type === 'user_type') { + userType = grandchild; + break; + } + } + break; + } + if (child.type === 'user_type') { + userType = child; + break; + } + } + if (!userType) continue; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + const fullName = userType.namedChildren + .filter((child) => child.type === 'type_identifier') + .map((child) => getNodeText(child, this.source).trim()) + .join('.'); + if (fullName) superTypes.push({ fullName, userType, hasCall }); + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, delegates, + // and objects nested in either retain their calls and structure. + for (const spec of delegationSpecifiers) this.visitNode(spec); + + if (this.nodeStack.length > 0) { + const fromId = this.nodeStack[this.nodeStack.length - 1]; + const primaryCtor = superTypes.find((s) => s.hasCall); + if (fromId && primaryCtor) { + this.unresolvedReferences.push({ + fromNodeId: fromId, + referenceName: primaryCtor.fullName, + referenceKind: 'instantiates', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let typeName = superTypes[0]?.fullName ?? 'Object'; + const lastDot = typeName.lastIndexOf('.'); + if (lastDot >= 0) typeName = typeName.slice(lastDot + 1); + typeName = typeName.trim() || 'Object'; + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + const anonName = `<${typeName}$anon@${node.startPosition.row + 1}:${node.startPosition.column}>`; + const classNode = this.createNode('class', anonName, node, {}); + if (!classNode) return; + + for (const { fullName, userType } of superTypes) { + this.unresolvedReferences.push({ + fromNodeId: classNode.id, + referenceName: fullName, + referenceKind: 'extends', + line: userType.startPosition.row, + column: userType.startPosition.column, + }); + } + + this.nodeStack.push(classNode.id); + this.kotlinObjectScopeDepth++; + try { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) this.visitNode(child); + } + } finally { + this.kotlinObjectScopeDepth--; + this.nodeStack.pop(); + } + } + /** * Scan `declNode` and its preceding siblings (within the parent's * named children) for decorator nodes, emitting a `decorates` @@ -7489,6 +7641,11 @@ export class TreeSitterExtractor { } } else if (this.language === 'kotlin' && nodeType === 'infix_expression') { this.extractKotlinInfixCall(node); + } else if (nodeType === 'object_literal') { + // Kotlin `object : IFoo.Stub() { ... }` inside a function body — + // same rationale and structure as the visitNode branch above. + this.extractKotlinObjectLiteral(node); + return; } else if (this.extractor!.extractBareCall) { const calleeName = this.extractor!.extractBareCall(node, this.source); if (calleeName && this.nodeStack.length > 0) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index afa8514e5a..1893adcae3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12972,6 +12972,26 @@ export function matchByExactName( return null; } +/** + * Whether `qualifiedName` ends with `suffix` at a real `::` scope boundary — + * NOT a plain `String.endsWith`, which false-matches across an identifier + * boundary purely by character coincidence: `"Aaa::operator+"` ends with the + * literal substring `"a::operator+"` only because `Aaa` itself happens to + * end in the letter `a`, even though `"a"` there was never meant as a scope + * qualifier at all — it was a C++ receiver *variable* named `a` in a + * `a.operator+(b)` call, decoy-matching the unrelated `Aaa::operator+` + * method instead of leaving receiver-type inference (`matchMethodCall`) to + * find the real `V::operator+`. A match only counts when the + * suffix is the WHOLE qualifiedName, or the two characters immediately + * preceding it are a real `::` separator. + */ +function endsWithQualifiedSegment(qualifiedName: string, suffix: string): boolean { + if (qualifiedName === suffix) return true; + if (!qualifiedName.endsWith(suffix)) return false; + const boundary = qualifiedName.length - suffix.length; + return boundary >= 2 && qualifiedName[boundary - 1] === ':' && qualifiedName[boundary - 2] === ':'; +} + /** * Try to resolve by qualified name */ @@ -12991,12 +13011,15 @@ export function matchByQualifiedName( // must never resolve to a yaml/properties config node — that's a wrong edge // AND it hides the real callee. Drop those from both the exact and the partial // candidate sets so resolution falls through to method resolution below (#1180). - const keepForRef = (nodes: Node[]): Node[] => - ref.referenceKind === 'calls' - ? nodes.filter( - (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), - ) - : nodes; + const keepForRef = (nodes: Node[]): Node[] => { + let kept = nodes; + if (ref.referenceKind === 'calls') { + kept = kept.filter( + (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), + ); + } + return isInheritanceRef(ref) ? kept.filter(isSupertypeTarget) : kept; + }; // A C or C++ name written from the global scope (`::hpb::CreateMessage`, // `::operator delete`) is the declaration of exactly that name. The suffix @@ -13018,7 +13041,21 @@ export function matchByQualifiedName( }; } - let candidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(ref.referenceName)), ref, context), ref, context); + // The GENERIC class-hierarchy walker's qualifiedName always joins scope + // with `::` (buildQualifiedName in tree-sitter.ts) regardless of source + // language, but an extends/implements clause's own text is recorded + // verbatim from the language's own syntax — so a Java/C# dotted supertype + // (`Outer.Inner`, `IFoo.Stub`) never matched a real indexed type's + // `::`-joined qualifiedName without normalizing the separator first. + // Scoped to ONLY inheritance references: several non-generic extractors + // (MyBatis XML statements, for one) deliberately build a MIXED + // qualifiedName that keeps literal dots from an already-dotted Java + // package/namespace string and adds `::` only at one specific boundary — + // blanket-normalizing every reference's dots to `::` mangled those. + const normalizedQualifiedRef = isInheritanceRef(ref) + ? ref.referenceName.replace(/\./g, '::') + : ref.referenceName; + let candidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(normalizedQualifiedRef)), ref, context), ref, context); // A C# `using X.Y;` names a namespace: one the project declares, else it is // the file's own (external) using — never another file's using of that name. // That own using ends the lookup here, and resolveOne drops it (an import @@ -13093,8 +13130,12 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { + // Inheritance uses normalized JVM scope names; other qualified names + // retain upstream's scope-boundary and specialization checks. const partialCandidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByName(lastName)) - .filter((candidate) => endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); + .filter((candidate) => isInheritanceRef(ref) + ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) + : endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); const ownLanguage = partialCandidates.filter((candidate) => sameLanguageFamily(candidate.language, ref.language)); const pool = ownLanguage.length > 0 ? ownLanguage : partialCandidates; const chosen = preferCallSiteFile(pool, ref.filePath)[0]; @@ -13997,6 +14038,11 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { + // This helper resolves a receiver's invoked member, never a type/member + // reference such as Java `class Foo extends IBar.Stub`. Keep the guard here + // as a backstop for every current and future caller of this call-only API. + if (ref.referenceKind !== 'calls') return null; + // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate @@ -18369,6 +18415,7 @@ export function matchCppCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const looked = matchCppChainOnWrittenType(m[1], m[2], ref, context); @@ -18444,6 +18491,7 @@ export function matchScopedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; @@ -18483,6 +18531,7 @@ export function matchDottedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; // `Foo.getInstance` @@ -21477,6 +21526,12 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + // A dotted/scoped name is not necessarily an invocation: Java inheritance + // refs such as `IBar.Stub` have the same surface shape. Let type-reference + // strategies handle non-call refs rather than falling through to the + // same-named-method heuristics below. + if (ref.referenceKind !== 'calls') return null; + // Parse method call patterns like "obj.method" or "Class::method". The method // part allows trailing `:` keywords so Objective-C selectors resolve // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other @@ -29022,7 +29077,7 @@ export function matchFuzzy( // lowercase index is for. const bareR = isBareRCall(ref, context); const solidityBare = isReceiverLessSolidityCall(ref, context); - const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) && + const callableCandidates = candidates.filter((n) => (isInheritanceRef(ref) ? isSupertypeTarget(n) : callableKinds.has(n.kind)) && !(typeRef && !canNameInTypePosition(n)) && !(bareR && n.kind === 'method') && !(solidityBare && !isSolidityMemberInScope(n, ref, context)) && // `new …MockData()` makes an instance of a type; a method is never what it names. From 1e60b291493d856257c5f9cb1a015bb3bfe77093 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 13:21:40 +0900 Subject: [PATCH 2/7] Disambiguate Java nested supertypes across source trees --- __tests__/resolution.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 11 +++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 55f72b706b..db7220b088 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6739,5 +6739,43 @@ class UiModeManagerService { // the sole same-named constructor elsewhere in the project. expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); }); + + it('resolves a nested Java supertype to the closest source tree', async () => { + for (const tree of ['framework', 'androidx']) { + const dir = path.join(tempDir, tree); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'RecyclerView.java'), + `package ${tree}; class RecyclerView { static class LayoutManager {} }`); + } + fs.writeFileSync(path.join(tempDir, 'androidx', 'LinearLayoutManager.java'), + 'package androidx; class LinearLayoutManager extends RecyclerView.LayoutManager {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.file_path as targetPath from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'LinearLayoutManager'` + ).all() as Array<{ targetPath: string }>; + expect(rows.map((row) => row.targetPath)).toEqual(['androidx/RecyclerView.java']); + }); + + it('does not let Spring naming conventions invent an inheritance edge', async () => { + fs.writeFileSync(path.join(tempDir, 'Service.java'), + '@Service class Service {} class Child implements View.OnClickListener {}'); + fs.writeFileSync(path.join(tempDir, 'OnClickListener.java'), + 'package unrelated; class OnClickListener {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.qualified_name as target from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'implements' and src.name = 'Child'` + ).all() as Array<{ target: string }>; + expect(rows).toEqual([]); + }); }); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1893adcae3..498b9fd3bb 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13138,7 +13138,16 @@ export function matchByQualifiedName( : endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); const ownLanguage = partialCandidates.filter((candidate) => sameLanguageFamily(candidate.language, ref.language)); const pool = ownLanguage.length > 0 ? ownLanguage : partialCandidates; - const chosen = preferCallSiteFile(pool, ref.filePath)[0]; + // `RecyclerView.LayoutManager` can exist in both AndroidX and the + // framework. For inheritance, pick the uniquely closest source tree; + // an equal-distance tie has no safe target. + const closest = isInheritanceRef(ref) && pool.length > 1 + ? pool.map((node) => ({ node, distance: computePathProximity(ref.filePath, node.filePath) })) + .sort((a, b) => b.distance - a.distance) + : null; + const chosen = closest + ? (closest[0]!.distance > closest[1]!.distance ? closest[0]!.node : null) + : preferCallSiteFile(pool, ref.filePath)[0]; if (chosen) { return { original: ref, From e959418febbd37e38982ce6c41fa11ebc1cf65a5 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 16:54:55 +0900 Subject: [PATCH 3/7] docs: keep Java and Kotlin extraction notes self-contained --- __tests__/extraction.test.ts | 10 +++------- __tests__/resolution.test.ts | 2 +- codegraph-kernel/src/java.rs | 5 ++--- src/extraction/tree-sitter.ts | 11 +++-------- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 906627db8d..5b60cd8246 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2043,13 +2043,9 @@ class T { // `instantiates` resolves (by bare class name). But the `extends` // reference is a DIFFERENT resolution path: a real named class's // `extends IFoo.Stub` clause is extracted verbatim (untruncated), and - // AOSP-style qualified lookups (hal.ts/aidl.ts's `IFoo`/`IFoo.%` prefix - // match) depend on that full dotted text surviving. Truncating the - // anon-class extends reference to "Stub" the same way made a real field - // initializer like `new ICarPropertyEventListener.Stub() { ... }` - // invisible to `aidl-impl` even after the anon body itself was extracted - // (found on the real platform_packages_services_car AOSP mirror, - // 2026-09-11). + // qualified lookups depend on that full dotted text surviving. + // Truncating the anonymous class's extends reference to "Stub" would + // lose the relationship to `ICarPropertyEventListener.Stub`. const code = ` package p; interface ICarPropertyEventListener { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index db7220b088..0177aefea3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6281,7 +6281,7 @@ in }); }); - describe('A dotted qualified extends/implements reference resolves to a real nested type (2026-09-11)', () => { + describe('A dotted qualified extends/implements reference resolves to a real nested type', () => { it('resolves `extends Outer.Inner` (named class) and `new Outer.Inner() { ... }` (anonymous class) to the SAME real, indexed nested type', async () => { // Every qualifiedName the engine builds joins scope with `::` // (buildQualifiedName), but a Java/C# extends clause or anonymous-class diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 85b2ac9ca9..e565ae8688 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -961,9 +961,8 @@ impl<'t> Walker<'t> { .or_else(|| node.named_child(0)); let raw_type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); // The `extends` reference must carry the FULL dotted name (generics - // stripped, qualifier kept) — AOSP-style qualified-name lookups - // (hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on it - // surviving. Only the anon class's own cosmetic name is truncated to + // stripped, qualifier kept) so nested-type resolution can find it. + // Only the anon class's own cosmetic name is truncated to // the bare last segment, matching the portable extractor. let full_type_name = { let mut n = raw_type_name.clone(); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 4b7ec8c80e..f5e111493b 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7022,12 +7022,8 @@ export class TreeSitterExtractor { // `extends` reference itself must NOT be truncated to the bare last // segment the way the anon class's own name and the `instantiates` edge // are: a NAMED class's real `extends IFoo.Stub` clause is extracted - // verbatim (untruncated) precisely because AOSP-style qualified-name - // lookups (e.g. hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on - // the full dotted text surviving. Truncating this to "Stub" made a real - // AIDL `new ICarPropertyEventListener.Stub() { ... }` field initializer - // invisible to aidl-impl even after the anon class body itself started - // being extracted. + // verbatim (untruncated) so qualified-name resolution can find the + // nested type. Truncating this to "Stub" loses the enclosing interface. // We can't tell at extraction time whether T is a class or an interface, // so emit `extends`. Resolution will still bind T to whatever it is, and // Phase 5.5 (which already handles both `extends` and `implements`) will @@ -7068,8 +7064,7 @@ export class TreeSitterExtractor { * Before this function existed, `object_literal` was not in * INSTANTIATION_KINDS and had no anonymous-class handling at all, so this * idiom produced neither an `instantiates` nor an `extends` reference — - * the interface→impl synthesizer (Phase 5.5) and aidl.ts's unresolved_refs - * lookup never saw these implementations. + * the interface→impl synthesizer cannot see these implementations. */ private extractKotlinObjectLiteral(node: SyntaxNode): void { if (!this.extractor) return; From a7dbdd6251b2012f708c626e0532b0d7430dd352 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 17:56:48 +0900 Subject: [PATCH 4/7] fix: scope anonymous object methods to enclosing function --- __tests__/resolution.test.ts | 27 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 12 ++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 0177aefea3..8969ca759c 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,33 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('does not match a Kotlin local anonymous-object method from a sibling function', () => { + const node = (id: string, kind: Node['kind'], name: string, qualifiedName: string, filePath: string, startLine: number, endLine: number): Node => ({ + id, kind, name, qualifiedName, filePath, language: 'kotlin', + startLine, endLine, startColumn: 0, endColumn: 0, updatedAt: 0, + }); + const outer = node('outer', 'method', 'otherTest', 'ProbeTest::otherTest', 'ProbeTest.kt', 10, 20); + const anon = node('anon', 'class', '', 'ProbeTest::otherTest::', 'ProbeTest.kt', 12, 18); + const localMethod = node('local', 'method', 'probe', `${anon.qualifiedName}::probe`, 'ProbeTest.kt', 13, 17); + const interfaceMethod = node('interface', 'method', 'probe', 'Probe::probe', 'Probe.kt', 1, 2); + const nodes = [outer, anon, localMethod, interfaceMethod]; + const context = { + getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: (name: string) => nodes.filter((n) => n.qualifiedName === name), + getNodesInFile: (filePath: string) => nodes.filter((n) => n.filePath === filePath), + getNodesByKind: (kind: Node['kind']) => nodes.filter((n) => n.kind === kind), + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['ProbeTest.kt', 'Probe.kt'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'testA', referenceName: 'probe.probe', referenceKind: 'calls', + filePath: 'ProbeTest.kt', language: 'kotlin', line: 5, column: 0, + }; + + expect(matchMethodCall(ref, context)?.targetNodeId).toBe(interfaceMethod.id); + expect(matchMethodCall({ ...ref, fromNodeId: outer.id, line: 15 }, context)?.targetNodeId).toBe(localMethod.id); + }); + it('accepts every supported supertype kind in exact-name inheritance matching', () => { for (const kind of ['component', 'namespace'] as const) { const target: Node = { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 498b9fd3bb..8c6a1d8688 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1290,15 +1290,15 @@ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** - * A function nested inside another FUNCTION is only callable from within its - * container — Python, JS/TS, and every closure language scope it lexically. + * A function nested inside another function, or an anonymous-class method + * inside a function, is only callable from within that container. * Resolving a bare name from elsewhere to a nested local fabricates an edge * scope already rules out: `join(...)` in one function must never bind to a * `join` defined inside a DIFFERENT function (#1230). A candidate whose * qualifiedName parent is a same-file function/method is kept only when the - * ref originates inside that parent's line range. Class members are - * unaffected (their parent resolves to a class-like node), as are top-level - * symbols and C++ namespace-prefixed names (the prefix has no node). + * ref originates inside that parent's line range. Ordinary class members and + * top-level symbols have no enclosing function; C++ namespace prefixes have + * no function node. */ export function isLexicallyReachable( candidate: Node, @@ -22433,7 +22433,7 @@ export function matchMethodCall( // another package with neither it nor its package imported, is no // candidate at all: as if no method had its name there. const methods = methodCandidates.filter( - (n) => n.kind === 'method' && n.name === methodName && + (n) => n.kind === 'method' && n.name === methodName && isLexicallyReachable(n, ref, context) && !(ref.language === 'kotlin' && !isKotlinExtensionInScope(n, ref, context)) ); From 644646b725c3d40e2396edbe22c08c172846fc1d Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 28 Sep 2026 08:11:18 +0900 Subject: [PATCH 5/7] fix(resolution): keep captured Go method values eligible --- src/resolution/name-matcher.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 8c6a1d8688..bec22a3684 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14047,10 +14047,9 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { - // This helper resolves a receiver's invoked member, never a type/member - // reference such as Java `class Foo extends IBar.Stub`. Keep the guard here - // as a backstop for every current and future caller of this call-only API. - if (ref.referenceKind !== 'calls') return null; + // Only calls and captured method values have a callable receiver. Inheritance + // references such as `class Foo extends IBar.Stub` must not reach this helper. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref') return null; // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined From 18264010b14ae1872ec35cf64a607ac89a30430e Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:16:33 +0900 Subject: [PATCH 6/7] fix: adapt PR to current upstream behavior --- CHANGELOG.md | 3 +++ __tests__/resolution.test.ts | 3 +++ codegraph-kernel/src/kotlin.rs | 8 +------- src/extraction/tree-sitter.ts | 7 +------ src/resolution/name-matcher.ts | 2 +- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84aa32430..129316df8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -405,6 +405,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. +- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 8969ca759c..e207e854e6 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -93,6 +93,8 @@ describe('Resolution Module', () => { const nodes = [outer, anon, localMethod, interfaceMethod]; const context = { getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name), + getImportMappings: () => [], getNodesByQualifiedName: (name: string) => nodes.filter((n) => n.qualifiedName === name), getNodesInFile: (filePath: string) => nodes.filter((n) => n.filePath === filePath), getNodesByKind: (kind: Node['kind']) => nodes.filter((n) => n.kind === kind), @@ -117,6 +119,7 @@ describe('Resolution Module', () => { }; const context = { getNodesByName: () => [target], getNodesInFile: () => [target], + getNodesByLowerName: () => [target], getImportMappings: () => [], getNodesByQualifiedName: () => [], getNodesByKind: () => [], fileExists: () => true, readFile: () => null, getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 0e2fb10e34..158e69c5c9 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -413,14 +413,8 @@ impl<'t> Walker<'t> { return None; } let start_line = self.line_of(node); - // Members in different anonymous objects can share a name and line. - let identity_name = if self.kotlin_object_scope_depth > 0 && !self.stack.is_empty() { - format!("{}::{}", self.node_ids[self.top_row() as usize], name) - } else { - name.to_string() - }; let column = self.col_of(node); - let id = self.node_id_allocator.generate(self.file_path, kind, &identity_name, start_line, column); + let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column); // endLine extension via resolveBody — LIVE for kotlin function/method // kinds (in-range for this grammar, so practically a no-op — but the // hook is part of the contract). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f5e111493b..1dc73b4fc5 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2065,12 +2065,7 @@ export class TreeSitterExtractor { return null; } - // Members in different Kotlin anonymous objects can share both name and - // line. Bind their identity to the enclosing owner as well. - const identityName = this.kotlinObjectScopeDepth > 0 - ? `${this.nodeStack[this.nodeStack.length - 1]}::${name}` - : name; - const id = this.nodeIds.generate(this.filePath, kind, identityName, node.startPosition.row + 1, node.startPosition.column); + const id = this.nodeIds.generate(this.filePath, kind, name, node.startPosition.row + 1, node.startPosition.column); // Some grammars (e.g. Dart) model a function/method body as a *sibling* of // the signature node, so the declaration node's own range is just the diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index bec22a3684..e526fd3b19 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -22561,7 +22561,7 @@ export function matchMethodCall( // The owner type's own name — not its namespace (`eShop.ClientApp…` // shares `Client` with every `httpClient`) nor the method's. const cut = method.qualifiedName.lastIndexOf('::'); - const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!) : []; + const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!.replace(/^<(.+)\$anon@[^>]*>$/, '$1')) : []; // An untyped python attribute chain is a guess only through its head // noun: `self.session` names a `SessionBase` and `self.client` a // `ClientMixin`, but mealie's `database.group_shopping_lists` no From 0e8a08d83e37d00f294070883412d361b4597358 Mon Sep 17 00:00:00 2001 From: mixxer Date: Sat, 24 Oct 2026 12:02:37 +0900 Subject: [PATCH 7/7] fix(resolution): retain inherited framework handler references --- CHANGELOG.md | 3 --- src/resolution/name-matcher.ts | 15 +++++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129316df8a..d84aa32430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -405,9 +405,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. -- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. -- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. -- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. ## [1.6.2] - 2026-10-03 diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e526fd3b19..c58d74c5d3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14047,9 +14047,10 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { - // Only calls and captured method values have a callable receiver. Inheritance - // references such as `class Foo extends IBar.Stub` must not reach this helper. - if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref') return null; + // Frameworks can name an inherited handler with a qualified reference. + // Inheritance refs still name a type, not a callable receiver. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref' && + ref.referenceKind !== 'references') return null; // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined @@ -21534,11 +21535,9 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { - // A dotted/scoped name is not necessarily an invocation: Java inheritance - // refs such as `IBar.Stub` have the same surface shape. Let type-reference - // strategies handle non-call refs rather than falling through to the - // same-named-method heuristics below. - if (ref.referenceKind !== 'calls') return null; + // A dotted/scoped name can be a framework's handler reference, while + // inheritance refs must stay with type lookup. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'references') return null; // Parse method call patterns like "obj.method" or "Class::method". The method // part allows trailing `:` keywords so Objective-C selectors resolve