Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__tests__/codebase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 8 additions & 2 deletions src/__tests__/http-repo-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});

Expand Down
142 changes: 142 additions & 0 deletions src/__tests__/import-dir.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({ nodes: 1, edges: 0 });
(autoPushTeamRepo as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
5 changes: 4 additions & 1 deletion src/codebase-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export interface ExtractCodebaseOptions {
project?: string;
maxFiles?: number;
skipEnrich?: boolean;
/** 产出根目录(teamwiki/ 写到此目录下)。默认与 path 相同。 */
outputRoot?: string;
}

interface ExtractResult {
Expand Down Expand Up @@ -511,8 +513,9 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise<voi
const root = path.resolve(opts.path || '.');
const project = opts.project || path.basename(root);
const maxFiles = opts.maxFiles || 200;
const outputBase = opts.outputRoot ? path.resolve(opts.outputRoot) : root;

const wikiRoot = path.join(root, 'teamwiki');
const wikiRoot = path.join(outputBase, 'teamwiki');
const evidenceDir = path.join(wikiRoot, 'evidence', 'code', project);
const manifestPath = path.join(wikiRoot, 'source-manifest.json');

Expand Down
6 changes: 3 additions & 3 deletions src/codebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'node:path';

import matter from 'gray-matter';

import { callClaude } from './utils/ai-client.js';
import { callClaude, getAICliName } from './utils/ai-client.js';
import { createGit } from './utils/git.js';
import { log } from './utils/logger.js';
import type { CodebaseSuggestion, LintIssue, LintReport } from './types.js';
Expand Down Expand Up @@ -384,7 +384,7 @@ export async function generateCodebaseMd(opts: {
learningsInjection;
}

log.debug('generateCodebaseMd: 调用 AI 生成文档');
log.debug(`generateCodebaseMd: 调用 AI 生成文档 (model: ${getAICliName()})`);
const rawResult = await callClaude(prompt);

// 剥离 AI 可能自行附加的 frontmatter,再 prepend 标准 frontmatter
Expand Down Expand Up @@ -456,7 +456,7 @@ export async function generateCodebaseIndex(codebaseMd: string): Promise<string>
return (
frontmatter +
`# Codebase 索引\n\n` +
`> ⚠️ 索引生成失败,请重新运行 \`teamai import --workspace\` 以重新生成。\n`
`> ⚠️ 索引生成失败,请重新运行 \`teamai import --dir <path>\` 以重新生成。\n`
);
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/enrich-with-ai.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -93,6 +93,8 @@ export async function enrichWithAI(ctx: EnrichContext): Promise<EnrichResult | n
return null;
}

log.debug(`enrichWithAI: ${moduleEntries.length} modules, AI model: ${getAICliName()}`);

// Step 1: AI enrichment per module (parallel)
const tasks = moduleEntries.map(([moduleName, moduleFacts]) => ({
prompt: buildModulePrompt(moduleName, moduleFacts, ctx.interfaceInventory),
Expand Down
3 changes: 3 additions & 0 deletions src/import-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface ImportFromOrgOptions {
dryRun?: boolean;
output?: string;
forceSsh?: boolean;
/** 跳过 AI enrichment */
skipEnrich?: boolean;
}

// ─── 辅助函数 ────────────────────────────────────────────
Expand Down Expand Up @@ -273,6 +275,7 @@ export async function importFromOrg(opts: ImportFromOrgOptions): Promise<void> {
output: opts.output,
skipAggregate: false,
incremental: false,
skipEnrich: opts.skipEnrich ?? false,
});
log.info(
`批量导入完成:成功 ${result.succeeded},失败 ${result.failed.length},跳过 ${result.skipped.length}`,
Expand Down
5 changes: 5 additions & 0 deletions src/import-repo-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface ImportFromRepoListOptions {
skipAggregate?: boolean;
/** 增量模式:缓存命中时仅 fetch+reset,未命中时 fallback 到全量 clone */
incremental?: boolean;
/** 跳过 AI enrichment(只做 clone + extract + graph,不调用 LLM) */
skipEnrich?: boolean;
}

/** importFromRepoList 汇总结果。 */
Expand Down Expand Up @@ -74,6 +76,7 @@ export async function importFromRepoList(
output,
skipAggregate = false,
incremental = false,
skipEnrich = false,
} = opts;

// 1. 加载白名单
Expand Down Expand Up @@ -116,6 +119,7 @@ export async function importFromRepoList(
interactive: false,
incremental,
skipAutoPush: true,
skipEnrich,
});
succeeded.push(1);
} catch (err) {
Expand Down Expand Up @@ -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}`);
}
Expand Down
3 changes: 3 additions & 0 deletions src/import-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,10 +644,12 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise<void>

// 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');
}
Expand Down Expand Up @@ -840,6 +842,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise<void>
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})` : ''}`);
}
}

Expand Down
Loading
Loading