mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
The middleware/controller/service heuristics took the first same-named node anywhere; a JS/TS module reaches another file's only through an import or require (the import resolver's), so they now keep a same-file pick only. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
22 KiB
TypeScript
572 lines
22 KiB
TypeScript
/**
|
|
* Express/Node.js Framework Resolver
|
|
*
|
|
* Handles Express and general Node.js patterns.
|
|
*/
|
|
|
|
import { Node } from '../../types';
|
|
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
|
import { stripCommentsForRegex } from '../strip-comments';
|
|
import { resolveImportPath } from '../import-resolver';
|
|
import { dependsOn } from './package-deps';
|
|
|
|
function extractTailIdent(expr: string): string | null {
|
|
const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, '');
|
|
const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
return m ? m[1]! : null;
|
|
}
|
|
|
|
/**
|
|
* Index of the delimiter matching the one at `open`, skipping string/template
|
|
* literals so a `)` or `}` inside a string doesn't throw off the balance.
|
|
*/
|
|
function matchDelim(s: string, open: number, oc: string, cc: string): number {
|
|
let depth = 0;
|
|
for (let i = open; i < s.length; i++) {
|
|
const ch = s[i];
|
|
if (ch === '"' || ch === "'" || ch === '`') {
|
|
const q = ch;
|
|
i++;
|
|
while (i < s.length && s[i] !== q) { if (s[i] === '\\') i++; i++; }
|
|
continue;
|
|
}
|
|
if (ch === oc) depth++;
|
|
else if (ch === cc) { depth--; if (depth === 0) return i; }
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// Express res/req methods + common JS builtins — calls to these inside a handler
|
|
// body are framework/noise, not the business flow we want to surface as route edges.
|
|
const RESERVED_CALLS = new Set([
|
|
'json', 'jsonp', 'send', 'sendStatus', 'sendFile', 'status', 'end', 'redirect',
|
|
'render', 'set', 'get', 'header', 'type', 'format', 'attachment', 'download',
|
|
'cookie', 'clearCookie', 'append', 'location', 'vary', 'links', 'accepts', 'is',
|
|
'next', 'then', 'catch', 'finally', 'resolve', 'reject', 'all', 'race',
|
|
'map', 'filter', 'forEach', 'reduce', 'find', 'push', 'pop', 'slice', 'splice',
|
|
'includes', 'keys', 'values', 'entries', 'assign', 'parse', 'stringify',
|
|
'log', 'error', 'warn', 'info', 'String', 'Number', 'Boolean', 'Array', 'Object',
|
|
'Date', 'Math', 'JSON', 'Promise', 'require', 'fail', 'redirect',
|
|
]);
|
|
|
|
/**
|
|
* The calls an inline handler's body makes, each once, framework noise aside.
|
|
* A member call keeps its receiver (`userService.find`, `c.text`) so it
|
|
* resolves as one: bare, hono's `c.text('…')` matched its client's
|
|
* `ClientResponse.text` 423 times. A member of an expression (`a.b().c(`)
|
|
* names nothing this can follow.
|
|
*/
|
|
function handlerCallNames(body: string): string[] {
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
const callRe = /((?:[A-Za-z_$][\w$]*\s*\??\.\s*)*)([A-Za-z_$][\w$]*)\s*\(/g;
|
|
let cm: RegExpExecArray | null;
|
|
while ((cm = callRe.exec(body)) !== null) {
|
|
const method = cm[2]!;
|
|
const receiver = cm[1]!.replace(/\s|\?/g, '').replace(/\.$/, '');
|
|
if (!receiver && /\.\s*$/.test(body.slice(0, cm.index))) continue;
|
|
const name = receiver ? `${receiver}.${method}` : method;
|
|
if (seen.has(name) || RESERVED_CALLS.has(method)) continue;
|
|
seen.add(name);
|
|
out.push(name);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The replies an inline handler makes — `res.status(404).json({…})`,
|
|
* `res.json(user)`, `reply.send(…)`, `ctx.body = …` aside — as references the
|
|
* Steps view's effect table reads at their own line and column. The body's
|
|
* plain calls above skip these names as framework noise on purpose (they are
|
|
* not the business flow); for the endpoint's contract they are the point.
|
|
*/
|
|
const REPLY_CALL = /\b(res|response|reply|rep|ctx)\s*\.\s*(?:[A-Za-z_$][\w$]*\s*\([^()]*\)\s*\.\s*)*([A-Za-z_$][\w$]*)\s*\(/g;
|
|
function replyRefs(safe: string, bodyStart: number, bodyEnd: number, fromNodeId: string, filePath: string, language: 'typescript' | 'javascript'): UnresolvedRef[] {
|
|
const out: UnresolvedRef[] = [];
|
|
const body = safe.slice(bodyStart, bodyEnd);
|
|
REPLY_CALL.lastIndex = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = REPLY_CALL.exec(body)) !== null) {
|
|
const at = bodyStart + m.index;
|
|
out.push({
|
|
fromNodeId,
|
|
referenceName: `${m[1]}.${m[2]}`,
|
|
referenceKind: 'calls',
|
|
line: safe.slice(0, at).split('\n').length,
|
|
column: at - (safe.lastIndexOf('\n', at - 1) + 1),
|
|
filePath,
|
|
language,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const expressResolver: FrameworkResolver = {
|
|
name: 'express',
|
|
languages: ['javascript', 'typescript'],
|
|
|
|
detect(context: ResolutionContext): boolean {
|
|
// Express in a package.json — the root's, or a workspace's (`backend/`, `apps/api/`).
|
|
if (dependsOn(context, 'express', 'fastify', 'koa', 'hapi', '@hapi/hapi')) return true;
|
|
|
|
// Check for common Express patterns
|
|
const allFiles = context.getAllFiles();
|
|
for (const file of allFiles) {
|
|
if (
|
|
file.includes('routes') ||
|
|
file.includes('controllers') ||
|
|
file.includes('middleware')
|
|
) {
|
|
const content = context.readFile(file);
|
|
if (content && (content.includes('express') || content.includes('app.get') || content.includes('router.get'))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
|
// A JS/TS module reaches another file's middleware, controller or service
|
|
// only through an `import` / `require` — the import resolver's — never by
|
|
// name: SvelteKit's remote functions' `validate(arg)` went to the config
|
|
// loader's `validate` in another package. The patterns below stay for the
|
|
// file's own declarations.
|
|
const sameFile = (id: string | null): string | null =>
|
|
id !== null && context.getNodeById?.(id)?.filePath === ref.filePath ? id : null;
|
|
// Pattern 1: Middleware references
|
|
if (isMiddlewareName(ref.referenceName)) {
|
|
const result = sameFile(resolveMiddleware(ref.referenceName, context, ref.filePath));
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 2: Controller method references
|
|
const controllerMatch = ref.referenceName.match(/^(\w+)Controller\.(\w+)$/);
|
|
if (controllerMatch) {
|
|
const [, controller, method] = controllerMatch;
|
|
const result = sameFile(resolveControllerMethod(controller!, method!, context));
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.85,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 3: Service/helper references
|
|
const serviceMatch = ref.referenceName.match(/^(\w+)(Service|Helper|Utils?)\.(\w+)$/);
|
|
if (serviceMatch) {
|
|
const [, name, suffix, method] = serviceMatch;
|
|
const result = sameFile(resolveServiceMethod(name! + suffix!, method!, context));
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
extract(filePath, content) {
|
|
if (!/\.(m?js|tsx?|cjs)$/.test(filePath)) return { nodes: [], references: [] };
|
|
const nodes: Node[] = [];
|
|
const references: UnresolvedRef[] = [];
|
|
const now = Date.now();
|
|
const lang = detectLanguage(filePath);
|
|
const safe = stripCommentsForRegex(content, lang);
|
|
// Match the route head up to the first arg: (app|router).METHOD('/path',
|
|
// (NOT the whole call — handlers are often inline arrows whose `)`/`{}` the
|
|
// old single-regex couldn't span, so inline-handler routes connected to nothing.)
|
|
const head = /\b(app|router)\s*\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*['"]([^'"]+)['"]\s*,/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = head.exec(safe)) !== null) {
|
|
const method = match[2]!;
|
|
const routePath = match[3]!;
|
|
if (method === 'use' && !routePath.startsWith('/')) continue;
|
|
const line = safe.slice(0, match.index).split('\n').length;
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`,
|
|
kind: 'route',
|
|
name: `${method.toUpperCase()} ${routePath}`,
|
|
qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`,
|
|
filePath,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: 0,
|
|
endColumn: match[0].length,
|
|
language: lang,
|
|
updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
|
|
// The full argument list = balanced parens from the route call's open paren.
|
|
const openParen = safe.indexOf('(', match.index);
|
|
const closeParen = openParen >= 0 ? matchDelim(safe, openParen, '(', ')') : -1;
|
|
const args = closeParen > openParen ? safe.slice(openParen + 1, closeParen) : '';
|
|
const arrowAt = args.indexOf('=>');
|
|
|
|
if (arrowAt >= 0) {
|
|
// Inline arrow handler (`router.post('/x', async (req,res) => {…})`). The
|
|
// arrow is anonymous, so its body — the actual request→service flow — would
|
|
// be lost. Attribute the body's calls to the route node as `calls` edges so
|
|
// `trace(route, service)` connects. Body = balanced `{…}` after `=>`, or the
|
|
// single-expression tail for `=> expr` arrows.
|
|
const afterArrow = args.slice(arrowAt + 2);
|
|
const braceAt = afterArrow.indexOf('{');
|
|
let body = afterArrow;
|
|
let bodyStart = openParen + 1 + arrowAt + 2;
|
|
if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') {
|
|
const end = matchDelim(afterArrow, braceAt, '{', '}');
|
|
if (end > braceAt) {
|
|
body = afterArrow.slice(braceAt + 1, end);
|
|
bodyStart += braceAt + 1;
|
|
}
|
|
}
|
|
for (const name of handlerCallNames(body)) {
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: name,
|
|
referenceKind: 'calls',
|
|
line,
|
|
column: 0,
|
|
filePath,
|
|
language: lang,
|
|
});
|
|
}
|
|
references.push(...replyRefs(safe, bodyStart, bodyStart + body.length, routeNode.id, filePath, lang));
|
|
} else {
|
|
// Named handler: the LAST comma-separated arg (earlier ones are middleware).
|
|
const parts = args.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const last = parts[parts.length - 1];
|
|
const handlerName = last ? extractTailIdent(last) : null;
|
|
if (handlerName) {
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: handlerName,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: 0,
|
|
filePath,
|
|
language: lang,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// The chained form: `router.route('/:id').get(getProduct).put(protect, updateProduct)`
|
|
// — one path, several methods, each with its own handler. One route node
|
|
// per method, at the line of its `.method(`, bound like the plain form.
|
|
const chainHead = /\b(?:app|router)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((match = chainHead.exec(safe)) !== null) {
|
|
const routePath = match[1]!;
|
|
let at = match.index + match[0].length;
|
|
for (;;) {
|
|
const link = /^\s*\.\s*(get|post|put|patch|delete|all)\s*\(/.exec(safe.slice(at, at + 64));
|
|
if (!link) break;
|
|
const openParen = at + link[0].length - 1;
|
|
const closeParen = matchDelim(safe, openParen, '(', ')');
|
|
if (closeParen < 0) break;
|
|
const method = link[1]!;
|
|
const line = safe.slice(0, openParen).split('\n').length;
|
|
const args = safe.slice(openParen + 1, closeParen);
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${method.toUpperCase()}:${routePath}`,
|
|
kind: 'route',
|
|
name: `${method.toUpperCase()} ${routePath}`,
|
|
qualifiedName: `${filePath}::${method.toUpperCase()}:${routePath}`,
|
|
filePath,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: 0,
|
|
endColumn: link[0].length,
|
|
language: lang,
|
|
updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
if (args.includes('=>')) {
|
|
for (const name of handlerCallNames(args)) {
|
|
references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang });
|
|
}
|
|
references.push(...replyRefs(safe, openParen + 1, closeParen, routeNode.id, filePath, lang));
|
|
} else {
|
|
const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean);
|
|
const last = parts[parts.length - 1];
|
|
const handlerName = last ? extractTailIdent(last) : null;
|
|
if (handlerName) {
|
|
references.push({ fromNodeId: routeNode.id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: lang });
|
|
}
|
|
}
|
|
at = closeParen + 1;
|
|
}
|
|
}
|
|
return { nodes, references };
|
|
},
|
|
|
|
/**
|
|
* Cross-file finalization for mounts. A router's routes are written
|
|
* relative to where it is mounted —
|
|
*
|
|
* app.use('/api', routes) // app.js
|
|
* router.use('/users', usersRouter) // routes/index.js
|
|
* router.post('/', createUser) // routes/users.js → POST /api/users
|
|
*
|
|
* — and per-file `extract()` can only see `POST /`. This pass reads every
|
|
* `X.use('/prefix', …, router)` whose last argument names a router file
|
|
* (an import, or an inline `require('./x')`), composes the prefixes down
|
|
* the mount tree, and renames the routes of each mounted file to the path
|
|
* a request actually takes. A file mounted at two different prefixes is
|
|
* left alone: one name cannot be two paths.
|
|
*
|
|
* The route node's `id` and `qualifiedName` are preserved (`qualifiedName`
|
|
* still encodes the in-file `METHOD:path`), so the pass is idempotent on
|
|
* every sync, exactly as the NestJS `RouterModule` pass is.
|
|
*/
|
|
postExtract(context: ResolutionContext): Node[] {
|
|
const files = context.getAllFiles().filter((f) => /\.(m?js|tsx?|cjs)$/.test(f));
|
|
const mounts = new Map<string, Array<{ prefix: string; target: string }>>();
|
|
for (const file of files) {
|
|
// Most files mount nothing: look for `.use(` before decoding the file.
|
|
if (context.fileContains && !context.fileContains(file, '.use(')) continue;
|
|
const content = context.readFile(file);
|
|
if (!content || !content.includes('.use(')) continue;
|
|
const lang = detectLanguage(file);
|
|
const safe = stripCommentsForRegex(content, lang);
|
|
const mount = /\b[A-Za-z_$][\w$]*\.use\s*\(\s*(['"`])(\/[^'"`]*)\1\s*,/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = mount.exec(safe)) !== null) {
|
|
const open = safe.indexOf('(', m.index);
|
|
const close = open >= 0 ? matchDelim(safe, open, '(', ')') : -1;
|
|
if (close < 0) continue;
|
|
const args = splitTopLevel(safe.slice(open + 1, close));
|
|
const last = args[args.length - 1]?.trim() ?? '';
|
|
const target = mountTarget(last, safe, file, lang, context);
|
|
if (!target || target === file) continue;
|
|
const list = mounts.get(file) ?? [];
|
|
list.push({ prefix: m[2]!, target });
|
|
mounts.set(file, list);
|
|
}
|
|
}
|
|
if (mounts.size === 0) return [];
|
|
|
|
// Compose prefixes down the mount tree until nothing changes; a file
|
|
// reached at two different paths is ambiguous and dropped.
|
|
let prefixOf = new Map<string, string>();
|
|
for (let round = 0; round < 8; round++) {
|
|
const next = new Map<string, string>();
|
|
const ambiguous = new Set<string>();
|
|
for (const [file, list] of mounts) {
|
|
const base = prefixOf.get(file) ?? '';
|
|
for (const { prefix, target } of list) {
|
|
const full = joinPaths(base, prefix);
|
|
const seen = next.get(target);
|
|
if (seen !== undefined && seen !== full) ambiguous.add(target);
|
|
else next.set(target, full);
|
|
}
|
|
}
|
|
for (const a of ambiguous) next.delete(a);
|
|
let changed = next.size !== prefixOf.size;
|
|
if (!changed) for (const [k, v] of next) if (prefixOf.get(k) !== v) changed = true;
|
|
prefixOf = next;
|
|
if (!changed) break;
|
|
}
|
|
|
|
const updates: Node[] = [];
|
|
for (const [file, prefix] of prefixOf) {
|
|
if (prefix === '' || prefix === '/') continue;
|
|
for (const route of context.getNodesInFile(file)) {
|
|
if (route.kind !== 'route') continue;
|
|
const sep = route.qualifiedName.indexOf('::');
|
|
if (sep < 0) continue;
|
|
const colon = route.qualifiedName.indexOf(':', sep + 2);
|
|
if (colon < 0) continue;
|
|
const method = route.qualifiedName.slice(sep + 2, colon);
|
|
const original = route.qualifiedName.slice(colon + 1);
|
|
if (!original.startsWith('/')) continue;
|
|
const name = `${method} ${joinPaths(prefix, original)}`;
|
|
if (name !== route.name) updates.push({ ...route, name });
|
|
}
|
|
}
|
|
return updates;
|
|
},
|
|
};
|
|
|
|
/** Top-level comma split of an argument list, strings and brackets respected. */
|
|
function splitTopLevel(args: string): string[] {
|
|
const out: string[] = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
for (let i = 0; i < args.length; i++) {
|
|
const ch = args[i];
|
|
if (ch === '"' || ch === "'" || ch === '`') {
|
|
const q = ch;
|
|
i++;
|
|
while (i < args.length && args[i] !== q) {
|
|
if (args[i] === '\\') i++;
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
else if (ch === ',' && depth === 0) {
|
|
out.push(args.slice(start, i));
|
|
start = i + 1;
|
|
}
|
|
}
|
|
out.push(args.slice(start));
|
|
return out;
|
|
}
|
|
|
|
/** `/api` + `/users` → `/api/users`; `/api/` + `/` → `/api`. */
|
|
function joinPaths(prefix: string, path: string): string {
|
|
const a = prefix.replace(/\/+$/, '');
|
|
const b = path.replace(/^\/+/, '');
|
|
const joined = b ? `${a}/${b}` : a;
|
|
return joined === '' ? '/' : joined;
|
|
}
|
|
|
|
/**
|
|
* The file a mount's last argument names: an inline `require('./x')`, an
|
|
* identifier imported from a project file, or one bound to `require('./x')`
|
|
* in the same file. `x.default` / `x.router` count as `x`.
|
|
*/
|
|
function mountTarget(expr: string, safe: string, file: string, lang: 'typescript' | 'javascript', context: ResolutionContext): string | null {
|
|
const inline = /^require\s*\(\s*(['"])([^'"]+)\1\s*\)(?:\.\w+)?$/.exec(expr);
|
|
if (inline) return resolveImportPath(inline[2]!, file, lang, context);
|
|
const ident = /^([A-Za-z_$][\w$]*)(?:\.(?:default|router|routes))?$/.exec(expr);
|
|
if (!ident) return null;
|
|
const name = ident[1]!;
|
|
const mapping = context.getImportMappings(file, lang).find((im) => im.localName === name);
|
|
if (mapping) return resolveImportPath(mapping.source, file, lang, context);
|
|
const required = new RegExp(`\\b(?:const|let|var)\\s+${name.replace(/\$/g, '\\$')}\\s*=\\s*require\\s*\\(\\s*(['"])([^'"]+)\\1\\s*\\)`).exec(safe);
|
|
return required ? resolveImportPath(required[2]!, file, lang, context) : null;
|
|
}
|
|
|
|
/**
|
|
* Check if a name looks like middleware
|
|
*/
|
|
function isMiddlewareName(name: string): boolean {
|
|
const middlewarePatterns = [
|
|
/^auth$/i,
|
|
/^authenticate$/i,
|
|
/^authorization$/i,
|
|
/^validate/i,
|
|
/^sanitize/i,
|
|
/^rateLimit/i,
|
|
/^cors$/i,
|
|
/^helmet$/i,
|
|
/^logger$/i,
|
|
/^errorHandler$/i,
|
|
/^notFound$/i,
|
|
/Middleware$/i,
|
|
];
|
|
|
|
return middlewarePatterns.some((p) => p.test(name));
|
|
}
|
|
|
|
/**
|
|
* Resolve middleware reference using name-based lookup
|
|
*/
|
|
function resolveMiddleware(
|
|
name: string,
|
|
context: ResolutionContext,
|
|
fromFile?: string,
|
|
): string | null {
|
|
// Try exact name first — the calling file's own first.
|
|
const candidates = context.getNodesByName(name).sort((a, b) => Number(b.filePath === fromFile) - Number(a.filePath === fromFile));
|
|
const match = candidates.find((n) =>
|
|
n.name.toLowerCase() === name.toLowerCase() ||
|
|
n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase()
|
|
);
|
|
if (match) return match.id;
|
|
|
|
// Try without Middleware suffix
|
|
const baseName = name.replace(/Middleware$/i, '');
|
|
if (baseName !== name) {
|
|
const baseCandidates = context.getNodesByName(baseName);
|
|
const MIDDLEWARE_DIRS = ['/middleware/', '/middlewares/'];
|
|
const preferred = baseCandidates.filter((n) =>
|
|
MIDDLEWARE_DIRS.some((d) => n.filePath.includes(d))
|
|
);
|
|
if (preferred.length > 0) return preferred[0]!.id;
|
|
if (baseCandidates.length > 0) return baseCandidates[0]!.id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve controller method using name-based lookup
|
|
*/
|
|
function resolveControllerMethod(
|
|
controller: string,
|
|
method: string,
|
|
context: ResolutionContext
|
|
): string | null {
|
|
// Look for the method name directly
|
|
const methodCandidates = context.getNodesByName(method);
|
|
const methodNodes = methodCandidates.filter(
|
|
(n) => (n.kind === 'method' || n.kind === 'function') &&
|
|
n.filePath.toLowerCase().includes(controller.toLowerCase())
|
|
);
|
|
|
|
if (methodNodes.length > 0) return methodNodes[0]!.id;
|
|
|
|
// Fall back: look for controller class, then find the method in its file
|
|
const controllerName = controller + 'Controller';
|
|
const controllerCandidates = context.getNodesByName(controllerName);
|
|
for (const ctrl of controllerCandidates) {
|
|
const nodesInFile = context.getNodesInFile(ctrl.filePath);
|
|
const methodNode = nodesInFile.find(
|
|
(n) => (n.kind === 'method' || n.kind === 'function') && n.name === method
|
|
);
|
|
if (methodNode) return methodNode.id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve service/helper method using name-based lookup
|
|
*/
|
|
function resolveServiceMethod(
|
|
serviceName: string,
|
|
method: string,
|
|
context: ResolutionContext
|
|
): string | null {
|
|
// Look for the method in files matching the service name
|
|
const methodCandidates = context.getNodesByName(method);
|
|
const stripped = serviceName.replace(/(Service|Helper|Utils?)$/i, '').toLowerCase();
|
|
const methodNodes = methodCandidates.filter(
|
|
(n) => (n.kind === 'method' || n.kind === 'function') &&
|
|
n.filePath.toLowerCase().includes(stripped)
|
|
);
|
|
|
|
if (methodNodes.length > 0) return methodNodes[0]!.id;
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect language from file extension
|
|
*/
|
|
function detectLanguage(filePath: string): 'typescript' | 'javascript' {
|
|
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
|
|
return 'typescript';
|
|
}
|
|
return 'javascript';
|
|
}
|