diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a4f9bfe..d84aa3243 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 2814a4a2c..5b60cd824 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2035,6 +2035,46 @@ 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 + // 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 { + 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 000000000..82f533721 --- /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 e20d2d997..e207e854e 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,57 @@ 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), + 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), + 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 = { + 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], + getNodesByLowerName: () => [target], getImportMappings: () => [], + 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 +6311,54 @@ in }); }); + 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 + // 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 +6699,113 @@ 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([]); + }); + + 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/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 6a7735a20..e565ae868 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -959,13 +959,27 @@ 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) 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(); + 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 +989,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 5459e63c9..158e69c5c 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; @@ -895,10 +901,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 +974,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 +1417,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 22889804e..1dc73b4fc 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 @@ -6980,16 +6989,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 +7013,19 @@ 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) 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 // 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 +7041,128 @@ 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 cannot see 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 +7631,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 afa8514e5..c58d74c5d 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, @@ -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,11 +13130,24 @@ 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]; + // `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, @@ -13997,6 +14047,11 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | 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 // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate @@ -18369,6 +18424,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 +18500,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 +18540,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 +21535,10 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | 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 // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other @@ -22369,7 +22431,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)) ); @@ -22498,7 +22560,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 @@ -29022,7 +29084,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.