mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
feat(codebase): expose wiki reconciliation
This commit is contained in:
@@ -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 <url>` | CI: extract knowledge from MR, post comments, write after merge |
|
||||
| `teamai members` | List team members |
|
||||
|
||||
@@ -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 <url>` | CI:从 MR 提取知识、发评论、合并后写入 |
|
||||
| `teamai members` | 查看团队成员 |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -891,7 +891,7 @@ _review/ ← 过程文件(不入知识库)
|
||||
| Phase 0 结构基线 | `teamai codebase --extract <repo> --project <slug>`(writes `<repo>/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 <repo>` after product pages and extracted code pages are under `<repo>/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. |
|
||||
| 一键刷新 | Use `teamai codebase --extract <repo> --project <slug> --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 <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. |
|
||||
|
||||
|
||||
@@ -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<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
readFile: (...args: Parameters<typeof actual.readFile>) => {
|
||||
if (String(args[0]) === fsFailurePaths.read) return Promise.reject(new Error('read denied'));
|
||||
return actual.readFile(...args);
|
||||
},
|
||||
stat: (...args: Parameters<typeof actual.stat>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 === '<repo>' ? 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -919,6 +919,7 @@ program
|
||||
.addOption(new Option('--max-files <n>', '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 <level>', 'Minimum severity to report: high|medium|low|info').default('info').hideHelp())
|
||||
|
||||
@@ -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<string, WikiConfidence> = {
|
||||
high: "EXTRACTED",
|
||||
medium: "INFERRED",
|
||||
low: "AMBIGUOUS",
|
||||
};
|
||||
const LEGACY_RELATIONS: Record<string, RelationType> = {
|
||||
imports: "DEPENDS_ON",
|
||||
};
|
||||
const LEGACY_WIKI_CATEGORIES: Record<string, WikiCategory> = {
|
||||
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<string, unknown>;
|
||||
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<WikiCategory>((value) => WIKI_CATEGORIES.includes(value as WikiCategory)),
|
||||
confidence: WikiConfidenceSchema,
|
||||
title: z.string(),
|
||||
domain: z.string().optional(),
|
||||
source: z.custom<GraphEdgeSource>((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<GraphEdgeSource>((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<string>();
|
||||
const edgeKeys = new Set<string>();
|
||||
|
||||
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<GraphIndex | nul
|
||||
try {
|
||||
const raw = await readFile(graphPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed?.nodes) || !Array.isArray(parsed?.edges)) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.schemaVersion !== GRAPH_INDEX_SCHEMA_VERSION) {
|
||||
return null;
|
||||
}
|
||||
return parsed as GraphIndex;
|
||||
return parseGraphIndex(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -392,16 +511,15 @@ export function mergeGraphs(base: GraphIndex, overlay: GraphIndex): GraphIndex {
|
||||
for (const n of base.nodes) nodeMap.set(nodeKey(n), n);
|
||||
for (const n of overlay.nodes) nodeMap.set(nodeKey(n), n); // overlay wins
|
||||
|
||||
const edgeKey = (e: GraphEdge) => `${e.from}|${e.to}|${e.relation}`;
|
||||
const edgeMap = new Map<string, GraphEdge>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<GraphEdgeSource>(['code-ast', 'code-heuristic']);
|
||||
const MISSING_PATH_ERROR_CODE = 'ENOENT';
|
||||
const REPAIRABLE_VALIDATION_ISSUE_CODE = 'edge.missing_node';
|
||||
|
||||
async function loadReconciliationBase(wikiRoot: string): Promise<GraphIndex> {
|
||||
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<boolean> {
|
||||
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<PageRecord[]> {
|
||||
@@ -86,7 +137,7 @@ async function readPages(dirPath: string): Promise<PageRecord[]> {
|
||||
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<Rec
|
||||
}
|
||||
}
|
||||
|
||||
// Write merged graph edges unless dryRun
|
||||
if (!dryRun && graphEdges.length > 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
|
||||
|
||||
Reference in New Issue
Block a user