#!/usr/bin/env node /* * API inventory checker * - Enumerates /api routes from unified-server + mounted routers * - Detects duplicate method+path combinations */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SERVER_FILE = path.join(ROOT, 'backend', 'unified-server.js'); function readFileSafe(filePath) { try { return fs.readFileSync(filePath, 'utf8'); } catch (error) { return null; } } function resolveRequirePath(baseFile, requirePath) { const baseDir = path.dirname(baseFile); const raw = path.resolve(baseDir, requirePath); const candidates = [ raw, `${raw}.js`, path.join(raw, 'index.js') ]; return candidates.find((candidate) => fs.existsSync(candidate)) || null; } function normalizePath(routePath) { if (!routePath) return '/'; let normalized = routePath.replace(/\/+/g, '/'); normalized = normalized.replace(/\/\/+/, '/'); // Preserve wildcard endings, but collapse duplicate slashes. normalized = normalized.replace(/\/+/g, '/'); if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } return normalized; } function joinPaths(basePath, subPath) { const base = normalizePath(basePath); const sub = normalizePath(subPath || '/'); if (sub === '/' || sub === '') return base; if (base === '/') return sub; return normalizePath(`${base}/${sub.replace(/^\//, '')}`); } function parseDirectApiRoutes(serverSource) { const routes = []; const regex = /app\.(get|post|put|delete|patch|options|head|all)\(\s*['"`]([^'"`]+)['"`]/g; let match; while ((match = regex.exec(serverSource)) !== null) { const method = match[1].toUpperCase(); const routePath = match[2]; if (!routePath.startsWith('/api')) continue; routes.push({ method, path: normalizePath(routePath), source: 'backend/unified-server.js' }); } return routes; } function parseMountedRouters(serverSource) { const mounts = []; const regex = /app\.use\(\s*['"`]([^'"`]+)['"`]\s*,\s*require\(\s*['"`]([^'"`]+)['"`]\s*\)\s*\)/g; let match; while ((match = regex.exec(serverSource)) !== null) { const mountPath = match[1]; const requirePath = match[2]; if (!mountPath.startsWith('/api')) continue; const routerFile = resolveRequirePath(SERVER_FILE, requirePath); if (!routerFile) { mounts.push({ mountPath, routerFile: null, requirePath }); continue; } mounts.push({ mountPath, routerFile, requirePath }); } return mounts; } function parseRouterFile(routerFile, mountPath) { const source = readFileSafe(routerFile); if (!source) return []; const routes = []; const regex = /router\.(get|post|put|delete|patch|options|head|all)\(\s*['"`]([^'"`]+)['"`]/g; let match; while ((match = regex.exec(source)) !== null) { const method = match[1].toUpperCase(); const localPath = match[2]; const fullPath = joinPaths(mountPath, localPath); routes.push({ method, path: fullPath, source: path.relative(ROOT, routerFile) }); } return routes; } function findDuplicates(routes) { const map = new Map(); routes.forEach((route) => { const key = `${route.method} ${route.path}`; const existing = map.get(key) || []; existing.push(route); map.set(key, existing); }); const duplicates = []; for (const [key, entries] of map.entries()) { if (entries.length > 1) { duplicates.push({ key, entries }); } } return duplicates; } function printInventory(routes, duplicates, unresolvedMounts) { console.log(`API inventory: ${routes.length} endpoint(s)`); if (unresolvedMounts.length > 0) { console.log(''); console.log('Unresolved mounted routers:'); unresolvedMounts.forEach((mount) => { console.log(`- ${mount.mountPath} -> ${mount.requirePath}`); }); } console.log(''); if (duplicates.length === 0) { console.log('No duplicate method+path routes detected.'); } else { console.log(`Duplicate method+path routes detected: ${duplicates.length}`); duplicates.forEach((dup) => { console.log(`- ${dup.key}`); dup.entries.forEach((entry) => { console.log(` - ${entry.source}`); }); }); } } function printFullInventory(routes, duplicates, unresolvedMounts) { console.log(`API inventory: ${routes.length} endpoint(s)`); console.log(''); routes .sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)) .forEach((route) => { console.log(`${route.method.padEnd(7)} ${route.path} (${route.source})`); }); if (unresolvedMounts.length > 0) { console.log(''); console.log('Unresolved mounted routers:'); unresolvedMounts.forEach((mount) => { console.log(`- ${mount.mountPath} -> ${mount.requirePath}`); }); } console.log(''); if (duplicates.length === 0) { console.log('No duplicate method+path routes detected.'); } else { console.log(`Duplicate method+path routes detected: ${duplicates.length}`); duplicates.forEach((dup) => { console.log(`- ${dup.key}`); dup.entries.forEach((entry) => { console.log(` - ${entry.source}`); }); }); } } function main() { const args = new Set(process.argv.slice(2)); const failOnDuplicates = args.has('--check') || args.has('--fail-on-duplicates'); const fullOutput = args.has('--full'); const serverSource = readFileSafe(SERVER_FILE); if (!serverSource) { console.error(`Unable to read ${SERVER_FILE}`); process.exit(2); } const directRoutes = parseDirectApiRoutes(serverSource); const mounts = parseMountedRouters(serverSource); const mountedRoutes = []; const unresolvedMounts = []; mounts.forEach((mount) => { if (!mount.routerFile) { unresolvedMounts.push(mount); return; } mountedRoutes.push(...parseRouterFile(mount.routerFile, mount.mountPath)); }); const allRoutes = [...directRoutes, ...mountedRoutes]; const duplicates = findDuplicates(allRoutes); if (fullOutput) { printFullInventory(allRoutes, duplicates, unresolvedMounts); } else { printInventory(allRoutes, duplicates, unresolvedMounts); } if (failOnDuplicates && duplicates.length > 0) { process.exit(1); } if (unresolvedMounts.length > 0) { process.exit(2); } } main();