diff --git a/src/__tests__/codebase.test.ts b/src/__tests__/codebase.test.ts index fe63ede..669075d 100644 --- a/src/__tests__/codebase.test.ts +++ b/src/__tests__/codebase.test.ts @@ -40,6 +40,7 @@ vi.mock('../utils/git.js', () => ({ // ─── mock utils/ai-client ───────────────────────────────────────────────── vi.mock('../utils/ai-client.js', () => ({ callClaude: vi.fn(), + getAICliName: () => 'claude', })); import fs from 'node:fs'; diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts index 16b9c09..988c2a0 100644 --- a/src/__tests__/http-repo-integration.test.ts +++ b/src/__tests__/http-repo-integration.test.ts @@ -91,8 +91,14 @@ describe('read-only protection (http kind)', () => { const { init } = await import('../init.js'); await init({ http: server.url, force: true }); - const { push } = await import('../push.js'); - await expect(push({})).rejects.toThrow(/read-only HTTP source/); + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + const { push } = await import('../push.js'); + await expect(push({})).rejects.toThrow(/read-only HTTP source/); + } finally { + process.chdir(originalCwd); + } }); }); diff --git a/src/__tests__/import-dir.test.ts b/src/__tests__/import-dir.test.ts new file mode 100644 index 0000000..7cd7aab --- /dev/null +++ b/src/__tests__/import-dir.test.ts @@ -0,0 +1,142 @@ +// -*- coding: utf-8 -*- +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'fs-extra'; + +// Mock external dependencies +vi.mock('../codebase-extract.js', () => ({ + extractCodebase: vi.fn(), +})); + +vi.mock('../graph-aggregate.js', () => ({ + aggregateGlobalGraph: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + autoPushTeamRepo: vi.fn(), +})); + +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn(), +})); + +import { extractCodebase } from '../codebase-extract.js'; +import { aggregateGlobalGraph } from '../graph-aggregate.js'; +import { autoPushTeamRepo } from '../utils/git.js'; +import { autoDetectInit } from '../config.js'; + +describe('import --dir', () => { + let tmpDir: string; + let projectDir: string; + let teamRepoDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'import-dir-test-')); + projectDir = path.join(tmpDir, 'my-project'); + teamRepoDir = path.join(tmpDir, 'team-repo'); + + await fs.ensureDir(projectDir); + await fs.ensureDir(path.join(teamRepoDir, 'teamwiki')); + + // Create a simple source file + await fs.writeFile(path.join(projectDir, 'main.py'), '# hello\n'); + + vi.clearAllMocks(); + + (autoDetectInit as ReturnType).mockResolvedValue({ + localConfig: { + repo: { localPath: teamRepoDir, remote: 'https://git.example.com/team/repo.git' }, + scope: 'user', + username: 'test', + }, + teamConfig: { team: 'test', repo: 'test/repo' }, + }); + + // extractCodebase mock: simulate writing teamwiki output to outputRoot + (extractCodebase as ReturnType).mockImplementation(async (opts: { outputRoot?: string; project?: string }) => { + const outputBase = opts.outputRoot ?? projectDir; + const wikiRoot = path.join(outputBase, 'teamwiki'); + const evidenceDir = path.join(wikiRoot, 'evidence', 'code', opts.project ?? 'my-project'); + await fs.ensureDir(evidenceDir); + await fs.writeFile(path.join(evidenceDir, 'index.md'), '# test\n'); + await fs.ensureDir(path.join(wikiRoot, '.indices')); + await fs.writeFile(path.join(wikiRoot, '.indices', 'graph-index.json'), JSON.stringify({ nodes: [{ slug: 'a' }], edges: [] })); + }); + + (aggregateGlobalGraph as ReturnType).mockResolvedValue({ nodes: 1, edges: 0 }); + (autoPushTeamRepo as ReturnType).mockResolvedValue(undefined); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + async function runImportDir(opts: { dir: string; dryRun?: boolean; output?: string; skipEnrich?: boolean }) { + const { importCmd } = await import('../import.js'); + await importCmd({ dir: opts.dir, dryRun: opts.dryRun, output: opts.output, skipEnrich: opts.skipEnrich }); + } + + it('calls extractCodebase with outputRoot (not source dir) and skipEnrich', async () => { + await runImportDir({ dir: projectDir, skipEnrich: true }); + + expect(extractCodebase).toHaveBeenCalledWith(expect.objectContaining({ + path: projectDir, + project: 'my-project', + skipEnrich: true, + outputRoot: expect.stringContaining('teamai-extract-'), + })); + }); + + it('copies evidence + graph to team-repo and aggregates', async () => { + await runImportDir({ dir: projectDir }); + + const evidenceDest = path.join(teamRepoDir, 'teamwiki', 'evidence', 'code', 'my-project'); + expect(await fs.pathExists(path.join(evidenceDest, 'index.md'))).toBe(true); + expect(await fs.pathExists(path.join(evidenceDest, '.indices', 'graph-index.json'))).toBe(true); + expect(aggregateGlobalGraph).toHaveBeenCalledWith(path.join(teamRepoDir, 'teamwiki')); + expect(autoPushTeamRepo).toHaveBeenCalled(); + }); + + it('--output writes to specified dir without touching team-repo', async () => { + const outputDir = path.join(tmpDir, 'output'); + await fs.ensureDir(outputDir); + + await runImportDir({ dir: projectDir, output: outputDir }); + + // Should write to output dir + expect(await fs.pathExists(path.join(outputDir, 'teamwiki', 'evidence', 'code', 'my-project', 'index.md'))).toBe(true); + // Should NOT touch team-repo + expect(await fs.pathExists(path.join(teamRepoDir, 'teamwiki', 'evidence', 'code', 'my-project'))).toBe(false); + // Should NOT call push or aggregate + expect(aggregateGlobalGraph).not.toHaveBeenCalled(); + expect(autoPushTeamRepo).not.toHaveBeenCalled(); + }); + + it('cleans up tmpdir even when extractCodebase throws', async () => { + (extractCodebase as ReturnType).mockRejectedValue(new Error('AI unavailable')); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runImportDir({ dir: projectDir }); + exitSpy.mockRestore(); + + // Verify no teamai-extract- tmpdir leaked + const tmpFiles = await fs.readdir(os.tmpdir()); + const leaked = tmpFiles.filter(f => f.startsWith('teamai-extract-')); + expect(leaked).toHaveLength(0); + }); + + it('source directory is not polluted with teamwiki/', async () => { + await runImportDir({ dir: projectDir }); + + expect(await fs.pathExists(path.join(projectDir, 'teamwiki'))).toBe(false); + }); + + it('dryRun skips extraction entirely', async () => { + await runImportDir({ dir: projectDir, dryRun: true }); + + expect(extractCodebase).not.toHaveBeenCalled(); + expect(aggregateGlobalGraph).not.toHaveBeenCalled(); + expect(autoPushTeamRepo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts index 475c242..f54c757 100644 --- a/src/codebase-extract.ts +++ b/src/codebase-extract.ts @@ -35,6 +35,8 @@ export interface ExtractCodebaseOptions { project?: string; maxFiles?: number; skipEnrich?: boolean; + /** 产出根目录(teamwiki/ 写到此目录下)。默认与 path 相同。 */ + outputRoot?: string; } interface ExtractResult { @@ -511,8 +513,9 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise return ( frontmatter + `# Codebase 索引\n\n` + - `> ⚠️ 索引生成失败,请重新运行 \`teamai import --workspace\` 以重新生成。\n` + `> ⚠️ 索引生成失败,请重新运行 \`teamai import --dir \` 以重新生成。\n` ); } } diff --git a/src/enrich-with-ai.ts b/src/enrich-with-ai.ts index b79c4be..bb87d6b 100644 --- a/src/enrich-with-ai.ts +++ b/src/enrich-with-ai.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { writeFile, mkdir } from 'node:fs/promises'; -import { callClaudeParallel } from './utils/ai-client.js'; +import { callClaudeParallel, getAICliName } from './utils/ai-client.js'; import { log } from './utils/logger.js'; import type { CodeFact } from './wiki-engine/adapters/index.js'; import type { InterfaceInventory } from './wiki-engine/interface-scanner.js'; @@ -93,6 +93,8 @@ export async function enrichWithAI(ctx: EnrichContext): Promise ({ prompt: buildModulePrompt(moduleName, moduleFacts, ctx.interfaceInventory), diff --git a/src/import-org.ts b/src/import-org.ts index 92359b0..423b88b 100644 --- a/src/import-org.ts +++ b/src/import-org.ts @@ -60,6 +60,8 @@ export interface ImportFromOrgOptions { dryRun?: boolean; output?: string; forceSsh?: boolean; + /** 跳过 AI enrichment */ + skipEnrich?: boolean; } // ─── 辅助函数 ──────────────────────────────────────────── @@ -273,6 +275,7 @@ export async function importFromOrg(opts: ImportFromOrgOptions): Promise { output: opts.output, skipAggregate: false, incremental: false, + skipEnrich: opts.skipEnrich ?? false, }); log.info( `批量导入完成:成功 ${result.succeeded},失败 ${result.failed.length},跳过 ${result.skipped.length}`, diff --git a/src/import-repo-list.ts b/src/import-repo-list.ts index 42c0801..186ab0d 100644 --- a/src/import-repo-list.ts +++ b/src/import-repo-list.ts @@ -25,6 +25,8 @@ export interface ImportFromRepoListOptions { skipAggregate?: boolean; /** 增量模式:缓存命中时仅 fetch+reset,未命中时 fallback 到全量 clone */ incremental?: boolean; + /** 跳过 AI enrichment(只做 clone + extract + graph,不调用 LLM) */ + skipEnrich?: boolean; } /** importFromRepoList 汇总结果。 */ @@ -74,6 +76,7 @@ export async function importFromRepoList( output, skipAggregate = false, incremental = false, + skipEnrich = false, } = opts; // 1. 加载白名单 @@ -116,6 +119,7 @@ export async function importFromRepoList( interactive: false, incremental, skipAutoPush: true, + skipEnrich, }); succeeded.push(1); } catch (err) { @@ -194,6 +198,7 @@ export async function importFromRepoList( const { localConfig: lc } = await autoDetectInit(); const { autoPushTeamRepo } = await import('./utils/git.js'); await autoPushTeamRepo(lc.repo.localPath, '[teamai] Batch import: graph + aggregate'); + log.success(`已推送到团队知识仓库 (${lc.repo.remote})`); } catch (e) { log.warn(`[git] 批量推送失败(不中断流程):${(e as Error).message}`); } diff --git a/src/import-repo.ts b/src/import-repo.ts index ae42a63..133ba55 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -644,10 +644,12 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise // Resolve team-repo directory (needed for both docs/team-codebase and teamwiki) let teamRepoDir: string; + let teamRepoRemote = ''; try { const { autoDetectInit } = await import('./config.js'); const { localConfig: lc } = await autoDetectInit(); teamRepoDir = lc.repo.localPath; + teamRepoRemote = lc.repo.remote; } catch { teamRepoDir = path.join(process.cwd(), '.teamai', 'team-repo'); } @@ -840,6 +842,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise if (await fs.pathExists(teamRepoDir)) { const { autoPushTeamRepo } = await import('./utils/git.js'); await autoPushTeamRepo(teamRepoDir, `[teamai] Import codebase knowledge from ${owner}/${repoName}`); + log.success(`已推送到团队知识仓库${teamRepoRemote ? ` (${teamRepoRemote})` : ''}`); } } diff --git a/src/import.ts b/src/import.ts index d213556..cc920d8 100644 --- a/src/import.ts +++ b/src/import.ts @@ -1,9 +1,8 @@ import path from 'node:path'; -import fs from 'node:fs/promises'; -import fsSync from 'node:fs'; +import os from 'node:os'; +import fs from 'fs-extra'; import { autoDetectInit } from './config.js'; -import { generateCodebaseMd, generateCodebaseIndex, lintCodebaseMd } from './codebase.js'; import { scanCandidates, classifyWithAI, interactiveReview, pushAccepted } from './import-local.js'; import { importFromIWiki } from './import-iwiki.js'; import { importFromMR } from './import-mr.js'; @@ -23,8 +22,6 @@ interface ImportOptions extends GlobalOptions { dir?: string; /** 是否扫描 Claude/Cursor rule 目录 */ fromClaude?: boolean; - /** 是否从当前 git 工作区生成 codebase.md */ - workspace?: boolean; /** 从已合并 MR/PR URL 提取知识 */ fromMr?: string; /** iWiki Space ID 或页面 URL,用于批量导入 iWiki 文档 */ @@ -76,7 +73,7 @@ interface ImportOptions extends GlobalOptions { } /** - * import 命令主入口,根据选项组合 local、workspace、MR 三条导入流程。 + * import 命令主入口,根据选项组合 dir、MR、org 等导入流程。 * * @param opts - 合并了全局选项与子命令选项的参数对象 */ @@ -95,6 +92,7 @@ export async function importCmd(opts: ImportOptions): Promise { dryRun: opts.dryRun, output: opts.output, forceSsh: opts.ssh ?? false, + skipEnrich: opts.skipEnrich ?? false, }); return; } else if (opts.fromRepo) { @@ -120,6 +118,7 @@ export async function importCmd(opts: ImportOptions): Promise { output: opts.output, skipAggregate: opts.skipAggregate ?? false, incremental: opts.incremental ?? false, + skipEnrich: opts.skipEnrich ?? false, }); log.info(`完成:成功 ${result.succeeded},失败 ${result.failed.length},跳过 ${result.skipped.length}`); if (result.failed.length > 0) process.exitCode = 1; @@ -187,61 +186,78 @@ export async function importCmd(opts: ImportOptions): Promise { if (!opts.dryRun && !opts.output) { await autoPushTeamRepo(localConfig.repo.localPath, `[teamai] Import from MR: ${opts.fromMr}`); } - } else if (opts.workspace) { - // 分支 2:--workspace,从当前 git 工作区生成 codebase.md - const repoPath = process.cwd(); - - // 尝试使用默认 learnings 目录(不增加 CLI flag) - const defaultLearningsDir = path.join(repoPath, 'learnings'); - const learningsDir = fsSync.existsSync(defaultLearningsDir) ? defaultLearningsDir : undefined; - - const codebaseMd = await generateCodebaseMd({ repoPath, learningsDir }); - - // 决定 codebase.md 的写出路径 - let codebaseOutputPath: string | undefined; - if (opts.output) { - await fs.writeFile(opts.output, codebaseMd, 'utf-8'); - log.info(`已写入:${opts.output}`); - codebaseOutputPath = opts.output; - } else { - log.info(codebaseMd); - // stdout 模式:把索引写到 cwd/codebase-index.md - codebaseOutputPath = path.join(repoPath, 'codebase.md'); + } else if (opts.dir) { + // 分支 3:--dir ,代码知识提取(等同于 --from-repo 但跳过 clone) + const dirPath = path.resolve(opts.dir); + if (!(await fs.pathExists(dirPath))) { + throw new Error(`目录不存在: ${dirPath}`); } + const slug = path.basename(dirPath); + log.info(`扫描本地目录: ${dirPath} (project: ${slug})`); - // 生成并写出索引 - try { - const indexMd = await generateCodebaseIndex(codebaseMd); - const indexDir = opts.output ? path.dirname(codebaseOutputPath) : repoPath; - const indexPath = path.join(indexDir, 'codebase-index.md'); - await fs.writeFile(indexPath, indexMd, 'utf-8'); - log.info(`索引已写入:${indexPath}`); - } catch (indexErr) { - log.debug(`生成索引失败(不中断流程):${String(indexErr)}`); + if (opts.dryRun) { + log.info(`[dry-run] 跳过代码提取,不执行实际操作`); + log.success(`本地目录 ${slug} 导入完成 (dry-run)`); + return; } - // 执行 lint 检查(只打印不写文件,不因失败中断) + // 使用临时目录承接 extractCodebase 产物,避免污染源码目录已有的 teamwiki/ + const tmpExtractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-extract-')); try { - const lintReport = await lintCodebaseMd(codebaseMd); - const highIssues = lintReport.issues.filter((i) => i.severity === 'high'); - log.info(`[lint] ${lintReport.summary}(共 ${lintReport.issues.length} 个问题)`); - if (highIssues.length > 0) { - const displayCount = Math.min(highIssues.length, 5); - log.info(`[lint] 高严重度问题(${highIssues.length} 条):`); - for (let idx = 0; idx < displayCount; idx++) { - const issue = highIssues[idx]!; - log.info(` ⚠️ [${issue.category}] ${issue.location}: ${issue.description}`); - } - if (highIssues.length > 5) { - log.info(` … 还有 ${highIssues.length - 5} 条 high 级 lint 问题,请查阅完整报告`); + const { extractCodebase } = await import('./codebase-extract.js'); + await extractCodebase({ + path: dirPath, + project: slug, + json: false, + skipEnrich: opts.skipEnrich ?? false, + outputRoot: tmpExtractDir, + }); + + const srcWiki = path.join(tmpExtractDir, 'teamwiki'); + + if (opts.output) { + // --output 模式:写到指定目录,不碰团队仓库 + const outputWiki = path.join(opts.output, 'teamwiki'); + if (await fs.pathExists(srcWiki)) { + await fs.copy(srcWiki, outputWiki, { overwrite: true }); + log.info(`产物已写入:${outputWiki}`); + } + } else { + // 默认模式:写入 team-repo 并推送 + const { localConfig } = await autoDetectInit(); + const teamRepoPath = localConfig.repo.localPath; + const teamwikiRoot = path.join(teamRepoPath, 'teamwiki'); + + if (await fs.pathExists(srcWiki)) { + const evidenceSrc = path.join(srcWiki, 'evidence', 'code', slug); + const evidenceDest = path.join(teamwikiRoot, 'evidence', 'code', slug); + if (await fs.pathExists(evidenceSrc)) { + await fs.ensureDir(path.dirname(evidenceDest)); + await fs.copy(evidenceSrc, evidenceDest, { overwrite: true }); + } + const srcGraph = path.join(srcWiki, '.indices', 'graph-index.json'); + if (await fs.pathExists(srcGraph)) { + const destGraphDir = path.join(evidenceDest, '.indices'); + await fs.ensureDir(destGraphDir); + await fs.copy(srcGraph, path.join(destGraphDir, 'graph-index.json'), { overwrite: true }); + } + log.info(`teamwiki/ 知识图谱已更新: ${slug}`); + } + + const { aggregateGlobalGraph } = await import('./graph-aggregate.js'); + await aggregateGlobalGraph(teamwikiRoot); + + const { autoPushTeamRepo } = await import('./utils/git.js'); + await autoPushTeamRepo(teamRepoPath, `[teamai] Import from local dir: ${slug}`); + log.success(`已推送到团队知识仓库 (${localConfig.repo.remote})`); } - } - } catch (lintErr) { - log.debug(`lint 检查失败(不中断流程):${String(lintErr)}`); + } finally { + await fs.remove(tmpExtractDir); } - } else if (opts.dir || opts.fromClaude) { - // 分支 3:--dir 或 --from-claude,扫描本地文件并交互式导入 - const candidates = await scanCandidates({ dir: opts.dir, fromClaude: opts.fromClaude }); + log.success(`本地目录 ${slug} 导入完成`); + } else if (opts.fromClaude) { + // 分支 3b:--from-claude,扫描规则文件并交互式导入 + const candidates = await scanCandidates({ fromClaude: true }); if (candidates.length === 0) { log.info('未发现可导入的文件'); return; @@ -255,11 +271,11 @@ export async function importCmd(opts: ImportOptions): Promise { }); log.success('导入完成'); if (pushed > 0 && !opts.dryRun && !opts.output) { - await autoPushTeamRepo(localConfig.repo.localPath, `[teamai] Import from local: ${opts.dir ?? 'claude-rules'}`); + await autoPushTeamRepo(localConfig.repo.localPath, `[teamai] Import from local: claude-rules`); } } else { // 默认:未指定来源,提示用户 - log.info('请指定导入来源:--dir 、--from-claude、--workspace、--from-mr 或 --from-iwiki '); + log.info('请指定导入来源:--dir 、--from-repo 、--from-repo-list 、--from-org 、--from-mr 或 --from-iwiki '); return; } } catch (err: unknown) { diff --git a/src/index.ts b/src/index.ts index 379756c..495742d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -589,10 +589,9 @@ program program .command('import') - .description('Import knowledge from local files, Claude/Cursor rules, git workspace, MRs, or iWiki') - .option('--dir ', 'Scan local directory for importable Markdown files') + .description('Import knowledge from local directories, remote repos, organizations, MRs, or iWiki') + .option('--dir ', 'Extract code knowledge from a local directory (same as --from-repo but no clone)') .addOption(new Option('--from-claude', 'Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)').hideHelp()) - .addOption(new Option('--workspace', 'Generate codebase.md from current git workspace').hideHelp()) .option('--from-mr ', 'Extract learning and codebase suggestions from a merged MR/PR URL') .option('--from-iwiki ', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)') .addOption(new Option('--resume', 'Resume an interrupted import session').hideHelp()) diff --git a/src/utils/ai-client.ts b/src/utils/ai-client.ts index a571d3e..3966297 100644 --- a/src/utils/ai-client.ts +++ b/src/utils/ai-client.ts @@ -10,8 +10,13 @@ const ALLOWED_CLI_CANDIDATES = [ /** CLI 探测超时(毫秒),防止 execFileSync 挂死。 */ const CLI_DETECT_TIMEOUT_MS = 5_000; -/** 默认 AI 调用超时时间(毫秒)。 */ -const DEFAULT_TIMEOUT_MS = 120_000; +/** + * 默认 AI 调用超时时间(毫秒)。 + * 10 分钟适用于大型仓库(200+ 文件)的 codebase 文档生成场景。 + * 小型操作(enrichWithAI 单模块)通常在 30s 内完成。 + * 批量场景中 callClaudeParallel 限制并发为 3,单任务超时不影响其他。 + */ +const DEFAULT_TIMEOUT_MS = 600_000; /** 默认并发数量上限。 */ const DEFAULT_CONCURRENCY = 3; @@ -111,6 +116,14 @@ function buildCliArgs(cmd: string, prompt: string): string[] { /** 缓存探测到的 CLI 信息,避免重复 execFileSync。 */ let _cliInfo: CliInfo | undefined; +/** 获取当前使用的 AI CLI 名称(用于日志显示)。 */ +export function getAICliName(): string { + if (_cliInfo === undefined) { + _cliInfo = detectClaudeCli(); + } + return _cliInfo.cmd; +} + /** * 通过子进程直接调用 AI CLI(claude/codex 等),返回 stdout 文本。 * @@ -137,7 +150,9 @@ export async function callClaude( if (_cliInfo === undefined) { _cliInfo = detectClaudeCli(); + log.debug(`[ai-client] using CLI: ${_cliInfo.cmd} (${_cliInfo.absPath})`); } + log.debug(`[ai-client] calling ${_cliInfo.cmd}, timeout=${Math.round(timeoutMs / 1000)}s, prompt=${prompt.slice(0, 60).replace(/\n/g, ' ')}...`); const child = spawn(_cliInfo.absPath, buildCliArgs(_cliInfo.cmd, prompt), { stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));