feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for: - projects-meta-mcp v2.25.0 (TypeScript/Node) - wiki-graph v0.3.1 (TypeScript/Node) - interns-mcp v0.3.3 (Python/FastMCP) .gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc) bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
59
lib/wiki-graph/tests/cache.test.ts
Normal file
59
lib/wiki-graph/tests/cache.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync, utimesSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { GraphCache } from '../src/cache.js';
|
||||
|
||||
describe('GraphCache (mtime revalidation)', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'wiki-graph-cache-'));
|
||||
writeFileSync(join(dir, 'a.md'), '# A\n[[b]]\n');
|
||||
writeFileSync(join(dir, 'b.md'), '# B\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns the same graph instance when nothing changed', () => {
|
||||
const cache = new GraphCache();
|
||||
const g1 = cache.load(dir);
|
||||
const g2 = cache.load(dir);
|
||||
expect(g2).toBe(g1);
|
||||
expect(g1.stats().nodes).toBe(2);
|
||||
});
|
||||
|
||||
test('rebuilds when a new file appears', () => {
|
||||
const cache = new GraphCache();
|
||||
const g1 = cache.load(dir);
|
||||
writeFileSync(join(dir, 'c.md'), '# C\n');
|
||||
const g2 = cache.load(dir);
|
||||
expect(g2).not.toBe(g1);
|
||||
expect(g2.stats().nodes).toBe(3);
|
||||
});
|
||||
|
||||
test('rebuilds when a file is modified (mtime bump)', () => {
|
||||
const cache = new GraphCache();
|
||||
const g1 = cache.load(dir);
|
||||
expect(g1.backlinks('c')).toEqual([]);
|
||||
writeFileSync(join(dir, 'a.md'), '# A\n[[b]] [[c]]\n');
|
||||
writeFileSync(join(dir, 'c.md'), '# C\n');
|
||||
const future = new Date(Date.now() + 5000);
|
||||
utimesSync(join(dir, 'a.md'), future, future);
|
||||
const g2 = cache.load(dir);
|
||||
expect(g2).not.toBe(g1);
|
||||
expect(g2.backlinks('c')).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('only changed files are re-read', () => {
|
||||
const cache = new GraphCache();
|
||||
cache.load(dir);
|
||||
writeFileSync(join(dir, 'c.md'), '# C\n');
|
||||
const before = cache.reads;
|
||||
cache.load(dir);
|
||||
// a.md and b.md unchanged → only c.md read on the second build
|
||||
expect(cache.reads - before).toBe(1);
|
||||
});
|
||||
});
|
||||
3
lib/wiki-graph/tests/fixtures/wiki/concepts/a.md
vendored
Normal file
3
lib/wiki-graph/tests/fixtures/wiki/concepts/a.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# A
|
||||
|
||||
Points to [[b]].
|
||||
3
lib/wiki-graph/tests/fixtures/wiki/concepts/b.md
vendored
Normal file
3
lib/wiki-graph/tests/fixtures/wiki/concepts/b.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# B
|
||||
|
||||
Points to [[c]].
|
||||
3
lib/wiki-graph/tests/fixtures/wiki/concepts/c.md
vendored
Normal file
3
lib/wiki-graph/tests/fixtures/wiki/concepts/c.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# C
|
||||
|
||||
Leaf page, links nowhere.
|
||||
3
lib/wiki-graph/tests/fixtures/wiki/concepts/d.md
vendored
Normal file
3
lib/wiki-graph/tests/fixtures/wiki/concepts/d.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# D
|
||||
|
||||
Also points to [[c]] and a placeholder [[<todo-module>]].
|
||||
3
lib/wiki-graph/tests/fixtures/wiki/concepts/e.md
vendored
Normal file
3
lib/wiki-graph/tests/fixtures/wiki/concepts/e.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# E
|
||||
|
||||
Orphan: no outgoing links, nobody links here.
|
||||
4
lib/wiki-graph/tests/fixtures/wiki/raw/a.md
vendored
Normal file
4
lib/wiki-graph/tests/fixtures/wiki/raw/a.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# A (raw provenance copy)
|
||||
|
||||
Verbatim ingested doc. Shares basename with concepts/a.md and links [[e]]
|
||||
directly — this edge must NOT pollute the canonical graph.
|
||||
4
lib/wiki-graph/tests/fixtures/wiki/sources/ghost.md
vendored
Normal file
4
lib/wiki-graph/tests/fixtures/wiki/sources/ghost.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Ghost source
|
||||
|
||||
A source-layer summary page that must not appear as a graph node by default.
|
||||
Links [[a]].
|
||||
102
lib/wiki-graph/tests/graph.test.ts
Normal file
102
lib/wiki-graph/tests/graph.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { buildGraphFromDir, buildGraph } from '../src/graph.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = join(here, 'fixtures', 'wiki');
|
||||
|
||||
describe('WikiGraph over a fixture corpus', () => {
|
||||
const g = buildGraphFromDir(FIXTURE);
|
||||
|
||||
test('path: direct edge', () => {
|
||||
expect(g.path('a', 'b')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('path: two hops', () => {
|
||||
expect(g.path('a', 'c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('path: undirected — walks edges against their direction', () => {
|
||||
expect(g.path('c', 'a')).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
test('path: no path returns empty', () => {
|
||||
expect(g.path('a', 'e')).toEqual([]);
|
||||
});
|
||||
|
||||
test('neighbors: directed, depth 1', () => {
|
||||
expect(g.neighbors('a', 1)).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('neighbors: directed, depth 2 accumulates', () => {
|
||||
expect(g.neighbors('a', 2)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
test('backlinks: incoming edges, sorted', () => {
|
||||
expect(g.backlinks('c')).toEqual(['b', 'd']);
|
||||
});
|
||||
|
||||
test('orphans: unlinked pages and dangling targets', () => {
|
||||
expect(g.orphans()).toEqual({ pages: ['e'], dangling: ['<todo-module>'] });
|
||||
});
|
||||
|
||||
test('stats: counts', () => {
|
||||
expect(g.stats()).toEqual({
|
||||
nodes: 5,
|
||||
edges: 3,
|
||||
components: 2,
|
||||
dangling: 1,
|
||||
collisions: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('provenance-dir exclusion', () => {
|
||||
test('by default raw/ sources/ assets/ are excluded — canonical graph stays clean', () => {
|
||||
const g = buildGraphFromDir(FIXTURE);
|
||||
const s = g.stats();
|
||||
expect(s.nodes).toBe(5); // a,b,c,d,e — ghost (sources) and raw/a ignored
|
||||
expect(s.collisions).toBe(0); // raw/a would collide with concepts/a if indexed
|
||||
expect(g.path('a', 'e')).toEqual([]); // raw/a's a→e edge must not leak in
|
||||
expect(g.neighbors('a', 2)).not.toContain('ghost');
|
||||
});
|
||||
|
||||
test('exclude:[] indexes everything (provenance leaks in)', () => {
|
||||
const g = buildGraphFromDir(FIXTURE, { exclude: [] });
|
||||
const s = g.stats();
|
||||
expect(s.nodes).toBe(6); // + ghost
|
||||
expect(s.collisions).toBe(1); // a appears in concepts/ and raw/
|
||||
expect(g.path('a', 'e')).toEqual(['a', 'e']); // raw/a injected the direct edge
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangling case-normalization', () => {
|
||||
test('case-variant unresolved targets dedupe to one dangling', () => {
|
||||
const g = buildGraph([
|
||||
{ slug: 'p1', dir: 'concepts', content: 'link [[Foo]]' },
|
||||
{ slug: 'p2', dir: 'concepts', content: 'link [[foo]]' },
|
||||
]);
|
||||
// resolution is case-insensitive, so dangling must be too — both are the same missing page
|
||||
expect(g.stats().dangling).toBe(1);
|
||||
expect(g.orphans().dangling).toEqual(['foo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basename collision', () => {
|
||||
const g = buildGraph([
|
||||
{ slug: 'clock', dir: 'concepts', content: '# Clock concept' },
|
||||
{ slug: 'clock', dir: 'entities', content: '# Clock entity' },
|
||||
{ slug: 'uses', dir: 'concepts', content: 'see [[clock]]' },
|
||||
]);
|
||||
|
||||
test('two files sharing a basename collapse to one node and count as a collision', () => {
|
||||
const s = g.stats();
|
||||
expect(s.nodes).toBe(2);
|
||||
expect(s.collisions).toBe(1);
|
||||
});
|
||||
|
||||
test('a link to a colliding slug still resolves (edge created)', () => {
|
||||
expect(g.backlinks('clock')).toEqual(['uses']);
|
||||
});
|
||||
});
|
||||
34
lib/wiki-graph/tests/parser.test.ts
Normal file
34
lib/wiki-graph/tests/parser.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { extractLinks } from '../src/parser.js';
|
||||
|
||||
describe('extractLinks', () => {
|
||||
test('extracts a simple [[slug]] link', () => {
|
||||
const links = extractLinks('see [[clock-modulation]] for details');
|
||||
expect(links).toEqual([{ target: 'clock-modulation', alias: null }]);
|
||||
});
|
||||
|
||||
test('takes left part of [[slug|alias]]', () => {
|
||||
const links = extractLinks('[[euclidean-rhythms|Euclidean Rhythms]]');
|
||||
expect(links).toEqual([
|
||||
{ target: 'euclidean-rhythms', alias: 'Euclidean Rhythms' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('extracts multiple links across the document', () => {
|
||||
const links = extractLinks('[[a]] then\nsome text [[b|B]] and [[c]]');
|
||||
expect(links).toEqual([
|
||||
{ target: 'a', alias: null },
|
||||
{ target: 'b', alias: 'B' },
|
||||
{ target: 'c', alias: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps placeholder targets verbatim (resolver decides dangling)', () => {
|
||||
const links = extractLinks('TODO link [[<module-name>]]');
|
||||
expect(links).toEqual([{ target: '<module-name>', alias: null }]);
|
||||
});
|
||||
|
||||
test('returns empty for content with no wikilinks', () => {
|
||||
expect(extractLinks('plain text, a [single] bracket, no links')).toEqual([]);
|
||||
});
|
||||
});
|
||||
46
lib/wiki-graph/tests/resolver.test.ts
Normal file
46
lib/wiki-graph/tests/resolver.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildSlugIndex, resolveTarget, type FileRef } from '../src/resolver.js';
|
||||
|
||||
const refs: FileRef[] = [
|
||||
{ slug: 'clock-modulation', dir: 'concepts' },
|
||||
{ slug: 'euclidean-rhythms', dir: 'concepts' },
|
||||
{ slug: 'clock', dir: 'concepts' },
|
||||
{ slug: 'clock', dir: 'entities' },
|
||||
];
|
||||
|
||||
describe('resolveTarget', () => {
|
||||
const index = buildSlugIndex(refs);
|
||||
|
||||
test('resolves an exact slug to its node id', () => {
|
||||
expect(resolveTarget('euclidean-rhythms', 'concepts', index)).toEqual({
|
||||
node: 'euclidean-rhythms',
|
||||
collision: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('matches case-insensitively, normalizing node id to lowercase', () => {
|
||||
expect(resolveTarget('Clock-Modulation', 'concepts', index)).toEqual({
|
||||
node: 'clock-modulation',
|
||||
collision: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for an unresolved (dangling) target', () => {
|
||||
expect(resolveTarget('<todo-module>', 'concepts', index)).toBeNull();
|
||||
expect(resolveTarget('does-not-exist', 'concepts', index)).toBeNull();
|
||||
});
|
||||
|
||||
test('on basename collision prefers same-dir and flags collision', () => {
|
||||
expect(resolveTarget('clock', 'entities', index)).toEqual({
|
||||
node: 'clock',
|
||||
collision: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('on collision with no same-dir match still resolves and flags collision', () => {
|
||||
expect(resolveTarget('clock', 'packages', index)).toEqual({
|
||||
node: 'clock',
|
||||
collision: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
81
lib/wiki-graph/tests/tools.test.ts
Normal file
81
lib/wiki-graph/tests/tools.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { makeWikiGraphTools } from '../src/tools.js';
|
||||
import { GraphCache } from '../src/cache.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = join(here, 'fixtures', 'wiki');
|
||||
|
||||
function toolMap() {
|
||||
const tools = makeWikiGraphTools(new GraphCache());
|
||||
return new Map(tools.map((t) => [t.name, t]));
|
||||
}
|
||||
|
||||
function textOf(res: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
return res.content.map((c) => c.text).join('\n');
|
||||
}
|
||||
|
||||
describe('wiki-graph MCP tools', () => {
|
||||
const tools = toolMap();
|
||||
|
||||
test('exposes exactly the five v1 tools', () => {
|
||||
expect([...tools.keys()].sort()).toEqual([
|
||||
'backlinks',
|
||||
'neighbors',
|
||||
'orphans',
|
||||
'path',
|
||||
'stats',
|
||||
]);
|
||||
});
|
||||
|
||||
test('every tool requires a corpus argument', () => {
|
||||
for (const t of tools.values()) {
|
||||
expect(t.inputSchema.required).toContain('corpus');
|
||||
}
|
||||
});
|
||||
|
||||
test('path: returns the chain', () => {
|
||||
const res = tools.get('path')!.handler({ corpus: FIXTURE, from: 'a', to: 'c' });
|
||||
expect(textOf(res)).toContain('a → b → c');
|
||||
});
|
||||
|
||||
test('path: reports absence', () => {
|
||||
const res = tools.get('path')!.handler({ corpus: FIXTURE, from: 'a', to: 'e' });
|
||||
expect(textOf(res).toLowerCase()).toContain('no path');
|
||||
});
|
||||
|
||||
test('neighbors: honors depth', () => {
|
||||
const res = tools.get('neighbors')!.handler({ corpus: FIXTURE, node: 'a', depth: 2 });
|
||||
const txt = textOf(res);
|
||||
expect(txt).toContain('b');
|
||||
expect(txt).toContain('c');
|
||||
});
|
||||
|
||||
test('backlinks: lists incoming', () => {
|
||||
const res = tools.get('backlinks')!.handler({ corpus: FIXTURE, node: 'c' });
|
||||
const txt = textOf(res);
|
||||
expect(txt).toContain('b');
|
||||
expect(txt).toContain('d');
|
||||
});
|
||||
|
||||
test('orphans: lists unlinked pages and dangling targets', () => {
|
||||
const res = tools.get('orphans')!.handler({ corpus: FIXTURE });
|
||||
const txt = textOf(res);
|
||||
expect(txt).toContain('e');
|
||||
expect(txt).toContain('<todo-module>');
|
||||
});
|
||||
|
||||
test('stats: reports counts', () => {
|
||||
const res = tools.get('stats')!.handler({ corpus: FIXTURE });
|
||||
const txt = textOf(res);
|
||||
expect(txt).toContain('5');
|
||||
expect(txt).toContain('nodes');
|
||||
});
|
||||
|
||||
test('missing corpus is a clean error, not a throw', () => {
|
||||
const res = tools.get('stats')!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(textOf(res).toLowerCase()).toContain('corpus');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user