NestJS's RouterModule lets apps compose modular route prefixes across files
(`RouterModule.register([{ path: 'admin', module: AdminModule, children: [...] }])`
in `app.module.ts` sets the prefix for controllers declared in another file's
`@Controller()`). The per-file `extract()` only sees one file at a time, so a
`UsersController` indexed in isolation showed up as `GET /` instead of
`GET /admin/users`.
Add an optional cross-file `postExtract(context)` hook to FrameworkResolver,
called by the orchestrator once after each `indexAll` and after every
incremental `sync` that touched files. The nestjs implementation:
* walks every `*.module.{ts,js}` for `RouterModule.{register,forRoot,forChild}([...])`
and recursively resolves `children` into `Module → /full/prefix`,
* walks `@Module({ controllers: [...] })` for `Controller → Module`,
* matches each route node against its controller's class line range
(multi-controller files keep getting attributed correctly), and
* rewrites `name` while preserving `id` (route→handler edges intact) and
`qualifiedName` (still encodes the *original* in-file path, which keeps
the pass idempotent on a re-sync — `app.module.ts` edits propagate to
controllers in unchanged files without double-prefixing).
End-to-end validated against the exact reproduction in #459 (admin children
users) — all four routes (`GET /admin`, `GET /admin/users`,
`GET /admin/users/:id`, `POST /admin/users`) resolve correctly, edits to the
RouterModule tree re-propagate on the next sync, and route→handler edges in
`codegraph context` are preserved.
Closes #459
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
180ba785ce
commit
8876defbc1
@@ -336,6 +336,9 @@ export class CodeGraph {
|
||||
// chance to see the actual project before resolution runs.
|
||||
if (result.success && result.filesIndexed > 0) {
|
||||
this.resolver.initialize();
|
||||
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
|
||||
// before resolution so updated names show up in subsequent reads.
|
||||
this.resolver.runPostExtract();
|
||||
}
|
||||
|
||||
// Resolve references to create call/import/extends edges
|
||||
@@ -406,6 +409,14 @@ export class CodeGraph {
|
||||
try {
|
||||
const result = await this.orchestrator.sync(options.onProgress);
|
||||
|
||||
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on
|
||||
// every sync that touched files so edits to `app.module.ts` propagate
|
||||
// to controllers in unchanged files. The pass is idempotent and cheap
|
||||
// (regex over *.module.ts only).
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
this.resolver.runPostExtract();
|
||||
}
|
||||
|
||||
// Resolve references if files were updated
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
if (result.changedFilePaths) {
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface — see comment at top of file. This file owns four NestJS
|
||||
// concerns: HTTP routes, GraphQL ops, microservice handlers, WebSocket
|
||||
// handlers, and (in postExtract below) cross-file RouterModule prefixing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type JsLang = 'typescript' | 'javascript';
|
||||
|
||||
const HTTP_METHODS = ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options', 'All'];
|
||||
@@ -185,6 +191,79 @@ export const nestjsResolver: FrameworkResolver = {
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-file finalization for `RouterModule.register([...])`. The per-file
|
||||
* extract() above only sees `@Controller(prefix) + @Get(path)` — it can't
|
||||
* learn about the route prefix supplied by a sibling `app.module.ts` like:
|
||||
*
|
||||
* RouterModule.register([
|
||||
* { path: 'admin', module: AdminModule, children: [
|
||||
* { path: 'users', module: UsersModule } ] } ])
|
||||
*
|
||||
* This pass scans every `*.module.{ts,js}` file, walks the registration
|
||||
* tree to build a `Module → /full/prefix` map, walks each `@Module({
|
||||
* controllers: [...] })` to build a `Controller → Module` map, and rewrites
|
||||
* affected route nodes so `GET /` becomes `GET /admin/users` (and
|
||||
* `@Controller('foo') + @Get(':id')` under that same module becomes
|
||||
* `GET /admin/users/foo/:id`).
|
||||
*
|
||||
* The route node's `id` and `qualifiedName` are deliberately preserved
|
||||
* across the update: `id` because existing route→handler edges reference
|
||||
* it, `qualifiedName` because it still encodes the *original* in-file
|
||||
* `method:path` — which keeps this pass idempotent (a second run recovers
|
||||
* the same input regardless of how many times it has already prefixed).
|
||||
*/
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const moduleToPrefix = new Map<string, string>();
|
||||
const controllerToModule = new Map<string, string>();
|
||||
|
||||
for (const filePath of context.getAllFiles()) {
|
||||
if (!/\.module\.(m?[jt]s|cjs)$/.test(filePath)) continue;
|
||||
const content = context.readFile(filePath);
|
||||
if (!content) continue;
|
||||
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
|
||||
collectRouterModuleRegistrations(safe, moduleToPrefix);
|
||||
collectModuleControllers(safe, controllerToModule);
|
||||
}
|
||||
|
||||
const controllerToPrefix = new Map<string, string>();
|
||||
for (const [controller, module] of controllerToModule) {
|
||||
const prefix = moduleToPrefix.get(module);
|
||||
// `''` and `'/'` are no-op prefixes; skip them so we don't run updates
|
||||
// that would set name to the value it already has.
|
||||
if (prefix && prefix !== '' && prefix !== '/') {
|
||||
controllerToPrefix.set(controller, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (controllerToPrefix.size === 0) return [];
|
||||
|
||||
const updates: Node[] = [];
|
||||
for (const [controllerName, prefix] of controllerToPrefix) {
|
||||
const classes = context
|
||||
.getNodesByName(controllerName)
|
||||
.filter((n) => n.kind === 'class');
|
||||
for (const cls of classes) {
|
||||
const routes = context
|
||||
.getNodesInFile(cls.filePath)
|
||||
.filter((n) => n.kind === 'route');
|
||||
for (const route of routes) {
|
||||
// Multiple controllers can live in one file (covered by the
|
||||
// existing "attributes methods to the right controller" test);
|
||||
// each route must be associated with the controller whose line
|
||||
// range contains it.
|
||||
if (route.startLine < cls.startLine || route.startLine > cls.endLine) {
|
||||
continue;
|
||||
}
|
||||
const updated = applyModulePrefix(route, prefix);
|
||||
if (updated && updated.name !== route.name) updates.push(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -436,3 +515,252 @@ function detectLanguage(filePath: string): JsLang {
|
||||
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'typescript';
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RouterModule + @Module walkers (used by postExtract above)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk every `RouterModule.register([...])` call (and the equivalent
|
||||
* `RouterModule.forRoot([...])` and `forChild([...])` aliases) and populate
|
||||
* `out` with `Module → /full/prefix`. Recursive `children` arrays inherit
|
||||
* their parent's prefix.
|
||||
*
|
||||
* First-write-wins: if the same module appears in two registrations we keep
|
||||
* the first prefix seen rather than overwriting. NestJS itself does the same.
|
||||
*/
|
||||
function collectRouterModuleRegistrations(safe: string, out: Map<string, string>): void {
|
||||
const re = /\bRouterModule\s*\.\s*(?:register|forRoot|forChild)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const openIndex = m.index + m[0].length - 1;
|
||||
const parsed = readArgs(safe, openIndex);
|
||||
if (!parsed) continue;
|
||||
const items = parseRoutesArray(parsed.args);
|
||||
walkRoutesTree(items, '', out);
|
||||
re.lastIndex = parsed.end;
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteItem {
|
||||
path: string;
|
||||
moduleName: string | null;
|
||||
children: RouteItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `[ {...}, {...} ]` argument list into a list of `RouteItem`s. The
|
||||
* args are expected to be an inline literal — references to a `const routes:
|
||||
* Routes = [...]` declared earlier in the file aren't followed (rare in
|
||||
* practice; the registration is usually inline).
|
||||
*/
|
||||
function parseRoutesArray(args: string): RouteItem[] {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed.startsWith('[')) return [];
|
||||
// Strip outer [ ... ] respecting balanced brackets.
|
||||
const close = matchingClose(trimmed, 0);
|
||||
if (close < 0) return [];
|
||||
return parseRouteObjects(trimmed.slice(1, close));
|
||||
}
|
||||
|
||||
function parseRouteObjects(s: string): RouteItem[] {
|
||||
const items: RouteItem[] = [];
|
||||
for (const obj of splitTopLevelObjects(s)) {
|
||||
const path = parseStringField(obj, 'path');
|
||||
const moduleName = parseIdentField(obj, 'module');
|
||||
const childrenStr = parseArrayField(obj, 'children');
|
||||
const children = childrenStr ? parseRouteObjects(childrenStr) : [];
|
||||
items.push({ path, moduleName, children });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function walkRoutesTree(
|
||||
items: RouteItem[],
|
||||
parentPrefix: string,
|
||||
out: Map<string, string>
|
||||
): void {
|
||||
for (const item of items) {
|
||||
const myPrefix = joinHttpPath(parentPrefix, item.path);
|
||||
if (item.moduleName && !out.has(item.moduleName)) {
|
||||
out.set(item.moduleName, myPrefix);
|
||||
}
|
||||
if (item.children.length > 0) {
|
||||
walkRoutesTree(item.children, myPrefix, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `@Module(...)` decorator and populate `out` with
|
||||
* `Controller → enclosingModuleClassName`, based on the decorator's
|
||||
* `controllers: [...]` field and the class declaration that follows the
|
||||
* decorator (skipping stacked decorators and export/default/abstract
|
||||
* modifiers).
|
||||
*/
|
||||
function collectModuleControllers(safe: string, out: Map<string, string>): void {
|
||||
for (const hit of findDecorators(safe, ['Module'])) {
|
||||
const className = classNameAfter(safe, hit.end);
|
||||
if (!className) continue;
|
||||
for (const controller of parseControllersField(hit.args)) {
|
||||
// First-write-wins, same as RouterModule, so a controller listed in two
|
||||
// modules picks up the one declared earliest in source.
|
||||
if (!out.has(controller)) out.set(controller, className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseControllersField(args: string): string[] {
|
||||
const inner = parseArrayField(args, 'controllers');
|
||||
if (inner === null) return [];
|
||||
return inner
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /^[A-Za-z_$][\w$]*$/.test(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting just after a class decorator's `)`, return the name of the class
|
||||
* it decorates. Mirrors `methodNameAfter` for methods: skips stacked
|
||||
* decorators and `export`/`default`/`abstract` modifiers.
|
||||
*/
|
||||
function classNameAfter(safe: string, start: number): string | null {
|
||||
let i = start;
|
||||
const ws = /\s*/y;
|
||||
const decoName = /@[\w.]+/y;
|
||||
const classDecl = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/y;
|
||||
|
||||
const eatWs = (): void => {
|
||||
ws.lastIndex = i;
|
||||
if (ws.exec(safe)) i = ws.lastIndex;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
eatWs();
|
||||
if (safe[i] !== '@') break;
|
||||
decoName.lastIndex = i;
|
||||
if (!decoName.exec(safe)) break;
|
||||
i = decoName.lastIndex;
|
||||
eatWs();
|
||||
if (safe[i] === '(') {
|
||||
const parsed = readArgs(safe, i);
|
||||
if (!parsed) return null;
|
||||
i = parsed.end;
|
||||
}
|
||||
}
|
||||
|
||||
eatWs();
|
||||
classDecl.lastIndex = i;
|
||||
const m = classDecl.exec(safe);
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a route node's `name` by prepending `prefix` to the *original*
|
||||
* in-file path. The original is recovered from `qualifiedName`, which the
|
||||
* per-file extract emits as `${filePath}::${method}:${path}` and which this
|
||||
* pass deliberately never mutates — that's what keeps the update idempotent.
|
||||
*/
|
||||
function applyModulePrefix(route: Node, prefix: string): Node | null {
|
||||
const sep = '::';
|
||||
const idx = route.qualifiedName.indexOf(sep);
|
||||
if (idx < 0) return null;
|
||||
const tail = route.qualifiedName.slice(idx + sep.length);
|
||||
const colon = tail.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
const method = tail.slice(0, colon);
|
||||
const original = tail.slice(colon + 1);
|
||||
const newName = `${method} ${joinHttpPath(prefix, original)}`;
|
||||
return { ...route, name: newName, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small string utilities (object/array literal splitters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return the index of the bracket that closes the one at `open`, or -1. */
|
||||
function matchingClose(s: string, open: number): number {
|
||||
const opener = s[open];
|
||||
if (opener !== '[' && opener !== '{' && opener !== '(') return -1;
|
||||
let depth = 0;
|
||||
let inStr: string | null = null;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (inStr) {
|
||||
if (ch === '\\') { i++; continue; }
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `s` into the contents of each top-level object literal. Brackets and
|
||||
* string literals are balanced so nested arrays/objects/strings inside an
|
||||
* object don't cause an early split.
|
||||
*/
|
||||
function splitTopLevelObjects(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let objStart = -1;
|
||||
let inStr: string | null = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (inStr) {
|
||||
if (ch === '\\') { i++; continue; }
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
||||
if (depth === 0 && ch === '{') {
|
||||
depth = 1;
|
||||
objStart = i;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0 && objStart >= 0 && ch === '}') {
|
||||
out.push(s.slice(objStart + 1, i));
|
||||
objStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a string-valued field — `key: 'value'` — out of one object literal's
|
||||
* body. Returns `''` if not present. The leading character class guards
|
||||
* against matching a field whose name *contains* the target as a suffix.
|
||||
*/
|
||||
function parseStringField(obj: string, name: string): string {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*['"\`]([^'"\`]*)['"\`]`);
|
||||
const m = obj.match(re);
|
||||
return m ? m[1]! : '';
|
||||
}
|
||||
|
||||
/** Read an identifier-valued field — `key: SomeIdent` — out of one object body. */
|
||||
function parseIdentField(obj: string, name: string): string | null {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*([A-Za-z_$][\\w$]*)`);
|
||||
const m = obj.match(re);
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
/** Read an array-valued field — `key: [ ... ]` — as the raw inner text. */
|
||||
function parseArrayField(obj: string, name: string): string | null {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*\\[`);
|
||||
const m = re.exec(obj);
|
||||
if (!m) return null;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const close = matchingClose(obj, open);
|
||||
if (close < 0) return null;
|
||||
return obj.slice(open + 1, close);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,35 @@ export class ReferenceResolver {
|
||||
this.clearCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each framework resolver's cross-file finalization pass and persist
|
||||
* the returned node updates. Idempotent — safe to call after every indexAll
|
||||
* and every incremental sync. Returns the number of nodes updated.
|
||||
*
|
||||
* Caches are cleared before/after so the post-extract pass sees fresh DB
|
||||
* state and downstream queries see the updated names.
|
||||
*/
|
||||
runPostExtract(): number {
|
||||
let updated = 0;
|
||||
this.clearCaches();
|
||||
for (const fw of this.frameworks) {
|
||||
if (!fw.postExtract) continue;
|
||||
try {
|
||||
const nodes = fw.postExtract(this.context);
|
||||
for (const node of nodes) {
|
||||
this.queries.updateNode(node);
|
||||
updated++;
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`Framework '${fw.name}' postExtract failed`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updated > 0) this.clearCaches();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-build lightweight caches for resolution.
|
||||
* Node lookups are now handled by indexed SQLite queries instead of
|
||||
|
||||
@@ -148,6 +148,20 @@ export interface FrameworkResolver {
|
||||
* pipeline; the framework's own `resolve()` is one of the strategies tried.
|
||||
*/
|
||||
extract?(filePath: string, content: string): FrameworkExtractionResult;
|
||||
/**
|
||||
* Cross-file finalization pass, called once after all per-file extraction
|
||||
* completes (and again on every incremental sync). Used by frameworks where
|
||||
* a symbol's final representation depends on a sibling file the per-file
|
||||
* `extract()` never saw — e.g. NestJS's `RouterModule.register([...])`
|
||||
* sets route prefixes for controllers declared elsewhere.
|
||||
*
|
||||
* Implementations return route/etc. nodes with mutated fields (typically
|
||||
* `name`); the orchestrator persists each via `updateNode`. The node `id`
|
||||
* MUST be preserved so existing edges (route → handler, etc.) stay intact;
|
||||
* `qualifiedName` SHOULD be preserved so the pass stays idempotent — a
|
||||
* second run can recover the original in-file form from `qualifiedName`.
|
||||
*/
|
||||
postExtract?(context: ResolutionContext): Node[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user