fix(spring): name heuristics start from the reference's own file and package (#2222)

The Spring resolver's entity/service/controller name heuristics took the first
same-named class under a conventional folder; they now prefer the ref's own
file, then its package, and never reach another file's nested class by its
bare name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 00:16:23 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ff190b30d8
commit d6f68648cb
3 changed files with 93 additions and 8 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In Spring projects, a class name now resolves to the one in the reference's own file or package before any guess by folder convention. A class nested inside another file's class is never reached by its bare name. Before, every MyBatis `Example` class's `new Criteria()` in mall linked to the first `Example`'s nested `Criteria`, and halo's Lombok `@Builder` linked to a nested `Builder` class elsewhere.
- A method that hands its call on to another object is no longer linked to itself. In TypeScript and JavaScript, a call through `this.<field>` or `window.<x>` counts as recursion only when the field is declared as the method's own class. A guess from a receiver's name alone never lands on the calling method. Before, BookStack's `toggle()` doing `this.container.classList.toggle('open')`, `listen()` doing `window.$events.listen(…)`, and `FileStorage::delete` doing `$storage->delete($path)` each pointed at themselves.
- C# names now follow the language's scopes. A block `namespace X { … }` qualifies only the types inside it, so a type declared after the block, or in a file's second namespace, is no longer filed under the first. A namespace written inside another is `Outer.Inner`. A `using` links to the project's namespace instead of another file's `using` of the same name. A `global using` applies only within its own project. A nested type is reachable by bare name only from inside its owner or a type deriving from it, including through another partial part. Before, serilog's `Guard` was filed under `JetBrains.Annotations`, its tests' `Some.InformationEvent()` went to the performance tests' `Some`, and AutoMapper's same-file `new Source()` could reach another test class's nested `Source`.
- A function or name passed as a value now resolves to what is in scope where it's written. A function nested inside another function is only reachable from inside it. In Python, a parameter or local of the same name is that local. A pytest fixture is a test's parameter only in its own module or under its `conftest.py`. Before, httpx's `self._build_auth(auth)` linked to an `auth` a test defines inside another function, and `auth_flow(self, request)` handing `request` on linked to the package's `request()` function.
+64
View File
@@ -0,0 +1,64 @@
/**
* The Spring resolver's name heuristics (an entity under `/model/`, a
* `…Service`, a `…Controller`) start from what the reference's own scope
* declares — its file, then its package — and never reach a class nested in
* another file's class by its bare name: every MyBatis `XExample` declares its
* own nested `Criteria`, and mall's `new Criteria()` all went to the first.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
const example = (name: string) => `package com.macro.mall.model;
public class ${name} {
public Criteria createCriteria() {
Criteria criteria = new Criteria();
return criteria;
}
public static class Criteria {
}
}
`;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-model-'));
const files: Record<string, string> = {
'pom.xml': '<project><dependency>spring-boot-starter</dependency></project>\n',
'mall-mbg/src/main/java/com/macro/mall/model/CmsHelpCategoryExample.java': example('CmsHelpCategoryExample'),
'mall-mbg/src/main/java/com/macro/mall/model/OmsOrderExample.java': example('OmsOrderExample'),
'mall-admin/src/main/java/com/macro/mall/service/OrderService.java': `package com.macro.mall.service;
@Service
public class OrderService {
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('Spring name heuristics', () => {
it('resolve a name to its own file’s nested class, not another file’s', () => {
const file = 'mall-mbg/src/main/java/com/macro/mall/model/OmsOrderExample.java';
const ids = cg.getNodesInFile(file).map((n) => n.id);
const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'instantiates' || e.kind === 'references')
.map((e) => cg.getNode(e.target)!).filter((t) => t.name === 'Criteria').map((t) => t.filePath);
expect(targets.length).toBeGreaterThan(0);
expect(new Set(targets)).toEqual(new Set([file]));
});
});
+28 -8
View File
@@ -137,7 +137,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 1: Service references (dependency injection)
if (ref.referenceName.endsWith('Service')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
const result = resolveByNameAndKind(ref, SERVICE_KINDS, SERVICE_DIRS, context);
if (result) {
return {
original: ref,
@@ -150,7 +150,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 2: Repository references
if (ref.referenceName.endsWith('Repository')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context);
const result = resolveByNameAndKind(ref, SERVICE_KINDS, REPO_DIRS, context);
if (result) {
return {
original: ref,
@@ -163,7 +163,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 3: Controller references
if (ref.referenceName.endsWith('Controller')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, CONTROLLER_DIRS, context);
if (result) {
return {
original: ref,
@@ -176,7 +176,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 4: Entity/Model references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, ENTITY_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, ENTITY_DIRS, context);
if (result) {
return {
original: ref,
@@ -189,7 +189,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 5: Component references
if (ref.referenceName.endsWith('Component') || ref.referenceName.endsWith('Config')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, COMPONENT_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, COMPONENT_DIRS, context);
if (result) {
return {
original: ref,
@@ -555,19 +555,30 @@ function joinPath(prefix: string, sub: string): string {
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
* What the reference's own scope declares comes first — its file, then its
* package (directory) — and a class nested in another file's class is out of
* reach by its bare name: every MyBatis `XExample` declares its own nested
* `Criteria`, and mall's 8,747 `new Criteria()` went to the first one.
*/
function resolveByNameAndKind(
name: string,
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
const candidates = context.getNodesByName(ref.referenceName);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
const kindFiltered = candidates.filter((n) => kinds.has(n.kind) &&
(n.filePath === ref.filePath || !isNestedType(n, context)));
if (kindFiltered.length === 0) return null;
const sameFile = kindFiltered.find((n) => n.filePath === ref.filePath);
if (sameFile) return sameFile.id;
const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1);
const samePackage = kindFiltered.find((n) => n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/'));
if (samePackage) return samePackage.id;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
@@ -578,3 +589,12 @@ function resolveByNameAndKind(
// Fall back to any match
return kindFiltered[0]!.id;
}
/** Whether a JVM type is declared inside another type of its file. */
function isNestedType(n: Node, context: ResolutionContext): boolean {
const cut = n.qualifiedName.lastIndexOf('::');
if (cut < 0) return false;
const owner = n.qualifiedName.slice(0, cut);
return context.getNodesInFile(n.filePath).some((p) =>
p.qualifiedName === owner && (p.kind === 'class' || p.kind === 'interface' || p.kind === 'enum' || p.kind === 'struct' || p.kind === 'trait'));
}