@graphpilot-oss/graphpilot 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -126
- package/README.md +359 -101
- package/dist/cli.js +20 -0
- package/dist/cli.js.map +1 -1
- package/dist/indexer.js +3 -3
- package/dist/indexer.js.map +1 -1
- package/dist/init.d.ts +28 -0
- package/dist/init.js +112 -0
- package/dist/init.js.map +1 -0
- package/dist/interactions.d.ts +5 -4
- package/dist/interactions.js +0 -0
- package/dist/interactions.js.map +1 -1
- package/dist/mcp.js +126 -46
- package/dist/mcp.js.map +1 -1
- package/dist/repo-resolve.d.ts +47 -0
- package/dist/repo-resolve.js +195 -0
- package/dist/repo-resolve.js.map +1 -0
- package/dist/storage.js +10 -1
- package/dist/storage.js.map +1 -1
- package/dist/validation.js +30 -4
- package/dist/validation.js.map +1 -1
- package/dist/watcher.d.ts +10 -0
- package/dist/watcher.js +70 -7
- package/dist/watcher.js.map +1 -1
- package/examples/README.md +105 -0
- package/examples/claude-code/README.md +125 -0
- package/examples/claude-code/claude-routing.md +102 -0
- package/examples/claude-code/claude_config.json +8 -0
- package/examples/cline/.clinerules +39 -0
- package/examples/cline/README.md +104 -0
- package/examples/cline/cline_mcp_settings.json +10 -0
- package/examples/continue/.continuerules +39 -0
- package/examples/continue/README.md +98 -0
- package/examples/continue/config.json +13 -0
- package/examples/cursor/.cursorrules +39 -0
- package/examples/cursor/README.md +98 -0
- package/examples/cursor/mcp.json +11 -0
- package/examples/windsurf/.windsurfrules +39 -0
- package/examples/windsurf/README.md +85 -0
- package/examples/windsurf/mcp_config.json +8 -0
- package/package.json +12 -3
- package/.editorconfig +0 -15
- package/.github/CODEOWNERS +0 -22
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
- package/.github/dependabot.yml +0 -15
- package/.github/workflows/ci.yml +0 -62
- package/.github/workflows/release.yml +0 -50
- package/.prettierignore +0 -19
- package/.prettierrc.json +0 -20
- package/CODE_OF_CONDUCT.md +0 -83
- package/CONTRIBUTING.md +0 -111
- package/bench/README.md +0 -544
- package/bench/results/agent-tier-2026-05-22.md +0 -28
- package/bench/results/agent-tier-summary.md +0 -44
- package/bench/results/baseline-tier-2026-05-22.md +0 -23
- package/bench/results/baseline.json +0 -810
- package/bench/results/baseline.md +0 -28
- package/bench/run-agent-tier-automated.ts +0 -234
- package/bench/run-agent-tier.md +0 -125
- package/bench/run-baseline-tier.ts +0 -200
- package/bench/run.ts +0 -210
- package/bench/runner-baseline.ts +0 -177
- package/bench/runner-graphpilot.ts +0 -131
- package/bench/score-agent-tier.ts +0 -191
- package/bench/score.ts +0 -59
- package/bench/tasks.ts +0 -236
- package/dist/provenance.d.ts +0 -74
- package/dist/provenance.js +0 -95
- package/dist/provenance.js.map +0 -1
- package/docs/architecture.md +0 -311
- package/docs/limitations.md +0 -156
- package/docs/mcp-setup.md +0 -231
- package/docs/quickstart.md +0 -202
- package/eslint.config.js +0 -148
- package/lefthook.yml +0 -81
- package/pnpm-workspace.yaml +0 -6
- package/scripts/smoke-stdio.mjs +0 -97
- package/src/cli.ts +0 -171
- package/src/edges.ts +0 -202
- package/src/git.ts +0 -255
- package/src/graph-schema.ts +0 -229
- package/src/impact.ts +0 -218
- package/src/indexer.ts +0 -152
- package/src/interactions.ts +0 -0
- package/src/mcp.ts +0 -652
- package/src/parser.ts +0 -138
- package/src/provenance.ts +0 -115
- package/src/query.ts +0 -148
- package/src/redact.ts +0 -122
- package/src/storage.ts +0 -115
- package/src/symbols.ts +0 -173
- package/src/validation.ts +0 -69
- package/src/validators.ts +0 -253
- package/src/watcher.ts +0 -383
- package/tests/edges.test.ts +0 -175
- package/tests/fixtures/sample.ts +0 -32
- package/tests/git.test.ts +0 -303
- package/tests/graph-schema.test.ts +0 -321
- package/tests/impact.test.ts +0 -454
- package/tests/interactions.test.ts +0 -180
- package/tests/lint-policy.test.ts +0 -106
- package/tests/mcp-stdio.test.ts +0 -171
- package/tests/mcp.test.ts +0 -335
- package/tests/parser.test.ts +0 -31
- package/tests/provenance.test.ts +0 -132
- package/tests/query.test.ts +0 -160
- package/tests/redact.test.ts +0 -167
- package/tests/security.test.ts +0 -144
- package/tests/symbols.test.ts +0 -78
- package/tests/validators.test.ts +0 -193
- package/tests/watcher.test.ts +0 -250
- package/tsconfig.json +0 -18
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Meta-tests for the ESLint policy (T12).
|
|
3
|
-
*
|
|
4
|
-
* These tests use the ESLint Node API to verify that:
|
|
5
|
-
* - A `src/` file importing a banned network module is flagged
|
|
6
|
-
* - A `src/` file importing child_process is flagged
|
|
7
|
-
* - The same imports in tests/ and scripts/ are allowed (they need to
|
|
8
|
-
* spawn subprocesses for the smoke runner / bench harness)
|
|
9
|
-
*
|
|
10
|
-
* Why this matters: a future PR could quietly relax the rule. CI would
|
|
11
|
-
* still pass because no current src/ file imports banned modules. These
|
|
12
|
-
* tests assert that the policy stays in force by feeding ESLint
|
|
13
|
-
* adversarial sources and checking the verdict.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect, beforeAll } from 'vitest';
|
|
17
|
-
import { ESLint } from 'eslint';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
-
import { dirname, join } from 'node:path';
|
|
20
|
-
|
|
21
|
-
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
-
let eslint: ESLint;
|
|
23
|
-
|
|
24
|
-
beforeAll(async () => {
|
|
25
|
-
eslint = new ESLint({ cwd: repoRoot });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// Helper: lint a synthetic file at a given (real) path with a given source.
|
|
29
|
-
// The path is required so the flat-config file-pattern matchers fire.
|
|
30
|
-
async function lintAs(filePath: string, source: string) {
|
|
31
|
-
const results = await eslint.lintText(source, { filePath });
|
|
32
|
-
return results[0];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('T12 — banned network imports in src/', () => {
|
|
36
|
-
const bannedNetwork = [
|
|
37
|
-
'http',
|
|
38
|
-
'https',
|
|
39
|
-
'node:http',
|
|
40
|
-
'node:https',
|
|
41
|
-
'undici',
|
|
42
|
-
'axios',
|
|
43
|
-
'node-fetch',
|
|
44
|
-
'cross-fetch',
|
|
45
|
-
'got',
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
for (const mod of bannedNetwork) {
|
|
49
|
-
it(`flags "import ... from '${mod}'" inside src/`, async () => {
|
|
50
|
-
const result = await lintAs(
|
|
51
|
-
join(repoRoot, 'src', '_fake.ts'),
|
|
52
|
-
`import x from '${mod}';\nexport const v = x;\n`,
|
|
53
|
-
);
|
|
54
|
-
expect(result.errorCount).toBeGreaterThanOrEqual(1);
|
|
55
|
-
const restricted = result.messages.find((m) => m.ruleId === 'no-restricted-imports');
|
|
56
|
-
expect(restricted).toBeDefined();
|
|
57
|
-
expect(restricted!.message).toContain('No network code in src/');
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('T6 — child_process banned in src/', () => {
|
|
63
|
-
for (const mod of ['child_process', 'node:child_process']) {
|
|
64
|
-
it(`flags "import ... from '${mod}'" inside src/`, async () => {
|
|
65
|
-
const result = await lintAs(
|
|
66
|
-
join(repoRoot, 'src', '_fake.ts'),
|
|
67
|
-
`import cp from '${mod}';\nexport const v = cp;\n`,
|
|
68
|
-
);
|
|
69
|
-
expect(result.errorCount).toBeGreaterThanOrEqual(1);
|
|
70
|
-
const restricted = result.messages.find((m) => m.ruleId === 'no-restricted-imports');
|
|
71
|
-
expect(restricted).toBeDefined();
|
|
72
|
-
expect(restricted!.message).toContain('No child_process in src/');
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('Looser rules in tests/ and scripts/', () => {
|
|
78
|
-
it('allows child_process inside tests/ (subprocess test needs it)', async () => {
|
|
79
|
-
const result = await lintAs(
|
|
80
|
-
join(repoRoot, 'tests', '_fake.test.ts'),
|
|
81
|
-
`import cp from 'child_process';\nexport const v = cp;\n`,
|
|
82
|
-
);
|
|
83
|
-
const restricted = result.messages.find((m) => m.ruleId === 'no-restricted-imports');
|
|
84
|
-
expect(restricted).toBeUndefined();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('allows child_process inside scripts/', async () => {
|
|
88
|
-
const result = await lintAs(
|
|
89
|
-
join(repoRoot, 'scripts', '_fake.mjs'),
|
|
90
|
-
`import cp from 'child_process';\nexport const v = cp;\n`,
|
|
91
|
-
);
|
|
92
|
-
const restricted = result.messages.find((m) => m.ruleId === 'no-restricted-imports');
|
|
93
|
-
expect(restricted).toBeUndefined();
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe('Normal imports in src/ pass clean', () => {
|
|
98
|
-
it('allows internal imports in src/', async () => {
|
|
99
|
-
const result = await lintAs(
|
|
100
|
-
join(repoRoot, 'src', '_fake.ts'),
|
|
101
|
-
`import { join } from 'node:path';\nexport const v = join('a', 'b');\n`,
|
|
102
|
-
);
|
|
103
|
-
const restricted = result.messages.find((m) => m.ruleId === 'no-restricted-imports');
|
|
104
|
-
expect(restricted).toBeUndefined();
|
|
105
|
-
});
|
|
106
|
-
});
|
package/tests/mcp-stdio.test.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Real-subprocess test of the MCP server over stdio. The other mcp.test.ts
|
|
3
|
-
* uses InMemoryTransport which would not have caught the
|
|
4
|
-
* "process.exit() kills the server before initialize completes" regression
|
|
5
|
-
* that bit us during Day-10 testing.
|
|
6
|
-
*
|
|
7
|
-
* Each test spawns `node dist/cli.js mcp`, drives it via JSON-RPC over its
|
|
8
|
-
* stdin/stdout, and tears it down by closing stdin.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect } from 'vitest';
|
|
12
|
-
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { dirname, join } from 'node:path';
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
16
|
-
|
|
17
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const cli = join(here, '..', 'dist', 'cli.js');
|
|
19
|
-
|
|
20
|
-
const isWindows = process.platform === 'win32';
|
|
21
|
-
|
|
22
|
-
// These tests require the built artifact. Skip locally before the first
|
|
23
|
-
// build; CI runs `pnpm build` before tests so this is fine there.
|
|
24
|
-
const shouldSkip = !existsSync(cli) || isWindows;
|
|
25
|
-
|
|
26
|
-
interface Driver {
|
|
27
|
-
proc: ChildProcessWithoutNullstreams;
|
|
28
|
-
send: (method: string, params?: unknown) => void;
|
|
29
|
-
awaitReplies: (n: number, timeoutMs?: number) => Promise<any[]>;
|
|
30
|
-
replies: any[];
|
|
31
|
-
stderr: string;
|
|
32
|
-
close: () => Promise<void>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function spawnMcp(): Driver {
|
|
36
|
-
const proc = spawn('node', [cli, 'mcp'], {
|
|
37
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
-
}) as ChildProcessWithoutNullStreams;
|
|
39
|
-
|
|
40
|
-
const replies: any[] = [];
|
|
41
|
-
let stderr = '';
|
|
42
|
-
let buffer = '';
|
|
43
|
-
|
|
44
|
-
proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
|
|
45
|
-
proc.stdout.on('data', (chunk: Buffer) => {
|
|
46
|
-
buffer += chunk.toString();
|
|
47
|
-
const lines = buffer.split('\n');
|
|
48
|
-
buffer = lines.pop() ?? '';
|
|
49
|
-
for (const line of lines) {
|
|
50
|
-
const trimmed = line.trim();
|
|
51
|
-
if (!trimmed) continue;
|
|
52
|
-
try {
|
|
53
|
-
replies.push(JSON.parse(trimmed));
|
|
54
|
-
} catch {
|
|
55
|
-
// ignore
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
let nextId = 1;
|
|
61
|
-
function send(method: string, params: unknown = {}) {
|
|
62
|
-
const isNotif = method.startsWith('notifications/');
|
|
63
|
-
const msg = isNotif
|
|
64
|
-
? { jsonrpc: '2.0', method, params }
|
|
65
|
-
: { jsonrpc: '2.0', id: nextId++, method, params };
|
|
66
|
-
proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function awaitReplies(n: number, timeoutMs = 4000) {
|
|
70
|
-
const deadline = Date.now() + timeoutMs;
|
|
71
|
-
while (replies.length < n) {
|
|
72
|
-
if (Date.now() > deadline) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Timed out waiting for ${n} replies; got ${replies.length}. ` + `STDERR was:\n${stderr}`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
78
|
-
}
|
|
79
|
-
return replies.slice(0, n);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function close() {
|
|
83
|
-
proc.stdin.end();
|
|
84
|
-
await new Promise<void>((resolve) => {
|
|
85
|
-
const t = setTimeout(() => {
|
|
86
|
-
proc.kill('SIGKILL');
|
|
87
|
-
resolve();
|
|
88
|
-
}, 1000);
|
|
89
|
-
proc.once('exit', () => {
|
|
90
|
-
clearTimeout(t);
|
|
91
|
-
resolve();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
proc,
|
|
98
|
-
send,
|
|
99
|
-
awaitReplies,
|
|
100
|
-
replies,
|
|
101
|
-
get stderr() {
|
|
102
|
-
return stderr;
|
|
103
|
-
},
|
|
104
|
-
close,
|
|
105
|
-
} as Driver;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
describe.skipIf(shouldSkip)('MCP server over real stdio (subprocess)', () => {
|
|
109
|
-
it('responds to initialize within 4s', async () => {
|
|
110
|
-
const d = spawnMcp();
|
|
111
|
-
try {
|
|
112
|
-
d.send('initialize', {
|
|
113
|
-
protocolVersion: '2024-11-05',
|
|
114
|
-
capabilities: {},
|
|
115
|
-
clientInfo: { name: 'test', version: '0' },
|
|
116
|
-
});
|
|
117
|
-
const [init] = await d.awaitReplies(1);
|
|
118
|
-
expect(init.id).toBe(1);
|
|
119
|
-
expect(init.result?.serverInfo?.name).toBe('graphpilot');
|
|
120
|
-
} finally {
|
|
121
|
-
await d.close();
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('completes initialize → tools/list → tools/call without exiting', async () => {
|
|
126
|
-
const d = spawnMcp();
|
|
127
|
-
try {
|
|
128
|
-
d.send('initialize', {
|
|
129
|
-
protocolVersion: '2024-11-05',
|
|
130
|
-
capabilities: {},
|
|
131
|
-
clientInfo: { name: 'test', version: '0' },
|
|
132
|
-
});
|
|
133
|
-
await d.awaitReplies(1);
|
|
134
|
-
d.send('notifications/initialized');
|
|
135
|
-
d.send('tools/list');
|
|
136
|
-
const replies = await d.awaitReplies(2);
|
|
137
|
-
const list = replies[1];
|
|
138
|
-
const names = (list.result?.tools ?? []).map((t: any) => t.name).sort();
|
|
139
|
-
expect(names).toEqual(['gp_callers', 'gp_impact', 'gp_index', 'gp_recall', 'gp_stats']);
|
|
140
|
-
|
|
141
|
-
// Now call a tool — proves the process is still alive after tools/list
|
|
142
|
-
d.send('tools/call', {
|
|
143
|
-
name: 'gp_stats',
|
|
144
|
-
arguments: { path: '/tmp/graphpilot-definitely-not-indexed' },
|
|
145
|
-
});
|
|
146
|
-
const replies2 = await d.awaitReplies(3);
|
|
147
|
-
const call = replies2[2];
|
|
148
|
-
// We expect isError true (no index) but the IMPORTANT thing is that
|
|
149
|
-
// the server replied at all — i.e., didn't exit after initialize.
|
|
150
|
-
expect(call.result?.isError).toBe(true);
|
|
151
|
-
} finally {
|
|
152
|
-
await d.close();
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('exits cleanly when stdin closes', async () => {
|
|
157
|
-
const d = spawnMcp();
|
|
158
|
-
d.send('initialize', {
|
|
159
|
-
protocolVersion: '2024-11-05',
|
|
160
|
-
capabilities: {},
|
|
161
|
-
clientInfo: { name: 'test', version: '0' },
|
|
162
|
-
});
|
|
163
|
-
await d.awaitReplies(1);
|
|
164
|
-
d.proc.stdin.end();
|
|
165
|
-
const exitCode: number | null = await new Promise((resolve) => {
|
|
166
|
-
d.proc.once('exit', (code) => resolve(code));
|
|
167
|
-
});
|
|
168
|
-
// 0 or null are both acceptable (different platforms, different SIGPIPE behaviour)
|
|
169
|
-
expect(exitCode === 0 || exitCode === null).toBe(true);
|
|
170
|
-
});
|
|
171
|
-
});
|
package/tests/mcp.test.ts
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
-
import { writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
6
|
-
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
7
|
-
import { buildMcpServer } from '../src/mcp.js';
|
|
8
|
-
import { indexDirectory } from '../src/indexer.js';
|
|
9
|
-
import { saveGraph, repoIdFor, type Graph } from '../src/storage.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* End-to-end test: spin up the MCP server in-process, paired with an MCP
|
|
13
|
-
* client over an in-memory transport. Exercises the full protocol path
|
|
14
|
-
* (initialize -> tools/list -> tools/call) without spawning a subprocess.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
let workDir: string;
|
|
18
|
-
let client: Client;
|
|
19
|
-
|
|
20
|
-
beforeAll(async () => {
|
|
21
|
-
// Make a tiny repo and index it so gp_stats has something to report.
|
|
22
|
-
workDir = mkdtempSync(join(tmpdir(), 'graphpilot-mcp-'));
|
|
23
|
-
writeFileSync(join(workDir, 'hello.ts'), 'export function hello() { return 1; }\n');
|
|
24
|
-
const result = await indexDirectory(workDir);
|
|
25
|
-
const graph: Graph = {
|
|
26
|
-
version: 1,
|
|
27
|
-
repoId: repoIdFor(workDir),
|
|
28
|
-
rootPath: workDir,
|
|
29
|
-
indexedAt: new Date().toISOString(),
|
|
30
|
-
filesIndexed: result.filesIndexed,
|
|
31
|
-
symbolCount: result.symbols.length,
|
|
32
|
-
edgeCount: result.edges.length,
|
|
33
|
-
symbols: result.symbols,
|
|
34
|
-
edges: result.edges,
|
|
35
|
-
};
|
|
36
|
-
saveGraph(graph);
|
|
37
|
-
|
|
38
|
-
// Wire client + server over an in-memory pipe.
|
|
39
|
-
const server = buildMcpServer();
|
|
40
|
-
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
|
41
|
-
client = new Client({ name: 'graphpilot-test', version: '0.0.0' }, { capabilities: {} });
|
|
42
|
-
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterAll(async () => {
|
|
46
|
-
await client?.close();
|
|
47
|
-
if (workDir && existsSync(workDir)) {
|
|
48
|
-
rmSync(workDir, { recursive: true, force: true });
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('MCP server: protocol handshake + tool catalog', () => {
|
|
53
|
-
it('lists the v0.1 tools', async () => {
|
|
54
|
-
const { tools } = await client.listTools();
|
|
55
|
-
const names = tools.map((t) => t.name).sort();
|
|
56
|
-
expect(names).toEqual(['gp_callers', 'gp_impact', 'gp_index', 'gp_recall', 'gp_stats']);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('every tool has a description and an object input schema', async () => {
|
|
60
|
-
const { tools } = await client.listTools();
|
|
61
|
-
for (const t of tools) {
|
|
62
|
-
expect(t.description?.length).toBeGreaterThan(20);
|
|
63
|
-
expect(t.inputSchema?.type).toBe('object');
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('MCP server: gp_stats tool', () => {
|
|
69
|
-
it('returns index summary for a known repo', async () => {
|
|
70
|
-
const res = await client.callTool({
|
|
71
|
-
name: 'gp_stats',
|
|
72
|
-
arguments: { path: workDir },
|
|
73
|
-
});
|
|
74
|
-
expect(res.isError).not.toBe(true);
|
|
75
|
-
const text = (res.content as Array<{ type: string; text: string }>)
|
|
76
|
-
.filter((c) => c.type === 'text')
|
|
77
|
-
.map((c) => c.text)
|
|
78
|
-
.join('\n');
|
|
79
|
-
expect(text).toContain('Symbols:');
|
|
80
|
-
expect(text).toContain('Calls:');
|
|
81
|
-
expect(text).toContain(workDir);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('returns a friendly error for an un-indexed path', async () => {
|
|
85
|
-
const fakePath = join(tmpdir(), `graphpilot-noindex-${Date.now()}`);
|
|
86
|
-
const res = await client.callTool({
|
|
87
|
-
name: 'gp_stats',
|
|
88
|
-
arguments: { path: fakePath },
|
|
89
|
-
});
|
|
90
|
-
expect(res.isError).toBe(true);
|
|
91
|
-
const text = (res.content as Array<{ type: string; text: string }>)
|
|
92
|
-
.map((c) => c.text)
|
|
93
|
-
.join('\n');
|
|
94
|
-
expect(text).toMatch(/No GraphPilot index/i);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('MCP server: unknown tool', () => {
|
|
99
|
-
it('responds with isError true for tools we did not register', async () => {
|
|
100
|
-
// Call a tool that doesn't exist. The SDK may either throw or surface an
|
|
101
|
-
// error in the response — we accept both shapes.
|
|
102
|
-
let caught = false;
|
|
103
|
-
let res: Awaited<ReturnType<Client['callTool']>> | null = null;
|
|
104
|
-
try {
|
|
105
|
-
res = await client.callTool({ name: 'gp_does_not_exist', arguments: {} });
|
|
106
|
-
} catch {
|
|
107
|
-
caught = true;
|
|
108
|
-
}
|
|
109
|
-
if (!caught) {
|
|
110
|
-
expect(res?.isError).toBe(true);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// Day-9 tools
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
function textOf(res: Awaited<ReturnType<Client['callTool']>>): string {
|
|
120
|
-
return (res.content as Array<{ type: string; text: string }>)
|
|
121
|
-
.filter((c) => c.type === 'text')
|
|
122
|
-
.map((c) => c.text)
|
|
123
|
-
.join('\n');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
describe('MCP server: gp_recall', () => {
|
|
127
|
-
it('finds the seeded symbol by name', async () => {
|
|
128
|
-
const res = await client.callTool({
|
|
129
|
-
name: 'gp_recall',
|
|
130
|
-
arguments: { query: 'hello', path: workDir },
|
|
131
|
-
});
|
|
132
|
-
expect(res.isError).not.toBe(true);
|
|
133
|
-
const text = textOf(res);
|
|
134
|
-
expect(text).toContain('hello');
|
|
135
|
-
expect(text).toContain('hello.ts');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('handles "no match" gracefully', async () => {
|
|
139
|
-
const res = await client.callTool({
|
|
140
|
-
name: 'gp_recall',
|
|
141
|
-
arguments: { query: 'definitelyNotHere', path: workDir },
|
|
142
|
-
});
|
|
143
|
-
expect(res.isError).not.toBe(true);
|
|
144
|
-
expect(textOf(res)).toMatch(/no symbols match/i);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('rejects empty queries', async () => {
|
|
148
|
-
const res = await client.callTool({
|
|
149
|
-
name: 'gp_recall',
|
|
150
|
-
arguments: { query: '', path: workDir },
|
|
151
|
-
});
|
|
152
|
-
expect(res.isError).toBe(true);
|
|
153
|
-
expect(textOf(res)).toMatch(/Invalid input/i);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('rejects unknown fields', async () => {
|
|
157
|
-
const res = await client.callTool({
|
|
158
|
-
name: 'gp_recall',
|
|
159
|
-
arguments: { query: 'hello', shellOut: 'rm -rf', path: workDir },
|
|
160
|
-
});
|
|
161
|
-
expect(res.isError).toBe(true);
|
|
162
|
-
expect(textOf(res)).toMatch(/shellOut/);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('rejects out-of-range limit', async () => {
|
|
166
|
-
const res = await client.callTool({
|
|
167
|
-
name: 'gp_recall',
|
|
168
|
-
arguments: { query: 'hello', limit: 9999, path: workDir },
|
|
169
|
-
});
|
|
170
|
-
expect(res.isError).toBe(true);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe('MCP server: gp_callers', () => {
|
|
175
|
-
it('returns isError when the symbol is unknown', async () => {
|
|
176
|
-
const res = await client.callTool({
|
|
177
|
-
name: 'gp_callers',
|
|
178
|
-
arguments: { symbol: 'doesNotExist', path: workDir },
|
|
179
|
-
});
|
|
180
|
-
expect(res.isError).toBe(true);
|
|
181
|
-
expect(textOf(res)).toMatch(/no symbol found/i);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('rejects invalid direction', async () => {
|
|
185
|
-
const res = await client.callTool({
|
|
186
|
-
name: 'gp_callers',
|
|
187
|
-
arguments: { symbol: 'hello', direction: 'sideways', path: workDir },
|
|
188
|
-
});
|
|
189
|
-
expect(res.isError).toBe(true);
|
|
190
|
-
expect(textOf(res)).toMatch(/direction/i);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("returns 'no callers found' when target exists but nothing calls it", async () => {
|
|
194
|
-
const res = await client.callTool({
|
|
195
|
-
name: 'gp_callers',
|
|
196
|
-
arguments: { symbol: 'hello', direction: 'callers', path: workDir },
|
|
197
|
-
});
|
|
198
|
-
expect(res.isError).not.toBe(true);
|
|
199
|
-
expect(textOf(res)).toMatch(/no callers/i);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe('MCP server: gp_index', () => {
|
|
204
|
-
it('re-indexes the repo end-to-end', async () => {
|
|
205
|
-
const res = await client.callTool({
|
|
206
|
-
name: 'gp_index',
|
|
207
|
-
arguments: { path: workDir },
|
|
208
|
-
});
|
|
209
|
-
expect(res.isError).not.toBe(true);
|
|
210
|
-
const text = textOf(res);
|
|
211
|
-
expect(text).toContain('Indexed');
|
|
212
|
-
expect(text).toContain('Files:');
|
|
213
|
-
expect(text).toContain('Symbols:');
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('MCP server: gp_impact', () => {
|
|
218
|
-
// Build a richer fixture with a real caller chain so blast-radius output
|
|
219
|
-
// is non-trivial.
|
|
220
|
-
let impactDir: string;
|
|
221
|
-
|
|
222
|
-
beforeAll(async () => {
|
|
223
|
-
impactDir = mkdtempSync(join(tmpdir(), 'graphpilot-mcp-impact-'));
|
|
224
|
-
writeFileSync(
|
|
225
|
-
join(impactDir, 'auth.ts'),
|
|
226
|
-
`export function parseToken(t: string): string {\n` +
|
|
227
|
-
` return t.trim();\n` +
|
|
228
|
-
`}\n` +
|
|
229
|
-
`\n` +
|
|
230
|
-
`export function authenticate(t: string): boolean {\n` +
|
|
231
|
-
` return parseToken(t).length > 0;\n` +
|
|
232
|
-
`}\n`,
|
|
233
|
-
);
|
|
234
|
-
writeFileSync(
|
|
235
|
-
join(impactDir, 'api.ts'),
|
|
236
|
-
`import { parseToken } from './auth';\n` +
|
|
237
|
-
`\n` +
|
|
238
|
-
`export function handleLogin(t: string): string {\n` +
|
|
239
|
-
` return parseToken(t);\n` +
|
|
240
|
-
`}\n`,
|
|
241
|
-
);
|
|
242
|
-
writeFileSync(
|
|
243
|
-
join(impactDir, 'auth.test.ts'),
|
|
244
|
-
`import { parseToken } from './auth';\n` +
|
|
245
|
-
`\n` +
|
|
246
|
-
`function testParse() {\n` +
|
|
247
|
-
` return parseToken('x');\n` +
|
|
248
|
-
`}\n`,
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
const r = await indexDirectory(impactDir);
|
|
252
|
-
saveGraph({
|
|
253
|
-
version: 1,
|
|
254
|
-
repoId: repoIdFor(impactDir),
|
|
255
|
-
rootPath: impactDir,
|
|
256
|
-
indexedAt: new Date().toISOString(),
|
|
257
|
-
filesIndexed: r.filesIndexed,
|
|
258
|
-
symbolCount: r.symbols.length,
|
|
259
|
-
edgeCount: r.edges.length,
|
|
260
|
-
symbols: r.symbols,
|
|
261
|
-
edges: r.edges,
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
afterAll(() => {
|
|
266
|
-
if (impactDir && existsSync(impactDir)) {
|
|
267
|
-
rmSync(impactDir, { recursive: true, force: true });
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('returns isError when the symbol is unknown', async () => {
|
|
272
|
-
const res = await client.callTool({
|
|
273
|
-
name: 'gp_impact',
|
|
274
|
-
arguments: { symbol: 'doesNotExist', path: impactDir },
|
|
275
|
-
});
|
|
276
|
-
expect(res.isError).toBe(true);
|
|
277
|
-
expect(textOf(res)).toMatch(/no symbol found/i);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('reports direct + transitive callers + public-API flag', async () => {
|
|
281
|
-
const res = await client.callTool({
|
|
282
|
-
name: 'gp_impact',
|
|
283
|
-
arguments: { symbol: 'parseToken', path: impactDir },
|
|
284
|
-
});
|
|
285
|
-
expect(res.isError).not.toBe(true);
|
|
286
|
-
const text = textOf(res);
|
|
287
|
-
// The target line
|
|
288
|
-
expect(text).toMatch(/Impact of changing parseToken/);
|
|
289
|
-
// Direct callers
|
|
290
|
-
expect(text).toMatch(/Direct callers/);
|
|
291
|
-
expect(text).toMatch(/authenticate/);
|
|
292
|
-
expect(text).toMatch(/handleLogin/);
|
|
293
|
-
// Test affected
|
|
294
|
-
expect(text).toMatch(/Tests likely affected/);
|
|
295
|
-
expect(text).toMatch(/auth\.test\.ts/);
|
|
296
|
-
// Public API
|
|
297
|
-
expect(text).toMatch(/Public API: YES/);
|
|
298
|
-
expect(text).toMatch(/BREAKING/i);
|
|
299
|
-
// Summary
|
|
300
|
-
expect(text).toMatch(/Summary:/);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('respects the depth argument', async () => {
|
|
304
|
-
const res = await client.callTool({
|
|
305
|
-
name: 'gp_impact',
|
|
306
|
-
arguments: { symbol: 'parseToken', depth: 1, path: impactDir },
|
|
307
|
-
});
|
|
308
|
-
expect(res.isError).not.toBe(true);
|
|
309
|
-
// depth=1 should NOT include a Transitive section header
|
|
310
|
-
const text = textOf(res);
|
|
311
|
-
expect(text).toMatch(/Direct callers/);
|
|
312
|
-
expect(text).not.toMatch(/Transitive callers/);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('rejects depth out of range', async () => {
|
|
316
|
-
const res = await client.callTool({
|
|
317
|
-
name: 'gp_impact',
|
|
318
|
-
arguments: { symbol: 'parseToken', depth: 99, path: impactDir },
|
|
319
|
-
});
|
|
320
|
-
expect(res.isError).toBe(true);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('rejects unknown fields', async () => {
|
|
324
|
-
const res = await client.callTool({
|
|
325
|
-
name: 'gp_impact',
|
|
326
|
-
arguments: {
|
|
327
|
-
symbol: 'parseToken',
|
|
328
|
-
evilExtra: true,
|
|
329
|
-
path: impactDir,
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
expect(res.isError).toBe(true);
|
|
333
|
-
expect(textOf(res)).toMatch(/evilExtra/);
|
|
334
|
-
});
|
|
335
|
-
});
|
package/tests/parser.test.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseFile, listFunctions } from '../src/parser.js';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const fixture = (name: string) => join(here, 'fixtures', name);
|
|
8
|
-
|
|
9
|
-
describe('parser', () => {
|
|
10
|
-
it('parses a TypeScript file', () => {
|
|
11
|
-
const parsed = parseFile(fixture('sample.ts'));
|
|
12
|
-
expect(parsed).not.toBeNull();
|
|
13
|
-
expect(parsed!.lang).toBe('typescript');
|
|
14
|
-
expect(parsed!.tree.rootNode.type).toBe('program');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('lists all function-like names in sample.ts', () => {
|
|
18
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
19
|
-
const names = listFunctions(parsed);
|
|
20
|
-
expect(names).toContain('parseToken');
|
|
21
|
-
expect(names).toContain('validateJwt');
|
|
22
|
-
expect(names).toContain('internalHelper');
|
|
23
|
-
expect(names).toContain('authenticate');
|
|
24
|
-
expect(names).toContain('fetchUser');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('returns null for unsupported extensions', () => {
|
|
28
|
-
const parsed = parseFile(fixture('sample.ts').replace('.ts', '.txt'));
|
|
29
|
-
expect(parsed).toBeNull();
|
|
30
|
-
});
|
|
31
|
-
});
|