From d6f68648cb3c83712a66206a07118b975b9bf8fe Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 00:16:23 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/spring-model-name-scope.test.ts | 64 +++++++++++++++++++++++ src/resolution/frameworks/java.ts | 36 ++++++++++--- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 __tests__/spring-model-name-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b990520..c6e97b338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` or `window.` 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. diff --git a/__tests__/spring-model-name-scope.test.ts b/__tests__/spring-model-name-scope.test.ts new file mode 100644 index 000000000..d8d60c7c2 --- /dev/null +++ b/__tests__/spring-model-name-scope.test.ts @@ -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 = { + 'pom.xml': 'spring-boot-starter\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])); + }); +}); diff --git a/src/resolution/frameworks/java.ts b/src/resolution/frameworks/java.ts index bd36b1e4e..3e1b7d605 100644 --- a/src/resolution/frameworks/java.ts +++ b/src/resolution/frameworks/java.ts @@ -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, 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')); +}