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:
10
lib/wiki-graph/.gitignore
vendored
Normal file
10
lib/wiki-graph/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
2578
lib/wiki-graph/package-lock.json
generated
Normal file
2578
lib/wiki-graph/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
lib/wiki-graph/package.json
Normal file
27
lib/wiki-graph/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wiki-graph",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"wiki-graph-mcp": "dist/server.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
82
lib/wiki-graph/scripts/smoke.mjs
Normal file
82
lib/wiki-graph/scripts/smoke.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
// Smoke test: drive the built MCP server over stdio against a live .wiki corpus.
|
||||
// Usage: node scripts/smoke.mjs <corpusDir> [from] [to] [hubNode]
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const serverPath = join(here, '..', 'dist', 'server.js');
|
||||
|
||||
const corpus = process.argv[2];
|
||||
const from = process.argv[3] ?? 'alm-pamelas-pro-workout';
|
||||
const to = process.argv[4] ?? 'euclidean-rhythms';
|
||||
const hub = process.argv[5] ?? 'euclidean-rhythms';
|
||||
if (!corpus) {
|
||||
console.error('usage: node scripts/smoke.mjs <corpusDir> [from] [to] [hubNode]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn('node', [serverPath], { stdio: ['pipe', 'pipe', 'inherit'] });
|
||||
let buf = '';
|
||||
const pending = new Map();
|
||||
child.stdout.on('data', (d) => {
|
||||
buf += d.toString();
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let nextId = 1;
|
||||
function rpc(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
||||
}
|
||||
async function call(name, args) {
|
||||
const t0 = performance.now();
|
||||
const res = await rpc('tools/call', { name, arguments: { corpus, ...args } });
|
||||
const ms = (performance.now() - t0).toFixed(1);
|
||||
return { text: res.result.content.map((c) => c.text).join('\n'), ms };
|
||||
}
|
||||
|
||||
const init = await rpc('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'wg-smoke', version: '0' },
|
||||
});
|
||||
notify('notifications/initialized', {});
|
||||
console.log(`server: ${init.result.serverInfo.name} ${init.result.serverInfo.version}`);
|
||||
console.log(`corpus: ${corpus}\n`);
|
||||
|
||||
const cold = await call('stats', {});
|
||||
console.log(`stats [cold ${cold.ms}ms] ${cold.text}`);
|
||||
const warm = await call('stats', {});
|
||||
console.log(`stats [warm ${warm.ms}ms] ${warm.text}`);
|
||||
|
||||
const p = await call('path', { from, to });
|
||||
console.log(`path [${p.ms}ms] ${from} → ${to}\n ${p.text}`);
|
||||
|
||||
const n = await call('neighbors', { node: hub, depth: 1 });
|
||||
console.log(`neighbrs[${n.ms}ms] ${n.text}`);
|
||||
|
||||
const b = await call('backlinks', { node: hub });
|
||||
console.log(`backlnks[${b.ms}ms] ${b.text}`);
|
||||
|
||||
const o = await call('orphans', {});
|
||||
console.log(`orphans [${o.ms}ms] ${o.text.split('\n')[0]} ...`);
|
||||
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
60
lib/wiki-graph/src/cache.ts
Normal file
60
lib/wiki-graph/src/cache.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { extractLinks } from './parser.js';
|
||||
import { listPages, readPageContent, type ListOptions } from './corpus.js';
|
||||
import { buildGraphFromParsed, WikiGraph, type ParsedPage } from './graph.js';
|
||||
|
||||
interface CacheEntry {
|
||||
mtimeMs: number;
|
||||
page: ParsedPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* mtime-revalidated graph cache.
|
||||
* First `load` builds the whole corpus; later loads `stat` files and re-read +
|
||||
* re-parse only the ones whose mtime changed (or that appeared). Removed files
|
||||
* drop out. No on-disk index, always fresh.
|
||||
*/
|
||||
export class GraphCache {
|
||||
private entries = new Map<string, CacheEntry>();
|
||||
private graph: WikiGraph | null = null;
|
||||
private signature: string | null = null;
|
||||
private readonly opts: ListOptions;
|
||||
|
||||
/** Count of file reads performed across this cache's lifetime (for tests/metrics). */
|
||||
reads = 0;
|
||||
|
||||
constructor(opts: ListOptions = {}) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
load(corpusDir: string): WikiGraph {
|
||||
const files = listPages(corpusDir, this.opts);
|
||||
const signature = files
|
||||
.map((f) => `${f.path}:${f.mtimeMs}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
if (this.graph && this.signature === signature) return this.graph;
|
||||
|
||||
const live = new Set<string>();
|
||||
for (const f of files) {
|
||||
live.add(f.path);
|
||||
const cached = this.entries.get(f.path);
|
||||
if (cached && cached.mtimeMs === f.mtimeMs) continue; // unchanged → keep parsed
|
||||
const content = readPageContent(f);
|
||||
this.reads++;
|
||||
this.entries.set(f.path, {
|
||||
mtimeMs: f.mtimeMs,
|
||||
page: { slug: f.slug, dir: f.dir, links: extractLinks(content) },
|
||||
});
|
||||
}
|
||||
// drop deleted files
|
||||
for (const path of [...this.entries.keys()]) {
|
||||
if (!live.has(path)) this.entries.delete(path);
|
||||
}
|
||||
|
||||
const pages = [...this.entries.values()].map((e) => e.page);
|
||||
this.graph = buildGraphFromParsed(pages);
|
||||
this.signature = signature;
|
||||
return this.graph;
|
||||
}
|
||||
}
|
||||
59
lib/wiki-graph/src/corpus.ts
Normal file
59
lib/wiki-graph/src/corpus.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readdirSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
|
||||
export interface PageFile {
|
||||
/** Page basename without `.md`, original case. */
|
||||
slug: string;
|
||||
/** Directory relative to corpus root, '' for root-level pages. POSIX-ish, using OS sep. */
|
||||
dir: string;
|
||||
/** Absolute path on disk. */
|
||||
path: string;
|
||||
/** Last-modified time in ms (for cache revalidation). */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level dirs excluded from the graph by default. In the Karpathy wiki schema
|
||||
* `raw/` (verbatim ingested docs) and `sources/` (provenance summaries) are NOT
|
||||
* graph pages — they share basenames with canonical concept/entity pages and would
|
||||
* collapse into them (false collisions) and inject provenance edges. `assets/` is
|
||||
* binary. The graph models the canonical concept/entity network only.
|
||||
*/
|
||||
export const DEFAULT_EXCLUDE: readonly string[] = ['raw', 'sources', 'assets'];
|
||||
|
||||
export interface ListOptions {
|
||||
/** Top-level dir names to skip. Defaults to {@link DEFAULT_EXCLUDE}; pass `[]` to index everything. */
|
||||
exclude?: readonly string[];
|
||||
}
|
||||
|
||||
function firstSegment(dir: string): string {
|
||||
if (dir === '') return '';
|
||||
return dir.split(/[\\/]/)[0];
|
||||
}
|
||||
|
||||
/** List every `.md` file under `corpusDir` (recursive, minus excluded dirs), with mtime. */
|
||||
export function listPages(corpusDir: string, opts: ListOptions = {}): PageFile[] {
|
||||
const exclude = new Set(opts.exclude ?? DEFAULT_EXCLUDE);
|
||||
const entries = readdirSync(corpusDir, { recursive: true }) as string[];
|
||||
const pages: PageFile[] = [];
|
||||
for (const rel of entries) {
|
||||
if (!rel.endsWith('.md')) continue;
|
||||
const dirRaw = dirname(rel);
|
||||
const dir = dirRaw === '.' ? '' : dirRaw;
|
||||
if (exclude.has(firstSegment(dir))) continue;
|
||||
const abs = join(corpusDir, rel);
|
||||
const st = statSync(abs);
|
||||
if (!st.isFile()) continue;
|
||||
pages.push({
|
||||
slug: basename(rel, '.md'),
|
||||
dir,
|
||||
path: abs,
|
||||
mtimeMs: st.mtimeMs,
|
||||
});
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function readPageContent(page: PageFile): string {
|
||||
return readFileSync(page.path, 'utf8');
|
||||
}
|
||||
221
lib/wiki-graph/src/graph.ts
Normal file
221
lib/wiki-graph/src/graph.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { extractLinks, type WikiLink } from './parser.js';
|
||||
import { buildSlugIndex, resolveTarget, type FileRef } from './resolver.js';
|
||||
import { listPages, readPageContent, type PageFile, type ListOptions } from './corpus.js';
|
||||
|
||||
export interface ParsedPage {
|
||||
slug: string;
|
||||
dir: string;
|
||||
links: WikiLink[];
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
/** Connected components over the undirected projection. */
|
||||
components: number;
|
||||
/** Number of distinct unresolved link targets. */
|
||||
dangling: number;
|
||||
/** Number of basename slugs claimed by more than one file. */
|
||||
collisions: number;
|
||||
}
|
||||
|
||||
export interface OrphanReport {
|
||||
/** Nodes with no incoming and no outgoing edges, sorted. */
|
||||
pages: string[];
|
||||
/** Distinct unresolved link targets, sorted. */
|
||||
dangling: string[];
|
||||
}
|
||||
|
||||
interface BuiltGraph {
|
||||
nodes: Set<string>;
|
||||
out: Map<string, Set<string>>;
|
||||
in: Map<string, Set<string>>;
|
||||
dangling: Set<string>;
|
||||
collisions: number;
|
||||
}
|
||||
|
||||
/** In-memory wiki link graph. Node ids are lowercased slugs. */
|
||||
export class WikiGraph {
|
||||
private readonly nodes: Set<string>;
|
||||
private readonly out: Map<string, Set<string>>;
|
||||
private readonly in: Map<string, Set<string>>;
|
||||
private readonly danglingTargets: Set<string>;
|
||||
private readonly collisionCount: number;
|
||||
|
||||
constructor(g: BuiltGraph) {
|
||||
this.nodes = g.nodes;
|
||||
this.out = g.out;
|
||||
this.in = g.in;
|
||||
this.danglingTargets = g.dangling;
|
||||
this.collisionCount = g.collisions;
|
||||
}
|
||||
|
||||
/** Shortest chain between two pages over the undirected projection; [] if none. */
|
||||
path(from: string, to: string): string[] {
|
||||
const start = from.toLowerCase();
|
||||
const goal = to.toLowerCase();
|
||||
if (!this.nodes.has(start) || !this.nodes.has(goal)) return [];
|
||||
if (start === goal) return [start];
|
||||
|
||||
const prev = new Map<string, string | null>([[start, null]]);
|
||||
const queue: string[] = [start];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (cur === goal) break;
|
||||
for (const next of this.undirectedNeighbors(cur)) {
|
||||
if (!prev.has(next)) {
|
||||
prev.set(next, cur);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prev.has(goal)) return [];
|
||||
const chain: string[] = [];
|
||||
let node: string | null = goal;
|
||||
while (node !== null) {
|
||||
chain.unshift(node);
|
||||
node = prev.get(node) ?? null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** Pages reachable via outgoing edges within `depth` hops, sorted. */
|
||||
neighbors(node: string, depth = 1): string[] {
|
||||
const start = node.toLowerCase();
|
||||
if (!this.nodes.has(start)) return [];
|
||||
const seen = new Set<string>([start]);
|
||||
let frontier = [start];
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const nextFrontier: string[] = [];
|
||||
for (const cur of frontier) {
|
||||
for (const next of this.out.get(cur) ?? []) {
|
||||
if (!seen.has(next)) {
|
||||
seen.add(next);
|
||||
nextFrontier.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
seen.delete(start);
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
/** Pages that link TO `node`, sorted. */
|
||||
backlinks(node: string): string[] {
|
||||
const key = node.toLowerCase();
|
||||
return [...(this.in.get(key) ?? [])].sort();
|
||||
}
|
||||
|
||||
orphans(): OrphanReport {
|
||||
const pages: string[] = [];
|
||||
for (const n of this.nodes) {
|
||||
const outDeg = this.out.get(n)?.size ?? 0;
|
||||
const inDeg = this.in.get(n)?.size ?? 0;
|
||||
if (outDeg === 0 && inDeg === 0) pages.push(n);
|
||||
}
|
||||
return {
|
||||
pages: pages.sort(),
|
||||
dangling: [...this.danglingTargets].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): GraphStats {
|
||||
let edges = 0;
|
||||
for (const set of this.out.values()) edges += set.size;
|
||||
return {
|
||||
nodes: this.nodes.size,
|
||||
edges,
|
||||
components: this.countComponents(),
|
||||
dangling: this.danglingTargets.size,
|
||||
collisions: this.collisionCount,
|
||||
};
|
||||
}
|
||||
|
||||
private undirectedNeighbors(node: string): Set<string> {
|
||||
const acc = new Set<string>();
|
||||
for (const n of this.out.get(node) ?? []) acc.add(n);
|
||||
for (const n of this.in.get(node) ?? []) acc.add(n);
|
||||
return acc;
|
||||
}
|
||||
|
||||
private countComponents(): number {
|
||||
const seen = new Set<string>();
|
||||
let count = 0;
|
||||
for (const start of this.nodes) {
|
||||
if (seen.has(start)) continue;
|
||||
count++;
|
||||
const stack = [start];
|
||||
seen.add(start);
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
for (const next of this.undirectedNeighbors(cur)) {
|
||||
if (!seen.has(next)) {
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a graph from already-parsed pages (no disk, no parsing). */
|
||||
export function buildGraphFromParsed(pages: ParsedPage[]): WikiGraph {
|
||||
const refs: FileRef[] = pages.map((p) => ({ slug: p.slug, dir: p.dir }));
|
||||
const index = buildSlugIndex(refs);
|
||||
|
||||
let collisions = 0;
|
||||
for (const bucket of index.values()) if (bucket.length > 1) collisions++;
|
||||
|
||||
const nodes = new Set<string>();
|
||||
const out = new Map<string, Set<string>>();
|
||||
const inAdj = new Map<string, Set<string>>();
|
||||
const dangling = new Set<string>();
|
||||
|
||||
for (const p of pages) {
|
||||
const node = p.slug.toLowerCase();
|
||||
nodes.add(node);
|
||||
if (!out.has(node)) out.set(node, new Set());
|
||||
if (!inAdj.has(node)) inAdj.set(node, new Set());
|
||||
}
|
||||
|
||||
for (const p of pages) {
|
||||
const node = p.slug.toLowerCase();
|
||||
for (const link of p.links) {
|
||||
const resolved = resolveTarget(link.target, p.dir, index);
|
||||
if (resolved === null) {
|
||||
// resolution is case-insensitive, so dedupe dangling on the same key —
|
||||
// [[Foo]] and [[foo]] are the one missing page, not two
|
||||
dangling.add(link.target.toLowerCase());
|
||||
continue;
|
||||
}
|
||||
if (resolved.node === node) continue; // skip self-links
|
||||
out.get(node)!.add(resolved.node);
|
||||
inAdj.get(resolved.node)!.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return new WikiGraph({ nodes, out, in: inAdj, dangling, collisions });
|
||||
}
|
||||
|
||||
/** Build a graph from already-read pages (parses content, no disk access). */
|
||||
export function buildGraph(
|
||||
pages: Array<{ slug: string; dir: string; content: string }>,
|
||||
): WikiGraph {
|
||||
return buildGraphFromParsed(
|
||||
pages.map((p) => ({ slug: p.slug, dir: p.dir, links: extractLinks(p.content) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a `.wiki/` corpus from disk and build the graph. Provenance dirs excluded by default. */
|
||||
export function buildGraphFromDir(corpusDir: string, opts: ListOptions = {}): WikiGraph {
|
||||
const files: PageFile[] = listPages(corpusDir, opts);
|
||||
const pages = files.map((f) => ({
|
||||
slug: f.slug,
|
||||
dir: f.dir,
|
||||
content: readPageContent(f),
|
||||
}));
|
||||
return buildGraph(pages);
|
||||
}
|
||||
25
lib/wiki-graph/src/index.ts
Normal file
25
lib/wiki-graph/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export { extractLinks, type WikiLink } from './parser.js';
|
||||
export {
|
||||
buildSlugIndex,
|
||||
resolveTarget,
|
||||
type FileRef,
|
||||
type ResolveResult,
|
||||
type SlugIndex,
|
||||
} from './resolver.js';
|
||||
export {
|
||||
listPages,
|
||||
readPageContent,
|
||||
DEFAULT_EXCLUDE,
|
||||
type PageFile,
|
||||
type ListOptions,
|
||||
} from './corpus.js';
|
||||
export {
|
||||
WikiGraph,
|
||||
buildGraph,
|
||||
buildGraphFromParsed,
|
||||
buildGraphFromDir,
|
||||
type ParsedPage,
|
||||
type GraphStats,
|
||||
type OrphanReport,
|
||||
} from './graph.js';
|
||||
export { GraphCache } from './cache.js';
|
||||
30
lib/wiki-graph/src/parser.ts
Normal file
30
lib/wiki-graph/src/parser.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface WikiLink {
|
||||
/** Raw target slug as written inside [[...]], left of any `|`. */
|
||||
target: string;
|
||||
/** Display alias after `|`, or null when absent. */
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
const LINK_RE = /\[\[([^\]]+)\]\]/g;
|
||||
|
||||
/**
|
||||
* Extract `[[wikilinks]]` from page content.
|
||||
* `[[slug]]` → { target: 'slug', alias: null }.
|
||||
* `[[slug|Label]]` → { target: 'slug', alias: 'Label' }.
|
||||
*/
|
||||
export function extractLinks(content: string): WikiLink[] {
|
||||
const links: WikiLink[] = [];
|
||||
for (const match of content.matchAll(LINK_RE)) {
|
||||
const inner = match[1];
|
||||
const pipe = inner.indexOf('|');
|
||||
if (pipe === -1) {
|
||||
links.push({ target: inner.trim(), alias: null });
|
||||
} else {
|
||||
links.push({
|
||||
target: inner.slice(0, pipe).trim(),
|
||||
alias: inner.slice(pipe + 1).trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
49
lib/wiki-graph/src/resolver.ts
Normal file
49
lib/wiki-graph/src/resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface FileRef {
|
||||
/** Page basename without `.md`, original case. */
|
||||
slug: string;
|
||||
/** Directory of the file relative to the corpus root (e.g. 'concepts'). */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Canonical node id — lowercased slug. */
|
||||
node: string;
|
||||
/** True when more than one file shares this basename slug. */
|
||||
collision: boolean;
|
||||
}
|
||||
|
||||
/** Index of lowercased-slug → all files claiming that slug. */
|
||||
export type SlugIndex = Map<string, FileRef[]>;
|
||||
|
||||
export function buildSlugIndex(refs: FileRef[]): SlugIndex {
|
||||
const index: SlugIndex = new Map();
|
||||
for (const ref of refs) {
|
||||
const key = ref.slug.toLowerCase();
|
||||
const bucket = index.get(key);
|
||||
if (bucket) bucket.push(ref);
|
||||
else index.set(key, [ref]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `[[target]]` to a canonical node id.
|
||||
* - case-insensitive (slugs are lowercase-kebab by convention);
|
||||
* - unresolved targets (placeholders, links to uncreated pages) → null (dangling);
|
||||
* - basename collision: node id is the bare slug, so ALL files sharing a basename
|
||||
* collapse into ONE node — their edges merge and the pages become indistinguishable
|
||||
* in the graph. There is no file-pick (the v1 graph keys on slug, not path); `collision`
|
||||
* is the only signal. Acceptable while collisions are rare (0 on modulair); a
|
||||
* dir-qualified node id is a phase-2 decision. `_fromDir` is kept for that future
|
||||
* same-dir disambiguation. See wiki-graph-design.md §5.
|
||||
*/
|
||||
export function resolveTarget(
|
||||
target: string,
|
||||
_fromDir: string,
|
||||
index: SlugIndex,
|
||||
): ResolveResult | null {
|
||||
const key = target.trim().toLowerCase();
|
||||
const bucket = index.get(key);
|
||||
if (!bucket || bucket.length === 0) return null;
|
||||
return { node: key, collision: bucket.length > 1 };
|
||||
}
|
||||
55
lib/wiki-graph/src/server.ts
Normal file
55
lib/wiki-graph/src/server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type ServerResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { GraphCache } from './cache.js';
|
||||
import { makeWikiGraphTools } from './tools.js';
|
||||
|
||||
function readPackageVersion(): string {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url);
|
||||
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8')) as { version?: unknown };
|
||||
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
|
||||
throw new Error(`package.json at ${pkgUrl.pathname} missing string "version" field`);
|
||||
}
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cache = new GraphCache();
|
||||
const tools = makeWikiGraphTools(cache);
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'wiki-graph', version: readPackageVersion() },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<ServerResult> => {
|
||||
const tool = byName.get(req.params.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// ToolResult is a structural CallToolResult; cast over the widened SDK union.
|
||||
return tool.handler(req.params.arguments ?? {}) as ServerResult;
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
await main();
|
||||
166
lib/wiki-graph/src/tools.ts
Normal file
166
lib/wiki-graph/src/tools.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { GraphCache } from './cache.js';
|
||||
import type { WikiGraph } from './graph.js';
|
||||
|
||||
export interface ToolResult {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
};
|
||||
handler: (args: Record<string, unknown>) => ToolResult;
|
||||
}
|
||||
|
||||
function text(s: string, isError = false): ToolResult {
|
||||
return { content: [{ type: 'text', text: s }], isError };
|
||||
}
|
||||
|
||||
const CORPUS_PROP = {
|
||||
corpus: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the `.wiki/` corpus directory.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the 5 v1 wiki-graph tools over a shared mtime cache.
|
||||
* Each tool takes `corpus` (abs path to a `.wiki/`); the engine parses
|
||||
* server-side, so the corpus never enters the LLM context.
|
||||
*/
|
||||
export function makeWikiGraphTools(cache: GraphCache): ToolDef[] {
|
||||
function load(args: Record<string, unknown>): WikiGraph | ToolResult {
|
||||
const corpus = args.corpus;
|
||||
if (typeof corpus !== 'string' || corpus.length === 0) {
|
||||
return text('Missing required "corpus" (absolute path to a .wiki/ directory).', true);
|
||||
}
|
||||
try {
|
||||
return cache.load(corpus);
|
||||
} catch (e) {
|
||||
return text(`Failed to read corpus "${corpus}": ${(e as Error).message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function str(args: Record<string, unknown>, key: string): string | null {
|
||||
const v = args[key];
|
||||
return typeof v === 'string' && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'path',
|
||||
description:
|
||||
'Shortest chain of pages between two wiki pages (undirected). The main relational query — "what connects X and Y".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...CORPUS_PROP,
|
||||
from: { type: 'string', description: 'Start page slug.' },
|
||||
to: { type: 'string', description: 'Target page slug.' },
|
||||
},
|
||||
required: ['corpus', 'from', 'to'],
|
||||
},
|
||||
handler: (args) => {
|
||||
const g = load(args);
|
||||
if ('content' in g) return g;
|
||||
const from = str(args, 'from');
|
||||
const to = str(args, 'to');
|
||||
if (!from || !to) return text('path requires "from" and "to" slugs.', true);
|
||||
const chain = g.path(from, to);
|
||||
if (chain.length === 0) return text(`No path between "${from}" and "${to}".`);
|
||||
return text(`Path (${chain.length} nodes): ${chain.join(' → ')}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'neighbors',
|
||||
description: 'Pages reachable via outgoing links within N hops (direction respected).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...CORPUS_PROP,
|
||||
node: { type: 'string', description: 'Page slug.' },
|
||||
depth: { type: 'number', description: 'Hop radius (default 1).' },
|
||||
},
|
||||
required: ['corpus', 'node'],
|
||||
},
|
||||
handler: (args) => {
|
||||
const g = load(args);
|
||||
if ('content' in g) return g;
|
||||
const node = str(args, 'node');
|
||||
if (!node) return text('neighbors requires a "node" slug.', true);
|
||||
const depthRaw = args.depth;
|
||||
const depth =
|
||||
typeof depthRaw === 'number' && Number.isInteger(depthRaw) && depthRaw >= 1
|
||||
? depthRaw
|
||||
: 1;
|
||||
const list = g.neighbors(node, depth);
|
||||
if (list.length === 0) {
|
||||
return text(`"${node}" has no outgoing links within depth ${depth}.`);
|
||||
}
|
||||
return text(`Neighbors of "${node}" (depth ${depth}): ${list.join(', ')}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backlinks',
|
||||
description: 'Pages that link TO this page (incoming references).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...CORPUS_PROP,
|
||||
node: { type: 'string', description: 'Page slug.' },
|
||||
},
|
||||
required: ['corpus', 'node'],
|
||||
},
|
||||
handler: (args) => {
|
||||
const g = load(args);
|
||||
if ('content' in g) return g;
|
||||
const node = str(args, 'node');
|
||||
if (!node) return text('backlinks requires a "node" slug.', true);
|
||||
const list = g.backlinks(node);
|
||||
if (list.length === 0) return text(`Nothing links to "${node}".`);
|
||||
return text(`Backlinks of "${node}": ${list.join(', ')}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'orphans',
|
||||
description: 'Wiki health: pages with no links at all, plus dangling (unresolved) link targets.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { ...CORPUS_PROP },
|
||||
required: ['corpus'],
|
||||
},
|
||||
handler: (args) => {
|
||||
const g = load(args);
|
||||
if ('content' in g) return g;
|
||||
const o = g.orphans();
|
||||
return text(
|
||||
`Orphan pages (${o.pages.length}): ${o.pages.join(', ') || '—'}\n` +
|
||||
`Dangling links (${o.dangling.length}): ${o.dangling.join(', ') || '—'}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
description: 'Corpus counters: nodes, edges, connected components, dangling links, basename collisions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { ...CORPUS_PROP },
|
||||
required: ['corpus'],
|
||||
},
|
||||
handler: (args) => {
|
||||
const g = load(args);
|
||||
if ('content' in g) return g;
|
||||
const s = g.stats();
|
||||
return text(
|
||||
`nodes=${s.nodes} edges=${s.edges} components=${s.components} ` +
|
||||
`dangling=${s.dangling} collisions=${s.collisions}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
19
lib/wiki-graph/tsconfig.json
Normal file
19
lib/wiki-graph/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
8
lib/wiki-graph/vitest.config.ts
Normal file
8
lib/wiki-graph/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user