fix(spring): gate DI convention patterns to Java/Kotlin non-inheritance refs (#1827)

In a polyglot repo the Spring resolver is detected globally (a sibling
Java module with spring-boot in its pom is enough), and its resolve()
then ran on references from every language. Pattern 4's bare-name
fallback ('any [A-Z][a-zA-Z]+ name, prefer /model/ dirs, else ANY
same-named class') could therefore hijack a Scala 'extends ExtCustomer'
to an unrelated same-named test class, corrupting the inheritance graph
that impact analysis walks.

Two gates in front of the DI/convention patterns (1-5):
- skip refs whose language is not java/kotlin - Spring DI conventions
  say nothing about other languages
- skip extends/implements refs entirely - inheriting from a class is
  never a DI injection point; those must resolve via imports/name
  matching

The Spring config-key resolution above the gates is untouched (it
already gates itself). Regression tests cover the hijack case and the
still-working Java DI path.

Co-authored-by: Hubert Tarnacki <hubert.tarnacki@comarch.com>
This commit is contained in:
htarnacki
2026-09-27 14:22:09 +02:00
committed by GitHub
parent 4ebd0d6ff5
commit c3faef260b
3 changed files with 82 additions and 0 deletions
+2
View File
@@ -283,6 +283,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)
- Spring's dependency-injection resolution patterns no longer capture inheritance references or references from other languages. In a polyglot repository where Spring is detected (a sibling Java module is enough), a Scala or Kotlin `extends`/`implements` reference could be resolved by Spring's directory-convention heuristics to an unrelated same-named class — for example a test fixture — corrupting the inheritance graph that `codegraph_impact` and `codegraph_explore` walk. Inheritance now always resolves through imports and name matching, and the Spring DI patterns only apply to Java/Kotlin references. Re-index after upgrading. (#1825)
- 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)
+71
View File
@@ -992,6 +992,77 @@ class OwnerController {
});
});
describe('springResolver.resolve — DI heuristics are gated to Java/Kotlin non-inheritance refs', () => {
// A polyglot repo (Scala + a sibling Java module) detects Spring globally.
// The DI/convention patterns (bare-name Pattern 4 especially) must then not
// hijack a Scala `extends X` to a same-named class found via directory
// heuristics — inheritance must resolve through imports/name matching.
const decoyNode: Node = {
id: 'class:src/test/model/ExtCustomer.java:ExtCustomer:3',
kind: 'class',
name: 'ExtCustomer',
qualifiedName: 'src/test/model/ExtCustomer.java::ExtCustomer',
filePath: 'src/test/model/ExtCustomer.java',
language: 'java',
startLine: 3,
endLine: 10,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const context = {
getNodesInFile: () => [],
getNodesByName: (n: string) => (n === 'ExtCustomer' ? [decoyNode] : []),
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: () => false,
readFile: () => null,
getProjectRoot: () => '/test',
getAllFiles: () => [],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
const baseRef = {
fromNodeId: 'class:A.scala:MExtCustomer:5',
referenceName: 'ExtCustomer',
line: 5,
column: 10,
filePath: 'A.scala',
};
it('does NOT resolve a Scala extends reference (Pattern 4 bare-name fallback)', () => {
const ref = { ...baseRef, referenceKind: 'extends' as const, language: 'scala' as const };
expect(springResolver.resolve(ref, context as any)).toBeNull();
});
it('does NOT resolve a non-Java/Kotlin plain reference either', () => {
const ref = { ...baseRef, referenceKind: 'references' as const, language: 'scala' as const };
expect(springResolver.resolve(ref, context as any)).toBeNull();
});
it('does NOT resolve a Java extends reference — inheritance is never a DI pattern', () => {
const ref = {
...baseRef,
filePath: 'B.java',
referenceKind: 'extends' as const,
language: 'java' as const,
};
expect(springResolver.resolve(ref, context as any)).toBeNull();
});
it('still resolves a Java DI reference through the entity pattern', () => {
const ref = {
...baseRef,
filePath: 'B.java',
referenceKind: 'references' as const,
language: 'java' as const,
};
const result = springResolver.resolve(ref, context as any);
expect(result?.targetNodeId).toBe(decoyNode.id);
expect(result?.resolvedBy).toBe('framework');
});
});
import { playResolver } from '../src/resolution/frameworks/play';
import { isSourceFile, isPlayRoutesFile } from '../src/extraction/grammars';
+9
View File
@@ -126,6 +126,15 @@ export const springResolver: FrameworkResolver = {
}
}
// The DI/convention patterns below (1–5) are Spring-specific heuristics:
// they must only fire on Java/Kotlin refs, and never on inheritance refs.
// Without these gates a Scala `extends ExtCustomer` (Spring detected via a
// sibling Java module) was hijacked by Pattern 4's bare-name fallback to an
// unrelated same-named test class — `extends`/`implements` must resolve via
// imports/name matching, not directory heuristics.
if (ref.language !== 'java' && ref.language !== 'kotlin') return null;
if (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') return null;
// Pattern 1: Service references (dependency injection)
if (ref.referenceName.endsWith('Service')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);