P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
561 lines
19 KiB
TypeScript
561 lines
19 KiB
TypeScript
/**
|
||
* 开发工具集(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<Record<string, unknown> | null> {
|
||
try {
|
||
const content = await readFile(join(workspacePath, 'package.json'), 'utf-8');
|
||
return JSON.parse(content) as Record<string, unknown>;
|
||
} 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,
|
||
// v0.7.4 P2-8: lint_code 升 SAFE → LOW + 需确认 —— 其通过 npx tsc/eslint 执行
|
||
// 工作区代码(tsconfig/eslint 配置的 plugins 可含任意 JS),与 run_command 的
|
||
// 执行边界对齐。npx 也加 --no-install 防止自动联网下载(供应链风险)。
|
||
riskLevel: MetonaRiskLevel.LOW,
|
||
requiresPermission: true,
|
||
timeoutMs: 60_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
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') {
|
||
// v0.7.4 P2-8: npx --no-install —— 项目缺依赖时禁止 npx 自动联网下载
|
||
// (供应链风险:被污染的 package.json 可诱导下载恶意包)
|
||
const result = await execFileAsync('npx', ['--no-install', 'tsc', '--noEmit'], {
|
||
cwd: context.workspacePath,
|
||
maxBuffer: 5 * 1024 * 1024,
|
||
timeout: 60_000,
|
||
shell: isWindows,
|
||
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
|
||
signal: context.signal, // P0-4: 用户中断时终止子进程
|
||
});
|
||
stdout = result.stdout;
|
||
stderr = result.stderr;
|
||
} else {
|
||
const result = await execFileAsync(
|
||
'npx',
|
||
['--no-install', 'eslint', '--ext', '.ts,.tsx', '.'],
|
||
{
|
||
cwd: context.workspacePath,
|
||
maxBuffer: 5 * 1024 * 1024,
|
||
timeout: 60_000,
|
||
shell: isWindows,
|
||
encoding: 'utf-8', // v0.3.1 修复 WARN-7: 显式设置编码
|
||
signal: context.signal, // P0-4: 用户中断时终止子进程
|
||
},
|
||
);
|
||
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 输出中的错误和警告数量 */
|
||
/** @visibleForTesting 纯函数,供单元测试直接断言 */
|
||
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,
|
||
// v0.7.4 P2-8: run_tests 升 LOW → MEDIUM + 需确认 —— npm test 执行 package.json
|
||
// scripts.test 的任意命令(被污染的工作区可诱导任意代码执行),与 run_command
|
||
// (HIGH + 确认 + 双层扫描)的执行边界对齐(介于其间的风险评级)。
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: 120_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
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 {
|
||
// 检测 package.json 的 scripts.test
|
||
const pkg = await readPackageJson(context.workspacePath);
|
||
const scripts = (pkg?.scripts as Record<string, string> | 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,
|
||
};
|
||
}
|
||
|
||
const actualCommand = filter ? `npm test -- ${filter}` : 'npm test';
|
||
const execCmd = 'npm';
|
||
const 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: 显式设置编码
|
||
signal: context.signal, // P0-4: 用户中断时终止子进程
|
||
});
|
||
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 格式) */
|
||
/** @visibleForTesting 纯函数,供单元测试直接断言 */
|
||
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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
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<unknown> {
|
||
const pkg = await readPackageJson(workspacePath);
|
||
if (!pkg) {
|
||
return { error: 'package.json not found', success: false };
|
||
}
|
||
|
||
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
|
||
const devDeps = (pkg.devDependencies as Record<string, string> | undefined) ?? {};
|
||
const scripts = (pkg.scripts as Record<string, string> | 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<unknown> {
|
||
const pkg = await readPackageJson(workspacePath);
|
||
if (!pkg) {
|
||
return { error: 'package.json not found', success: false };
|
||
}
|
||
|
||
const deps = (pkg.dependencies as Record<string, string> | undefined) ?? {};
|
||
const devDeps = (pkg.devDependencies as Record<string, string> | 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<unknown> {
|
||
const pkg = await readPackageJson(workspacePath);
|
||
if (!pkg) {
|
||
return { error: 'package.json not found', success: false };
|
||
}
|
||
|
||
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {};
|
||
const scriptList = Object.entries(scripts).map(([name, command]) => ({ name, command }));
|
||
|
||
return {
|
||
scripts: scriptList,
|
||
count: scriptList.length,
|
||
};
|
||
}
|
||
|
||
private async getStructure(workspacePath: string): Promise<unknown> {
|
||
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<
|
||
Array<{ name: string; type: 'dir' | 'file'; children?: Array<{ name: string; type: string }> }>
|
||
> {
|
||
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<Array<{ name: string; type: string }>> {
|
||
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);
|
||
}
|
||
}
|