diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b68b091..3376dbd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -265,6 +265,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536) +- Scala inheritance is no longer cut short by the companion-object idiom. A Scala `object` sharing its name with a trait or class (the ubiquitous trait + companion pattern) could win the resolution of `extends`/`with` references, detaching every subtype from the inheritance chain — so `codegraph_impact` on a widely-used trait stopped at depth 1. Scala `object` definitions are now indexed as modules and inheritance references prefer the actual type, so impact analysis follows the full subtype tree. Re-index after upgrading. (#1824) + - PHP static calls through imported class aliases now reach the correct class when services and repositories share method names, so callers and impact analysis show the right dependencies after re-indexing. (#1545) - TypeScript/JavaScript: a call through a field of the enclosing class — `this.mailer.send()` — now resolves on the field's declared type, so a delegating wrapper that shares the method's name no longer records itself as its own callee and `callers`, `impact` and trace stop lying on that shape. A field whose type is external or a builtin stays unresolved rather than guessed. Re-index after upgrading. (#1496) - TypeScript and JavaScript collection calls through local variables and their nested properties no longer link to unrelated project methods; re-index after upgrading. (#1566) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 16e0fc244..64e2c7524 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4409,7 +4409,7 @@ class APXCharacter { // the one real definition const scala = extractFromSource('M.scala', 'trait Marker\ncase object Red\nclass Foo\n'); const scalaNames = scala.nodes - .filter((n) => ['class', 'trait', 'interface'].includes(n.kind)) + .filter((n) => ['class', 'trait', 'interface', 'module'].includes(n.kind)) .map((n) => n.name); expect(scalaNames).toEqual(expect.arrayContaining(['Marker', 'Red', 'Foo'])); }); @@ -8296,14 +8296,14 @@ class UserService(private val repo: UserRepository) { expect(cls?.language).toBe('scala'); }); - it('should extract object definitions as class kind', () => { + it('should extract object definitions as module kind', () => { const code = ` object DatabaseConfig { val url = "jdbc:postgresql://localhost/mydb" } `; const result = extractFromSource('Config.scala', code); - const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig'); + const obj = result.nodes.find((n) => n.kind === 'module' && n.name === 'DatabaseConfig'); expect(obj).toBeDefined(); }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index b8d25f6b8..3acc449a6 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -4860,6 +4860,51 @@ object Main { }); }); + describe('Scala companion object vs extends resolution', () => { + it('resolves `extends X` to the trait, not the same-named companion object', async () => { + // The trait+companion idiom: both nodes share name AND file. Without a + // kind-aware tie-breaker the winner is arbitrary, and when the companion + // wins, every subtype is detached from the trait's inheritance chain. + fs.writeFileSync( + path.join(tempDir, 'ExtAgreement.scala'), + `trait ExtAgreement { + def extId: String = "x" +} +object ExtAgreement { + val Kind = "agreement" +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'MExtAgreement.scala'), + `class MExtAgreement extends ExtAgreement { + def render(): String = extId +} +` + ); + cg = await CodeGraph.init(tempDir, { index: true }); + + const traitNode = cg.getNodesByKind('trait').find((n) => n.name === 'ExtAgreement'); + const moduleNode = cg.getNodesByKind('module').find((n) => n.name === 'ExtAgreement'); + expect(traitNode).toBeDefined(); + expect(moduleNode).toBeDefined(); + + const traitExtends = cg + .getIncomingEdges(traitNode!.id) + .filter((e) => e.kind === 'extends'); + const moduleExtends = cg + .getIncomingEdges(moduleNode!.id) + .filter((e) => e.kind === 'extends'); + expect(traitExtends.length).toBe(1); + expect(moduleExtends.length).toBe(0); + + // Impact must now traverse THROUGH the trait to the subtype. + const impact = cg.getImpactRadius(traitNode!.id, 5); + const impactNames = [...impact.nodes.values()].map((n) => n.name); + expect(impactNames).toContain('MExtAgreement'); + }); + }); + describe('Dart chained static-factory / factory-constructor call resolution (#645/#608 mechanism)', () => { function callerNamesOf(qualifiedName: string): string[] { const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); diff --git a/src/extraction/languages/scala.ts b/src/extraction/languages/scala.ts index b0d995faa..14474da3d 100644 --- a/src/extraction/languages/scala.ts +++ b/src/extraction/languages/scala.ts @@ -104,6 +104,11 @@ export const scalaExtractor: LanguageExtractor = { classifyClassNode: (node: SyntaxNode) => { if (node.type === 'trait_definition') return 'trait'; + // A Scala `object` is a singleton (the companion-object idiom), not a + // type: `extends X` can never target it. Classifying it as `module` + // keeps it distinguishable from the same-named trait/class so the + // resolver can prefer the type node for extends/implements refs. + if (node.type === 'object_definition') return 'module'; return 'class'; }, diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 19f749cf5..7acaa5c11 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -229,7 +229,7 @@ export interface LanguageExtractor { * Classify a class_declaration node when the grammar reuses one node type * for multiple concepts (e.g. Swift uses class_declaration for classes, structs, and enums). */ - classifyClassNode?: (node: SyntaxNode) => 'class' | 'struct' | 'enum' | 'interface' | 'trait'; + classifyClassNode?: (node: SyntaxNode) => 'class' | 'struct' | 'enum' | 'interface' | 'trait' | 'module'; /** * Classify a methodTypes node when the grammar reuses one node type for diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 20f5dbb26..d2fff375d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1076,6 +1076,8 @@ export class TreeSitterExtractor { this.extractInterface(node); } else if (classification === 'trait') { this.extractClass(node, 'trait'); + } else if (classification === 'module') { + this.extractClass(node, 'module'); } else { this.extractClass(node); } @@ -5750,6 +5752,7 @@ export class TreeSitterExtractor { else if (classification === 'enum') this.extractEnum(node); else if (classification === 'interface') this.extractInterface(node); else if (classification === 'trait') this.extractClass(node, 'trait'); + else if (classification === 'module') this.extractClass(node, 'module'); else this.extractClass(node); return; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4db2c1f17..7c99553e9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2896,6 +2896,17 @@ function findBestMatch( } } + // For inheritance references (`extends X` / `implements X`), penalize + // `module` candidates — a Scala companion `object` shares its name (and + // file) with the trait/class it accompanies, but `extends` can never + // target a singleton. `module` stays in SUPERTYPE_TARGET_KINDS (Ruby + // `include`, TS namespaces), so it is still eligible; without this the + // trait and its companion tie and the winner is arbitrary, which detaches + // subtypes from the inheritance chain (impact analysis breaks). + if (isInheritanceRef(ref) && candidate.kind === 'module') { + score -= 50; + } + // For decorator references (`@Foo`), prefer functions. Class // decorators (Python `@SomeClass`, Java annotation interfaces) // also resolve here, hence the smaller class bonus.