Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/ast/extract-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,25 @@ function extractHttpFrameworkRoutes(
// Track router.use('/prefix', subRouter) for prefix resolution
const prefixMap = new Map<string, string>(); // variable name -> prefix

// Track createRoute() definitions: variable name -> { method, path }
const routeDefMap = new Map<string, { method: string; path: string }>();

function visit(node: any) {
// Track createRoute({ method, path }) variable assignments
if (node.kind === SK.VariableStatement) {
for (const decl of node.declarationList?.declarations || []) {
if (decl.initializer?.kind === SK.CallExpression) {
const callee = decl.initializer.expression;
if (callee?.kind === SK.Identifier && getText(sf, callee) === "createRoute") {
const routeDef = extractCreateRouteArgs(ts, sf, decl.initializer);
if (routeDef && decl.name?.kind === SK.Identifier) {
routeDefMap.set(getText(sf, decl.name), routeDef);
}
}
}
}
}

if (node.kind === SK.CallExpression) {
const expr = node.expression;

Expand All @@ -90,6 +108,47 @@ function extractHttpFrameworkRoutes(
}
}

// .openapi(routeDef, handler) — resolve createRoute() definitions
if (methodName === "openapi" && node.arguments?.length > 0) {
const routeArg = node.arguments[0];

// Inline createRoute({ method, path }) call
if (routeArg.kind === SK.CallExpression) {
const callee = routeArg.expression;
if (callee?.kind === SK.Identifier && getText(sf, callee) === "createRoute") {
const routeDef = extractCreateRouteArgs(ts, sf, routeArg);
if (routeDef) {
routes.push({
method: routeDef.method.toUpperCase(),
path: routeDef.path,
file: filePath,
tags,
framework,
params: extractPathParams(routeDef.path),
confidence: "ast",
});
}
}
}

// Variable reference: .openapi(getActiveRoute, handler)
if (routeArg.kind === SK.Identifier) {
const varName = getText(sf, routeArg);
const routeDef = routeDefMap.get(varName);
if (routeDef) {
routes.push({
method: routeDef.method.toUpperCase(),
path: routeDef.path,
file: filePath,
tags,
framework,
params: extractPathParams(routeDef.path),
confidence: "ast",
});
}
}
}

// Route registration: .get('/path', ...) .post('/path', ...) etc.
if (HTTP_METHODS.has(methodName) && node.arguments?.length > 0) {
const pathArg = node.arguments[0];
Expand Down Expand Up @@ -141,6 +200,35 @@ function extractHttpFrameworkRoutes(
return routes;
}

/** Extract method and path from createRoute({ method: '...', path: '...' }) */
function extractCreateRouteArgs(
ts: any,
sf: any,
callExpr: any
): { method: string; path: string } | null {
const SK = ts.SyntaxKind;
if (!callExpr.arguments?.length) return null;
const arg = callExpr.arguments[0];
if (arg.kind !== SK.ObjectLiteralExpression) return null;

let method: string | null = null;
let path: string | null = null;

for (const prop of arg.properties || []) {
if (prop.kind !== SK.PropertyAssignment || !prop.name) continue;
const name = getText(sf, prop.name);
const val = prop.initializer;
if (name === "method" && (val.kind === SK.StringLiteral || val.kind === SK.NoSubstitutionTemplateLiteral)) {
method = val.text;
}
if (name === "path" && (val.kind === SK.StringLiteral || val.kind === SK.NoSubstitutionTemplateLiteral)) {
path = val.text;
}
}

return method && path ? { method, path } : null;
}

// ─── NestJS ───

const NEST_METHOD_MAP: Record<string, string> = {
Expand Down
7 changes: 5 additions & 2 deletions src/detectors/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1224,12 +1224,15 @@ async function resolveRoutePrefixes(
const prefixMap = new Map<string, string>();

// Entry point files where mount registrations live
const wsNames = project.workspaces.map(w => w.path.replace(/\\/g, "/"));
const entryFiles = files.filter(f => {
const rel = relative(project.root, f).replace(/\\/g, "/");
return (
/^(?:src\/)?(?:index|server|app|main)\.(ts|js|mjs|py)$/.test(rel) ||
/^apps\/[^/]+\/(?:src\/)?(?:index|server|app|main)\.(ts|js|mjs)$/.test(rel) ||
/^backend\/(?:server|app|main)\.py$/.test(rel)
/^backend\/(?:server|app|main)\.py$/.test(rel) ||
// Monorepo workspace entry points (e.g. api/src/app.ts)
wsNames.some(ws => new RegExp(`^${ws}/(?:src/)?(?:index|server|app|main)\\.(ts|js|mjs)$`).test(rel))
);
});

Expand All @@ -1248,7 +1251,7 @@ async function resolveRoutePrefixes(
if (prefixMap.size === 0) return routes;

return routes.map(route => {
const prefix = prefixMap.get(route.file);
const prefix = prefixMap.get(route.file.replace(/\\/g, "/"));
if (!prefix || prefix === "/") return route;
// Don't double-prefix if path already starts with it
if (route.path.startsWith(prefix + "/") || route.path === prefix) return route;
Expand Down
41 changes: 30 additions & 11 deletions src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,36 @@ export async function detectProject(root: string): Promise<ProjectInfo> {
if (isMonorepo) {
const wsPatterns = await getWorkspacePatterns(root, pkg);
for (const pattern of wsPatterns) {
const wsRoot = join(root, pattern.replace("/*", ""));
try {
const wsDirs = await readdir(wsRoot, { withFileTypes: true });
for (const d of wsDirs) {
if (!d.isDirectory() || d.name.startsWith(".")) continue;
const wsPath = join(wsRoot, d.name);
if (pattern.includes("*")) {
// Glob pattern (e.g. "packages/*") — enumerate subdirectories
const wsRoot = join(root, pattern.replace("/*", ""));
try {
const wsDirs = await readdir(wsRoot, { withFileTypes: true });
for (const d of wsDirs) {
if (!d.isDirectory() || d.name.startsWith(".")) continue;
const wsPath = join(wsRoot, d.name);
const wsPkg = await readJsonSafe(join(wsPath, "package.json"));
workspaces.push({
name: wsPkg.name || d.name,
path: relative(root, wsPath),
frameworks: await detectFrameworks(wsPath, wsPkg),
orms: await detectORMs(wsPath, wsPkg),
});
}
} catch {}
} else {
// Direct path (e.g. "app", "api") — treat the path itself as a workspace
const wsPath = join(root, pattern);
try {
const wsPkg = await readJsonSafe(join(wsPath, "package.json"));
workspaces.push({
name: wsPkg.name || d.name,
path: relative(root, wsPath),
name: wsPkg.name || basename(pattern),
path: pattern,
frameworks: await detectFrameworks(wsPath, wsPkg),
orms: await detectORMs(wsPath, wsPkg),
});
}
} catch {}
} catch {}
}
}
}

Expand Down Expand Up @@ -152,6 +167,10 @@ export async function detectProject(root: string): Promise<ProjectInfo> {
if (!orms.includes(orm)) orms.push(orm);
}
}
// Remove raw-http fallback if real frameworks were found from workspaces
if (frameworks.length > 1 && frameworks.includes("raw-http")) {
frameworks = frameworks.filter((fw) => fw !== "raw-http");
}
}

return {
Expand Down Expand Up @@ -387,7 +406,7 @@ async function getWorkspacePatterns(
const patterns: string[] = [];
for (const line of yaml.split("\n")) {
const match = line.match(/^\s*-\s*['"]?([^'"]+)['"]?\s*$/);
if (match) patterns.push(match[1]);
if (match) patterns.push(match[1].trim());
}
if (patterns.length > 0) return patterns;
} catch {}
Expand Down