@graphpilot-oss/graphpilot 0.0.1 → 1.0.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 +72 -126
- package/README.md +290 -102
- package/dist/cli.js +41 -1
- package/dist/cli.js.map +1 -1
- package/dist/edges.js +22 -11
- package/dist/edges.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 +119 -90
- 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/symbols.js +26 -2
- package/dist/symbols.js.map +1 -1
- package/dist/validation.js +30 -4
- package/dist/validation.js.map +1 -1
- package/dist/validators.d.ts +1 -5
- package/dist/validators.js +0 -11
- package/dist/validators.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 +14 -4
- 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/src/watcher.ts
DELETED
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Watch mode (A2) — keep the on-disk graph fresh as the user edits.
|
|
3
|
-
*
|
|
4
|
-
* Algorithm per file event:
|
|
5
|
-
* 1. Remove all symbols + raw calls that came from this file
|
|
6
|
-
* 2. Re-parse the file, extract its symbols + raw calls
|
|
7
|
-
* 3. Re-resolve all raw calls against the full symbol table
|
|
8
|
-
* (cheap: under 50ms for a 2k-symbol repo)
|
|
9
|
-
* 4. Atomic save via storage.saveGraph (.tmp + rename)
|
|
10
|
-
*
|
|
11
|
-
* No schema change: rawCalls are reconstructed from existing edges on
|
|
12
|
-
* startup (every CallEdge carries fromId/toName/file/line/column, which
|
|
13
|
-
* is exactly the RawCall shape).
|
|
14
|
-
*
|
|
15
|
-
* Diagnostic lines go to stderr — never stdout — so stdin/stdout stays
|
|
16
|
-
* clean for any agent that's also reading the graph over MCP.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import chokidar, { type FSWatcher } from 'chokidar';
|
|
20
|
-
import { realpathSync } from 'node:fs';
|
|
21
|
-
import { resolve, relative } from 'node:path';
|
|
22
|
-
import { parseFile } from './parser.js';
|
|
23
|
-
import { extractSymbols, type SymbolRecord } from './symbols.js';
|
|
24
|
-
import { extractRawCalls, resolveCallEdges, type RawCall, type CallEdge } from './edges.js';
|
|
25
|
-
import { saveGraph, loadGraph, repoIdFor, type Graph } from './storage.js';
|
|
26
|
-
import { readGitInfo } from './git.js';
|
|
27
|
-
import { indexDirectory } from './indexer.js';
|
|
28
|
-
import { validateRootPath, MAX_FILES_PER_INDEX } from './validation.js';
|
|
29
|
-
|
|
30
|
-
/** Per-event delta the watcher reports back to the caller (and to stderr). */
|
|
31
|
-
export interface UpdateResult {
|
|
32
|
-
file: string;
|
|
33
|
-
kind: 'add' | 'change' | 'delete';
|
|
34
|
-
symbolsBefore: number;
|
|
35
|
-
symbolsAfter: number;
|
|
36
|
-
edgesBefore: number;
|
|
37
|
-
edgesAfter: number;
|
|
38
|
-
durationMs: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface WatcherOptions {
|
|
42
|
-
/** Debounce window for editor "save in 3 syscalls" patterns. Default 100 ms. */
|
|
43
|
-
awaitStabilityMs?: number;
|
|
44
|
-
/** Logger for human-readable progress lines. Default: process.stderr. */
|
|
45
|
-
log?: (line: string) => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const DEFAULT_IGNORE = [
|
|
49
|
-
/(^|[/\\])\.git([/\\]|$)/,
|
|
50
|
-
/(^|[/\\])node_modules([/\\]|$)/,
|
|
51
|
-
/(^|[/\\])dist([/\\]|$)/,
|
|
52
|
-
/(^|[/\\])build([/\\]|$)/,
|
|
53
|
-
/(^|[/\\])coverage([/\\]|$)/,
|
|
54
|
-
/(^|[/\\])\.next([/\\]|$)/,
|
|
55
|
-
/(^|[/\\])\.nuxt([/\\]|$)/,
|
|
56
|
-
/(^|[/\\])\.cache([/\\]|$)/,
|
|
57
|
-
/(^|[/\\])out([/\\]|$)/,
|
|
58
|
-
/\.d\.ts$/,
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
const WATCHED_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
62
|
-
|
|
63
|
-
function isWatchableFile(absPath: string): boolean {
|
|
64
|
-
const dot = absPath.lastIndexOf('.');
|
|
65
|
-
if (dot === -1) return false;
|
|
66
|
-
return WATCHED_EXT.has(absPath.slice(dot).toLowerCase());
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function defaultLogger(line: string): void {
|
|
70
|
-
process.stderr.write(`[graphpilot:watch] ${line}\n`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Stateful watcher. Owns the in-memory Graph + raw calls and reconciles
|
|
75
|
-
* the on-disk graph.json on every event.
|
|
76
|
-
*
|
|
77
|
-
* Methods come in two flavours:
|
|
78
|
-
* - lifecycle: start / stop, drive chokidar
|
|
79
|
-
* - applyUpdate / applyDeletion: synchronous-style helpers that tests
|
|
80
|
-
* can call directly, bypassing chokidar (which is racy in tests)
|
|
81
|
-
*/
|
|
82
|
-
export class GraphWatcher {
|
|
83
|
-
readonly absRoot: string;
|
|
84
|
-
private graph: Graph;
|
|
85
|
-
private rawCalls: RawCall[];
|
|
86
|
-
private watcher: FSWatcher | null = null;
|
|
87
|
-
private readonly log: (line: string) => void;
|
|
88
|
-
private readonly awaitStabilityMs: number;
|
|
89
|
-
/**
|
|
90
|
-
* Serializes the update queue so chokidar bursts (multiple `change`
|
|
91
|
-
* events in 200ms) don't race each other into a torn graph.
|
|
92
|
-
*/
|
|
93
|
-
private chain: Promise<void> = Promise.resolve();
|
|
94
|
-
|
|
95
|
-
constructor(rawRoot: string, opts: WatcherOptions = {}) {
|
|
96
|
-
this.absRoot = resolve(rawRoot);
|
|
97
|
-
this.log = opts.log ?? defaultLogger;
|
|
98
|
-
this.awaitStabilityMs = opts.awaitStabilityMs ?? 100;
|
|
99
|
-
|
|
100
|
-
const refusal = validateRootPath(this.absRoot);
|
|
101
|
-
if (refusal) throw new Error(refusal);
|
|
102
|
-
|
|
103
|
-
// Defence-in-depth realpath check (T2): make sure the user-given path
|
|
104
|
-
// resolves to a real directory. We do NOT overwrite this.absRoot with
|
|
105
|
-
// the canonical form — that would break the path → repoId hash and
|
|
106
|
-
// cause the watcher to read+write a different graph.json than `index`.
|
|
107
|
-
realpathSync(this.absRoot);
|
|
108
|
-
|
|
109
|
-
const loaded = loadGraph(this.absRoot);
|
|
110
|
-
if (loaded) {
|
|
111
|
-
this.graph = loaded;
|
|
112
|
-
this.rawCalls = this.deriveRawCalls(loaded.edges);
|
|
113
|
-
} else {
|
|
114
|
-
// No existing index — caller is expected to `start()`, which will
|
|
115
|
-
// build one. We initialize empty here so applyUpdate is safe to call
|
|
116
|
-
// pre-start in tests.
|
|
117
|
-
this.graph = {
|
|
118
|
-
version: 1,
|
|
119
|
-
repoId: repoIdFor(this.absRoot),
|
|
120
|
-
rootPath: this.absRoot,
|
|
121
|
-
indexedAt: new Date().toISOString(),
|
|
122
|
-
filesIndexed: 0,
|
|
123
|
-
symbolCount: 0,
|
|
124
|
-
edgeCount: 0,
|
|
125
|
-
symbols: [],
|
|
126
|
-
edges: [],
|
|
127
|
-
};
|
|
128
|
-
this.rawCalls = [];
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Build a one-shot full index if no graph exists, then start the watcher. */
|
|
133
|
-
async start(): Promise<void> {
|
|
134
|
-
if (this.graph.symbols.length === 0) {
|
|
135
|
-
this.log(`No existing index. Running full index of ${this.absRoot} ...`);
|
|
136
|
-
await this.fullReindex();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
this.log(
|
|
140
|
-
`Watching ${this.absRoot} ` +
|
|
141
|
-
`(${this.graph.symbols.length} symbols, ${this.graph.edges.length} calls, ` +
|
|
142
|
-
`${this.graph.filesIndexed} files). Edit a file to see updates.`,
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
this.watcher = chokidar.watch(this.absRoot, {
|
|
146
|
-
ignored: DEFAULT_IGNORE,
|
|
147
|
-
ignoreInitial: true, // we already have a graph
|
|
148
|
-
persistent: true,
|
|
149
|
-
followSymlinks: false,
|
|
150
|
-
awaitWriteFinish: {
|
|
151
|
-
stabilityThreshold: this.awaitStabilityMs,
|
|
152
|
-
pollInterval: 50,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
this.watcher.on('add', (abs: string) => this.enqueue(() => this.handleEvent(abs, 'add')));
|
|
157
|
-
this.watcher.on('change', (abs: string) => this.enqueue(() => this.handleEvent(abs, 'change')));
|
|
158
|
-
this.watcher.on('unlink', (abs: string) => this.enqueue(() => this.handleDeletion(abs)));
|
|
159
|
-
this.watcher.on('error', (err: unknown) => this.log(`watcher error: ${String(err)}`));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async stop(): Promise<void> {
|
|
163
|
-
if (this.watcher) {
|
|
164
|
-
await this.watcher.close();
|
|
165
|
-
this.watcher = null;
|
|
166
|
-
}
|
|
167
|
-
await this.chain;
|
|
168
|
-
this.log('Stopped.');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Process a single file update. Public so tests can drive it directly
|
|
173
|
-
* without spawning chokidar.
|
|
174
|
-
*/
|
|
175
|
-
async applyUpdate(
|
|
176
|
-
absFilePath: string,
|
|
177
|
-
kind: 'add' | 'change' = 'change',
|
|
178
|
-
): Promise<UpdateResult | null> {
|
|
179
|
-
if (!isWatchableFile(absFilePath)) return null;
|
|
180
|
-
if (!absFilePath.startsWith(this.absRoot)) return null;
|
|
181
|
-
|
|
182
|
-
const start = Date.now();
|
|
183
|
-
const rel = relative(this.absRoot, absFilePath);
|
|
184
|
-
const symbolsBefore = this.graph.symbols.length;
|
|
185
|
-
const edgesBefore = this.graph.edges.length;
|
|
186
|
-
|
|
187
|
-
// 1. Remove existing symbols + raw calls from this file
|
|
188
|
-
const keptSymbols = this.graph.symbols.filter((s) => s.file !== rel);
|
|
189
|
-
const keptRawCalls = this.rawCalls.filter((c) => c.file !== rel);
|
|
190
|
-
|
|
191
|
-
// 2. Parse the new content + extract
|
|
192
|
-
const parsed = parseFile(absFilePath);
|
|
193
|
-
if (!parsed) {
|
|
194
|
-
// Could not parse (too large, unknown ext, gone, etc.) — treat as a
|
|
195
|
-
// deletion of that file's contribution. Keeps the index honest.
|
|
196
|
-
this.commitState(keptSymbols, keptRawCalls);
|
|
197
|
-
const result = this.finalize(rel, kind, symbolsBefore, edgesBefore, start);
|
|
198
|
-
this.log(
|
|
199
|
-
`${rel} unparseable; dropped ${symbolsBefore - keptSymbols.length} symbols (${result.durationMs}ms).`,
|
|
200
|
-
);
|
|
201
|
-
return result;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const fileSymbols = extractSymbols(parsed);
|
|
205
|
-
const fileCalls = extractRawCalls(parsed, fileSymbols);
|
|
206
|
-
|
|
207
|
-
// 3. Normalize paths + rewrite ids to relative form (same as indexer.ts)
|
|
208
|
-
const idRewrites = new Map<string, string>();
|
|
209
|
-
for (const s of fileSymbols) {
|
|
210
|
-
const oldId = s.id;
|
|
211
|
-
s.file = rel;
|
|
212
|
-
s.id = oldId.replace(absFilePath, rel);
|
|
213
|
-
idRewrites.set(oldId, s.id);
|
|
214
|
-
}
|
|
215
|
-
for (const c of fileCalls) {
|
|
216
|
-
c.file = rel;
|
|
217
|
-
c.fromId = idRewrites.get(c.fromId) ?? c.fromId;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const newSymbols = [...keptSymbols, ...fileSymbols];
|
|
221
|
-
const newRawCalls = [...keptRawCalls, ...fileCalls];
|
|
222
|
-
|
|
223
|
-
// 4. Re-resolve every edge against the new symbol table. Cheap.
|
|
224
|
-
this.commitState(newSymbols, newRawCalls);
|
|
225
|
-
|
|
226
|
-
const result = this.finalize(rel, kind, symbolsBefore, edgesBefore, start);
|
|
227
|
-
this.log(
|
|
228
|
-
`${rel}: ${this.deltaStr(symbolsBefore, result.symbolsAfter)} symbols, ` +
|
|
229
|
-
`${this.deltaStr(edgesBefore, result.edgesAfter)} calls (${result.durationMs}ms).`,
|
|
230
|
-
);
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Drop everything that came from a deleted file. */
|
|
235
|
-
async applyDeletion(absFilePath: string): Promise<UpdateResult | null> {
|
|
236
|
-
if (!absFilePath.startsWith(this.absRoot)) return null;
|
|
237
|
-
|
|
238
|
-
const start = Date.now();
|
|
239
|
-
const rel = relative(this.absRoot, absFilePath);
|
|
240
|
-
const symbolsBefore = this.graph.symbols.length;
|
|
241
|
-
const edgesBefore = this.graph.edges.length;
|
|
242
|
-
|
|
243
|
-
const keptSymbols = this.graph.symbols.filter((s) => s.file !== rel);
|
|
244
|
-
const keptRawCalls = this.rawCalls.filter((c) => c.file !== rel);
|
|
245
|
-
|
|
246
|
-
// No change? File wasn't in the index, ignore.
|
|
247
|
-
if (
|
|
248
|
-
keptSymbols.length === this.graph.symbols.length &&
|
|
249
|
-
keptRawCalls.length === this.rawCalls.length
|
|
250
|
-
) {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
this.commitState(keptSymbols, keptRawCalls);
|
|
255
|
-
const result = this.finalize(rel, 'delete', symbolsBefore, edgesBefore, start);
|
|
256
|
-
this.log(
|
|
257
|
-
`${rel} deleted: ${this.deltaStr(symbolsBefore, result.symbolsAfter)} symbols, ` +
|
|
258
|
-
`${this.deltaStr(edgesBefore, result.edgesAfter)} calls (${result.durationMs}ms).`,
|
|
259
|
-
);
|
|
260
|
-
return result;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Force a full re-index from scratch. Used on startup if no graph exists. */
|
|
264
|
-
async fullReindex(): Promise<void> {
|
|
265
|
-
const result = await indexDirectory(this.absRoot);
|
|
266
|
-
if (result.symbols.length > MAX_FILES_PER_INDEX) {
|
|
267
|
-
throw new Error('Refusing to watch: index would exceed MAX_FILES_PER_INDEX.');
|
|
268
|
-
}
|
|
269
|
-
this.graph = {
|
|
270
|
-
version: 1,
|
|
271
|
-
repoId: repoIdFor(this.absRoot),
|
|
272
|
-
rootPath: this.absRoot,
|
|
273
|
-
indexedAt: new Date().toISOString(),
|
|
274
|
-
filesIndexed: result.filesIndexed,
|
|
275
|
-
symbolCount: result.symbols.length,
|
|
276
|
-
edgeCount: result.edges.length,
|
|
277
|
-
symbols: result.symbols,
|
|
278
|
-
edges: result.edges,
|
|
279
|
-
indexedSha: result.git.sha,
|
|
280
|
-
indexedBranch: result.git.branch,
|
|
281
|
-
};
|
|
282
|
-
this.rawCalls = this.deriveRawCalls(result.edges);
|
|
283
|
-
saveGraph(this.graph);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Read-only accessor, mainly for tests + diagnostics. */
|
|
287
|
-
get currentGraph(): Graph {
|
|
288
|
-
return this.graph;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// -------------------------------------------------------------------------
|
|
292
|
-
// internals
|
|
293
|
-
// -------------------------------------------------------------------------
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Chain updates so chokidar's burst events don't run in parallel. Errors
|
|
297
|
-
* are logged but never abort the chain — the next event still runs.
|
|
298
|
-
*/
|
|
299
|
-
private enqueue(work: () => Promise<void>): void {
|
|
300
|
-
this.chain = this.chain.then(
|
|
301
|
-
() =>
|
|
302
|
-
work().catch((err) => {
|
|
303
|
-
this.log(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
304
|
-
}),
|
|
305
|
-
() => undefined, // can't reach because catch above never rejects
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
private async handleEvent(abs: string, kind: 'add' | 'change'): Promise<void> {
|
|
310
|
-
if (!isWatchableFile(abs)) return;
|
|
311
|
-
await this.applyUpdate(abs, kind);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private async handleDeletion(abs: string): Promise<void> {
|
|
315
|
-
await this.applyDeletion(abs);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Apply a new (symbols, rawCalls) state: re-resolve edges, update the
|
|
320
|
-
* graph object, save atomically.
|
|
321
|
-
*/
|
|
322
|
-
private commitState(symbols: SymbolRecord[], rawCalls: RawCall[]): void {
|
|
323
|
-
const edges = resolveCallEdges(rawCalls, symbols);
|
|
324
|
-
|
|
325
|
-
// Recompute filesIndexed from surviving symbols' files.
|
|
326
|
-
const files = new Set<string>();
|
|
327
|
-
for (const s of symbols) files.add(s.file);
|
|
328
|
-
|
|
329
|
-
// Re-read git info on every commit — branch / sha can change between
|
|
330
|
-
// edits (e.g. user did `git checkout` mid-session) and we want the
|
|
331
|
-
// graph stamped with the *current* state. Cheap: a few fs reads.
|
|
332
|
-
const git = readGitInfo(this.absRoot);
|
|
333
|
-
|
|
334
|
-
this.graph = {
|
|
335
|
-
...this.graph,
|
|
336
|
-
indexedAt: new Date().toISOString(),
|
|
337
|
-
filesIndexed: files.size,
|
|
338
|
-
symbolCount: symbols.length,
|
|
339
|
-
edgeCount: edges.length,
|
|
340
|
-
symbols,
|
|
341
|
-
edges,
|
|
342
|
-
indexedSha: git.sha,
|
|
343
|
-
indexedBranch: git.branch,
|
|
344
|
-
};
|
|
345
|
-
this.rawCalls = rawCalls;
|
|
346
|
-
saveGraph(this.graph);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
private finalize(
|
|
350
|
-
file: string,
|
|
351
|
-
kind: UpdateResult['kind'],
|
|
352
|
-
symbolsBefore: number,
|
|
353
|
-
edgesBefore: number,
|
|
354
|
-
start: number,
|
|
355
|
-
): UpdateResult {
|
|
356
|
-
return {
|
|
357
|
-
file,
|
|
358
|
-
kind,
|
|
359
|
-
symbolsBefore,
|
|
360
|
-
symbolsAfter: this.graph.symbols.length,
|
|
361
|
-
edgesBefore,
|
|
362
|
-
edgesAfter: this.graph.edges.length,
|
|
363
|
-
durationMs: Date.now() - start,
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
private deriveRawCalls(edges: CallEdge[]): RawCall[] {
|
|
368
|
-
return edges.map((e) => ({
|
|
369
|
-
fromId: e.fromId,
|
|
370
|
-
toName: e.toName,
|
|
371
|
-
file: e.file,
|
|
372
|
-
line: e.line,
|
|
373
|
-
column: e.column,
|
|
374
|
-
}));
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
private deltaStr(before: number, after: number): string {
|
|
378
|
-
const delta = after - before;
|
|
379
|
-
if (delta === 0) return `${after} (=)`;
|
|
380
|
-
const sign = delta > 0 ? '+' : '';
|
|
381
|
-
return `${after} (${sign}${delta})`;
|
|
382
|
-
}
|
|
383
|
-
}
|
package/tests/edges.test.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join, dirname } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { parseFile } from '../src/parser.js';
|
|
7
|
-
import { extractSymbols } from '../src/symbols.js';
|
|
8
|
-
import { extractRawCalls, resolveCallEdges } from '../src/edges.js';
|
|
9
|
-
import { indexDirectory } from '../src/indexer.js';
|
|
10
|
-
|
|
11
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const fixture = (name: string) => join(here, 'fixtures', name);
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// extractRawCalls (per-file)
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
describe('extractRawCalls', () => {
|
|
19
|
-
it('finds in-file call: authenticate → parseToken', () => {
|
|
20
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
21
|
-
const syms = extractSymbols(parsed);
|
|
22
|
-
const calls = extractRawCalls(parsed, syms);
|
|
23
|
-
|
|
24
|
-
const authToParseToken = calls.find(
|
|
25
|
-
(c) => c.toName === 'parseToken' && c.fromId.includes('AuthService.authenticate'),
|
|
26
|
-
);
|
|
27
|
-
expect(authToParseToken).toBeDefined();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('finds method call: parseToken → trim (unresolvable but captured)', () => {
|
|
31
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
32
|
-
const syms = extractSymbols(parsed);
|
|
33
|
-
const calls = extractRawCalls(parsed, syms);
|
|
34
|
-
|
|
35
|
-
const trimCall = calls.find((c) => c.toName === 'trim');
|
|
36
|
-
expect(trimCall).toBeDefined();
|
|
37
|
-
expect(trimCall!.fromId).toContain('parseToken');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('does not falsely emit calls for property accesses', () => {
|
|
41
|
-
// validateJwt body is `return jwt.length > 0;` — `.length` is a property,
|
|
42
|
-
// not a call. We should NOT emit it as a call.
|
|
43
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
44
|
-
const syms = extractSymbols(parsed);
|
|
45
|
-
const calls = extractRawCalls(parsed, syms);
|
|
46
|
-
|
|
47
|
-
expect(calls.find((c) => c.toName === 'length')).toBeUndefined();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returns line+column for every call site', () => {
|
|
51
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
52
|
-
const syms = extractSymbols(parsed);
|
|
53
|
-
const calls = extractRawCalls(parsed, syms);
|
|
54
|
-
|
|
55
|
-
for (const c of calls) {
|
|
56
|
-
expect(c.line).toBeGreaterThan(0);
|
|
57
|
-
expect(c.column).toBeGreaterThan(0);
|
|
58
|
-
expect(c.fromId).toMatch(/.+#.+@\d+/);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// resolveCallEdges (post-pass)
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
describe('resolveCallEdges', () => {
|
|
68
|
-
it('resolves callee in same file', () => {
|
|
69
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
70
|
-
const syms = extractSymbols(parsed);
|
|
71
|
-
const raw = extractRawCalls(parsed, syms);
|
|
72
|
-
const edges = resolveCallEdges(raw, syms);
|
|
73
|
-
|
|
74
|
-
const e = edges.find((x) => x.toName === 'parseToken');
|
|
75
|
-
expect(e?.toId).not.toBeNull();
|
|
76
|
-
expect(e?.toId).toContain('parseToken');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('marks unknown calls as unresolved (toId: null)', () => {
|
|
80
|
-
const parsed = parseFile(fixture('sample.ts'))!;
|
|
81
|
-
const syms = extractSymbols(parsed);
|
|
82
|
-
const raw = extractRawCalls(parsed, syms);
|
|
83
|
-
const edges = resolveCallEdges(raw, syms);
|
|
84
|
-
|
|
85
|
-
const e = edges.find((x) => x.toName === 'trim');
|
|
86
|
-
expect(e?.toId).toBeNull();
|
|
87
|
-
expect(e?.toName).toBe('trim');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('prefers same-file candidate when multiple symbols share a name', () => {
|
|
91
|
-
const fileASym = {
|
|
92
|
-
id: 'a.ts#foo@1',
|
|
93
|
-
name: 'foo',
|
|
94
|
-
kind: 'function' as const,
|
|
95
|
-
file: 'a.ts',
|
|
96
|
-
line: 1,
|
|
97
|
-
column: 1,
|
|
98
|
-
endLine: 3,
|
|
99
|
-
signature: 'function foo()',
|
|
100
|
-
exported: false,
|
|
101
|
-
};
|
|
102
|
-
const fileBSym = {
|
|
103
|
-
...fileASym,
|
|
104
|
-
id: 'b.ts#foo@1',
|
|
105
|
-
file: 'b.ts',
|
|
106
|
-
};
|
|
107
|
-
const raw = [{ fromId: 'b.ts#bar@5', toName: 'foo', file: 'b.ts', line: 6, column: 10 }];
|
|
108
|
-
const edges = resolveCallEdges(raw, [fileASym, fileBSym]);
|
|
109
|
-
expect(edges[0].toId).toBe('b.ts#foo@1');
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Integration: cross-file resolution end-to-end
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
describe('indexDirectory: cross-file edges', () => {
|
|
118
|
-
let workDir: string;
|
|
119
|
-
|
|
120
|
-
beforeEach(() => {
|
|
121
|
-
workDir = mkdtempSync(join(tmpdir(), 'graphpilot-edges-'));
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
afterEach(() => {
|
|
125
|
-
if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('resolves a call across two files', async () => {
|
|
129
|
-
writeFileSync(join(workDir, 'a.ts'), `export function helper(): number { return 42; }\n`);
|
|
130
|
-
writeFileSync(
|
|
131
|
-
join(workDir, 'b.ts'),
|
|
132
|
-
`import { helper } from './a';\nexport function main(): number { return helper(); }\n`,
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
const result = await indexDirectory(workDir);
|
|
136
|
-
|
|
137
|
-
expect(result.symbols.map((s) => s.name).sort()).toEqual(['helper', 'main']);
|
|
138
|
-
|
|
139
|
-
const mainToHelper = result.edges.find((e) => e.toName === 'helper' && e.file === 'b.ts');
|
|
140
|
-
expect(mainToHelper).toBeDefined();
|
|
141
|
-
expect(mainToHelper!.toId).not.toBeNull();
|
|
142
|
-
expect(mainToHelper!.toId).toContain('a.ts#helper');
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('emits zero edges when no calls exist', async () => {
|
|
146
|
-
writeFileSync(join(workDir, 'pure.ts'), `export const x = 1;\nexport type Y = string;\n`);
|
|
147
|
-
const result = await indexDirectory(workDir);
|
|
148
|
-
expect(result.edges.length).toBe(0);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('does not attribute nested-arrow calls to the outer function', async () => {
|
|
152
|
-
writeFileSync(
|
|
153
|
-
join(workDir, 'nested.ts'),
|
|
154
|
-
`export function outer(): void {\n` +
|
|
155
|
-
` const inner = () => { doThing(); };\n` +
|
|
156
|
-
` inner();\n` +
|
|
157
|
-
`}\n` +
|
|
158
|
-
`function doThing(): void {}\n`,
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
const result = await indexDirectory(workDir);
|
|
162
|
-
|
|
163
|
-
// outer should only call `inner` (not `doThing`, which is called by the arrow).
|
|
164
|
-
const outerSym = result.symbols.find((s) => s.name === 'outer')!;
|
|
165
|
-
const outerCalls = result.edges.filter((e) => e.fromId === outerSym.id);
|
|
166
|
-
const outerCalleeNames = outerCalls.map((e) => e.toName).sort();
|
|
167
|
-
expect(outerCalleeNames).toEqual(['inner']);
|
|
168
|
-
|
|
169
|
-
// The arrow itself is a SymbolRecord (assigned to `inner`); it should be
|
|
170
|
-
// the one calling `doThing`.
|
|
171
|
-
const innerSym = result.symbols.find((s) => s.name === 'inner')!;
|
|
172
|
-
const innerCalls = result.edges.filter((e) => e.fromId === innerSym.id);
|
|
173
|
-
expect(innerCalls.map((e) => e.toName)).toContain('doThing');
|
|
174
|
-
});
|
|
175
|
-
});
|
package/tests/fixtures/sample.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
export function parseToken(token: string): string {
|
|
2
|
-
return token.trim();
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
export const validateJwt = (jwt: string): boolean => {
|
|
6
|
-
return jwt.length > 0;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const internalHelper = function (x: number) {
|
|
10
|
-
return x * 2;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export class AuthService {
|
|
14
|
-
authenticate(user: string): boolean {
|
|
15
|
-
return parseToken(user).length > 0;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
private async fetchUser(id: string): Promise<string> {
|
|
19
|
-
return id;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface Repository {
|
|
24
|
-
save(): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type UserId = string;
|
|
28
|
-
|
|
29
|
-
enum Role {
|
|
30
|
-
Admin,
|
|
31
|
-
Member,
|
|
32
|
-
}
|