fix(frameworks): name heuristics pick only what the reference can see, own scope first (#2225)

One shared pick behind the Django/FastAPI/Flask, ASP.NET, Gin, SwiftUI/Vapor,
Spring and Rust name heuristics: never a function-local declaration, a type
nested in another file's type, or a node the language keeps out of the
reference's file; the reference's own file first, then its directory (not
Rust), then a conventional folder.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 00:45:56 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7365427d4f
commit 179939055c
10 changed files with 198 additions and 182 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Framework name conventions for Django, FastAPI, Flask, ASP.NET, Gin, SwiftUI and Vapor now only pick a class the reference can see. Examples of these conventions are "a `…View` is a view", "a `…Service` is a service" and "a capitalized name is a model". The pick starts from the reference's own file and then its package, and never takes a class declared inside some function or nested in another file's class. Before, Django REST framework's tests linked each file's own `MockView` and `Serializer` to another file's, and netbox's tests linked `TestForm()` to a form declared inside a different test function.
- In Rust projects, a struct, handler or service name no longer resolves to an item declared inside some function body, and a file's own item comes before any other crate's. Before, axum's examples' `Uri` linked to a `struct Uri` declared inside a routing test, tokio's runtime `Handle` linked to `tokio-test`'s, and ripgrep's `Glob { … }` in globset linked to the CLI flags' `Glob`.
- A method that calls a same-named method on another expression's result is no longer linked to itself in any language. Examples are Scala's `requestToArmeria(request).execute()` inside `execute()`, Rust's `self.0.into_route(state)` inside `into_route()`, and Kotlin's `this@Buffer.write(…)` from an inner object. A value whose initializer chain calls a same-named method, like sttp's `val response = basicRequest.get(…).response(…)`, no longer links to itself either.
- 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.
@@ -0,0 +1,68 @@
/**
* The framework resolvers' name heuristics ("a `…View` is a view under
* `/views/`", "a `…Form` is a form") pick only what the reference can see,
* starting from its own scope: never a class a test declares inside one of
* its functions (netbox's `TestForm`, DRF's `MockView`), its own file's
* first, then its package's, then a conventional folder's.
*/
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;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fw-name-'));
const files: Record<string, string> = {
'manage.py': '',
'utilities/tests/test_forms.py': `class GetFieldValueTestCase:
def setUpTestData(self):
class TestForm:
pass
return TestForm()
`,
'utilities/tests/test_templatetags.py': `def test_any_required():
return TestForm()
`,
'tests/browsable_api/views.py': `class MockView:
pass
`,
'tests/authentication/test_authentication.py': `class MockView:
pass
def test_auth():
return MockView()
`,
};
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 });
});
const targetsFrom = (file: string) => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'instantiates' || e.kind === 'calls' || e.kind === 'references')
.map((e) => cg.getNode(e.target)!).map((t) => `${t.filePath}:${t.qualifiedName}`);
};
describe('framework name heuristics', () => {
it('never pick a class declared inside another file’s function', () => {
expect(targetsFrom('utilities/tests/test_templatetags.py').filter((t) => t.includes('TestForm'))).toEqual([]);
});
it('take the reference’s own file first', () => {
expect(targetsFrom('tests/authentication/test_authentication.py').filter((t) => t.includes('MockView')))
.toEqual(['tests/authentication/test_authentication.py:MockView']);
});
});
+3 -3
View File
@@ -998,11 +998,11 @@ describe('springResolver.resolve — DI heuristics are gated to Java/Kotlin non-
// 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',
id: 'class:src/main/model/ExtCustomer.java:ExtCustomer:3',
kind: 'class',
name: 'ExtCustomer',
qualifiedName: 'src/test/model/ExtCustomer.java::ExtCustomer',
filePath: 'src/test/model/ExtCustomer.java',
qualifiedName: 'src/main/model/ExtCustomer.java::ExtCustomer',
filePath: 'src/main/model/ExtCustomer.java',
language: 'java',
startLine: 3,
endLine: 10,
+9 -24
View File
@@ -7,6 +7,7 @@
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { pickByNameAndKind } from './name-heuristic';
export const aspnetResolver: FrameworkResolver = {
name: 'aspnet',
@@ -67,7 +68,7 @@ export const aspnetResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: 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,
@@ -80,7 +81,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 2: Service references (dependency injection)
if (ref.referenceName.endsWith('Service') || ref.referenceName.startsWith('I') && ref.referenceName.length > 1) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
const result = resolveByNameAndKind(ref, SERVICE_KINDS, SERVICE_DIRS, context);
if (result) {
return {
original: ref,
@@ -93,7 +94,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 3: 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,
@@ -106,7 +107,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 4: Model/Entity references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, MODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -119,7 +120,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 5: ViewModel references
if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Dto')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, VIEWMODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -410,28 +411,12 @@ const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/DTOs/', '/Dto/'];
const CLASS_KINDS = new Set(['class']);
const SERVICE_KINDS = new Set(['class', 'interface']);
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
*/
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
name: string,
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
);
if (preferred.length > 0) return preferred[0]!.id;
// Fall back to any match
return kindFiltered[0]!.id;
return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context);
}
+12 -30
View File
@@ -7,6 +7,7 @@
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { pickByNameAndKind } from './name-heuristic';
export const goResolver: FrameworkResolver = {
name: 'go',
@@ -27,7 +28,7 @@ export const goResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Handler references
if (ref.referenceName.endsWith('Handler') || ref.referenceName.startsWith('Handle')) {
const result = resolveByNameAndKind(ref.referenceName, 'function', HANDLER_DIRS, context);
const result = resolveByNameAndKind(ref, 'function', HANDLER_DIRS, context);
if (result) {
return {
original: ref,
@@ -40,7 +41,7 @@ export const goResolver: FrameworkResolver = {
// Pattern 2: Service/Repository references
if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository') || ref.referenceName.endsWith('Store')) {
const result = resolveByNameAndKind(ref.referenceName, null, SERVICE_DIRS, context, SERVICE_KINDS);
const result = resolveByNameAndKind(ref, null, SERVICE_DIRS, context, SERVICE_KINDS);
if (result) {
return {
original: ref,
@@ -53,7 +54,7 @@ export const goResolver: FrameworkResolver = {
// Pattern 3: Middleware references
if (ref.referenceName.endsWith('Middleware') || ref.referenceName.startsWith('Auth') || ref.referenceName.startsWith('Log')) {
const result = resolveByNameAndKind(ref.referenceName, 'function', MIDDLEWARE_DIRS, context);
const result = resolveByNameAndKind(ref, 'function', MIDDLEWARE_DIRS, context);
if (result) {
return {
original: ref,
@@ -66,7 +67,7 @@ export const goResolver: FrameworkResolver = {
// Pattern 4: Model/Entity references (typically PascalCase structs)
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, 'struct', MODEL_DIRS, context);
const result = resolveByNameAndKind(ref, 'struct', MODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -172,36 +173,17 @@ const MIDDLEWARE_DIRS = ['middleware', 'middlewares'];
const MODEL_DIRS = ['model', 'models', 'entity', 'entities', 'domain', 'pkg'];
const SERVICE_KINDS = new Set(['struct', 'interface']);
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
* Uses getNodesByName (O(log n) indexed lookup) instead of iterating every file.
*/
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
name: string,
ref: UnresolvedRef,
kind: string | null,
preferredDirs: string[],
context: ResolutionContext,
kinds?: Set<string>
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
// Filter by kind
const kindFiltered = candidates.filter((n) => {
if (kinds) return kinds.has(n.kind);
if (kind) return n.kind === kind;
return true;
});
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirs.some((d) => n.filePath.includes(`/${d}/`))
);
if (preferred.length > 0) return preferred[0]!.id;
// Fall back to any match
return kindFiltered[0]!.id;
const allowed: ReadonlySet<string> = kinds ?? (kind ? new Set([kind]) : GO_NAMED_KINDS);
return pickByNameAndKind(ref, allowed, (f) => preferredDirs.some((d) => f.includes(`/${d}/`)), context);
}
/** Any declaration a name can be (the heuristic with no kind). */
const GO_NAMED_KINDS: ReadonlySet<string> = new Set(['function', 'method', 'struct', 'interface', 'type_alias', 'variable', 'constant']);
+3 -37
View File
@@ -7,6 +7,7 @@
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { pickByNameAndKind } from './name-heuristic';
export const springResolver: FrameworkResolver = {
name: 'spring',
@@ -553,48 +554,13 @@ function joinPath(prefix: string, sub: string): string {
return '/' + parts.join('/');
}
/**
* 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.
*/
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(ref.referenceName);
if (candidates.length === 0) return null;
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))
);
if (preferred.length > 0) return preferred[0]!.id;
// Fall back to any match
return kindFiltered[0]!.id;
return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context);
}
/** 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'));
}
@@ -0,0 +1,70 @@
/**
* The shared pick behind the framework resolvers' name heuristics — "a
* `…Service` is a class under `/services/`", "a PascalCase name is a model
* under `/models/`", "`router` is a FastAPI router under `/routers/`".
*
* Each resolver used to take the FIRST same-named node of the right kind,
* preferring a conventional folder, and that heuristic outranks name matching
* on a tie. So every MyBatis `XExample`'s `new Criteria()` in mall went to the
* first `Example`'s nested `Criteria`, and axum's examples' `Uri` to a struct a
* routing test declares inside itself. The pick now considers only what the
* reference can see, and starts from its own scope.
*/
import { Node } from '../../types';
import { UnresolvedRef, ResolutionContext } from '../types';
import { isLexicallyReachable, isVisibleAcrossFiles } from '../name-matcher';
/** Node kinds a nested type can be declared in. */
const TYPE_OWNER_KINDS: ReadonlySet<string> = new Set(['class', 'struct', 'interface', 'enum', 'trait', 'protocol', 'record']);
export interface NamePickOptions {
/**
* Whether a file in the reference's directory comes before the conventional
* folders — its package (JVM, Go, C#, Python). Not for Rust, where a sibling
* file is another module, reached only through a `use`.
*/
sameDirectory?: boolean;
/** A language's own scope rule over the candidates (Rust's `use` / module paths). */
accept?: (n: Node) => boolean;
}
/**
* The id of the node named `ref.referenceName`, of one of `kinds`, that the
* reference can see: never a declaration inside some function body, never a
* type nested in another file's type (a nested type is reached by its bare
* name only from inside its owner), never one the language keeps out of the
* reference's file (a `private` member, an unexported Go name, a C# namespace
* the file does not use, a test suite from production code). Its own file's
* first, then (by default) its directory's, then a conventional folder's.
*/
export function pickByNameAndKind(
ref: UnresolvedRef,
kinds: ReadonlySet<string>,
inPreferredDir: (filePath: string) => boolean,
context: ResolutionContext,
options: NamePickOptions = {},
): string | null {
const candidates = context.getNodesByName(ref.referenceName).filter((n) =>
kinds.has(n.kind) &&
isLexicallyReachable(n, ref, context) &&
(n.filePath === ref.filePath || (!isNestedType(n, context) && isVisibleAcrossFiles(n, ref, context))) &&
(!options.accept || options.accept(n)));
if (candidates.length === 0) return null;
const sameFile = candidates.find((n) => n.filePath === ref.filePath);
if (sameFile) return sameFile.id;
if (options.sameDirectory !== false) {
const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1);
const sameDir = candidates.find((n) => n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/'));
if (sameDir) return sameDir.id;
}
return (candidates.find((n) => inPreferredDir(n.filePath)) ?? candidates[0]!).id;
}
/** Whether a node is declared inside a type of its own 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 && TYPE_OWNER_KINDS.has(p.kind));
}
+10 -26
View File
@@ -8,6 +8,7 @@ import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { resolveImportPath } from '../import-resolver';
import { pickByNameAndKind } from './name-heuristic';
export const djangoResolver: FrameworkResolver = {
name: 'django',
@@ -25,15 +26,15 @@ export const djangoResolver: FrameworkResolver = {
resolve(ref, context) {
if (ref.referenceName.endsWith('Model') || /^[A-Z][a-z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, MODEL_DIRS, context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
if (ref.referenceName.endsWith('View') || ref.referenceName.endsWith('ViewSet')) {
const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context);
const result = resolveByNameAndKind(ref, VIEW_KINDS, VIEW_DIRS, context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
if (ref.referenceName.endsWith('Form')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, FORM_DIRS, context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
// ORM dynamic dispatch: QuerySet._fetch_all (and siblings) call
@@ -202,7 +203,7 @@ export const flaskResolver: FrameworkResolver = {
resolve(ref, context) {
if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context);
const result = resolveByNameAndKind(ref, VARIABLE_KINDS, [], context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
return null;
@@ -265,11 +266,11 @@ export const fastapiResolver: FrameworkResolver = {
resolve(ref, context) {
if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, ROUTER_DIRS, context);
const result = resolveByNameAndKind(ref, VARIABLE_KINDS, ROUTER_DIRS, context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
if (ref.referenceName.startsWith('get_') || ref.referenceName.startsWith('Depends')) {
const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, DEP_DIRS, context);
const result = resolveByNameAndKind(ref, FUNCTION_KINDS, DEP_DIRS, context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.75, resolvedBy: 'framework' };
}
return null;
@@ -640,29 +641,12 @@ const VIEW_KINDS = new Set(['class', 'function']);
const VARIABLE_KINDS = new Set(['variable']);
const FUNCTION_KINDS = new Set(['function']);
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
*/
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
name: string,
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
if (preferredDirPatterns.length > 0) {
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
);
if (preferred.length > 0) return preferred[0]!.id;
}
// Fall back to any match
return kindFiltered[0]!.id;
return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context);
}
+8 -32
View File
@@ -8,7 +8,8 @@ import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { getCargoWorkspaceCrateMap } from './cargo-workspace';
import { isRustNameInScope, isLexicallyReachable } from '../name-matcher';
import { isRustNameInScope } from '../name-matcher';
import { pickByNameAndKind } from './name-heuristic';
/**
* Whether the item a name heuristic found is one the reference can name:
@@ -288,43 +289,18 @@ function findMatchingParen(s: string, openIdx: number): number {
return -1;
}
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
*/
/**
* The same-named item of these kinds that the reference can see — in scope
* by its `use`s and module paths, and not declared inside some function body
* (axum's examples' `Uri`, imported from `http`, went to a `struct Uri` a
* routing test declares inside itself) — its own file's first, then a
* framework-conventional directory's.
*/
/** A framework name heuristic's pick (see name-heuristic.ts): in scope by its `use`s and module paths. */
function resolveByNameAndKind(
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(ref.referenceName);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind) &&
isLexicallyReachable(n, ref, context) && isRustNameInScope(n, ref, context));
if (kindFiltered.length === 0) return null;
// The file is a module: its own items are in scope. A sibling file is
// another module, reached only through a `use` — no nearer than any other.
const sameFile = kindFiltered.find((n) => n.filePath === ref.filePath);
if (sameFile) return sameFile.id;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
);
if (preferred.length > 0) return preferred[0]!.id;
// Fall back to any match
return kindFiltered[0]!.id;
return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context, {
// The file is a module: a sibling file is another one, reached only through a `use`.
sameDirectory: false,
accept: (n) => isRustNameInScope(n, ref, context),
});
}
interface ModuleResolution {
+14 -30
View File
@@ -7,6 +7,7 @@
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { pickByNameAndKind } from './name-heuristic';
// No extract(): a SwiftUI view is its own struct node, and a UIKit controller
// its class. A one-line `component`/`class` twin per `struct X: View` (and per
@@ -41,7 +42,7 @@ export const swiftUIResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: View references (SwiftUI views are PascalCase ending in View)
if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context);
const result = resolveByNameAndKind(ref, VIEW_KINDS, VIEW_DIRS, context);
if (result) {
return {
original: ref,
@@ -54,7 +55,7 @@ export const swiftUIResolver: FrameworkResolver = {
// Pattern 2: ViewModel/ObservableObject references
if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Store') || ref.referenceName.endsWith('Manager')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, VIEWMODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -67,7 +68,7 @@ export const swiftUIResolver: FrameworkResolver = {
// Pattern 3: Model references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, MODEL_KINDS, MODEL_DIRS, context);
const result = resolveByNameAndKind(ref, MODEL_KINDS, MODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -107,7 +108,7 @@ export const uikitResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: ViewController references
if (ref.referenceName.endsWith('ViewController')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, VC_DIRS, context);
if (result) {
return {
original: ref,
@@ -120,7 +121,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 2: UIView subclass references
if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, UIVIEW_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, UIVIEW_DIRS, context);
if (result) {
return {
original: ref,
@@ -133,7 +134,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 3: Cell references
if (ref.referenceName.endsWith('Cell')) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, CELL_DIRS, context);
if (result) {
return {
original: ref,
@@ -146,7 +147,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 4: Delegate/DataSource references
if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) {
const result = resolveByNameAndKind(ref.referenceName, PROTOCOL_KINDS, [], context);
const result = resolveByNameAndKind(ref, PROTOCOL_KINDS, [], context);
if (result) {
return {
original: ref,
@@ -206,7 +207,7 @@ export const vaporResolver: FrameworkResolver = {
// Pattern 1: Controller references
if (ref.referenceName.endsWith('Controller')) {
const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context);
const result = resolveByNameAndKind(ref, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context);
if (result) {
return {
original: ref,
@@ -219,7 +220,7 @@ export const vaporResolver: FrameworkResolver = {
// Pattern 2: Model references (Fluent)
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FLUENT_MODEL_DIRS, context);
const result = resolveByNameAndKind(ref, CLASS_KINDS, FLUENT_MODEL_DIRS, context);
if (result) {
return {
original: ref,
@@ -232,7 +233,7 @@ export const vaporResolver: FrameworkResolver = {
// Pattern 3: Middleware references
if (ref.referenceName.endsWith('Middleware')) {
const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context);
const result = resolveByNameAndKind(ref, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context);
if (result) {
return {
original: ref,
@@ -495,29 +496,12 @@ const MODEL_KINDS = new Set(['struct', 'class']);
const PROTOCOL_KINDS = new Set(['protocol']);
const VAPOR_CONTROLLER_KINDS = new Set(['class', 'struct']);
/**
* Resolve a symbol by name using indexed queries instead of scanning all files.
*/
/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */
function resolveByNameAndKind(
name: string,
ref: UnresolvedRef,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
if (preferredDirPatterns.length > 0) {
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
);
if (preferred.length > 0) return preferred[0]!.id;
}
// Fall back to any match
return kindFiltered[0]!.id;
return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context);
}