fix(scala): resolve companion inheritance to type definitions (#1824) (#2029)

* 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.

* fix(scala): resolve companion inheritance to type definitions (#1824)

Scala objects and their companion types could tie during inheritance resolution, disconnecting subtype impact paths.
Classify objects as modules in both extraction engines and reject Scala singleton inheritance targets across resolution strategies.
Preserve object method lookup and inherited receiver methods, with declaration-order, impact, and native/WASM parity regressions.

Co-authored-by: Hubert Tarnacki <hubert.tarnacki@comarch.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Hubert Tarnacki <hubert.tarnacki@comarch.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:32:17 +00:00
committed by GitHub
co-authored by Hubert Tarnacki Claude Opus 5.5
parent b65e05b98f
commit e27d6da6b2
11 changed files with 203 additions and 22 deletions
+21 -9
View File
@@ -4462,7 +4462,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']));
});
@@ -8681,15 +8681,27 @@ class UserService(private val repo: UserRepository) {
expect(cls?.language).toBe('scala');
});
it('should extract object definitions as class kind', () => {
const code = `
object DatabaseConfig {
val url = "jdbc:postgresql://localhost/mydb"
}
`;
it.each(['\n', '\r\n'])('extracts objects as modules with method ownership (%j)', (eol) => {
const code = [
'object DatabaseConfig {',
' val url = "jdbc:postgresql://localhost/mydb"',
' def connect(): String = url',
'}',
'def scope(): Int = {',
' object Local { def value(): Int = 1 }',
' Local.value()',
'}',
'case object Empty',
].join(eol);
const result = extractFromSource('Config.scala', code);
const obj = result.nodes.find((n) => n.kind === 'class' && n.name === 'DatabaseConfig');
expect(obj).toBeDefined();
for (const [name, method] of [['DatabaseConfig', 'connect'], ['Local', 'value']]) {
const obj = result.nodes.find((n) => n.kind === 'module' && n.name === name)!;
const member = result.nodes.find((n) => n.kind === 'method' && n.name === method)!;
expect(obj).toBeDefined();
expect(member).toBeDefined();
expect(result.edges.some((e) => e.kind === 'contains' && e.source === obj.id && e.target === member.id)).toBe(true);
}
expect(result.nodes.some((n) => n.kind === 'module' && n.name === 'Empty')).toBe(true);
});
it('should extract trait definitions as trait kind', () => {
@@ -0,0 +1,23 @@
object ObjectFirst {
def create(): ObjectFirst = new ObjectFirst
}
class ObjectFirst {
def instance(): Int = 1
}
trait TraitFirst {
def value(): Int = 1
}
object TraitFirst {
def factory(): Int = 2
}
object Live extends TraitFirst {
def run(): Int = value()
}
case object Empty
def scope(): Int = {
object Local {
def localMethod(): Int = 3
}
Local.localMethod()
}
+19
View File
@@ -104,6 +104,7 @@ describe.skipIf(!kernelBuilt)('kernel Scala extraction parity', () => {
const FIXTURES = [
['torture.scala', 40],
['ScalaCompanions.scala', 14],
['TortureDocs.scala', 3],
['TortureVref.scala', 4],
['TortureFnref.scala', 4],
@@ -127,6 +128,24 @@ describe.skipIf(!kernelBuilt)('kernel Scala extraction parity', () => {
});
}
it.each(['LF', 'CRLF'])('companion kinds and method ownership (%s)', (eol) => {
let source = fs.readFileSync(path.join(FIXTURE_DIR, 'ScalaCompanions.scala'), 'utf8');
if (eol === 'CRLF') source = source.replace(/\n/g, '\r\n');
const result = assertParity('ScalaCompanions.scala', source, 14);
for (const [owner, method] of [
['ObjectFirst', 'create'], ['TraitFirst', 'factory'], ['Live', 'run'], ['Local', 'localMethod'],
]) {
const obj = result.nodes.find((n) => n.kind === 'module' && n.name === owner)!;
const member = result.nodes.find((n) => n.kind === 'method' && n.name === method)!;
expect(obj).toBeDefined();
expect(member).toBeDefined();
expect(result.edges.some((e) => e.kind === 'contains' && e.source === obj.id && e.target === member.id)).toBe(true);
}
expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'ObjectFirst')).toBe(true);
expect(result.nodes.some((n) => n.kind === 'trait' && n.name === 'TraitFirst')).toBe(true);
expect(result.nodes.some((n) => n.kind === 'module' && n.name === 'Empty')).toBe(true);
});
it('torture pins: import first-segment names, companion pairs, value-ref edges', () => {
const src = fs.readFileSync(path.join(FIXTURE_DIR, 'torture.scala'), 'utf8');
const result = assertParity('fixtures/torture.scala', src, 40);
+104
View File
@@ -5049,6 +5049,110 @@ object Main {
});
});
describe('Scala companion object vs extends resolution', () => {
for (const parentKind of ['trait', 'class'] as const) {
for (const objectFirst of [true, false]) {
it.each([false, true])(
`resolves ${parentKind} companions (objectFirst=${objectFirst}, imported=%s) through every impact depth`,
async (imported) => {
const typeDef = `${parentKind} ExtAgreement {
def extId: String = "x"
}
`;
const objectDef = `object ExtAgreement {
val Kind = "agreement"
}
`;
fs.writeFileSync(path.join(tempDir, 'ExtAgreement.scala'),
'package contracts\n' + (objectFirst ? objectDef + typeDef : typeDef + objectDef));
fs.writeFileSync(path.join(tempDir, 'Audit.scala'),
'package contracts\nobject Audit {}\ntrait Audit { def audit(): String = "ok" }\n');
fs.writeFileSync(path.join(tempDir, 'MExtAgreement.scala'),
(imported ? 'package model\nimport contracts.ExtAgreement\nimport contracts.Audit\n' : 'package contracts\n') +
'class MExtAgreement extends ExtAgreement with Audit {\n def render(): String = extId\n}\n');
fs.writeFileSync(path.join(tempDir, 'LeafAgreement.scala'),
(imported ? 'package model\n' : 'package contracts\n') +
'class LeafAgreement extends MExtAgreement {\n def leaf(): String = render()\n}\n');
cg = await CodeGraph.init(tempDir, { index: true });
const parent = cg.getNodesByKind(parentKind).find((n) => n.name === 'ExtAgreement');
const companion = cg.getNodesByKind('module').find((n) => n.name === 'ExtAgreement');
expect(parent).toBeDefined();
expect(companion).toBeDefined();
expect(cg.getIncomingEdges(parent!.id).filter((e) => e.kind === 'extends')).toHaveLength(1);
for (const name of ['ExtAgreement', 'Audit']) {
const obj = cg.getNodesByKind('module').find((n) => n.name === name)!;
expect(cg.getIncomingEdges(obj.id).filter((e) => e.kind === 'extends')).toEqual([]);
}
const audit = cg.getNodesByKind('trait').find((n) => n.name === 'Audit')!;
expect(cg.getIncomingEdges(audit.id).filter((e) => e.kind === 'extends')).toHaveLength(1);
// Impact must traverse THROUGH the type to descendants and their methods.
const impactNames = [...cg.getImpactRadius(parent!.id, 5).nodes.values()].map((n) => n.name);
expect(impactNames).toEqual(expect.arrayContaining(['MExtAgreement', 'render', 'LeafAgreement', 'leaf']));
}
);
}
}
it.each([false, true])('rejects a sole singleton parent (imported=%s)', async (imported) => {
fs.writeFileSync(path.join(tempDir, 'OnlyObject.scala'),
'package contracts\nobject OnlyObject { def value(): Int = 1 }\n');
fs.writeFileSync(path.join(tempDir, 'Invalid.scala'),
(imported ? 'package model\nimport contracts.OnlyObject\n' : 'package contracts\n') +
'class Invalid extends OnlyObject {}\ntrait AlsoInvalid extends OnlyObject {}\n');
cg = await CodeGraph.init(tempDir, { index: true });
const obj = cg.getNodesByKind('module').find((n) => n.name === 'OnlyObject')!;
expect(obj).toBeDefined();
expect(cg.getIncomingEdges(obj.id).filter((e) => e.kind === 'extends' || e.kind === 'implements')).toEqual([]);
});
it('keeps singleton objects as inheritance sources and owners of methods', async () => {
fs.writeFileSync(path.join(tempDir, 'Service.scala'),
'trait Service { def inherited(): Int = 1 }\nobject LiveService extends Service { def run(): Int = this.inherited() }\n');
cg = await CodeGraph.init(tempDir, { index: true });
const service = cg.getNodesByKind('trait').find((n) => n.name === 'Service')!;
const obj = cg.getNodesByKind('module').find((n) => n.name === 'LiveService')!;
expect(cg.getIncomingEdges(service.id).some((e) => e.kind === 'extends' && e.source === obj.id)).toBe(true);
const run = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'LiveService::run')!;
expect(cg.getOutgoingEdges(obj.id).some((e) => e.kind === 'contains' && e.target === run.id)).toBe(true);
const inherited = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Service::inherited')!;
expect(cg.getIncomingEdges(inherited.id).some((e) => e.kind === 'calls' && e.source === run.id)).toBe(true);
});
it('resolves inherited methods through a singleton receiver', async () => {
fs.writeFileSync(path.join(tempDir, 'Service.scala'),
'trait Service { def inherited(): Int = 1 }\n' +
'object LiveService extends Service {}\n' +
'object Unrelated { def inherited(): Int = 2 }\n' +
'object Client { def use(): Int = { val receiver = LiveService; receiver.inherited() } }\n');
cg = await CodeGraph.init(tempDir, { index: true });
const inherited = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Service::inherited')!;
const use = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Client::use')!;
expect(cg.getIncomingEdges(inherited.id).some((e) => e.kind === 'calls' && e.source === use.id)).toBe(true);
});
it('keeps object method calls anchored to their receiver', async () => {
fs.writeFileSync(path.join(tempDir, 'Api.scala'),
'class API { def send(): Int = 2 }\n' +
'object Api { def send(): Int = 1 }\n' +
'object Client { def run(): Int = Api.send() }\n');
cg = await CodeGraph.init(tempDir, { index: true });
const send = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Api::send')!;
const run = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Client::run')!;
expect(cg.getIncomingEdges(send.id).some((e) => e.kind === 'calls' && e.source === run.id)).toBe(true);
});
it('preserves Ruby module inclusion', async () => {
fs.writeFileSync(path.join(tempDir, 'trackable.rb'),
'module Trackable\n def track; end\nend\nclass Record\n include Trackable\nend\n');
cg = await CodeGraph.init(tempDir, { index: true });
const mod = cg.getNodesByKind('module').find((n) => n.name === 'Trackable')!;
expect(cg.getIncomingEdges(mod.id).some((e) =>
(e.kind === 'extends' || e.kind === 'implements') && cg.getNode(e.source)?.name === 'Record')).toBe(true);
});
});
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);
+10 -2
View File
@@ -548,10 +548,14 @@ impl<'t> Walker<'t> {
self.extract_method_or_function(node);
return; // skipChildren
}
"class_definition" | "object_definition" => {
"class_definition" => {
self.extract_class(node, "class");
return;
}
"object_definition" => {
self.extract_class(node, "module");
return;
}
"trait_definition" => {
self.extract_class(node, "trait");
return;
@@ -1193,10 +1197,14 @@ impl<'t> Walker<'t> {
// the inverse of kotlin). Body-local classes/objects/traits/enums DO
// extract fully.
match kind {
"class_definition" | "object_definition" => {
"class_definition" => {
self.extract_class(node, "class");
return;
}
"object_definition" => {
self.extract_class(node, "module");
return;
}
"trait_definition" => {
self.extract_class(node, "trait");
return;
+7 -3
View File
@@ -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';
},
@@ -140,9 +145,8 @@ export const scalaExtractor: LanguageExtractor = {
// idiom for `static final` — `object Config { val Timeout = 30 }`), so
// emit them as `constant`/`variable` like a top-level val, which lets
// value-reference edges target them. A `class`/`trait`/`enum`/`given` val
// is a per-instance immutable field. Both an `object` and a `class`
// extract as `class` kind, so the AST node type of the enclosing
// definition — not the parent node's kind — is what distinguishes them.
// is a per-instance immutable field. Use the AST node type of the enclosing
// definition to distinguish them, including given/enum scopes.
let enclosingDef: string | null = null;
for (let p = node.parent; p; p = p.parent) {
if (
+1 -1
View File
@@ -236,7 +236,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
+3
View File
@@ -1095,6 +1095,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);
}
@@ -5876,6 +5878,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;
}
+5 -3
View File
@@ -15,7 +15,7 @@ import {
ResolutionContext,
FrameworkResolver,
ImportMapping,
SUPERTYPE_TARGET_KINDS,
isSupertypeTarget,
isInheritanceRef,
isImportableKind,
} from './types';
@@ -634,7 +634,9 @@ export class ReferenceResolver {
if (hit && hit.gen === this.supertypeGen) return hit.supers;
const typeNodes = this.context
.getNodesByName(typeName)
.filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
// Scala singletons can inherit members even though they cannot be parents.
.filter((n) => n.language === language && (SUPERTYPE_BEARING_KINDS.has(n.kind) ||
(n.language === 'scala' && n.kind === 'module')));
let supers: string[];
if (typeNodes.length === 0) {
supers = [];
@@ -2664,7 +2666,7 @@ export class ReferenceResolver {
if (!isInheritanceRef(ref)) return result;
const target = this.queries.getNodeById(result.targetNodeId);
if (target && !SUPERTYPE_TARGET_KINDS.has(target.kind)) return null;
if (target && !isSupertypeTarget(target)) return null;
if (isBoundToOutOfRepoImport(ref, this.context)) return null;
return result;
}
+4 -4
View File
@@ -6,7 +6,7 @@
import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, isInheritanceRef, isImportableKind } from './types';
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins';
@@ -792,7 +792,7 @@ export function matchByExactName(
// the real `trait`, and as the sole candidate was adopted outright by the
// single-match shortcut. Restricting the pool BEFORE ranking lets the
// legitimate supertype win instead of merely dropping the false edge.
.filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind))
.filter((n) => !isInheritanceRef(ref) || isSupertypeTarget(n))
// Likewise for `imports`: a member that only exists inside a type is not
// importable, so it is not a candidate. Without this a `path`/`id`/`url`
// import resolved to some interface's same-named property.
@@ -2555,7 +2555,7 @@ export function matchMethodCall(
);
for (const classNode of classCandidates) {
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface' || (classNode.language === 'scala' && classNode.kind === 'module')) {
// Skip cross-language class matches
if (classNode.language !== ref.language) continue;
@@ -2599,7 +2599,7 @@ export function matchMethodCall(
ref.filePath,
);
for (const classNode of fuzzyClassCandidates) {
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface' || (classNode.language === 'scala' && classNode.kind === 'module')) {
// Skip cross-language class matches
if (classNode.language !== ref.language) continue;
+6
View File
@@ -329,6 +329,12 @@ export const SUPERTYPE_TARGET_KINDS = new Set<Node['kind']>([
'type_alias', 'component', 'module', 'namespace',
]);
/** Scala singleton objects are values, unlike inheritable Ruby modules. */
export function isSupertypeTarget(node: Node): boolean {
return SUPERTYPE_TARGET_KINDS.has(node.kind) &&
!(node.language === 'scala' && node.kind === 'module');
}
/** True for the reference kinds that assert an inheritance/conformance relation. */
export function isInheritanceRef(ref: UnresolvedRef): boolean {
return ref.referenceKind === 'extends' || ref.referenceKind === 'implements';