/** * 开发工具集(3 个) * * lint_code, run_tests, project_info * * 用于代码检查、测试运行和项目结构分析。 * 使用 execFile 执行命令,防止命令注入。 */ import { execFile } from 'child_process'; import { promisify } from 'util'; import { readFile, readdir } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; const execFileAsync = promisify(execFile); /** 输出最大长度(50KB) */ const MAX_OUTPUT_LENGTH = 50 * 1024; /** Windows 平台检测 */ const isWindows = process.platform === 'win32'; /** ESLint 配置文件列表 */ const ESLINT_CONFIG_FILES = [ '.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml', '.eslintrc.cjs', 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', ]; /** 检测工作空间是否存在 ESLint 配置 */ function hasEslintConfig(workspacePath: string): boolean { return ESLINT_CONFIG_FILES.some((f) => existsSync(join(workspacePath, f))); } /** 读取并解析 package.json */ async function readPackageJson(workspacePath: string): Promise | null> { try { const content = await readFile(join(workspacePath, 'package.json'), 'utf-8'); return JSON.parse(content) as Record; } catch { return null; } } /** 截断输出到 50KB */ function truncateOutput(output: string): { output: string; truncated: boolean } { if (output.length > MAX_OUTPUT_LENGTH) { return { output: output.slice(0, MAX_OUTPUT_LENGTH), truncated: true }; } return { output, truncated: false }; } /** 从 execFile 错误对象中提取 stdout/stderr 字符串 */ function extractOutput(err: { stdout?: string | Buffer; stderr?: string | Buffer }): { stdout: string; stderr: string } { return { stdout: typeof err.stdout === 'string' ? err.stdout : '', stderr: typeof err.stderr === 'string' ? err.stderr : '', }; } // ===== 1. lint_code ===== export class LintCodeTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'lint_code', description: 'Run code linting: TypeScript type checking (tsc) or ESLint code style checking. Auto-detects project configuration when type is "auto".', parameters: { type: 'object', properties: { type: { type: 'string', description: 'Check type: "tsc" (TypeScript type check), "eslint" (ESLint), or "auto" (auto-detect, prioritizes tsc then eslint). Defaults to "tsc".', enum: ['tsc', 'eslint', 'auto'], }, }, }, category: MetonaToolCategory.CODE_EXECUTION, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 60_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { const type = (args.type as string) ?? 'tsc'; try { let actualType: 'tsc' | 'eslint'; if (type === 'auto') { // 自动检测:优先 tsc,然后 eslint if (existsSync(join(context.workspacePath, 'tsconfig.json'))) { actualType = 'tsc'; } else if (hasEslintConfig(context.workspacePath)) { actualType = 'eslint'; } else { return { type: 'none', success: true, errorCount: 0, warningCount: 0, output: 'No TypeScript or ESLint configuration found in workspace.', truncated: false, }; } } else if (type === 'tsc') { if (!existsSync(join(context.workspacePath, 'tsconfig.json'))) { return { type: 'tsc', success: true, errorCount: 0, warningCount: 0, output: 'No tsconfig.json found, skipping TypeScript type check.', truncated: false, }; } actualType = 'tsc'; } else { // eslint if (!hasEslintConfig(context.workspacePath)) { return { type: 'eslint', success: true, errorCount: 0, warningCount: 0, output: 'No ESLint configuration found, skipping ESLint check.', truncated: false, }; } actualType = 'eslint'; } let stdout = ''; let stderr = ''; let exitCode = 0; try { if (actualType === 'tsc') { const result = await execFileAsync('npx', ['tsc', '--noEmit'], { cwd: context.workspacePath, maxBuffer: 5 * 1024 * 1024, timeout: 60_000, shell: isWindows, encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 }); stdout = result.stdout; stderr = result.stderr; } else { const result = await execFileAsync('npx', ['eslint', '--ext', '.ts,.tsx', '.'], { cwd: context.workspacePath, maxBuffer: 5 * 1024 * 1024, timeout: 60_000, shell: isWindows, encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 }); stdout = result.stdout; stderr = result.stderr; } } catch (error) { // lint 命令退出码非 0 是正常情况(代码有错误时),需要捕获输出用于解析 const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number }; const extracted = extractOutput(err); stdout = extracted.stdout; stderr = extracted.stderr; exitCode = err.code ?? 1; } const output = (stdout + (stderr ? '\n' + stderr : '')).trim(); const { errorCount, warningCount } = this.parseCounts(output, actualType); const { output: finalOutput, truncated } = truncateOutput(output); return { type: actualType, success: exitCode === 0, errorCount, warningCount, output: finalOutput, truncated, }; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } /** 解析 lint 输出中的错误和警告数量 */ private parseCounts(output: string, type: 'tsc' | 'eslint'): { errorCount: number; warningCount: number } { if (type === 'tsc') { // tsc 输出格式: "file.ts(line,col): error TS1234: message" const errorMatches = output.match(/error TS\d+:/g); return { errorCount: errorMatches?.length ?? 0, warningCount: 0 }; } // eslint 输出末尾汇总: "✖ X problems (Y errors, Z warnings)" const match = output.match(/(\d+)\s+problems?\s*\((\d+)\s+errors?,\s*(\d+)\s+warnings?\)/); if (match) { return { errorCount: parseInt(match[2], 10), warningCount: parseInt(match[3], 10), }; } return { errorCount: 0, warningCount: 0 }; } } // ===== 2. run_tests ===== export class RunTestsTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'run_tests', description: 'Run the project test suite. Auto-detects the test command from package.json scripts, or uses a custom command. Supports test file filtering.', parameters: { type: 'object', properties: { filter: { type: 'string', description: 'Test file filter pattern (e.g., "auth" or "utils/*"). Passed to the test runner.', }, watch: { type: 'boolean', description: 'Watch mode (not applicable in this context, ignored).', }, }, }, category: MetonaToolCategory.CODE_EXECUTION, riskLevel: MetonaRiskLevel.LOW, requiresPermission: false, timeoutMs: 120_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { const filter = args.filter as string | undefined; // v0.3.1 修复 WARN-2: 移除 customCommand — LLM 输出不可信,shell:true 有注入风险 // watch 参数不适用,忽略 // v0.3.1 修复 WARN-1: filter 字符白名单校验,防止 Windows shell:true 下的 cmd 元字符注入 // 允许字母/数字/连字符/下划线/斜杠/星号/点(常见测试文件路径模式) const FILTER_ALLOWED_RE = /^[A-Za-z0-9_\-/. *]+$/; if (filter && !FILTER_ALLOWED_RE.test(filter)) { return { command: 'npm test', success: false, passed: 0, failed: 0, duration: '0s', output: `Invalid filter: contains disallowed characters. Only letters, digits, '-', '_', '/', '.', '*', ' ' are allowed.`, truncated: false, }; } try { let actualCommand: string; let execCmd: string; let execArgs: string[]; // 检测 package.json 的 scripts.test const pkg = await readPackageJson(context.workspacePath); const scripts = (pkg?.scripts as Record | undefined) ?? {}; const testScript = scripts.test; if (!testScript || testScript === 'no test specified') { return { command: 'npm test', success: false, passed: 0, failed: 0, duration: '0s', output: 'No test script found in package.json.', truncated: false, }; } actualCommand = filter ? `npm test -- ${filter}` : 'npm test'; execCmd = 'npm'; execArgs = filter ? ['test', '--', filter] : ['test']; let stdout = ''; let stderr = ''; let exitCode = 0; try { const result = await execFileAsync(execCmd, execArgs, { cwd: context.workspacePath, maxBuffer: 5 * 1024 * 1024, timeout: 120_000, shell: isWindows, encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码 }); stdout = result.stdout; stderr = result.stderr; } catch (error) { // 测试失败(exitCode !== 0)是正常情况,需要捕获输出解析结果 const err = error as { stdout?: string | Buffer; stderr?: string | Buffer; code?: number }; const extracted = extractOutput(err); stdout = extracted.stdout; stderr = extracted.stderr; exitCode = err.code ?? 1; } const output = (stdout + (stderr ? '\n' + stderr : '')).trim(); const { passed, failed, duration } = this.parseTestResults(output); const { output: finalOutput, truncated } = truncateOutput(output); return { command: actualCommand, success: exitCode === 0, passed, failed, duration, output: finalOutput, truncated, }; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } /** 从测试输出中解析通过/失败数量和耗时(支持 jest/vitest/mocha 格式) */ private parseTestResults(output: string): { passed: number; failed: number; duration: string } { let passed = 0; let failed = 0; let duration = '0s'; // jest/vitest 格式: "Tests: 5 passed, 2 failed, 7 total" const jestMatch = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?/); if (jestMatch) { passed = parseInt(jestMatch[1], 10); failed = jestMatch[2] ? parseInt(jestMatch[2], 10) : 0; } // mocha 格式: "X passing (Ys)" / "X failing" const mochaPass = output.match(/(\d+)\s+passing/); const mochaFail = output.match(/(\d+)\s+failing/); if (mochaPass) passed = parseInt(mochaPass[1], 10); if (mochaFail) failed = parseInt(mochaFail[1], 10); // 耗时解析: "Time: 3.5 s" / "Duration: 120ms" / "(3.5s)" const timeMatch = output.match(/(?:Time|Duration|耗时)[:\s]+([\d.]+\s*(?:ms|s|m|h|seconds?|minutes?|hours?)?)/i); if (timeMatch) { duration = timeMatch[1]; } else { const parenTime = output.match(/\(([\d.]+\s*(?:ms|s|m|h))\)/); if (parenTime) duration = parenTime[1]; } return { passed, failed, duration }; } } // ===== 3. project_info ===== export class ProjectInfoTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'project_info', description: 'Analyze project structure and dependencies. Returns package.json overview, full dependency list, npm scripts, or directory structure (2 levels deep).', parameters: { type: 'object', properties: { detail: { type: 'string', description: 'Detail level: "overview" (project summary), "dependencies" (full dependency list), "scripts" (npm scripts), or "structure" (directory tree, 2 levels). Defaults to "overview".', enum: ['overview', 'dependencies', 'scripts', 'structure'], }, }, }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 10_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { const detail = (args.detail as string) ?? 'overview'; try { switch (detail) { case 'dependencies': return await this.getDependencies(context.workspacePath); case 'scripts': return await this.getScripts(context.workspacePath); case 'structure': return await this.getStructure(context.workspacePath); default: return await this.getOverview(context.workspacePath); } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); return { error: errMsg, success: false }; } } private async getOverview(workspacePath: string): Promise { const pkg = await readPackageJson(workspacePath); if (!pkg) { return { error: 'package.json not found', success: false }; } const deps = (pkg.dependencies as Record | undefined) ?? {}; const devDeps = (pkg.devDependencies as Record | undefined) ?? {}; const scripts = (pkg.scripts as Record | undefined) ?? {}; return { name: (pkg.name as string) ?? 'unknown', version: (pkg.version as string) ?? '0.0.0', description: (pkg.description as string) ?? '', type: pkg.type === 'module' ? 'module' : 'commonjs', mainEntry: (pkg.module as string) ?? (pkg.main as string) ?? 'index.js', dependencyCount: Object.keys(deps).length, devDependencyCount: Object.keys(devDeps).length, scriptCount: Object.keys(scripts).length, hasTypeScript: existsSync(join(workspacePath, 'tsconfig.json')), hasEslint: hasEslintConfig(workspacePath), }; } private async getDependencies(workspacePath: string): Promise { const pkg = await readPackageJson(workspacePath); if (!pkg) { return { error: 'package.json not found', success: false }; } const deps = (pkg.dependencies as Record | undefined) ?? {}; const devDeps = (pkg.devDependencies as Record | undefined) ?? {}; const dependencies = Object.entries(deps).map(([name, version]) => ({ name, version })); const devDependencies = Object.entries(devDeps).map(([name, version]) => ({ name, version })); return { dependencies, devDependencies, total: dependencies.length + devDependencies.length, }; } private async getScripts(workspacePath: string): Promise { const pkg = await readPackageJson(workspacePath); if (!pkg) { return { error: 'package.json not found', success: false }; } const scripts = (pkg.scripts as Record | undefined) ?? {}; const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command })); return { scripts: scriptList, count: scriptList.length, }; } private async getStructure(workspacePath: string): Promise { const directories = await this.listDirectory(workspacePath, 0, 2); return { root: workspacePath, directories, }; } /** 递归列出目录(最多 maxDepth 层),跳过 node_modules/.git/dist */ private async listDirectory( dirPath: string, currentDepth: number, maxDepth: number, ): Promise }>> { const results: Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }> = []; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (this.shouldSkip(entry.name)) continue; if (entry.isDirectory()) { const item: { name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> } = { name: entry.name, type: 'dir', }; if (currentDepth < maxDepth - 1) { item.children = await this.listChildren(join(dirPath, entry.name)); } results.push(item); } else { results.push({ name: entry.name, type: 'file' }); } } } catch { // 目录读取失败,跳过 } return results; } /** 列出子目录内容(仅 1 层,不再递归) */ private async listChildren(dirPath: string): Promise> { const results: Array<{ name: string; type: string }> = []; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (this.shouldSkip(entry.name)) continue; results.push({ name: entry.name, type: entry.isDirectory() ? 'dir' : 'file' }); } } catch { // 目录读取失败,跳过 } return results; } /** 判断目录/文件是否应跳过(node_modules/.git/dist 不递归) */ private shouldSkip(name: string): boolean { return ['node_modules', '.git', 'dist'].includes(name); } }