Files
codegraph/src/resolution/frameworks/python.ts
T
Colby McHenryandClaude Fable 5 bc45e9071d feat(routes): FastAPI prefixes, ASP.NET endpoint groups, wrapped server actions on the Screens tab
- python.ts postExtract: APIRouter(prefix=) and literal include_router(prefix=) composed down the include tree (module import, alias, local); a computed prefix leaves that mount alone; full-stack-fastapi-template 23 routes named by path
- csharp.ts: handler-first MapPost(Handler[, "path"]) under the endpoint-group class, the app's $"/api/{groupName}" head read in postExtract, RoutePrefix honoured; detection covers Endpoints/ files; CleanArchitecture 10 routes
- tier-synthesizer: a type argument between a client call and its parentheses (useSWR<T>(…), ky.get<T>(…)); recorded callee without it
- screens.ts: a file-scope navigation attributed to the value spanning it; a value nothing calls attributed to the functions mentioning it in importing files (request-time source read, bounded); steps.ts lends navigates edges to a value root
- tests: servers fixture (FastAPI prefixed routers, ASP.NET endpoint group end to end), frameworks.test.ts (group form, RoutePrefix), cross-tier (generic useSWR)
- docs: CHANGELOG, plan, playbook rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
2026-08-28 14:52:40 -05:00

578 lines
24 KiB
TypeScript

/**
* Python Framework Resolver
*
* Handles Django, Flask, and FastAPI patterns.
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { resolveImportPath } from '../import-resolver';
export const djangoResolver: FrameworkResolver = {
name: 'django',
languages: ['python'],
detect(context) {
const requirements = context.readFile('requirements.txt');
if (requirements && requirements.toLowerCase().includes('django')) return true;
const setup = context.readFile('setup.py');
if (setup && setup.toLowerCase().includes('django')) return true;
const pyproject = context.readFile('pyproject.toml');
if (pyproject && pyproject.toLowerCase().includes('django')) return true;
return context.fileExists('manage.py');
},
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);
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);
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);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
// ORM dynamic dispatch: QuerySet._fetch_all (and siblings) call
// `self._iterable_class(self)` — a runtime dispatch to the iterable class
// (default ModelIterable) whose __iter__ runs the SQL compiler. Static
// parsing can't resolve an attribute-as-callable, so it leaves an unresolved
// `_iterable_class` ref and a hole in the QuerySet→compiler chain. Bridge it
// to ModelIterable.__iter__ so the flow actually exists in the graph.
if (ref.referenceName === '_iterable_class') {
const target = resolveModelIterableIter(context);
if (target) return { original: ref, targetNodeId: target, confidence: 0.7, resolvedBy: 'framework' };
}
return null;
},
// Let two ref shapes past resolveOne's "no possible match" pre-filter so they
// reach resolution: the ORM dynamic-dispatch `_iterable_class` (a QuerySet
// attribute, not a declared symbol), and a Django `include('app.urls')` module
// path — a dotted module name with no symbol/import to match, which resolution
// (resolvePythonAbsoluteModule) then maps to its `urls.py` file so the included
// URLconf records a dependency on the root urlconf.
claimsReference(name) {
return name === '_iterable_class' || name.endsWith('.urls');
},
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
const safe = stripCommentsForRegex(content, 'python');
// path('url', handler, name=...) / re_path(r'...', handler) / url(r'...', handler)
// Capture groups: 1=function name, 2=url string, 3=handler expr
// Handler expr may contain one balanced () pair (e.g. View.as_view(), include('x.y'))
const routeRegex = /\b(path|re_path|url)\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([\w.]+(?:\s*\([^)]*\))?)/g;
let match: RegExpExecArray | null;
while ((match = routeRegex.exec(safe)) !== null) {
const [, _fn, urlPath, handlerExpr] = match;
const line = safe.slice(0, match.index).split('\n').length;
const routeNode: Node = {
id: `route:${filePath}:${line}:${urlPath}`,
kind: 'route',
name: urlPath!,
qualifiedName: `${filePath}::route:${urlPath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: 'python',
updatedAt: now,
};
nodes.push(routeNode);
const handler = handlerExpr!.trim();
const target = resolveHandlerName(handler);
if (target) {
references.push({
fromNodeId: routeNode.id,
referenceName: target.name,
referenceKind: target.kind,
line,
column: 0,
filePath,
language: 'python',
});
}
}
// DRF router registration: `router.register(r'articles', ArticleViewSet)` →
// route → the ViewSet class (the core CRUD endpoints, which path()/url() miss).
// The STRING first arg separates this from `admin.site.register(Model, Admin)`
// (whose first arg is a model class, not a string); the View/ViewSet suffix on
// the 2nd arg keeps it to DRF viewsets.
const routerRegex = /\.register\s*\(\s*r?['"]([^'"]+)['"]\s*,\s*([\w.]+)/g;
while ((match = routerRegex.exec(safe)) !== null) {
const prefix = match[1]!.replace(/^\^|\/?\$$/g, '');
const viewset = match[2]!.split('.').pop()!;
if (!/View(Set)?$/.test(viewset)) continue;
const line = safe.slice(0, match.index).split('\n').length;
const routeNode: Node = {
id: `route:${filePath}:${line}:VIEWSET:${prefix}`,
kind: 'route',
name: `VIEWSET /${prefix}`,
qualifiedName: `${filePath}::route:${prefix}`,
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
language: 'python', updatedAt: now,
};
nodes.push(routeNode);
references.push({
fromNodeId: routeNode.id,
referenceName: viewset,
referenceKind: 'references',
line, column: 0, filePath, language: 'python',
});
}
return { nodes, references };
},
};
/**
* Find ModelIterable.__iter__ — the default iterable QuerySet invokes via
* `self._iterable_class(self)`. Its __iter__ statically calls the SQL compiler,
* so linking the dynamic dispatch here closes the QuerySet→SQL call chain.
* (Over-approximates to the default iterable; .values()/.values_list() swap in
* other BaseIterable subclasses, but ModelIterable is the canonical path.)
*/
function resolveModelIterableIter(context: ResolutionContext): string | null {
const cls = context.getNodesByName('ModelIterable').find((n) => n.kind === 'class');
if (!cls) return null;
const iter = context.getNodesByName('__iter__').find(
(n) => n.filePath === cls.filePath && n.startLine >= cls.startLine && n.startLine <= cls.endLine
);
return iter ? iter.id : null;
}
/**
* Parse a Django URL handler expression and return the symbol/module to link.
* Returns null for shapes we can't confidently link (e.g. lambdas).
*/
function resolveHandlerName(expr: string): { name: string; kind: 'references' | 'imports' } | null {
// include('module.path')
const includeMatch = expr.match(/^include\s*\(\s*['"]([^'"]+)['"]/);
if (includeMatch) return { name: includeMatch[1]!, kind: 'imports' };
// Strip trailing .as_view(...) or .as_view()
let head = expr.replace(/\.as_view\s*\([^)]*\)\s*$/, '');
// Drop any other trailing method call
head = head.replace(/\.\w+\s*\([^)]*\)\s*$/, '');
const dotted = head.split('.').filter(Boolean);
if (dotted.length === 0) return null;
const last = dotted[dotted.length - 1]!;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(last)) return null;
return { name: last, kind: 'references' };
}
export const flaskResolver: FrameworkResolver = {
name: 'flask',
languages: ['python'],
detect(context) {
for (const f of ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py']) {
const c = context.readFile(f);
if (c && /\bflask\b/i.test(c)) return true;
}
// Any app entrypoint (root OR subdir, e.g. conduit/app.py) that imports flask
// and instantiates Flask(...) — covers Flask(__name__), Flask(__name__.split…),
// and the app-factory pattern. Bounded to entrypoint-named files.
const entrypoints = context
.getAllFiles()
.filter((f) => /(?:^|\/)(app|application|main|wsgi|__init__)\.py$/.test(f))
.slice(0, 50);
for (const f of entrypoints) {
const c = context.readFile(f);
if (c && /\bFlask\s*\(/.test(c) && /\bimport\s+flask\b|\bfrom\s+flask\b/.test(c)) return true;
}
return false;
},
resolve(ref, context) {
if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) {
const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context);
if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' };
}
return null;
},
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
const safe = stripCommentsForRegex(content, 'python');
const decorator = extractDecoratorRoutes(filePath, safe, {
// Flask: @x.route('/path', methods=[...] | (...)) — the handler is the next
// `def`, allowing intervening decorators (@login_required) and stacked
// @x.route() lines. methods may be a list OR a tuple (methods=('GET',)).
decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]*)['"](?:\s*,\s*methods\s*=\s*[[(]([^\])]+)[\])])?\s*\)/g,
defaultMethod: 'GET',
methodFromGroup: 3,
pathGroup: 2,
findHandler: true,
language: 'python',
});
const restful = extractFlaskRestful(filePath, safe);
return {
nodes: [...decorator.nodes, ...restful.nodes],
references: [...decorator.references, ...restful.references],
};
},
};
export const fastapiResolver: FrameworkResolver = {
name: 'fastapi',
languages: ['python'],
detect(context) {
const requirements = context.readFile('requirements.txt');
if (requirements && /\bfastapi\b/i.test(requirements)) return true;
const pyproject = context.readFile('pyproject.toml');
if (pyproject && /\bfastapi\b/i.test(pyproject)) return true;
for (const file of ['app.py', 'main.py', 'api.py']) {
const content = context.readFile(file);
if (content && content.includes('FastAPI(')) return true;
}
// A service that is one directory of a monorepo (`backend/pyproject.toml`,
// `backend/app/main.py`): its manifest or its app object sits below the root.
let looked = 0;
for (const file of context.getAllFiles()) {
const norm = file.replace(/\\/g, '/');
const base = norm.slice(norm.lastIndexOf('/') + 1);
if (base === 'requirements.txt' || base === 'pyproject.toml' || base === 'requirements-dev.txt') {
const content = context.readFile(file);
if (content && /\bfastapi\b/i.test(content)) return true;
if (++looked >= 40) break;
} else if ((base === 'main.py' || base === 'app.py' || base === 'api.py') && norm.split('/').length <= 4) {
const content = context.readFile(file);
if (content && content.includes('FastAPI(')) return true;
if (++looked >= 40) break;
}
}
return false;
},
resolve(ref, context) {
if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') {
const result = resolveByNameAndKind(ref.referenceName, 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);
if (result) return { original: ref, targetNodeId: result, confidence: 0.75, resolvedBy: 'framework' };
}
return null;
},
extract(filePath, content) {
if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
// FastAPI: @x.METHOD('/path') -> handler on the next def line. Path may be
// empty ("") for routes mounted at the router/prefix root.
decoratorRegex: /@(\w+)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"]([^'"]*)['"]/g,
defaultMethod: '',
methodGroup: 2,
pathGroup: 3,
findHandler: true,
language: 'python',
});
},
/**
* Cross-file finalization for prefixes. A router's routes are written
* relative to where it is mounted —
*
* router = APIRouter(prefix="/items") # items.py
* api_router.include_router(items.router) # api.py
* app.include_router(api_router, prefix="/api/v1") # main.py
* @router.get("/{id}") # → GET /api/v1/items/{id}
*
* — and per-file `extract()` can only see `GET /{id}`. This pass reads every
* `APIRouter(prefix=…)` and every `X.include_router(router, prefix=…)` whose
* prefix is a literal (a `settings.API_V1_STR` is unknown and that mount is
* left alone), resolves the included router to the file and variable it is
* (`items.router` through the module import, `items_router` through the
* alias, a local name), composes the prefixes down the tree, and renames the
* routes decorated with each router to the path a request takes. `id` and
* `qualifiedName` are preserved, so the pass is idempotent on every sync.
*/
postExtract(context) {
interface Mount {
fromVar: string;
prefix: string;
target: { file: string; variable: string };
}
const own = new Map<string, Map<string, string>>(); // file → router variable → APIRouter(prefix=)
const mounts = new Map<string, Mount[]>(); // mounting file → mounts
const receivers = new Map<string, Map<number, string>>(); // file → decorator line → router variable
for (const file of context.getAllFiles()) {
if (!file.endsWith('.py')) continue;
const content = context.readFile(file);
if (!content || (!content.includes('APIRouter') && !content.includes('include_router'))) continue;
const safe = stripCommentsForRegex(content, 'python');
const vars = new Map<string, string>();
const decl = /\b([A-Za-z_]\w*)\s*=\s*APIRouter\s*\(([^)]*)\)/g;
let m: RegExpExecArray | null;
while ((m = decl.exec(safe)) !== null) {
const p = /\bprefix\s*=\s*['"]([^'"]*)['"]/.exec(m[2]!);
vars.set(m[1]!, p ? p[1]! : '');
}
own.set(file, vars);
const byLine = new Map<number, string>();
const deco = /@([A-Za-z_]\w*)\.(?:get|post|put|patch|delete|options|head)\s*\(/g;
while ((m = deco.exec(safe)) !== null) byLine.set(safe.slice(0, m.index).split('\n').length, m[1]!);
receivers.set(file, byLine);
const inc = /\b([A-Za-z_]\w*)\.include_router\s*\(\s*([A-Za-z_][\w.]*)\s*((?:,[^)]*)?)\)/g;
while ((m = inc.exec(safe)) !== null) {
const rest = m[3] ?? '';
const literal = /\bprefix\s*=\s*['"]([^'"]*)['"]/.exec(rest);
if (!literal && /\bprefix\s*=/.test(rest)) continue; // a computed prefix: unknown, not guessed
const target = includedRouter(m[2]!, file, vars, context);
if (!target) continue;
const list = mounts.get(file) ?? [];
list.push({ fromVar: m[1]!, prefix: literal ? literal[1]! : '', target });
mounts.set(file, list);
}
}
if (mounts.size === 0 && ![...own.values()].some((vars) => [...vars.values()].some((p) => p !== ''))) return [];
// The include-derived base of each (file, variable): the mounting router's
// base, plus its own prefix, plus the mount's — to a fixed point.
const key = (file: string, variable: string): string => `${file}\0${variable}`;
let base = new Map<string, string>();
for (let round = 0; round < 8; round++) {
const next = new Map<string, string>();
for (const [file, list] of mounts) {
for (const mount of list) {
const fromBase = base.get(key(file, mount.fromVar)) ?? '';
const fromOwn = own.get(file)?.get(mount.fromVar) ?? '';
const full = joinPyPaths(joinPyPaths(fromBase, fromOwn), mount.prefix);
const k = key(mount.target.file, mount.target.variable);
const seen = next.get(k);
if (seen !== undefined && seen !== full) next.set(k, '\0'); // two mounts, two paths: ambiguous
else next.set(k, full);
}
}
for (const [k, v] of [...next]) if (v === '\0') next.delete(k);
let changed = next.size !== base.size;
if (!changed) for (const [k, v] of next) if (base.get(k) !== v) changed = true;
base = next;
if (!changed) break;
}
const updates: Node[] = [];
for (const [file, byLine] of receivers) {
const vars = own.get(file) ?? new Map<string, string>();
for (const route of context.getNodesInFile(file)) {
if (route.kind !== 'route') continue;
const variable = byLine.get(route.startLine);
if (!variable) continue;
const prefix = joinPyPaths(base.get(key(file, variable)) ?? '', vars.get(variable) ?? '');
if (prefix === '' || prefix === '/') continue;
const sep = route.qualifiedName.indexOf('::');
const colon = sep < 0 ? -1 : route.qualifiedName.indexOf(':', sep + 2);
if (colon < 0) continue;
const method = route.qualifiedName.slice(sep + 2, colon);
const original = route.qualifiedName.slice(colon + 1);
const name = `${method} ${joinPyPaths(prefix, original)}`.trim();
if (name !== route.name) updates.push({ ...route, name });
}
}
return updates;
},
};
/** `/api/v1` + `/items` → `/api/v1/items`; `/items` + `` → `/items`; `` + `` → ``. */
function joinPyPaths(prefix: string, path: string): string {
const a = prefix.replace(/\/+$/, '');
const b = path.replace(/^\/+/, '');
if (!a) return b ? `/${b}` : '';
return b ? `${a}/${b}` : a;
}
/**
* The file and variable an `include_router` argument names: `items.router`
* through the module's import, `items_router` through an alias import, or a
* router defined in the same file.
*/
function includedRouter(
expr: string,
file: string,
local: Map<string, string>,
context: ResolutionContext
): { file: string; variable: string } | null {
const segs = expr.split('.');
const head = segs[0]!;
if (segs.length === 1 && local.has(head)) return { file, variable: head };
const mapping = context.getImportMappings(file, 'python').find((im) => im.localName === head);
if (!mapping) return null;
if (segs.length > 1) {
// `items.router`: `items` is a module — `from app.api.routes import items`.
const moduleFile = resolveImportPath(`${mapping.source}.${mapping.exportedName}`, file, 'python', context) ?? resolveImportPath(mapping.source, file, 'python', context);
return moduleFile ? { file: moduleFile, variable: segs[segs.length - 1]! } : null;
}
// `items_router`: `from .items import router as items_router`.
const moduleFile = resolveImportPath(mapping.source, file, 'python', context);
return moduleFile ? { file: moduleFile, variable: mapping.exportedName } : null;
}
interface DecoratorRouteOpts {
decoratorRegex: RegExp;
defaultMethod: string;
methodGroup?: number;
methodFromGroup?: number; // methods=[...] list
pathGroup: number;
handlerGroup?: number;
findHandler?: boolean;
language: 'python';
}
function extractDecoratorRoutes(filePath: string, content: string, opts: DecoratorRouteOpts): FrameworkExtractionResult {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
let match: RegExpExecArray | null;
while ((match = opts.decoratorRegex.exec(content)) !== null) {
const routePath = match[opts.pathGroup];
let method = opts.defaultMethod;
if (opts.methodGroup && match[opts.methodGroup]) {
method = match[opts.methodGroup]!.toUpperCase();
} else if (opts.methodFromGroup && match[opts.methodFromGroup]) {
const m = match[opts.methodFromGroup]!.match(/['"]([A-Z]+)['"]/i);
if (m) method = m[1]!.toUpperCase();
}
const line = content.slice(0, match.index).split('\n').length;
const name = method ? `${method} ${routePath || '/'}` : (routePath || '/');
const routeNode: Node = {
id: `route:${filePath}:${line}:${method}:${routePath}`,
kind: 'route',
name,
qualifiedName: `${filePath}::${method}:${routePath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: match[0].length,
language: opts.language,
updatedAt: now,
};
nodes.push(routeNode);
let handlerName: string | undefined;
if (opts.handlerGroup && match[opts.handlerGroup]) {
handlerName = match[opts.handlerGroup];
} else if (opts.findHandler) {
const tail = content.slice(match.index + match[0].length);
const defMatch = tail.match(/\n\s*(?:async\s+)?def\s+(\w+)/);
if (defMatch) handlerName = defMatch[1];
}
if (handlerName) {
references.push({
fromNodeId: routeNode.id,
referenceName: handlerName,
referenceKind: 'references',
line,
column: 0,
filePath,
language: 'python',
});
}
}
return { nodes, references };
}
/**
* Flask-RESTful: `api.add_resource(ResourceClass, '/path'[, '/path2'])`
* (and variants like redash's `add_org_resource`). The ResourceClass holds the
* HTTP-verb methods (get/post/…), so the route references the class — its verb
* methods resolve as the handlers via the class. Method is ANY (the class
* decides which verbs it serves).
*/
function extractFlaskRestful(filePath: string, safe: string): FrameworkExtractionResult {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
const re = /\.add\w*[Rr]esource\s*\(\s*(\w+)\s*,\s*((?:['"][^'"]+['"]\s*,?\s*)+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(safe)) !== null) {
const className = m[1]!;
const paths = (m[2]!.match(/['"]([^'"]+)['"]/g) || []).map((s) => s.slice(1, -1));
const line = safe.slice(0, m.index).split('\n').length;
for (const routePath of paths) {
const routeNode: Node = {
id: `route:${filePath}:${line}:ANY:${routePath}`,
kind: 'route',
name: `ANY ${routePath}`,
qualifiedName: `${filePath}::ANY:${routePath}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: 0,
language: 'python',
updatedAt: now,
};
nodes.push(routeNode);
references.push({
fromNodeId: routeNode.id,
referenceName: className,
referenceKind: 'references',
line,
column: 0,
filePath,
language: 'python',
});
}
}
return { nodes, references };
}
// Directory patterns
const MODEL_DIRS = ['models', 'app/models', 'src/models'];
const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];
const FORM_DIRS = ['forms', 'app/forms', 'src/forms'];
const ROUTER_DIRS = ['/routers/', '/api/', '/routes/', '/endpoints/'];
const DEP_DIRS = ['/dependencies/', '/deps/', '/core/'];
const CLASS_KINDS = new Set(['class']);
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.
*/
function resolveByNameAndKind(
name: string,
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;
}