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:
2026-06-11 13:17:12 +03:00
parent 9eeae13e72
commit ca669d96e1
116 changed files with 25331 additions and 0 deletions

View 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);
});
});

View File

@@ -0,0 +1,3 @@
# A
Points to [[b]].

View File

@@ -0,0 +1,3 @@
# B
Points to [[c]].

View File

@@ -0,0 +1,3 @@
# C
Leaf page, links nowhere.

View File

@@ -0,0 +1,3 @@
# D
Also points to [[c]] and a placeholder [[<todo-module>]].

View File

@@ -0,0 +1,3 @@
# E
Orphan: no outgoing links, nobody links here.

View 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.

View File

@@ -0,0 +1,4 @@
# Ghost source
A source-layer summary page that must not appear as a graph node by default.
Links [[a]].

View 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']);
});
});

View 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([]);
});
});

View 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,
});
});
});

View 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');
});
});