Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ docs/codebase.md
docs/llm-wiki.md
roadmap_jael.md
validation/
teamwiki/
42 changes: 42 additions & 0 deletions src/__tests__/hook-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { formatStopHookOutput } from '../utils/hook-output.js';

describe('formatStopHookOutput', () => {
it('claude: returns hookSpecificOutput format', () => {
const result = formatStopHookOutput('hello', 'claude');
const parsed = JSON.parse(result);
expect(parsed.hookSpecificOutput.hookEventName).toBe('Stop');
expect(parsed.hookSpecificOutput.additionalContext).toBe('hello');
});

it('codebuddy: returns hookSpecificOutput format (same as claude)', () => {
const result = formatStopHookOutput('msg', 'codebuddy');
const parsed = JSON.parse(result);
expect(parsed.hookSpecificOutput).toBeDefined();
expect(parsed.hookSpecificOutput.additionalContext).toBe('msg');
});

it('cursor: returns {message} format', () => {
const result = formatStopHookOutput('test', 'cursor');
const parsed = JSON.parse(result);
expect(parsed.message).toBe('test');
expect(parsed.hookSpecificOutput).toBeUndefined();
});

it('unknown tool: defaults to hookSpecificOutput', () => {
const result = formatStopHookOutput('x', 'codex');
const parsed = JSON.parse(result);
expect(parsed.hookSpecificOutput.additionalContext).toBe('x');
});

it('returns valid JSON string', () => {
const result = formatStopHookOutput('any message', 'claude');
expect(() => JSON.parse(result)).not.toThrow();
});

it('empty message is preserved in output', () => {
const result = formatStopHookOutput('', 'claude');
const parsed = JSON.parse(result);
expect(parsed.hookSpecificOutput.additionalContext).toBe('');
});
});
346 changes: 346 additions & 0 deletions src/__tests__/wiki-engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import { describe, it, expect } from 'vitest';
import { scanInterfaces } from '../wiki-engine/interface-scanner.js';
import { traceCallChains } from '../wiki-engine/call-chain-tracer.js';
import { buildIndexHubOverlay } from '../wiki-engine/code-graph-overlay.js';
import { extractDocStructure, extractDocEntities, wikiLinkToPageSlug, entitySlugFor } from '../wiki-engine/doc-graph-extractor.js';
import type { CodeCollectedFile } from '../wiki-engine/code-knowledge/code-collector.js';
import type { CodeFact } from '../wiki-engine/code-knowledge/code-extractors.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const makeFile = (relativePath: string, content: string, language: string): CodeCollectedFile => ({
path: `/repo/${relativePath}`,
relativePath,
content,
language,
sha256: 'mock-sha',
});

const makeFact = (name: string, kind: string, file: string, lineStart = 1): CodeFact => ({
name,
kind: kind as CodeFact['kind'],
file,
lineStart,
lineEnd: lineStart + 5,
detail: '',
confidence: 'EXTRACTED' as const,
evidenceType: 'source' as CodeFact['evidenceType'],
});

// ---------------------------------------------------------------------------
// interface-scanner
// ---------------------------------------------------------------------------

describe('scanInterfaces', () => {
it('returns HTTP entry for TypeScript router.get pattern', async () => {
const files = [makeFile('src/routes.ts', "router.get('/users', handler);", 'typescript')];
const result = await scanInterfaces(files);
expect(result.entries.length).toBeGreaterThan(0);
const entry = result.entries[0];
expect(entry.type).toBe('HTTP');
});

it('returns HTTP with HIGH confidence for Python @app.route', async () => {
const files = [makeFile('api/app.py', "@app.route('/health')\ndef health(): pass", 'python')];
const result = await scanInterfaces(files);
const entry = result.entries.find(e => e.type === 'HTTP');
expect(entry).toBeDefined();
expect(entry!.confidence).toBe('HIGH');
});

it('returns RPC entry for Go grpc.NewServer pattern', async () => {
const files = [makeFile('server/grpc.go', 's := grpc.NewServer()', 'go')];
const result = await scanInterfaces(files);
const entry = result.entries.find(e => e.type === 'RPC');
expect(entry).toBeDefined();
});

it('returns MQ entry for channel.consume pattern', async () => {
const files = [makeFile('worker/mq.ts', 'channel.consume(queue, handler);', 'typescript')];
const result = await scanInterfaces(files);
const entry = result.entries.find(e => e.type === 'MQ');
expect(entry).toBeDefined();
// The generic .consume rule (MEDIUM) fires before the channel.consume rule (HIGH)
// because DETECTION_RULES applies the first matching rule per line.
expect(['HIGH', 'MEDIUM']).toContain(entry!.confidence);
});

it('returns empty entries when no patterns match', async () => {
const files = [makeFile('utils/helper.ts', 'export const add = (a: number) => a + 1;', 'typescript')];
const result = await scanInterfaces(files);
expect(result.entries).toHaveLength(0);
expect(result.scannedAt).toBeTruthy();
});

it('groups files by top-level directory as component', async () => {
const files = [
makeFile('api/handler.ts', "router.get('/a', fn);", 'typescript'),
makeFile('api/middleware.ts', "router.post('/b', fn);", 'typescript'),
];
const result = await scanInterfaces(files);
expect(result.entries[0].component).toBe('api');
expect(result.entries[0].count).toBeGreaterThanOrEqual(2);
});

it('returns multiple pattern lines up to 5 in patterns array', async () => {
const routes = Array.from({ length: 7 }, (_, i) => `router.get('/r${i}', fn);`).join('\n');
const files = [makeFile('routes/index.ts', routes, 'typescript')];
const result = await scanInterfaces(files);
const entry = result.entries.find(e => e.type === 'HTTP');
expect(entry!.patterns.length).toBeLessThanOrEqual(5);
});
});

// ---------------------------------------------------------------------------
// call-chain-tracer
// ---------------------------------------------------------------------------

describe('traceCallChains', () => {
it('returns a chain for a handler entry point fact', () => {
const facts: CodeFact[] = [
makeFact('UserHandler', 'component', 'src/handler.ts'),
];
const files: CodeCollectedFile[] = [
makeFile('src/handler.ts', 'export class UserHandler {}', 'typescript'),
];
const chains = traceCallChains(facts, files);
expect(chains.length).toBeGreaterThan(0);
expect(chains[0].steps[0].layer).toBe('entry');
});

it('returns a chain with entry layer for route-named component', () => {
const facts: CodeFact[] = [
makeFact('GET /api/users', 'interface', 'src/routes.ts'),
];
const files: CodeCollectedFile[] = [
makeFile('src/routes.ts', '', 'typescript'),
];
const chains = traceCallChains(facts, files);
expect(chains.length).toBeGreaterThan(0);
const firstStep = chains[0].steps[0];
expect(firstStep.layer).toBe('entry');
});

it('returns empty array when no entry points exist', () => {
const facts: CodeFact[] = [
makeFact('calculateTotal', 'component', 'src/math.ts'),
];
const files: CodeCollectedFile[] = [
makeFile('src/math.ts', 'export const calculateTotal = () => 0;', 'typescript'),
];
const chains = traceCallChains(facts, files);
expect(chains).toHaveLength(0);
});

it('depth does not exceed 4', () => {
// Create a chain of handler → relation → relation → ...
const facts: CodeFact[] = [
makeFact('handleRequest', 'component', 'src/controller.ts'),
makeFact('./service', 'relation', 'src/controller.ts'),
makeFact('doWork', 'component', 'src/service.ts'),
makeFact('./repo', 'relation', 'src/service.ts'),
makeFact('findAll', 'component', 'src/repo.ts'),
makeFact('./db', 'relation', 'src/repo.ts'),
makeFact('query', 'component', 'src/db.ts'),
makeFact('./extra', 'relation', 'src/db.ts'),
makeFact('extra', 'component', 'src/extra.ts'),
];
const files: CodeCollectedFile[] = [
makeFile('src/controller.ts', '', 'typescript'),
makeFile('src/service.ts', '', 'typescript'),
makeFile('src/repo.ts', '', 'typescript'),
makeFile('src/db.ts', '', 'typescript'),
makeFile('src/extra.ts', '', 'typescript'),
];
const chains = traceCallChains(facts, files);
for (const chain of chains) {
expect(chain.depth).toBeLessThanOrEqual(4);
}
});

it('picks up key file with handler-like path as entry', () => {
const facts: CodeFact[] = [];
const files: CodeCollectedFile[] = [
{
path: '/repo/src/handler.ts',
relativePath: 'src/handler.ts',
content: '',
language: 'typescript',
sha256: 'x',
isKeyFile: true,
},
];
const chains = traceCallChains(facts, files);
expect(chains.length).toBeGreaterThan(0);
});
});

// ---------------------------------------------------------------------------
// code-graph-overlay
// ---------------------------------------------------------------------------

describe('buildIndexHubOverlay', () => {
it('produces index node plus one component node per slug', () => {
const slugs = ['code/myproject/functions', 'code/myproject/types', 'code/myproject/errors'];
const result = buildIndexHubOverlay('myproject', 'code', slugs);
// 1 index node + 3 component nodes
expect(result.nodes).toHaveLength(4);
});

it('all edges have relation CONTAINS from index to each slug', () => {
const slugs = ['code/proj/a', 'code/proj/b'];
const result = buildIndexHubOverlay('proj', 'code', slugs);
expect(result.edges).toHaveLength(2);
for (const edge of result.edges) {
expect(edge.relation).toBe('CONTAINS');
expect(slugs).toContain(edge.to);
}
});

it('empty slugs → returns only index node, no edges', () => {
const result = buildIndexHubOverlay('proj', 'code', []);
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(0);
expect(result.nodes[0].type).toBe('architecture');
});

it('skips a slug equal to the index slug to avoid self-loops', () => {
const indexSlug = 'code/proj/index';
const slugs = [indexSlug, 'code/proj/other'];
const result = buildIndexHubOverlay('proj', 'code', slugs);
// index node + 1 component node (self-slug skipped)
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
expect(result.edges[0].to).toBe('code/proj/other');
});

it('returns a valid GraphIndex with schemaVersion', () => {
const result = buildIndexHubOverlay('p', 'out', ['out/p/x']);
expect(result.schemaVersion).toBe('team-wiki.graph-index.v1');
expect(result.generatedAt).toBeTruthy();
});
});

// ---------------------------------------------------------------------------
// doc-graph-extractor
// ---------------------------------------------------------------------------

describe('extractDocStructure', () => {
it('creates a page node with given slug and title', () => {
const result = extractDocStructure('# Hello\n\nContent', 'docs/hello', 'docs/hello.md');
const pageNode = result.nodes.find(n => n.slug === 'docs/hello');
expect(pageNode).toBeDefined();
expect(pageNode!.type).toBe('source');
});

it('extracts h2/h3 headings as section nodes with CONTAINS edges', () => {
const content = '## Overview\n\nSome text\n\n### Details\n\nMore';
const result = extractDocStructure(content, 'docs/page', 'docs/page.md');
const sectionNodes = result.nodes.filter(n => n.slug.includes('#'));
expect(sectionNodes.length).toBe(2);
const containsEdges = result.edges.filter(e => e.relation === 'CONTAINS');
expect(containsEdges.length).toBe(2);
});

it('extracts wiki links as REFERENCES edges', () => {
const content = 'See [[other-page]] for more.';
const result = extractDocStructure(content, 'docs/page', 'docs/page.md');
const refEdge = result.edges.find(e => e.relation === 'REFERENCES');
expect(refEdge).toBeDefined();
expect(refEdge!.from).toBe('docs/page');
});

it('deduplicates wiki links pointing to the same target', () => {
const content = 'See [[shared]] and also [[shared]].';
const result = extractDocStructure(content, 'docs/page', 'docs/page.md');
const refEdges = result.edges.filter(e => e.relation === 'REFERENCES');
expect(refEdges.length).toBe(1);
});

it('skips self-referencing wiki links', () => {
const content = '[[page]] self link';
const result = extractDocStructure(content, 'page', 'page.md');
const selfEdge = result.edges.find(e => e.to === 'page' && e.relation === 'REFERENCES');
expect(selfEdge).toBeUndefined();
});

it('respects pageCategory and domain options', () => {
const result = extractDocStructure('content', 'slug', 'file.md', {
pageCategory: 'component',
domain: 'infra',
pageTitle: 'My Page',
});
const pageNode = result.nodes[0];
expect(pageNode.type).toBe('component');
expect(pageNode.domain).toBe('infra');
expect(pageNode.title).toBe('My Page');
});

it('deduplicates duplicate heading slugs with numeric suffix', () => {
const content = '## Intro\n\ntext\n\n## Intro\n\nmore';
const result = extractDocStructure(content, 'p', 'p.md');
const sectionSlugs = result.nodes.filter(n => n.slug.includes('#')).map(n => n.slug);
expect(new Set(sectionSlugs).size).toBe(sectionSlugs.length);
expect(sectionSlugs.some(s => s.includes('-2'))).toBe(true);
});
});

describe('extractDocEntities', () => {
it('extracts HTTP API endpoints as interface nodes', () => {
const content = 'Call GET /v1/users to list users.';
const result = extractDocEntities(content, 'docs/api', 'docs/api.md');
const apiNode = result.nodes.find(n => n.type === 'interface');
expect(apiNode).toBeDefined();
expect(apiNode!.slug).toContain('api:');
});

it('extracts error codes', () => {
const content = 'Returns Err40001 on invalid input.';
const result = extractDocEntities(content, 'docs/errors', 'docs/errors.md');
const errNode = result.nodes.find(n => n.type === 'error');
expect(errNode).toBeDefined();
expect(errNode!.title).toBe('Err40001');
});

it('extracts config keys from backtick constants', () => {
const content = 'Set `MAX_RETRY` to control retries.';
const result = extractDocEntities(content, 'docs/config', 'docs/config.md');
const cfgNode = result.nodes.find(n => n.type === 'config');
expect(cfgNode).toBeDefined();
});

it('deduplicates repeated API mentions — one node, one edge', () => {
const content = 'GET /v1/items and GET /v1/items again.';
const result = extractDocEntities(content, 'docs/p', 'docs/p.md');
const apiNodes = result.nodes.filter(n => n.type === 'interface');
expect(apiNodes.length).toBe(1);
});

it('returns empty nodes for plain prose with no patterns', () => {
const content = 'Just some plain text without any special patterns.';
const result = extractDocEntities(content, 'docs/plain', 'docs/plain.md');
expect(result.nodes).toHaveLength(0);
});
});

describe('wikiLinkToPageSlug', () => {
it('strips leading slashes and .md extension', () => {
expect(wikiLinkToPageSlug('/docs/guide.md')).toBe('guide');
});

it('returns slugified last segment of a path link', () => {
expect(wikiLinkToPageSlug('folder/My Page')).toBe('my-page');
});
});

describe('entitySlugFor', () => {
it('returns doc-entity:<kind>:<normalized-anchor>', () => {
expect(entitySlugFor('api', 'GET /v1/users')).toBe('doc-entity:api:get-v1-users');
});

it('handles empty anchor with unknown fallback', () => {
expect(entitySlugFor('config', '---')).toBe('doc-entity:config:unknown');
});
});
Loading
Loading