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>
476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { mkdtemp, mkdir } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
|
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
|
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
|
|
|
let wikiRoot: string;
|
|
let projectCwd: string;
|
|
let cacheFile: string;
|
|
|
|
beforeEach(async () => {
|
|
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
|
await mkdir(wikiRoot, { recursive: true });
|
|
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
|
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
|
});
|
|
|
|
interface FetchedPage {
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* Pages are keyed by qualified `<owner>/<repo>` so the mock backend can verify
|
|
* that handlers route fetches to the correct owner — the whole point of the
|
|
* cross-owner regression in [multi-owner-ask-projects-owner-fix].
|
|
*/
|
|
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
|
|
return {
|
|
async listUserRepos() {
|
|
return [];
|
|
},
|
|
async getRawFile(owner, repo, path) {
|
|
const repoPages = pages[`${owner}/${repo}`];
|
|
if (!repoPages) return null;
|
|
const v = repoPages[path];
|
|
if (v === undefined) return null;
|
|
if (v instanceof Error) throw v;
|
|
return v.body;
|
|
},
|
|
async getFileWithSha(): Promise<FileWithSha | null> {
|
|
return null;
|
|
},
|
|
async commitFile(_a: CommitFileArgs) {
|
|
throw new Error('not used');
|
|
},
|
|
};
|
|
}
|
|
|
|
async function seedCache(projects: CacheFile['projects']) {
|
|
const file: CacheFile = {
|
|
schemaVersion: 3,
|
|
synced_at: '2026-04-30T00:00:00Z',
|
|
synced_from: 'https://g',
|
|
machine: 'm',
|
|
projects,
|
|
errors: [],
|
|
};
|
|
await writeCache(cacheFile, file);
|
|
}
|
|
|
|
function call(
|
|
tools: ReturnType<typeof makeKnowledgeTools>,
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
) {
|
|
const t = tools.find((x) => x.name === name);
|
|
if (!t) throw new Error(`tool ${name} missing`);
|
|
return t.handler(args);
|
|
}
|
|
|
|
const sampleIndex = `# Wiki Index
|
|
|
|
## Concepts
|
|
|
|
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
|
|
- [other](concepts/other.md) — Other Concept
|
|
|
|
## Raw
|
|
|
|
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
|
|
`;
|
|
|
|
describe('knowledge.ask_projects', () => {
|
|
it('returns matches across listed projects with snippets', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': {
|
|
'.wiki/concepts/yarn-on-windows.md': {
|
|
body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly',
|
|
},
|
|
},
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'yarn',
|
|
projects: ['u/foo'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.asked_projects).toEqual(['u/foo']);
|
|
expect(parsed.results).toHaveLength(1);
|
|
expect(parsed.results[0].project).toBe('u/foo');
|
|
expect(parsed.results[0].matches).toHaveLength(1);
|
|
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
|
expect(parsed.results[0].matches[0].type).toBe('concepts');
|
|
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
|
|
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
|
|
});
|
|
|
|
it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'victor/books',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
{
|
|
name: 'OpeItcLoc03/common',
|
|
default_branch: 'master',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const calls: Array<{ owner: string; repo: string }> = [];
|
|
const backend: Backend = {
|
|
async listUserRepos() {
|
|
return [];
|
|
},
|
|
async getRawFile(owner, repo, path) {
|
|
calls.push({ owner, repo });
|
|
if (owner === 'victor' && repo === 'books') {
|
|
return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null;
|
|
}
|
|
if (owner === 'OpeItcLoc03' && repo === 'common') {
|
|
return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null;
|
|
}
|
|
// Reject acting-identity routing — proves the bug is gone.
|
|
throw new Error(`unexpected fetch ${owner}/${repo}`);
|
|
},
|
|
async getFileWithSha(): Promise<FileWithSha | null> {
|
|
return null;
|
|
},
|
|
async commitFile() {
|
|
throw new Error('not used');
|
|
},
|
|
};
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'OpeItcLoc03',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'yarn',
|
|
projects: ['victor/books', 'OpeItcLoc03/common'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results).toHaveLength(2);
|
|
const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books');
|
|
const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common');
|
|
expect(victorResult.matches[0].snippet).toContain('victor body');
|
|
expect(commonResult.matches[0].snippet).toContain('common body');
|
|
// Owner extracted from projectName, not from acting giteaUser.
|
|
expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true);
|
|
expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true);
|
|
});
|
|
|
|
it('rejects bare project name with parseTargetProject hint', async () => {
|
|
await seedCache([]);
|
|
const backend = makeBackend({});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'x',
|
|
projects: ['books'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results).toHaveLength(1);
|
|
expect(parsed.results[0].project).toBe('books');
|
|
expect(parsed.results[0].error).toContain('must be qualified');
|
|
});
|
|
|
|
it('continues with valid projects when one is bare', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': {
|
|
'.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' },
|
|
},
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'yarn',
|
|
projects: ['books', 'u/foo'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results).toHaveLength(2);
|
|
expect(parsed.results[0].error).toContain('must be qualified');
|
|
expect(parsed.results[1].matches).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects agenda literal with explanatory error', async () => {
|
|
await seedCache([]);
|
|
const backend = makeBackend({});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'x',
|
|
projects: ['agenda'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results[0].error).toContain('agenda not supported');
|
|
});
|
|
|
|
it('reports "no wiki" for projects without wiki_index', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/no-wiki',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
]);
|
|
const backend = makeBackend({});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'anything',
|
|
projects: ['u/no-wiki'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results).toEqual([
|
|
{ project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
|
|
]);
|
|
});
|
|
|
|
it('reports "unknown project" when project not in cache', async () => {
|
|
await seedCache([]);
|
|
const backend = makeBackend({});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'x',
|
|
projects: ['u/ghost'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results).toEqual([
|
|
{ project: 'u/ghost', error: 'unknown project — run sync first' },
|
|
]);
|
|
});
|
|
|
|
it('excludes raw type by default', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': {
|
|
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
|
|
},
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'pdf',
|
|
projects: ['u/foo'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results[0].matches).toEqual([]);
|
|
});
|
|
|
|
it('includes raw when explicitly asked', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'pdf',
|
|
projects: ['u/foo'],
|
|
types: ['raw'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results[0].matches).toHaveLength(1);
|
|
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
|
|
});
|
|
|
|
it('respects limit per project', async () => {
|
|
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: wideIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': {
|
|
'.wiki/concepts/a.md': { body: '' },
|
|
'.wiki/concepts/b.md': { body: '' },
|
|
'.wiki/concepts/c.md': { body: '' },
|
|
},
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: '',
|
|
projects: ['u/foo'],
|
|
limit: 2,
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results[0].matches).toHaveLength(2);
|
|
});
|
|
|
|
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
|
|
await seedCache([
|
|
{
|
|
name: 'u/foo',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-04-30T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
wiki_index: sampleIndex,
|
|
},
|
|
]);
|
|
const backend = makeBackend({
|
|
'u/foo': {
|
|
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
|
|
},
|
|
});
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend,
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'yarn',
|
|
projects: ['u/foo'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
|
|
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
|
});
|
|
|
|
it('returns error when backend not configured', async () => {
|
|
await seedCache([]);
|
|
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'x',
|
|
projects: ['u/foo'],
|
|
});
|
|
expect(r.isError).toBe(true);
|
|
expect(r.content[0].text).toContain('disabled');
|
|
});
|
|
|
|
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
|
|
await seedCache([]);
|
|
const tools = makeKnowledgeTools({
|
|
wikiRoot,
|
|
projectCwd,
|
|
cacheFile,
|
|
backend: makeBackend({}),
|
|
giteaUser: 'u',
|
|
});
|
|
const r = await call(tools, 'knowledge.ask_projects', {
|
|
query: 'x',
|
|
projects: ['u/foo'],
|
|
});
|
|
const parsed = JSON.parse(r.content[0].text);
|
|
expect(parsed.next_action_hint).toContain('knowledge.get_from');
|
|
expect(parsed.next_action_hint).toContain('tasks.create');
|
|
});
|
|
});
|