diff --git a/README.md b/README.md index dfff1214..03f16def 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Matched: conflict | Missing: port teamai import --from-repo https://github.com/org/repo teamai import --from-org myorg # batch import all repos teamai codebase --extract /path/to/repo # local extract into teamwiki/ +teamai codebase --reconcile --output /path/to/repo # map product docs to code pages teamai codebase --lint --output /path/to/repo # check the locally extracted graph ``` @@ -243,6 +244,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai recall maintenance` | Maintain knowledge base health: prune low-confidence learnings, writeback confidence scores, flag stale entries | | `teamai import` | Import knowledge (`--dir`, `--from-repo`, `--from-org`, `--from-repo-list`, `--from-mr`) | | `teamai codebase --extract [path]` | Extract code facts and build the local graph under `teamwiki/` | +| `teamai codebase --reconcile` | Reconcile product documentation with extracted code knowledge | | `teamai codebase --lint` | Knowledge graph health check | | `teamai ci extract-mr --url ` | CI: extract knowledge from MR, post comments, write after merge | | `teamai members` | List team members | diff --git a/README.zh-CN.md b/README.zh-CN.md index fc6ba891..ed2fb5b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -191,6 +191,7 @@ Matched: conflict | Missing: port teamai import --from-repo https://github.com/org/repo teamai import --from-org myorg # 批量导入所有仓库 teamai codebase --extract /path/to/repo # 本地提取到 teamwiki/ +teamai codebase --reconcile --output /path/to/repo # 将产品文档映射到代码页面 teamai codebase --lint --output /path/to/repo # 检查本地提取的图谱 ``` @@ -243,6 +244,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai recall maintenance` | 维护知识库健康:清理低置信度 learnings、回写置信度、标记过时条目 | | `teamai import` | 导入知识(`--dir`、`--from-repo`、`--from-org`、`--from-repo-list`、`--from-mr`) | | `teamai codebase --extract [path]` | 提取代码事实并在 `teamwiki/` 下构建本地图谱 | +| `teamai codebase --reconcile` | 将产品文档与提取的代码知识进行对账 | | `teamai codebase --lint` | 知识图谱健康检查 | | `teamai ci extract-mr --url ` | CI:从 MR 提取知识、发评论、合并后写入 | | `teamai members` | 查看团队成员 | diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ee294bc6..6fecfc1e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1159,6 +1159,9 @@ teamai codebase --extract /path/to/repo --project my-service # Incremental refresh: reuse the original repository path and project slug teamai codebase --extract /path/to/repo --project my-service --incremental +# Reconcile teamwiki/product and teamwiki/docs with extracted code pages +teamai codebase --reconcile --output /path/to/repo + # Check the local graph; --output is the repository root, not teamwiki/ teamai codebase --lint --output /path/to/repo ``` diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 374d8d0e..1ecb81a2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1134,6 +1134,9 @@ teamai codebase --extract /path/to/repo --project my-service # 增量刷新:复用首次提取的仓库路径和项目名 teamai codebase --extract /path/to/repo --project my-service --incremental +# 将 teamwiki/product 和 teamwiki/docs 与提取的代码页面进行对账 +teamai codebase --reconcile --output /path/to/repo + # 检查本地提取的图谱;--output 指向仓库根目录,而非 teamwiki/ teamai codebase --lint --output /path/to/repo ``` diff --git a/skills/team-wiki-codebase/SKILL.md b/skills/team-wiki-codebase/SKILL.md index ad6f0863..5f753c88 100644 --- a/skills/team-wiki-codebase/SKILL.md +++ b/skills/team-wiki-codebase/SKILL.md @@ -891,7 +891,7 @@ _review/ ← 过程文件(不入知识库) | Phase 0 结构基线 | `teamai codebase --extract --project `(writes `/teamwiki/`) | | K3 后编译进 wiki | Skip. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. | | 产品文档入图 | Skip. Same English note as above. | -| 产品↔代码桥接 | Skip. Same English note as above. | +| 产品↔代码桥接 | Use `teamai codebase --reconcile --output ` after product pages and extracted code pages are under `/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. | | 一键刷新 | Use `teamai codebase --extract --project --incremental`, reusing the Phase 0 repository path and project slug even when running from another directory. Do not look for another CLI. | | 质量评估 | Use `scripts/validate_kb.py` and `teamai codebase --lint --output ` to check `/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | diff --git a/src/__tests__/codebase-reconcile.test.ts b/src/__tests__/codebase-reconcile.test.ts new file mode 100644 index 00000000..5eaa06e6 --- /dev/null +++ b/src/__tests__/codebase-reconcile.test.ts @@ -0,0 +1,398 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { codebaseCmd } from '../codebase-cmd.js'; +import { aggregateGlobalGraph } from '../graph-aggregate.js'; +import { + loadGraphIndex, + validateGraph, + type GraphIndex, +} from '../wiki-engine/core/graph-index.schema.js'; + +const fsFailurePaths = vi.hoisted(() => ({ read: '', stat: '' })); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: (...args: Parameters) => { + if (String(args[0]) === fsFailurePaths.read) return Promise.reject(new Error('read denied')); + return actual.readFile(...args); + }, + stat: (...args: Parameters) => { + if (String(args[0]) === fsFailurePaths.stat) { + return Promise.reject(Object.assign(new Error('stat denied'), { code: 'EACCES' })); + } + return actual.stat(...args); + }, + }; +}); + +const temporaryDirectories: string[] = []; + +function createWikiFixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reconcile-unit-')); + temporaryDirectories.push(root); + const productDir = path.join(root, 'teamwiki', 'product'); + const codeDir = path.join(root, 'teamwiki', 'evidence', 'code', 'auth'); + fs.mkdirSync(productDir, { recursive: true }); + fs.mkdirSync(codeDir, { recursive: true }); + fs.writeFileSync(path.join(productDir, 'login.md'), '# Login\n\n`LoginService` authenticates users.\n'); + fs.writeFileSync(path.join(codeDir, 'component.md'), '# LoginService\n\nLoginService implements authentication.\n'); + return root; +} + +afterEach(() => { + process.exitCode = undefined; + fsFailurePaths.read = ''; + fsFailurePaths.stat = ''; + vi.restoreAllMocks(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('codebase reconciliation', () => { + it('sets a failing exit code when the requested output has no teamwiki', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reconcile-missing-')); + temporaryDirectories.push(root); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ reconcile: true, output: root }); + + expect(process.exitCode).toBe(1); + }); + + it('lists reconciliation in the handler help', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({}); + + expect(log.mock.calls.flat()).toContain( + ' teamai codebase --reconcile Reconcile product and code knowledge', + ); + }); + + it('prints a summary in preview mode and emits JSON while writing the graph', async () => { + const root = createWikiFixture(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ reconcile: true, output: root, dryRun: true }); + expect(log).toHaveBeenLastCalledWith('Reconciliation complete: mappings=1, gaps=0, conflicts=0'); + expect(fs.existsSync(path.join(root, 'teamwiki', '.indices', 'graph-index.json'))).toBe(false); + + log.mockClear(); + await codebaseCmd({ reconcile: true, output: root, json: true }); + const report = JSON.parse(String(log.mock.calls.at(-1)?.[0])); + expect(report).toMatchObject({ mappings: 1 }); + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(graph).not.toBeNull(); + expect(validateGraph(graph as GraphIndex)).toEqual({ valid: true, issues: [] }); + }); + + it('replaces stale reconciliation edges when the pages no longer match', async () => { + const root = createWikiFixture(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await codebaseCmd({ reconcile: true, output: root, json: true }); + + fs.writeFileSync( + path.join(root, 'teamwiki', 'product', 'login.md'), + '# Login\n\n`SessionManager` authenticates users.\n', + ); + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(graph?.edges.filter((edge) => edge.source === 'bridge-reconcile')).toEqual([]); + }); + + it('removes stale reconciliation-owned nodes when their pages are deleted', async () => { + const root = createWikiFixture(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await codebaseCmd({ reconcile: true, output: root, json: true }); + const initialGraph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(initialGraph?.nodes.filter((node) => node.source === 'bridge-reconcile')).toHaveLength(2); + fs.rmSync(path.join(root, 'teamwiki', 'product', 'login.md')); + fs.rmSync(path.join(root, 'teamwiki', 'evidence', 'code', 'auth', 'component.md')); + + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(graph?.nodes.filter((node) => node.source === 'bridge-reconcile')).toEqual([]); + }); + + it('preserves a manual mapping with the same endpoints as a generated bridge', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')) as GraphIndex; + graph.edges[0].source = 'manual-mapping'; + fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2)); + fs.writeFileSync( + path.join(root, 'teamwiki', 'product', 'login.md'), + '# Sign In\n\n`LoginService` authenticates users.\n', + ); + await codebaseCmd({ reconcile: true, output: root, json: true }); + const refreshed = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(refreshed?.nodes).toContainEqual(expect.objectContaining({ slug: 'product/login', title: 'Sign In' })); + fs.rmSync(path.join(root, 'teamwiki', 'product', 'login.md')); + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const reconciled = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(reconciled?.edges).toContainEqual(expect.objectContaining({ source: 'manual-mapping' })); + expect(validateGraph(reconciled as GraphIndex)).toEqual({ valid: true, issues: [] }); + }); + + it('leaves the existing graph untouched when a page cannot be read', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await codebaseCmd({ reconcile: true, output: root, json: true }); + const originalGraph = fs.readFileSync(graphPath, 'utf8'); + fsFailurePaths.read = path.join(root, 'teamwiki', 'product', 'login.md'); + + await expect(codebaseCmd({ reconcile: true, output: root, json: true })).rejects.toThrow('read denied'); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(originalGraph); + }); + + it('leaves the existing graph untouched when a page directory cannot be inspected', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + await codebaseCmd({ reconcile: true, output: root, json: true }); + const originalGraph = fs.readFileSync(graphPath, 'utf8'); + fsFailurePaths.stat = path.join(root, 'teamwiki', 'product'); + + await expect(codebaseCmd({ reconcile: true, output: root, json: true })).rejects.toThrow('stat denied'); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(originalGraph); + }); + + it('rejects an invalid existing graph without overwriting it', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const invalidGraph = '{"schemaVersion":"wrong","nodes":[],"edges":[]}'; + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, invalidGraph); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(invalidGraph); + await expect(codebaseCmd({ reconcile: true, output: root })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(invalidGraph); + }); + + it('rejects invalid node metadata without overwriting it', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const invalidGraph = '{"schemaVersion":"team-wiki.graph-index.v1","generatedAt":"bad","nodes":[{"slug":"bad","type":"invalid","confidence":"invalid","title":"Bad"}],"edges":[{"from":"bad","to":"bad","relation":"invalid"}]}'; + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, invalidGraph); + + await expect(codebaseCmd({ reconcile: true, output: root })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(invalidGraph); + }); + + it.each([ + ['prototype confidence', { nodes: [{ slug: 'bad', type: 'component', confidence: 'toString', title: 'Bad' }], edges: [] }], + ['prototype relation', { + nodes: [ + { slug: 'from', type: 'component', confidence: 'EXTRACTED', title: 'From' }, + { slug: 'to', type: 'component', confidence: 'EXTRACTED', title: 'To' }, + ], + edges: [{ from: 'from', to: 'to', relation: 'constructor' }], + }], + ])('rejects %s metadata without overwriting it', async (_name, contents) => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const invalidGraph = JSON.stringify({ + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + ...contents, + }); + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, invalidGraph); + + await expect(codebaseCmd({ reconcile: true, output: root })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(invalidGraph); + }); + + it('normalizes graph variants emitted by legacy aggregation', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const repoGraphPath = path.join(root, 'teamwiki', 'evidence', 'code', 'auth', '.indices', 'graph-index.json'); + const legacyGraph = { + schemaVersion: 1, + generatedAt: '2026-01-01', + nodes: [ + { id: 'a/client', label: 'Client', type: 'module', confidence: 'high' }, + ], + edges: [{ from: 'a/client', to: 'libs/balance_service.py', relation: 'imports' }], + }; + vi.spyOn(console, 'log').mockImplementation(() => undefined); + fs.mkdirSync(path.dirname(repoGraphPath), { recursive: true }); + fs.writeFileSync(repoGraphPath, JSON.stringify(legacyGraph)); + await aggregateGlobalGraph(path.join(root, 'teamwiki')); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).resolves.toBeUndefined(); + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(graph?.nodes.map((node) => node.confidence)).toEqual(['EXTRACTED', 'EXTRACTED', 'EXTRACTED', 'EXTRACTED']); + expect(graph?.nodes).toContainEqual(expect.objectContaining({ slug: 'a/client', type: 'component' })); + expect(graph?.edges).toContainEqual(expect.objectContaining({ relation: 'DEPENDS_ON', source: 'code-heuristic' })); + }); + + it('repairs missing endpoints from extractor-owned code edges', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const extractorGraph = { + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + nodes: [ + { slug: 'component/a', title: 'a', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'component/b', title: 'b', type: 'component', confidence: 'EXTRACTED' }, + ], + edges: [ + { from: 'src/a.ts', to: 'src/b.ts', relation: 'DEPENDS_ON', source: 'code-ast' }, + { from: 'src/a.ts', to: 'component/a', relation: 'REFERENCES', source: 'code-ast' }, + ], + }; + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, JSON.stringify(extractorGraph)); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ reconcile: true, output: root, json: true }); + + const graph = await loadGraphIndex(path.join(root, 'teamwiki')); + expect(validateGraph(graph as GraphIndex)).toEqual({ valid: true, issues: [] }); + expect(graph?.nodes).toEqual(expect.arrayContaining([ + expect.objectContaining({ slug: 'src/a.ts', source: 'code-ast' }), + expect.objectContaining({ slug: 'src/b.ts', source: 'code-ast' }), + ])); + }); + + it('rejects structural corruption not owned by the extractor', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const invalidGraph = { + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + nodes: [{ slug: 'manual', title: 'Manual', type: 'component', confidence: 'EXTRACTED' }], + edges: [{ from: 'manual', to: 'missing', relation: 'MAPS_TO', source: 'manual-mapping' }], + }; + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, JSON.stringify(invalidGraph)); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + }); + + it('rejects duplicate persisted nodes before endpoint repair can deduplicate them', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const duplicateGraph = JSON.stringify({ + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + nodes: [ + { slug: 'duplicate', title: 'First', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'duplicate', title: 'Second', type: 'component', confidence: 'EXTRACTED' }, + ], + edges: [], + }); + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, duplicateGraph); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + await expect(codebaseCmd({ reconcile: true, output: root })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(duplicateGraph); + }); + + it('rejects duplicate persisted edges before merging can deduplicate them', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const duplicateGraph = JSON.stringify({ + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + nodes: [ + { slug: 'a', title: 'A', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'b', title: 'B', type: 'component', confidence: 'EXTRACTED' }, + ], + edges: [ + { from: 'a', to: 'b', relation: 'REFERENCES', source: 'manual-mapping' }, + { from: 'a', to: 'b', relation: 'REFERENCES', source: 'doc-semantic' }, + ], + }); + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, duplicateGraph); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + await expect(codebaseCmd({ reconcile: true, output: root })).rejects.toThrow( + `Cannot reconcile invalid graph index at ${graphPath}`, + ); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(duplicateGraph); + }); + + it('does not confuse distinct edge identities containing delimiters', async () => { + const root = createWikiFixture(); + const graphPath = path.join(root, 'teamwiki', '.indices', 'graph-index.json'); + const graph = { + schemaVersion: 'team-wiki.graph-index.v1', + generatedAt: '2026-01-01', + nodes: [ + { slug: 'a|b', title: 'A pipe B', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'c', title: 'C', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'a', title: 'A', type: 'component', confidence: 'EXTRACTED' }, + { slug: 'b|c', title: 'B pipe C', type: 'component', confidence: 'EXTRACTED' }, + ], + edges: [ + { from: 'a|b', to: 'c', relation: 'REFERENCES' }, + { from: 'a', to: 'b|c', relation: 'REFERENCES' }, + ], + }; + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.writeFileSync(graphPath, JSON.stringify(graph)); + + await expect(codebaseCmd({ reconcile: true, output: root, dryRun: true })).resolves.toBeUndefined(); + }); + + it('counts distinct mappings whose paths contain delimiters', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reconcile-delimiters-')); + temporaryDirectories.push(root); + const productDir = path.join(root, 'teamwiki', 'product'); + const codeDir = path.join(root, 'teamwiki', 'evidence', 'code'); + const nestedProduct = path.join(productDir, 'a||evidence', 'code', 'b.md'); + const nestedCode = path.join(codeDir, 'b||evidence', 'code', 'c.md'); + fs.mkdirSync(productDir, { recursive: true }); + fs.mkdirSync(codeDir, { recursive: true }); + fs.mkdirSync(path.dirname(nestedProduct), { recursive: true }); + fs.mkdirSync(path.dirname(nestedCode), { recursive: true }); + fs.writeFileSync(path.join(productDir, 'a.md'), '# A\n\n`ServiceOne` handles A.\n'); + fs.writeFileSync(nestedProduct, '# B\n\n`ServiceTwo` handles B.\n'); + fs.writeFileSync(nestedCode, '# ServiceOne\n\nServiceOne.\n'); + fs.writeFileSync(path.join(codeDir, 'c.md'), '# ServiceTwo\n\nServiceTwo.\n'); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await codebaseCmd({ reconcile: true, output: root, dryRun: true, json: true }); + + const result = JSON.parse(String(log.mock.calls.at(-1)?.[0])); + expect(result.graphEdges).toHaveLength(2); + expect(result.mappings).toBe(2); + }); +}); diff --git a/src/__tests__/e2e/codebase-extract-cli.test.ts b/src/__tests__/e2e/codebase-extract-cli.test.ts index 04193509..d479ab52 100644 --- a/src/__tests__/e2e/codebase-extract-cli.test.ts +++ b/src/__tests__/e2e/codebase-extract-cli.test.ts @@ -121,3 +121,74 @@ describe('teamai codebase extract CLI (issue #360 slice 1)', () => { } }); }); + +describe('teamai codebase reconcile CLI (issue #360 slice 2)', () => { + it('reconciles product and code pages with the built CLI', async () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reconcile-360-')); + const caller = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-reconcile-caller-')); + try { + const srcDir = path.join(fixture, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'a.ts'), "import { b } from './b';\nexport const a = b;\n"); + fs.writeFileSync(path.join(srcDir, 'b.ts'), 'export const b = 1;\n'); + const extracted = await runCLI( + ['codebase', '--extract', fixture, '--project', 'auth', '--json', '--max-files', '10'], + caller, + ); + expect(extracted.code, extracted.output).toBe(0); + + const productDir = path.join(fixture, 'teamwiki', 'product'); + fs.mkdirSync(productDir, { recursive: true }); + fs.writeFileSync( + path.join(productDir, 'login.md'), + '# Login\n\n`component` maps to extracted code.\n', + ); + + const help = await runCLI(['codebase', '--help']); + expect(help.code, help.output).toBe(0); + expect(help.stdout).toContain('--reconcile'); + const skill = fs.readFileSync(path.join(ROOT, 'skills/team-wiki-codebase/SKILL.md'), 'utf8'); + const command = [...skill.matchAll(/`(teamai codebase [^`]+)`/g)] + .map(match => match[1]) + .find(candidate => candidate.includes('--reconcile')); + expect(command).toBeDefined(); + const reconcileArgs = command!.split(/\s+/).slice(1) + .map(arg => arg === '' ? fixture : arg); + + const missing = await runCLI( + ['codebase', '--reconcile', '--output', path.join(fixture, 'missing')], + fixture, + ); + expect(missing.code, missing.output).toBe(1); + expect(missing.stdout).toContain('No teamwiki found'); + + const graphPath = path.join(fixture, 'teamwiki', '.indices', 'graph-index.json'); + const graphBeforePreview = fs.readFileSync(graphPath, 'utf8'); + const preview = await runCLI( + ['--dry-run', ...reconcileArgs, '--json'], + caller, + ); + expect(preview.code, preview.output).toBe(0); + expect(JSON.parse(preview.stdout)).toMatchObject({ mappings: 2 }); + expect(fs.readFileSync(graphPath, 'utf8')).toBe(graphBeforePreview); + + const result = await runCLI( + [...reconcileArgs, '--json'], + caller, + ); + expect(result.code, result.output).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ mappings: 2 }); + + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')) as { + nodes: Array<{ slug: string }>; + edges: Array<{ from: string; to: string; relation: string }>; + }; + expect(graph.edges).toContainEqual(expect.objectContaining({ relation: 'MAPS_TO' })); + const nodeSlugs = new Set(graph.nodes.map(node => node.slug)); + expect(graph.edges.every(edge => nodeSlugs.has(edge.from) && nodeSlugs.has(edge.to))).toBe(true); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + fs.rmSync(caller, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/index-codebase-help.test.ts b/src/__tests__/index-codebase-help.test.ts new file mode 100644 index 00000000..acc03de2 --- /dev/null +++ b/src/__tests__/index-codebase-help.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const originalArgv = process.argv; + +afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('codebase command registration', () => { + it('lists the public reconcile option', async () => { + let output = ''; + process.argv = ['node', 'teamai', 'codebase', '--help']; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output += String(chunk); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new Error(`process.exit(${code})`); + }) as never); + + await expect(import('../index.js')).rejects.toThrow('process.exit(0)'); + expect(output).toContain('--reconcile'); + }); +}); diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index 52741cc4..9920f8c6 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -20,6 +20,7 @@ export interface CodebaseCmdOptions extends GlobalOptions { project?: string; maxFiles?: string; status?: boolean; + reconcile?: boolean; } // ─── Command handler ───────────────────────────────────────────────────────── @@ -55,7 +56,7 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { return; } - if (!opts.lint) { + if (!opts.lint && !opts.reconcile) { console.log('teamai codebase — team codebase knowledge management'); console.log(''); console.log('Usage:'); @@ -64,6 +65,7 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { console.log(' teamai codebase --lint Run teamwiki consistency lint'); console.log(' teamai codebase --lint --json Output JSON report (for CI)'); console.log(' teamai codebase --lint --severity high Only report high-severity issues'); + console.log(' teamai codebase --reconcile Reconcile product and code knowledge'); console.log(' teamai codebase --status Show knowledge-base git baseline'); return; } @@ -85,6 +87,18 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { if (!(await pathExists(teamwikiDir))) { console.log('No teamwiki found. Run `teamai import` first.'); + if (opts.reconcile) process.exitCode = 1; + return; + } + + if (opts.reconcile) { + const { reconcileKnowledge } = await import('./wiki-engine/adapters/index.js'); + const result = await reconcileKnowledge({ wikiRoot: teamwikiDir, dryRun: opts.dryRun }); + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Reconciliation complete: mappings=${result.mappings}, gaps=${result.gaps.length}, conflicts=${result.conflicts.length}`); + } return; } diff --git a/src/index.ts b/src/index.ts index 30750adf..11a4fdf1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -919,6 +919,7 @@ program .addOption(new Option('--max-files ', 'Max source files to scan (default: 200)').hideHelp()) .addOption(new Option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format').hideHelp()) .option('--lint', 'Run global consistency lint over the teamwiki knowledge graph') + .option('--reconcile', 'Reconcile product and code knowledge in teamwiki') .addOption(new Option('--fix', 'Deprecated: teamwiki lint has no autofix; runs lint in report-only mode').hideHelp()) .option('--status', 'Show knowledge-base git baseline (headSha / repoUrl / branch)') .addOption(new Option('--severity ', 'Minimum severity to report: high|medium|low|info').default('info').hideHelp()) diff --git a/src/wiki-engine/core/graph-index.schema.ts b/src/wiki-engine/core/graph-index.schema.ts index 14b58328..ba0e5499 100644 --- a/src/wiki-engine/core/graph-index.schema.ts +++ b/src/wiki-engine/core/graph-index.schema.ts @@ -1,7 +1,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import path from "node:path"; +import { z } from "zod"; -import { CONFIDENCE_SCORE_DEFAULTS, type WikiCategory, type WikiConfidence, type WikiEvidence } from "./wiki-protocol.js"; +import { CONFIDENCE_SCORE_DEFAULTS, WIKI_CATEGORIES, type WikiCategory, type WikiConfidence, type WikiEvidence } from "./wiki-protocol.js"; /** * Graph Index Schema — team-wiki.graph-index.v1 @@ -37,6 +38,7 @@ export interface GraphNode { confidence: WikiConfidence; title: string; domain?: string; + source?: GraphEdgeSource; } /** Provenance of a graph edge (compile / reconcile pipeline). */ @@ -50,6 +52,34 @@ export type GraphEdgeSource = | "doc-semantic" | "manual-mapping"; +const LEGACY_GRAPH_EDGE_SOURCE = "code-heuristic" as const; + +export const GRAPH_EDGE_SOURCES: readonly GraphEdgeSource[] = [ + "code-ast", + LEGACY_GRAPH_EDGE_SOURCE, + "doc-structure", + "doc-entity", + "doc-triples", + "bridge-reconcile", + "doc-semantic", + "manual-mapping", +]; + +const WIKI_CONFIDENCES = Object.keys(CONFIDENCE_SCORE_DEFAULTS) as WikiConfidence[]; +const WIKI_EVIDENCE_TYPES = ["definition", "implementation", "usage", "schema", "config"] as const; +const LEGACY_GRAPH_INDEX_SCHEMA_VERSION = 1; +const LEGACY_WIKI_CONFIDENCES: Record = { + high: "EXTRACTED", + medium: "INFERRED", + low: "AMBIGUOUS", +}; +const LEGACY_RELATIONS: Record = { + imports: "DEPENDS_ON", +}; +const LEGACY_WIKI_CATEGORIES: Record = { + module: "component", +}; + export interface GraphEdge { from: string; to: string; @@ -61,6 +91,8 @@ export interface GraphEdge { source?: GraphEdgeSource; } +const graphEdgeKey = (edge: GraphEdge): string => JSON.stringify([edge.from, edge.to, edge.relation]); + /** Wiki page slug: relative path without `.md`. */ export function toPageSlug(relativePath: string): string { return relativePath.replace(/\.md$/u, "").replace(/\\/g, "/"); @@ -73,6 +105,89 @@ export interface GraphIndex { edges: GraphEdge[]; } +const WikiEvidenceSchema = z.object({ + ref: z.string(), + lineStart: z.number().optional(), + lineEnd: z.number().optional(), + commit: z.string().optional(), + type: z.enum(WIKI_EVIDENCE_TYPES).optional(), + note: z.string().optional(), +}).passthrough(); + +const WikiConfidenceSchema = z.string().transform((value, context): WikiConfidence => { + if (WIKI_CONFIDENCES.includes(value as WikiConfidence)) return value as WikiConfidence; + const normalized = Object.hasOwn(LEGACY_WIKI_CONFIDENCES, value) + ? LEGACY_WIKI_CONFIDENCES[value] + : undefined; + if (normalized) return normalized; + context.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid wiki confidence: ${value}` }); + return z.NEVER; +}); + +const GraphIndexVersionSchema = z.union([ + z.literal(GRAPH_INDEX_SCHEMA_VERSION), + z.literal(LEGACY_GRAPH_INDEX_SCHEMA_VERSION), +]).transform(() => GRAPH_INDEX_SCHEMA_VERSION); + +const GraphNodeSchema = z.preprocess((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const node = value as Record; + const rawType = node.type ?? node.kind; + return { + ...node, + slug: node.slug ?? node.id, + type: typeof rawType === "string" && Object.hasOwn(LEGACY_WIKI_CATEGORIES, rawType) + ? LEGACY_WIKI_CATEGORIES[rawType] + : rawType, + title: node.title ?? node.label, + }; +}, z.object({ + slug: z.string(), + type: z.custom((value) => WIKI_CATEGORIES.includes(value as WikiCategory)), + confidence: WikiConfidenceSchema, + title: z.string(), + domain: z.string().optional(), + source: z.custom((value) => GRAPH_EDGE_SOURCES.includes(value as GraphEdgeSource)).optional(), +}).passthrough()); + +const GraphEdgeSchema = z.object({ + from: z.string(), + to: z.string(), + relation: z.string(), + evidence: z.array(WikiEvidenceSchema).optional(), + weight: z.number().optional(), + predicate: z.string().optional(), + source: z.custom((value) => GRAPH_EDGE_SOURCES.includes(value as GraphEdgeSource)).optional(), +}).passthrough().transform((edge, context): GraphEdge => { + const legacyRelation = Object.hasOwn(LEGACY_RELATIONS, edge.relation) + ? LEGACY_RELATIONS[edge.relation] + : undefined; + const relation = RELATION_TYPES.includes(edge.relation as RelationType) + ? edge.relation as RelationType + : legacyRelation; + if (!relation) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid graph relation: ${edge.relation}` }); + return z.NEVER; + } + return { + ...edge, + relation, + source: edge.source ?? (legacyRelation ? LEGACY_GRAPH_EDGE_SOURCE : undefined), + }; +}); + +const GraphIndexSchema = z.object({ + schemaVersion: GraphIndexVersionSchema, + generatedAt: z.string(), + nodes: z.array(GraphNodeSchema), + edges: z.array(GraphEdgeSchema), +}).passthrough(); + +function parseGraphIndex(value: unknown): GraphIndex | null { + const result = GraphIndexSchema.safeParse(value); + return result.success ? result.data as GraphIndex : null; +} + /** * Create an empty GraphIndex with the current timestamp. */ @@ -178,7 +293,7 @@ export function findNeighborsNHop( } export interface GraphValidationIssue { - code: "node.duplicate" | "edge.missing_node" | "edge.self_loop" | "edge.invalid_weight"; + code: "node.duplicate" | "edge.duplicate" | "edge.missing_node" | "edge.self_loop" | "edge.invalid_weight"; message: string; } @@ -190,6 +305,7 @@ export interface GraphValidationResult { /** * Validate a graph index for structural correctness: * - No duplicate node slugs + * - No duplicate edge identities * - All edge endpoints reference existing nodes * - No self-loop edges * - Edge weights (if provided) are between 0 and 1 @@ -197,6 +313,7 @@ export interface GraphValidationResult { export function validateGraph(graph: GraphIndex): GraphValidationResult { const issues: GraphValidationIssue[] = []; const slugs = new Set(); + const edgeKeys = new Set(); for (const node of graph.nodes) { if (slugs.has(node.slug)) { @@ -209,6 +326,14 @@ export function validateGraph(graph: GraphIndex): GraphValidationResult { } for (const edge of graph.edges) { + const edgeKey = graphEdgeKey(edge); + if (edgeKeys.has(edgeKey)) { + issues.push({ + code: "edge.duplicate", + message: `Duplicate edge: ${edge.from} -> ${edge.to} (${edge.relation})`, + }); + } + edgeKeys.add(edgeKey); if (!slugs.has(edge.from)) { issues.push({ code: "edge.missing_node", @@ -353,13 +478,7 @@ export async function loadGraphIndex(wikiRoot: string): Promise `${e.from}|${e.to}|${e.relation}`; const edgeMap = new Map(); const evidenceLen = (e: GraphEdge) => e.evidence?.length ?? 0; for (const e of base.edges) { - edgeMap.set(edgeKey(e), e); + edgeMap.set(graphEdgeKey(e), e); } for (const e of overlay.edges) { - const key = edgeKey(e); + const key = graphEdgeKey(e); const existing = edgeMap.get(key); if (!existing) { edgeMap.set(key, e); diff --git a/src/wiki-engine/knowledge-reconciler.ts b/src/wiki-engine/knowledge-reconciler.ts index e3d257e6..a4e2b8f4 100644 --- a/src/wiki-engine/knowledge-reconciler.ts +++ b/src/wiki-engine/knowledge-reconciler.ts @@ -6,8 +6,9 @@ import { mergeGraphs, createGraphIndex, toPageSlug, + validateGraph, } from './core/graph-index.schema.js'; -import type { GraphIndex, GraphNode, GraphEdge } from './core/graph-index.schema.js'; +import type { GraphIndex, GraphNode, GraphEdge, GraphEdgeSource } from './core/graph-index.schema.js'; import type { WikiConfidence } from './core/wiki-protocol.js'; import { buildConfidence } from './reconciler-v2-types.js'; import type { @@ -71,10 +72,60 @@ interface PageRecord { updated?: string; } +const BRIDGE_EDGE_SOURCE = 'bridge-reconcile' as const; +const PRODUCT_PAGE_TYPE = 'source' as const; +const CODE_PAGE_TYPE = 'component' as const; +const PAGE_CONFIDENCE = 'EXTRACTED' as const; +const PRODUCT_PAGE_DOMAIN = 'product-knowledge'; +const CODE_PAGE_DOMAIN = 'code-knowledge'; +const REPAIRABLE_CODE_EDGE_SOURCES = new Set(['code-ast', 'code-heuristic']); +const MISSING_PATH_ERROR_CODE = 'ENOENT'; +const REPAIRABLE_VALIDATION_ISSUE_CODE = 'edge.missing_node'; + +async function loadReconciliationBase(wikiRoot: string): Promise { + const graphPath = path.join(wikiRoot, '.indices', 'graph-index.json'); + const graph = await loadGraphIndex(wikiRoot); + if (!graph) { + if (!(await exists(graphPath))) return createGraphIndex(); + throw new Error(`Cannot reconcile invalid graph index at ${graphPath}`); + } + if (validateGraph(graph).issues.some(issue => issue.code !== REPAIRABLE_VALIDATION_ISSUE_CODE)) { + throw new Error(`Cannot reconcile invalid graph index at ${graphPath}`); + } + const nodeSlugs = new Set(graph.nodes.map(node => node.slug)); + const endpointNodes: GraphNode[] = []; + for (const edge of graph.edges) { + if (!edge.source || !REPAIRABLE_CODE_EDGE_SOURCES.has(edge.source)) continue; + for (const slug of [edge.from, edge.to]) { + if (nodeSlugs.has(slug)) continue; + nodeSlugs.add(slug); + endpointNodes.push({ + slug, + type: CODE_PAGE_TYPE, + confidence: PAGE_CONFIDENCE, + title: path.basename(slug), + domain: CODE_PAGE_DOMAIN, + source: edge.source, + }); + } + } + const repaired = mergeGraphs(graph, createGraphIndex(endpointNodes)); + if (!validateGraph(repaired).valid) { + throw new Error(`Cannot reconcile invalid graph index at ${graphPath}`); + } + return repaired; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── async function exists(p: string): Promise { - return stat(p).then(() => true).catch(() => false); + try { + await stat(p); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === MISSING_PATH_ERROR_CODE) return false; + throw error; + } } async function readPages(dirPath: string): Promise { @@ -86,7 +137,7 @@ async function readPages(dirPath: string): Promise { if (entry.isDirectory()) { pages.push(...await readPages(full)); } else if (entry.isFile() && entry.name.endsWith('.md')) { - const text = await readFile(full, 'utf8').catch(() => ''); + const text = await readFile(full, 'utf8'); const headingMatch = text.match(/^#\s+(.+)/m); const title = headingMatch ? headingMatch[1].trim() : entry.name.replace(/\.md$/, ''); const updatedMatch = text.match(/updated[:\s]+(\d{4}-\d{2}-\d{2})/i); @@ -357,23 +408,57 @@ export async function reconcileKnowledge(options: ReconcileOptions): Promise 0) { - const existing = await loadGraphIndex(wikiRoot) ?? createGraphIndex(); + const existing = await loadReconciliationBase(wikiRoot); + + // Replace the reconciliation overlay so removed matches cannot leave stale state. + if (!dryRun) { + const baseEdges = existing.edges.filter(edge => edge.source !== BRIDGE_EDGE_SOURCE); + const preservedNodeSlugs = new Set(baseEdges.flatMap(edge => [edge.from, edge.to])); + const baseNodes = existing.nodes.filter( + node => node.source !== BRIDGE_EDGE_SOURCE || preservedNodeSlugs.has(node.slug), + ); + const existingNodeSlugs = new Set( + baseNodes.filter(node => node.source !== BRIDGE_EDGE_SOURCE).map(node => node.slug), + ); + const pageNodes: GraphNode[] = [ + ...productPages.map(page => ({ + slug: toPageSlug(path.relative(wikiRoot, page.path)), + type: PRODUCT_PAGE_TYPE, + confidence: PAGE_CONFIDENCE, + title: page.title, + domain: PRODUCT_PAGE_DOMAIN, + source: BRIDGE_EDGE_SOURCE, + })), + ...codePages.map(page => ({ + slug: toPageSlug(path.relative(wikiRoot, page.path)), + type: CODE_PAGE_TYPE, + confidence: PAGE_CONFIDENCE, + title: page.title, + domain: CODE_PAGE_DOMAIN, + source: BRIDGE_EDGE_SOURCE, + })), + ].filter(node => !existingNodeSlugs.has(node.slug)); const newEdges: GraphEdge[] = graphEdges.map(e => ({ from: e.from, to: e.to, relation: e.relation, weight: e.confidenceScore, - source: 'bridge-reconcile' as const, + source: BRIDGE_EDGE_SOURCE, })); - const overlay = createGraphIndex([], newEdges); - const merged = mergeGraphs(existing, overlay); + const base = createGraphIndex( + baseNodes, + baseEdges, + ); + const refreshedNodes = mergeGraphs(base, createGraphIndex(pageNodes)).nodes; + const merged = mergeGraphs( + createGraphIndex(refreshedNodes, newEdges), + createGraphIndex([], baseEdges), + ); await saveGraphIndex(wikiRoot, merged); } const durationMs = Date.now() - startMs; - const mappingCount = new Set(graphEdges.map(e => `${e.from}||${e.to}`)).size; + const mappingCount = new Set(graphEdges.map(e => JSON.stringify([e.from, e.to]))).size; const allScores = graphEdges.map(e => e.confidenceScore ?? 0); const averageConfidence = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length