@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
package/scripts/smoke-stdio.mjs
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Spawns the MCP server over stdio exactly like Claude Code would, sends
|
|
3
|
-
// initialize -> notifications/initialized -> tools/list -> tools/call, and
|
|
4
|
-
// prints every response. Exits non-zero on protocol failure or timeout.
|
|
5
|
-
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
|
|
10
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const cli = join(here, '..', 'dist', 'cli.js');
|
|
12
|
-
|
|
13
|
-
const proc = spawn('node', [cli, 'mcp'], {
|
|
14
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
let stderr = '';
|
|
18
|
-
proc.stderr.on('data', (d) => (stderr += d.toString()));
|
|
19
|
-
|
|
20
|
-
const replies = [];
|
|
21
|
-
let buffer = '';
|
|
22
|
-
proc.stdout.on('data', (chunk) => {
|
|
23
|
-
buffer += chunk.toString();
|
|
24
|
-
// MCP stdio framing is newline-delimited JSON.
|
|
25
|
-
const lines = buffer.split('\n');
|
|
26
|
-
buffer = lines.pop() ?? '';
|
|
27
|
-
for (const line of lines) {
|
|
28
|
-
const trimmed = line.trim();
|
|
29
|
-
if (!trimmed) continue;
|
|
30
|
-
try {
|
|
31
|
-
replies.push(JSON.parse(trimmed));
|
|
32
|
-
} catch {
|
|
33
|
-
console.error('parse error on line:', JSON.stringify(trimmed));
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
let nextId = 1;
|
|
39
|
-
function send(method, params) {
|
|
40
|
-
const isNotif = method.startsWith('notifications/');
|
|
41
|
-
const msg = isNotif
|
|
42
|
-
? { jsonrpc: '2.0', method, params }
|
|
43
|
-
: { jsonrpc: '2.0', id: nextId++, method, params };
|
|
44
|
-
proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const TIMEOUT_MS = 5000;
|
|
48
|
-
async function awaitReplyCount(n) {
|
|
49
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
50
|
-
while (replies.length < n) {
|
|
51
|
-
if (Date.now() > deadline) {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`Timed out waiting for ${n} replies (got ${replies.length}). ` + `STDERR was:\n${stderr}`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
(async () => {
|
|
61
|
-
// 1. initialize
|
|
62
|
-
send('initialize', {
|
|
63
|
-
protocolVersion: '2024-11-05',
|
|
64
|
-
capabilities: {},
|
|
65
|
-
clientInfo: { name: 'graphpilot-smoke', version: '0.0.0' },
|
|
66
|
-
});
|
|
67
|
-
await awaitReplyCount(1);
|
|
68
|
-
const init = replies[0];
|
|
69
|
-
console.log('✓ initialize:', JSON.stringify(init.result?.serverInfo));
|
|
70
|
-
|
|
71
|
-
// 2. initialized notification (no reply expected)
|
|
72
|
-
send('notifications/initialized', {});
|
|
73
|
-
|
|
74
|
-
// 3. tools/list
|
|
75
|
-
send('tools/list', {});
|
|
76
|
-
await awaitReplyCount(2);
|
|
77
|
-
const list = replies[1];
|
|
78
|
-
const names = (list.result?.tools ?? []).map((t) => t.name).sort();
|
|
79
|
-
console.log('✓ tools/list:', names.join(', '));
|
|
80
|
-
|
|
81
|
-
// 4. tools/call gp_stats on cwd (no index expected — should return isError)
|
|
82
|
-
send('tools/call', { name: 'gp_stats', arguments: { path: process.cwd() } });
|
|
83
|
-
await awaitReplyCount(3);
|
|
84
|
-
const call = replies[2];
|
|
85
|
-
console.log('✓ tools/call gp_stats. isError=', call.result?.isError);
|
|
86
|
-
const firstText = call.result?.content?.[0]?.text ?? '<no text>';
|
|
87
|
-
console.log(' first line:', firstText.split('\n')[0]);
|
|
88
|
-
|
|
89
|
-
proc.stdin.end();
|
|
90
|
-
await new Promise((r) => proc.once('exit', r));
|
|
91
|
-
console.log('✓ server exited cleanly after stdin close');
|
|
92
|
-
process.exit(0);
|
|
93
|
-
})().catch((err) => {
|
|
94
|
-
console.error('SMOKE FAIL:', err.message);
|
|
95
|
-
proc.kill('SIGKILL');
|
|
96
|
-
process.exit(1);
|
|
97
|
-
});
|
package/src/cli.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import { indexDirectory } from './indexer.js';
|
|
4
|
-
import { saveGraph, loadGraph, graphPath, repoIdFor, type Graph } from './storage.js';
|
|
5
|
-
import { validateRootPath } from './validation.js';
|
|
6
|
-
import { startMcpServer } from './mcp.js';
|
|
7
|
-
import { GraphWatcher } from './watcher.js';
|
|
8
|
-
import { resolveIndexRoot } from './git.js';
|
|
9
|
-
|
|
10
|
-
const HELP = `graphpilot — structural memory for coding agents
|
|
11
|
-
|
|
12
|
-
Usage:
|
|
13
|
-
graphpilot index <path> Index a TypeScript/JavaScript repo
|
|
14
|
-
graphpilot status <path> Show info about an indexed repo
|
|
15
|
-
graphpilot watch <path> Watch the repo and update the index on save
|
|
16
|
-
graphpilot mcp Start the MCP server (stdio)
|
|
17
|
-
graphpilot help Show this help
|
|
18
|
-
|
|
19
|
-
Examples:
|
|
20
|
-
graphpilot index .
|
|
21
|
-
graphpilot status .
|
|
22
|
-
graphpilot watch . # keeps the index fresh as you edit
|
|
23
|
-
graphpilot mcp # used by MCP clients (Claude Code, Cursor, ...)
|
|
24
|
-
`;
|
|
25
|
-
|
|
26
|
-
async function cmdIndex(pathArg: string, opts: { noWorktree?: boolean } = {}): Promise<number> {
|
|
27
|
-
const requested = resolve(pathArg);
|
|
28
|
-
// Worktree-scope: by default, if the user pointed inside a git worktree
|
|
29
|
-
// we re-root to the worktree top so the index covers the full branch.
|
|
30
|
-
// Pass --no-worktree to disable.
|
|
31
|
-
const { root: absRoot, redirected } = resolveIndexRoot(requested, { disable: opts.noWorktree });
|
|
32
|
-
if (redirected) {
|
|
33
|
-
process.stdout.write(
|
|
34
|
-
`[graphpilot] Re-rooting index to git worktree top: ${absRoot}\n` +
|
|
35
|
-
` (Pass --no-worktree to index ${requested} directly.)\n`,
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
// T10 defence: refuse `/`, `/etc`, `~`, and friends before walking.
|
|
39
|
-
const refusal = validateRootPath(absRoot);
|
|
40
|
-
if (refusal) {
|
|
41
|
-
process.stderr.write(`Error: ${refusal}\n`);
|
|
42
|
-
return 2;
|
|
43
|
-
}
|
|
44
|
-
process.stdout.write(`Indexing ${absRoot} ...\n`);
|
|
45
|
-
const result = await indexDirectory(absRoot);
|
|
46
|
-
const graph: Graph = {
|
|
47
|
-
version: 1,
|
|
48
|
-
repoId: repoIdFor(absRoot),
|
|
49
|
-
rootPath: absRoot,
|
|
50
|
-
indexedAt: new Date().toISOString(),
|
|
51
|
-
filesIndexed: result.filesIndexed,
|
|
52
|
-
symbolCount: result.symbols.length,
|
|
53
|
-
edgeCount: result.edges.length,
|
|
54
|
-
symbols: result.symbols,
|
|
55
|
-
edges: result.edges,
|
|
56
|
-
indexedSha: result.git.sha,
|
|
57
|
-
indexedBranch: result.git.branch,
|
|
58
|
-
};
|
|
59
|
-
const saved = saveGraph(graph);
|
|
60
|
-
const resolved = result.edges.filter((e) => e.toId !== null).length;
|
|
61
|
-
// Build the git stamp line lazily — only printed when we're in a git repo.
|
|
62
|
-
let gitLine = '';
|
|
63
|
-
if (result.git.shortSha || result.git.branch) {
|
|
64
|
-
const parts: string[] = [];
|
|
65
|
-
if (result.git.branch) parts.push(`branch ${result.git.branch}`);
|
|
66
|
-
if (result.git.shortSha) parts.push(`sha ${result.git.shortSha}`);
|
|
67
|
-
gitLine = ` Git: ${parts.join(' @ ')}\n`;
|
|
68
|
-
}
|
|
69
|
-
process.stdout.write(
|
|
70
|
-
`\n✓ Remembered ${result.symbols.length} symbols, ${result.edges.length} calls ` +
|
|
71
|
-
`(${resolved} resolved) across ${result.filesIndexed} files in ${result.durationMs}ms.\n` +
|
|
72
|
-
` Repo id: ${graph.repoId}\n` +
|
|
73
|
-
gitLine +
|
|
74
|
-
` Graph file: ${saved}\n` +
|
|
75
|
-
(result.filesFailed ? ` Failed: ${result.filesFailed} file(s)\n` : ''),
|
|
76
|
-
);
|
|
77
|
-
return 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function cmdStatus(pathArg: string): number {
|
|
81
|
-
const absRoot = resolve(pathArg);
|
|
82
|
-
const graph = loadGraph(absRoot);
|
|
83
|
-
if (!graph) {
|
|
84
|
-
process.stderr.write(`No index found for ${absRoot}\n` + `Run: graphpilot index ${pathArg}\n`);
|
|
85
|
-
return 1;
|
|
86
|
-
}
|
|
87
|
-
// Compose a git line if the indexed repo had provenance at the time.
|
|
88
|
-
let gitLine = '';
|
|
89
|
-
if (graph.indexedSha || graph.indexedBranch) {
|
|
90
|
-
const parts: string[] = [];
|
|
91
|
-
if (graph.indexedBranch) parts.push(`branch ${graph.indexedBranch}`);
|
|
92
|
-
if (graph.indexedSha) parts.push(`sha ${graph.indexedSha.slice(0, 7)}`);
|
|
93
|
-
gitLine = `Git: ${parts.join(' @ ')}\n`;
|
|
94
|
-
}
|
|
95
|
-
process.stdout.write(
|
|
96
|
-
`Repo id: ${graph.repoId}\n` +
|
|
97
|
-
`Root: ${graph.rootPath}\n` +
|
|
98
|
-
`Indexed at: ${graph.indexedAt}\n` +
|
|
99
|
-
gitLine +
|
|
100
|
-
`Files: ${graph.filesIndexed}\n` +
|
|
101
|
-
`Symbols: ${graph.symbolCount}\n` +
|
|
102
|
-
`Calls: ${graph.edgeCount ?? 0}\n` +
|
|
103
|
-
`Graph file: ${graphPath(absRoot)}\n`,
|
|
104
|
-
);
|
|
105
|
-
return 0;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function main(): Promise<number> {
|
|
109
|
-
const [, , cmd, ...rest] = process.argv;
|
|
110
|
-
switch (cmd) {
|
|
111
|
-
case 'index': {
|
|
112
|
-
const noWorktree = rest.includes('--no-worktree');
|
|
113
|
-
const path = rest.find((a) => !a.startsWith('--')) ?? '.';
|
|
114
|
-
return cmdIndex(path, { noWorktree });
|
|
115
|
-
}
|
|
116
|
-
case 'status': {
|
|
117
|
-
const path = rest[0] ?? '.';
|
|
118
|
-
return cmdStatus(path);
|
|
119
|
-
}
|
|
120
|
-
case 'mcp': {
|
|
121
|
-
// Server runs until stdin closes (MCP client disconnect). Never
|
|
122
|
-
// returns under normal operation.
|
|
123
|
-
await startMcpServer();
|
|
124
|
-
return 0;
|
|
125
|
-
}
|
|
126
|
-
case 'watch': {
|
|
127
|
-
const noWorktree = rest.includes('--no-worktree');
|
|
128
|
-
const path = rest.find((a) => !a.startsWith('--')) ?? '.';
|
|
129
|
-
const requested = resolve(path);
|
|
130
|
-
const { root: absRoot, redirected } = resolveIndexRoot(requested, { disable: noWorktree });
|
|
131
|
-
if (redirected) {
|
|
132
|
-
process.stderr.write(`[graphpilot:watch] Re-rooting to worktree top: ${absRoot}\n`);
|
|
133
|
-
}
|
|
134
|
-
const refusal = validateRootPath(absRoot);
|
|
135
|
-
if (refusal) {
|
|
136
|
-
process.stderr.write(`Error: ${refusal}\n`);
|
|
137
|
-
return 2;
|
|
138
|
-
}
|
|
139
|
-
const watcher = new GraphWatcher(absRoot);
|
|
140
|
-
await watcher.start();
|
|
141
|
-
process.stderr.write(`[graphpilot:watch] Ctrl+C to stop.\n`);
|
|
142
|
-
// Hold the process open until SIGINT or stdin EOF.
|
|
143
|
-
await new Promise<void>((res) => {
|
|
144
|
-
const finish = (): void => res();
|
|
145
|
-
process.once('SIGINT', finish);
|
|
146
|
-
process.once('SIGTERM', finish);
|
|
147
|
-
process.stdin.once('end', finish);
|
|
148
|
-
process.stdin.once('close', finish);
|
|
149
|
-
});
|
|
150
|
-
await watcher.stop();
|
|
151
|
-
return 0;
|
|
152
|
-
}
|
|
153
|
-
case 'help':
|
|
154
|
-
case '--help':
|
|
155
|
-
case '-h':
|
|
156
|
-
case undefined:
|
|
157
|
-
process.stdout.write(HELP);
|
|
158
|
-
return 0;
|
|
159
|
-
default:
|
|
160
|
-
process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
161
|
-
return 2;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
main().then(
|
|
166
|
-
(code) => process.exit(code),
|
|
167
|
-
(err) => {
|
|
168
|
-
process.stderr.write(`Error: ${err?.stack ?? err}\n`);
|
|
169
|
-
process.exit(1);
|
|
170
|
-
},
|
|
171
|
-
);
|
package/src/edges.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import type Parser from 'tree-sitter';
|
|
2
|
-
import { walk, type ParsedFile } from './parser.js';
|
|
3
|
-
import type { SymbolRecord } from './symbols.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A resolved call edge.
|
|
7
|
-
*
|
|
8
|
-
* `toId` is null when the call's target couldn't be resolved to a known symbol
|
|
9
|
-
* (e.g. it's a stdlib call like `Array.from`, a third-party import, or a
|
|
10
|
-
* dynamic dispatch we don't track in v1). `toName` is always set, so the agent
|
|
11
|
-
* still knows what was called.
|
|
12
|
-
*/
|
|
13
|
-
export interface CallEdge {
|
|
14
|
-
fromId: string;
|
|
15
|
-
toId: string | null;
|
|
16
|
-
toName: string;
|
|
17
|
-
file: string;
|
|
18
|
-
line: number;
|
|
19
|
-
column: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* A pre-resolution call site. Same shape as CallEdge minus `toId`. Used during
|
|
24
|
-
* indexing before we have the full symbol table.
|
|
25
|
-
*/
|
|
26
|
-
export interface RawCall {
|
|
27
|
-
fromId: string;
|
|
28
|
-
toName: string;
|
|
29
|
-
file: string;
|
|
30
|
-
line: number;
|
|
31
|
-
column: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const FUNCTION_NODE_TYPES = new Set([
|
|
35
|
-
'function_declaration',
|
|
36
|
-
'generator_function_declaration',
|
|
37
|
-
'function_expression',
|
|
38
|
-
'arrow_function',
|
|
39
|
-
'method_definition',
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Walk a function body, but stop descending into nested function definitions.
|
|
44
|
-
* This way a call inside `(_ => foo())` placed inside `outer()` is attributed
|
|
45
|
-
* to the arrow, not to `outer`. (When the arrow itself has a SymbolRecord —
|
|
46
|
-
* because it was assigned to a const — we'll visit it separately.)
|
|
47
|
-
*/
|
|
48
|
-
function* walkBodyExcludingNestedFns(rootNode: Parser.SyntaxNode): Generator<Parser.SyntaxNode> {
|
|
49
|
-
const stack: { node: Parser.SyntaxNode; isRoot: boolean }[] = [{ node: rootNode, isRoot: true }];
|
|
50
|
-
while (stack.length > 0) {
|
|
51
|
-
const { node, isRoot } = stack.pop()!;
|
|
52
|
-
if (!isRoot && FUNCTION_NODE_TYPES.has(node.type)) continue;
|
|
53
|
-
yield node;
|
|
54
|
-
for (let i = node.childCount - 1; i >= 0; i--) {
|
|
55
|
-
const child = node.child(i);
|
|
56
|
-
if (child) stack.push({ node: child, isRoot: false });
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Extract the callee name from a `call_expression` or `new_expression` node.
|
|
63
|
-
* Returns null for dynamic forms we don't try to resolve in v1.
|
|
64
|
-
*
|
|
65
|
-
* Examples handled:
|
|
66
|
-
* foo() -> "foo"
|
|
67
|
-
* obj.method() -> "method"
|
|
68
|
-
* this.helper() -> "helper"
|
|
69
|
-
* new Foo() -> "Foo"
|
|
70
|
-
*
|
|
71
|
-
* Examples not handled (returns null):
|
|
72
|
-
* arr[x]()
|
|
73
|
-
* (function(){})()
|
|
74
|
-
* func.call(this, ...) (we'd just see "call", which is fine — it's a
|
|
75
|
-
* known limitation. Agent can still find the call.)
|
|
76
|
-
*/
|
|
77
|
-
function calleeName(callNode: Parser.SyntaxNode): string | null {
|
|
78
|
-
const fnField =
|
|
79
|
-
callNode.childForFieldName('function') ?? callNode.childForFieldName('constructor');
|
|
80
|
-
if (!fnField) return null;
|
|
81
|
-
if (fnField.type === 'identifier' || fnField.type === 'type_identifier') {
|
|
82
|
-
return fnField.text;
|
|
83
|
-
}
|
|
84
|
-
if (fnField.type === 'member_expression') {
|
|
85
|
-
const prop = fnField.childForFieldName('property');
|
|
86
|
-
return prop?.text ?? null;
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Match the AST node a SymbolRecord was extracted from. We use line+name as
|
|
93
|
-
* the key; collisions are vanishingly rare (would need two same-named symbols
|
|
94
|
-
* on the same line, which TS would reject).
|
|
95
|
-
*/
|
|
96
|
-
function nodeMatchKey(node: Parser.SyntaxNode): string | null {
|
|
97
|
-
if (
|
|
98
|
-
node.type === 'function_declaration' ||
|
|
99
|
-
node.type === 'generator_function_declaration' ||
|
|
100
|
-
node.type === 'method_definition'
|
|
101
|
-
) {
|
|
102
|
-
const name = node.childForFieldName('name')?.text;
|
|
103
|
-
if (!name) return null;
|
|
104
|
-
return `${node.startPosition.row + 1}:${name}`;
|
|
105
|
-
}
|
|
106
|
-
if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
|
107
|
-
// We stored these under their variable name; the variable_declarator is
|
|
108
|
-
// the parent we need. The declarator's startLine matches the SymbolRecord
|
|
109
|
-
// line (we record the declarator, not the value).
|
|
110
|
-
const parent = node.parent;
|
|
111
|
-
if (parent?.type === 'variable_declarator') {
|
|
112
|
-
const name = parent.childForFieldName('name')?.text;
|
|
113
|
-
if (!name) return null;
|
|
114
|
-
return `${parent.startPosition.row + 1}:${name}`;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* For every function-like symbol in `fileSymbols`, walk its body and emit a
|
|
122
|
-
* RawCall for every call/new expression directly inside it.
|
|
123
|
-
*
|
|
124
|
-
* Returns calls keyed by *line+name lookup* so resolution can happen later.
|
|
125
|
-
*/
|
|
126
|
-
export function extractRawCalls(parsed: ParsedFile, fileSymbols: SymbolRecord[]): RawCall[] {
|
|
127
|
-
const calls: RawCall[] = [];
|
|
128
|
-
|
|
129
|
-
// Index symbols by line:name so we can match an AST node back to its record.
|
|
130
|
-
const symByKey = new Map<string, SymbolRecord>();
|
|
131
|
-
for (const s of fileSymbols) {
|
|
132
|
-
symByKey.set(`${s.line}:${s.name}`, s);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (const node of walk(parsed.tree.rootNode)) {
|
|
136
|
-
if (!FUNCTION_NODE_TYPES.has(node.type)) continue;
|
|
137
|
-
const key = nodeMatchKey(node);
|
|
138
|
-
if (!key) continue;
|
|
139
|
-
const sym = symByKey.get(key);
|
|
140
|
-
if (!sym) continue;
|
|
141
|
-
|
|
142
|
-
const body = node.childForFieldName('body');
|
|
143
|
-
if (!body) continue;
|
|
144
|
-
|
|
145
|
-
for (const sub of walkBodyExcludingNestedFns(body)) {
|
|
146
|
-
if (sub.type !== 'call_expression' && sub.type !== 'new_expression') {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const name = calleeName(sub);
|
|
150
|
-
if (!name) continue;
|
|
151
|
-
calls.push({
|
|
152
|
-
fromId: sym.id,
|
|
153
|
-
toName: name,
|
|
154
|
-
file: parsed.path,
|
|
155
|
-
line: sub.startPosition.row + 1,
|
|
156
|
-
column: sub.startPosition.column + 1,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return calls;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Second-pass resolver. Given the full symbol table and a list of raw calls,
|
|
166
|
-
* fill in `toId` where the callee name matches a known symbol.
|
|
167
|
-
*
|
|
168
|
-
* Resolution strategy (v1 — deliberately dumb):
|
|
169
|
-
* 1. Prefer a symbol with the same name in the same file (likely the right one)
|
|
170
|
-
* 2. Otherwise pick any symbol with that name (first match — non-deterministic
|
|
171
|
-
* across reruns of ambiguous names, but stable within a single index)
|
|
172
|
-
* 3. Otherwise leave toId null
|
|
173
|
-
*
|
|
174
|
-
* Known limitations (documented as v1 caveats):
|
|
175
|
-
* - No import resolution: if `parseToken` is imported from another file we'll
|
|
176
|
-
* still find it globally, but if two files both export `parseToken` we may
|
|
177
|
-
* pick the wrong one.
|
|
178
|
-
* - No method-of-class disambiguation: `obj.method()` resolves to the first
|
|
179
|
-
* symbol named `method`, regardless of receiver type.
|
|
180
|
-
* - No re-export chains.
|
|
181
|
-
*
|
|
182
|
-
* These are fine for v1; the goal is "better than grep" not "compiler-grade".
|
|
183
|
-
*/
|
|
184
|
-
export function resolveCallEdges(rawCalls: RawCall[], allSymbols: SymbolRecord[]): CallEdge[] {
|
|
185
|
-
const byName = new Map<string, SymbolRecord[]>();
|
|
186
|
-
for (const s of allSymbols) {
|
|
187
|
-
const list = byName.get(s.name);
|
|
188
|
-
if (list) list.push(s);
|
|
189
|
-
else byName.set(s.name, [s]);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return rawCalls.map((c) => {
|
|
193
|
-
const candidates = byName.get(c.toName);
|
|
194
|
-
if (!candidates || candidates.length === 0) {
|
|
195
|
-
return { ...c, toId: null };
|
|
196
|
-
}
|
|
197
|
-
// Prefer same-file candidates first.
|
|
198
|
-
const sameFile = candidates.find((s) => s.file === c.file);
|
|
199
|
-
if (sameFile) return { ...c, toId: sameFile.id };
|
|
200
|
-
return { ...c, toId: candidates[0].id };
|
|
201
|
-
});
|
|
202
|
-
}
|