Skip to main content
Glama

Frontend Test Generation & Code Review MCP Server

project-root.ts19.3 kB
import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { logger } from './logger.js'; /** * 项目根目录检测结果 */ export interface ProjectRoot { /** 项目根目录路径 */ root: string; /** 是否为 monorepo */ isMonorepo: boolean; /** 如果是 monorepo,返回子包根目录(相对于 monorepo 根) */ packageRoot?: string; /** 检测到的 workspace 类型 */ workspaceType?: 'pnpm' | 'yarn' | 'npm' | 'lerna' | 'rush'; } /** * 通过验证相对路径来确认候选目录是否为项目根目录 * * 策略: * 1. 检查候选目录是否包含项目标识文件(.cursorrules 或 package.json) * 2. 验证相对路径的目录结构是否存在(至少第一级目录应该存在) * * @param candidateRoot - 候选根目录 * @param relativePaths - 相对路径列表 * @returns 如果验证通过返回项目根目录,否则返回 null */ function verifyProjectRootByRelativePaths( candidateRoot: string, relativePaths: string[] ): string | null { // 1. 检查是否包含项目标识文件 const hasCursorRules = existsSync(join(candidateRoot, '.cursorrules')); const hasPackageJson = existsSync(join(candidateRoot, 'package.json')); if (!hasCursorRules && !hasPackageJson) { return null; } // 2. 验证相对路径的目录结构 // 至少要有一个路径的第一级目录存在(新文件可能不存在,但目录结构应该存在) let validPathCount = 0; for (const relativePath of relativePaths) { // 提取第一级目录 const firstSegment = relativePath.split('/')[0]; if (!firstSegment) continue; const firstSegmentPath = join(candidateRoot, firstSegment); if (existsSync(firstSegmentPath)) { validPathCount++; // 找到一个匹配就够了 if (validPathCount >= 1) { logger.info(`Verified project root by relative paths`, { candidateRoot, validPaths: validPathCount, totalPaths: relativePaths.length, hasCursorRules, hasPackageJson, }); return candidateRoot; } } } return null; } /** * 检测当前工作目录是否在项目内 * * 检测优先级: * 1. .cursorrules 文件(Cursor 项目标识) * 2. package.json 文件(Node.js 项目标识) * * @returns 如果当前工作目录在项目内,返回项目根目录;否则返回 null */ export function findProjectRootFromCwd(): string | null { const cwd = process.cwd(); let currentPath = cwd; let depth = 0; const maxDepth = 10; while (depth < maxDepth) { // 优先检查 .cursorrules(Cursor 项目标识) const cursorRulesPath = join(currentPath, '.cursorrules'); if (existsSync(cursorRulesPath)) { logger.info(`Found project root via .cursorrules: ${currentPath}`); return currentPath; } // 检查 package.json const packageJsonPath = join(currentPath, 'package.json'); if (existsSync(packageJsonPath)) { return currentPath; } const parentPath = dirname(currentPath); if (parentPath === currentPath) { break; } currentPath = parentPath; depth++; } return null; } /** * 从 diff 文件路径自动检测项目根目录 * * **重要**:MCP 服务器是独立进程,process.cwd() 指向服务器启动时的目录, * 而不是客户端的工作目录。因此优先从 diff 文件路径推断项目根目录。 * * @param diffFilePaths - diff 中的文件路径列表(通常是相对路径,相对于项目根目录) * @param manualRoot - 手动指定的根目录(可选,用于覆盖自动检测) * @returns 项目根目录信息 * @throws 如果无法检测到项目根目录,抛出错误 */ export function detectProjectRoot( diffFilePaths: string[], manualRoot?: string ): ProjectRoot { // 1. 如果手动指定了根目录,直接使用 // 优先级:manualRoot 参数 > 环境变量 PROJECT_ROOT const effectiveRoot = manualRoot || process.env.PROJECT_ROOT; if (effectiveRoot) { logger.info(`Using project root: ${effectiveRoot} (${manualRoot ? 'from config' : 'from env'})`); // 验证手动指定的根目录是否有效 const packageJsonPath = join(effectiveRoot, 'package.json'); if (!existsSync(packageJsonPath)) { throw new Error( `指定的项目根目录无效:${effectiveRoot}\n` + `原因:该目录下不存在 package.json 文件。\n` + `请确保指定的目录是正确的项目根目录。` ); } const result = analyzeProjectRoot(effectiveRoot); return { root: effectiveRoot, ...result, }; } // 2. 最直接的策略:如果 MCP 在项目根目录启动,直接从当前目录验证相对路径 // 这是最简单的情况:当前工作目录就是项目根目录 if (diffFilePaths.length > 0) { const cwdRoot = verifyProjectRootByRelativePaths(process.cwd(), diffFilePaths); if (cwdRoot) { logger.info(`Project root verified from current directory: ${cwdRoot}`); const result = analyzeProjectRoot(cwdRoot); return { root: cwdRoot, ...result, }; } } // 3. 备用策略:从 diff 文件路径推断项目根目录 // 这对于 MCP 服务器在非项目目录启动的情况 if (diffFilePaths.length > 0) { const inferredRoot = findProjectRootFromDiffPaths(diffFilePaths); if (inferredRoot) { logger.info(`Project root inferred from diff file paths: ${inferredRoot}`); const result = analyzeProjectRoot(inferredRoot); return { root: inferredRoot, ...result, }; } } // 4. 再备用策略:从当前工作目录向上查找项目根目录 // 这适用于服务器在项目子目录内启动的情况 const cwdProjectRoot = findProjectRootFromCwd(); if (cwdProjectRoot) { logger.info(`Project root detected from current working directory: ${cwdProjectRoot}`); // 验证:如果有 diff 文件路径,检查它们是否与检测到的项目根目录兼容 if (diffFilePaths.length > 0) { // 对于相对路径,验证它们是否可能存在于项目根目录下 // 注意:新文件可能不存在,所以只做基本验证 const isValid = diffFilePaths.every(filePath => { // 如果路径是绝对路径,检查是否在项目根目录下 if (filePath.startsWith('/')) { return filePath.startsWith(cwdProjectRoot); } // 相对路径应该总是有效的(相对于项目根目录) return true; }); if (!isValid) { logger.warn( `检测到的项目根目录与 diff 文件路径不兼容。` + `项目根目录: ${cwdProjectRoot}, ` + `文件路径: ${diffFilePaths.join(', ')}` ); } } const result = analyzeProjectRoot(cwdProjectRoot); return { root: cwdProjectRoot, ...result, }; } // 5. 如果都无法检测到,抛出明确的错误 const cwd = process.cwd(); throw new Error( `无法检测到项目根目录。\n` + `当前工作目录: ${cwd}\n` + `diff 文件路径: ${diffFilePaths.length > 0 ? diffFilePaths.join(', ') : '无'}\n\n` + `可能原因:\n` + `1. diff 中没有文件路径信息\n` + `2. 项目根目录不在常见位置(从当前目录向上查找未找到 package.json)\n\n` + `解决方案:\n` + `1. 通过环境变量 PROJECT_ROOT 手动指定项目根目录\n` + `2. 或在 MCP 配置的 env 字段中设置 PROJECT_ROOT\n\n` + `提示:diff 中的文件路径是相对路径(相对于项目根目录),` + `因此需要确定项目根目录才能正确解析文件路径。` ); } /** * 从 diff 文件路径列表查找项目根目录 * * 核心思路:对于相对路径(如 `spa/overseas-oa/src/components/Button.tsx`), * 我们需要在文件系统中搜索包含这个目录结构的项目根目录。 * * @param diffFilePaths - diff 中的文件路径列表(通常是相对路径) * @returns 项目根目录,如果找不到返回 null */ function findProjectRootFromDiffPaths(diffFilePaths: string[]): string | null { if (diffFilePaths.length === 0) { return null; } // 策略 1: 如果文件路径是绝对路径,直接从其路径向上查找 for (const filePath of diffFilePaths) { if (filePath.startsWith('/')) { const root = findProjectRootFromFilePath(filePath); if (root) { logger.info(`Found project root from absolute path: ${root}`); return root; } } } // 策略 2: 对于相对路径,通过查找包含该路径结构的目录来定位项目根目录 // 例如:`spa/overseas-oa/src/components/Button.tsx` -> 搜索包含 `spa/overseas-oa` 目录结构的项目根目录 // 提取路径的前几个段作为搜索线索(通常前 1-2 段就足够) const pathSegments = diffFilePaths[0]?.split('/').filter(Boolean) || []; if (pathSegments.length === 0) { return null; } // 使用前 2 个路径段作为搜索线索(对于 monorepo,通常是 `spa/overseas-oa`) const searchKey = pathSegments.slice(0, 2).join('/'); const firstSegment = pathSegments[0]; logger.info(`Searching for project root using path segments: ${searchKey} (first: ${firstSegment})`); // 策略 2a: 从当前工作目录开始,向上查找包含该路径结构的目录 const cwd = process.cwd(); let searchPath = cwd; let depth = 0; const maxDepth = 15; while (depth < maxDepth) { // 检查当前目录是否包含该路径结构 const segmentPath = join(searchPath, firstSegment); if (existsSync(segmentPath)) { // 对于多段路径,检查更深层的结构 if (pathSegments.length > 1) { const deeperPath = join(searchPath, pathSegments[0], pathSegments[1]); if (!existsSync(deeperPath)) { // 如果深层路径不存在,继续向上查找 const parentPath = dirname(searchPath); if (parentPath === searchPath) { break; } searchPath = parentPath; depth++; continue; } } // 找到包含该路径结构的目录,向上查找项目根目录(通过 .cursorrules 或 package.json) const root = findProjectRootFromFilePath(searchPath); if (root) { // 验证:检查该根目录下是否真的包含 diff 文件路径的目录结构 const verifyPath = join(root, firstSegment); if (existsSync(verifyPath)) { logger.info(`Found project root by path structure: ${root}`); return root; } } } // 继续向上查找 const parentPath = dirname(searchPath); if (parentPath === searchPath) { break; } searchPath = parentPath; depth++; } // 策略 2b: 尝试从常见项目目录位置搜索 // 例如:~/web, ~/projects, ~/work 等 const homeDir = process.env.HOME || process.env.USERPROFILE; if (homeDir) { const commonDirs = [ join(homeDir, 'web'), join(homeDir, 'projects'), join(homeDir, 'work'), join(homeDir, 'code'), join(homeDir, 'dev'), ]; for (const commonDir of commonDirs) { if (existsSync(commonDir)) { const root = searchProjectRootInDirectory(commonDir, firstSegment); if (root) { logger.info(`Found project root in common directory: ${root}`); return root; } } } } return null; } /** * 在指定目录及其子目录中搜索项目根目录 * * @param searchDir - 搜索起始目录 * @param pathSegment - 路径段(如 "spa") * @returns 项目根目录,如果找不到返回 null */ function searchProjectRootInDirectory(searchDir: string, pathSegment: string): string | null { try { // 检查当前目录是否包含该路径段 const segmentPath = join(searchDir, pathSegment); if (existsSync(segmentPath)) { // 向上查找 package.json const root = findProjectRootFromFilePath(searchDir); if (root) { const packageJsonPath = join(root, 'package.json'); if (existsSync(packageJsonPath)) { return root; } } } // 递归搜索子目录(限制深度,避免搜索 node_modules) const entries = readdirSync(searchDir, { withFileTypes: true }); for (const entry of entries) { // 跳过隐藏目录和 node_modules if (entry.name.startsWith('.') || entry.name === 'node_modules') { continue; } if (entry.isDirectory()) { const subDir = join(searchDir, entry.name); const result = searchProjectRootInDirectory(subDir, pathSegment); if (result) { return result; } } } } catch (error) { // 忽略权限错误等 } return null; } /** * 从文件路径向上查找项目根目录 * * 检测优先级: * 1. .cursorrules 文件(Cursor 项目标识) * 2. package.json 文件(Node.js 项目标识) * * @param filePath - 文件路径(可以是绝对路径或相对路径) * @returns 项目根目录,如果找不到返回 null */ function findProjectRootFromFilePath(filePath: string): string | null { // 如果是绝对路径,直接使用 let currentPath = filePath.startsWith('/') ? dirname(filePath) : resolve(process.cwd(), dirname(filePath)); // 如果文件路径本身就是一个文件(不是目录),先获取其目录 if (existsSync(currentPath)) { try { const stats = statSync(currentPath); if (!stats.isDirectory()) { currentPath = dirname(currentPath); } } catch { // 忽略错误,继续处理 } } // 向上查找项目根目录标识文件 let depth = 0; const maxDepth = 10; while (depth < maxDepth) { // 优先检查 .cursorrules(Cursor 项目标识) const cursorRulesPath = join(currentPath, '.cursorrules'); if (existsSync(cursorRulesPath)) { return currentPath; } // 检查 package.json const packageJsonPath = join(currentPath, 'package.json'); if (existsSync(packageJsonPath)) { return currentPath; } // 到达根目录 const parentPath = dirname(currentPath); if (parentPath === currentPath) { break; } currentPath = parentPath; depth++; } return null; } /** * 分析项目根目录,判断是否为 monorepo * * @param rootPath - 项目根目录 * @returns 分析结果 */ function analyzeProjectRoot(rootPath: string): Omit<ProjectRoot, 'root'> { // 检查是否为 monorepo const monorepoIndicators = [ { file: 'pnpm-workspace.yaml', type: 'pnpm' as const }, { file: 'lerna.json', type: 'lerna' as const }, { file: 'rush.json', type: 'rush' as const }, ]; for (const { file, type } of monorepoIndicators) { if (existsSync(join(rootPath, file))) { logger.info(`Detected ${type} monorepo`); return { isMonorepo: true, workspaceType: type, }; } } // 检查 package.json 中的 workspaces 字段 const packageJsonPath = join(rootPath, 'package.json'); if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); if (packageJson.workspaces) { // Yarn/NPM workspaces const workspaceType = existsSync(join(rootPath, 'yarn.lock')) ? 'yarn' : 'npm'; logger.info(`Detected ${workspaceType} monorepo (via package.json workspaces)`); return { isMonorepo: true, workspaceType, }; } } catch (error) { logger.warn('Failed to parse package.json', { error }); } } // 不是 monorepo return { isMonorepo: false, }; } /** * 根据 monorepo 结构找到子包的根目录 * * @param monorepoRoot - monorepo 根目录 * @param filePath - 文件路径 * @returns 子包根目录,如果找不到返回 monorepo 根目录 */ export function findPackageRootInMonorepo( monorepoRoot: string, filePath: string ): string { // 从文件路径向上查找最近的 package.json let currentPath = filePath.startsWith('/') ? dirname(filePath) : resolve(monorepoRoot, dirname(filePath)); while (currentPath.startsWith(monorepoRoot) && currentPath !== monorepoRoot) { const packageJsonPath = join(currentPath, 'package.json'); if (existsSync(packageJsonPath)) { // 确认这个 package.json 不是 monorepo 根的 package.json if (currentPath !== monorepoRoot) { return currentPath; } } const parentPath = dirname(currentPath); if (parentPath === currentPath) { break; } currentPath = parentPath; } return monorepoRoot; } /** * 检测侧车项目目录(.local-tests) * * 侧车项目是 monorepo 中的一种特殊模式,测试配置在子包根目录下的 `.local-tests` 目录中。 * * @param packageRoot - 子包根目录 * @returns 如果存在侧车项目目录,返回其路径;否则返回 null */ function findSidecarTestDir(packageRoot: string): string | null { const sidecarDir = join(packageRoot, '.local-tests'); if (existsSync(sidecarDir)) { try { const stats = statSync(sidecarDir); if (stats.isDirectory()) { logger.info(`Found sidecar test directory: ${sidecarDir}`); return sidecarDir; } } catch { // 忽略错误 } } return null; } /** * 获取测试框架检测的目标目录 * * 检测优先级: * 1. 对于 monorepo:优先检测侧车项目目录(.local-tests) * 2. 如果不存在侧车项目,检测子包的测试框架配置 * 3. 如果子包没有,回退到 monorepo 根目录 * 4. 对于普通项目:直接使用项目根目录 * * @param projectRoot - 项目根目录信息 * @param sampleFilePath - 示例文件路径(用于定位子包) * @returns 用于检测测试框架的目录 */ export function getTestStackDetectionPath( projectRoot: ProjectRoot, sampleFilePath?: string ): string { // 普通项目:直接使用项目根目录 if (!projectRoot.isMonorepo || !sampleFilePath) { // 即使不是 monorepo,也检查是否有侧车项目目录 const sidecarDir = findSidecarTestDir(projectRoot.root); if (sidecarDir) { logger.info(`Test stack detection path: ${sidecarDir} (sidecar project)`); return sidecarDir; } return projectRoot.root; } // Monorepo:先找到子包根目录 const packageRoot = findPackageRootInMonorepo(projectRoot.root, sampleFilePath); // 检查是否存在侧车项目目录(.local-tests) const sidecarDir = findSidecarTestDir(packageRoot); if (sidecarDir) { logger.info(`Test stack detection path: ${sidecarDir} (sidecar project in monorepo)`); return sidecarDir; } // 如果没有侧车项目,使用子包根目录 logger.info(`Test stack detection path: ${packageRoot} (monorepo package)`); return packageRoot; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/NorthSeacoder/fe-testgen-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server