From 1498217d3352d139bb2e44c7271a0a35d9dd1e82 Mon Sep 17 00:00:00 2001 From: Hubert Tarnacki Date: Mon, 31 Aug 2026 09:19:36 +0200 Subject: [PATCH] fix(scala): resolve extends/with to the trait, not its companion object The Scala trait + companion object idiom puts two same-named symbols in one file. Both were indexed as kind 'class', so an 'extends X' reference tied between the trait and its companion and the winner was arbitrary. When the companion won, every subtype was detached from the trait's inheritance chain, and impact analysis on a widely-used trait stopped at depth 1. - classify Scala object_definition as 'module' (a singleton value, not a type - 'extends' can never target it) - add 'module' to the classifyClassNode contract and handle it in the extractor dispatch - in name matching, bias extends/implements references toward actual type definitions and penalize 'module' candidates, so the trait wins the tie deterministically Regression test: trait + companion in one file, subclass in another - the extends edge must land on the trait and impact must reach the subtype through it. --- CHANGELOG.md | 2 ++ __tests__/extraction.test.ts | 6 ++-- __tests__/resolution.test.ts | 45 +++++++++++++++++++++++++++++ src/extraction/languages/scala.ts | 5 ++++ src/extraction/tree-sitter-types.ts | 2 +- src/extraction/tree-sitter.ts | 3 ++ src/resolution/name-matcher.ts | 11 +++++++ 7 files changed, 70 insertions(+), 4 deletions(-) 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.