#!/usr/bin/env node /** * 校验 contracts/rest-api.yaml 中声明的路由与 NestJS 控制器实际实现的路由一致。 * * 双向检查: * - 契约中声明但未实现 → 失败(契约不可落空) * - 已实现但契约未声明 → 失败(实现不可绕过契约) * * 用法:node scripts/check-routes.mjs */ import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, '..'); const specPath = join(root, '..', 'contracts', 'rest-api.yaml'); const METHODS = new Set(['get', 'post', 'patch', 'put', 'delete']); /** 从 OpenAPI YAML 中提取 paths 下的路由(不引入 YAML 依赖,按缩进解析) */ function parseSpecRoutes(yaml) { const lines = yaml.split(/\r?\n/); const routes = new Set(); let inPaths = false; let currentPath = null; for (const line of lines) { if (/^paths:\s*$/.test(line)) { inPaths = true; continue; } if (inPaths && /^[A-Za-z]/.test(line)) { inPaths = false; // 离开 paths 段(进入 components 等) currentPath = null; continue; } if (!inPaths) continue; const pathMatch = /^ {2}(\/\S*):\s*$/.exec(line); if (pathMatch) { currentPath = pathMatch[1].replace(/\{([^}]+)\}/g, ':$1'); continue; } const methodMatch = /^ {4}([a-z]+):\s*/.exec(line); if (methodMatch && currentPath && METHODS.has(methodMatch[1])) { routes.add(`${methodMatch[1].toUpperCase()} ${currentPath}`); } } return routes; } /** 递归查找 *.controller.ts(避免依赖 fs.globSync 的 Node 版本差异) */ function findControllers(dir) { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) out.push(...findControllers(full)); else if (entry.isFile() && entry.name.endsWith('.controller.ts')) out.push(full); } return out; } /** 从 NestJS 控制器提取路由 */ function parseControllerRoutes(dir) { const routes = new Set(); for (const file of findControllers(dir)) { const source = readFileSync(file, 'utf8'); const controllerMatch = /@Controller\(\s*['"]([^'"]*)['"]/.exec(source); const prefix = controllerMatch ? `/${controllerMatch[1].replace(/^\/|\/$/g, '')}` : ''; const decorator = /@(Get|Post|Patch|Put|Delete)\(\s*(?:['"]([^'"]*)['"])?[^)]*\)/g; let match; while ((match = decorator.exec(source)) !== null) { const method = match[1].toUpperCase(); const sub = (match[2] ?? '').replace(/^\/|\/$/g, ''); let full = sub ? `${prefix}/${sub}` : prefix; if (!full) full = '/'; // Nest 路由参数是 :id,契约是 {id},统一成 :name full = full.replace(/\{([^}]+)\}/g, ':$1').replace(/\/+$/, '') || '/'; routes.add(`${method} ${full}`); } } return routes; } const specRoutes = parseSpecRoutes(readFileSync(specPath, 'utf8')); const implRoutes = parseControllerRoutes(join(root, 'src')); const missing = [...specRoutes].filter((r) => !implRoutes.has(r)).sort(); const extra = [...implRoutes].filter((r) => !specRoutes.has(r)).sort(); const problems = []; if (missing.length) problems.push(`契约中声明但未实现:\n${missing.map((r) => ` - ${r}`).join('\n')}`); if (extra.length) problems.push(`已实现但契约未声明:\n${extra.map((r) => ` + ${r}`).join('\n')}`); if (problems.length) { console.error(`✗ 路由与契约不一致(契约 ${specRoutes.size} 条 / 实现 ${implRoutes.size} 条)`); console.error(problems.join('\n\n')); console.error('\n请同步 contracts/rest-api.yaml 与 web-server/src/**/*.controller.ts'); process.exit(1); } console.log(`✓ 路由与契约一致(共 ${specRoutes.size} 条)`);