mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-27 12:57:10 +08:00
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:
co-authored by
Claude Opus 5.5
parent
7365427d4f
commit
179939055c
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user