@graphpilot-oss/graphpilot 0.0.1

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.
Files changed (130) hide show
  1. package/.editorconfig +15 -0
  2. package/.github/CODEOWNERS +22 -0
  3. package/.github/FUNDING.yml +1 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  5. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  8. package/.github/dependabot.yml +15 -0
  9. package/.github/workflows/ci.yml +62 -0
  10. package/.github/workflows/release.yml +50 -0
  11. package/.prettierignore +19 -0
  12. package/.prettierrc.json +20 -0
  13. package/CHANGELOG.md +138 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +111 -0
  16. package/LICENSE +201 -0
  17. package/README.md +132 -0
  18. package/SECURITY.md +44 -0
  19. package/assets/logo.png +0 -0
  20. package/assets/logo.svg +1 -0
  21. package/bench/README.md +544 -0
  22. package/bench/results/agent-tier-2026-05-22.md +28 -0
  23. package/bench/results/agent-tier-summary.md +44 -0
  24. package/bench/results/baseline-tier-2026-05-22.md +23 -0
  25. package/bench/results/baseline.json +810 -0
  26. package/bench/results/baseline.md +28 -0
  27. package/bench/run-agent-tier-automated.ts +234 -0
  28. package/bench/run-agent-tier.md +125 -0
  29. package/bench/run-baseline-tier.ts +200 -0
  30. package/bench/run.ts +210 -0
  31. package/bench/runner-baseline.ts +177 -0
  32. package/bench/runner-graphpilot.ts +131 -0
  33. package/bench/score-agent-tier.ts +191 -0
  34. package/bench/score.ts +59 -0
  35. package/bench/tasks.ts +236 -0
  36. package/dist/cli.d.ts +2 -0
  37. package/dist/cli.js +162 -0
  38. package/dist/cli.js.map +1 -0
  39. package/dist/edges.d.ts +57 -0
  40. package/dist/edges.js +170 -0
  41. package/dist/edges.js.map +1 -0
  42. package/dist/git.d.ts +95 -0
  43. package/dist/git.js +247 -0
  44. package/dist/git.js.map +1 -0
  45. package/dist/graph-schema.d.ts +36 -0
  46. package/dist/graph-schema.js +208 -0
  47. package/dist/graph-schema.js.map +1 -0
  48. package/dist/impact.d.ts +99 -0
  49. package/dist/impact.js +123 -0
  50. package/dist/impact.js.map +1 -0
  51. package/dist/indexer.d.ts +28 -0
  52. package/dist/indexer.js +111 -0
  53. package/dist/indexer.js.map +1 -0
  54. package/dist/interactions.d.ts +46 -0
  55. package/dist/interactions.js +0 -0
  56. package/dist/interactions.js.map +1 -0
  57. package/dist/mcp.d.ts +3 -0
  58. package/dist/mcp.js +567 -0
  59. package/dist/mcp.js.map +1 -0
  60. package/dist/parser.d.ts +24 -0
  61. package/dist/parser.js +128 -0
  62. package/dist/parser.js.map +1 -0
  63. package/dist/provenance.d.ts +74 -0
  64. package/dist/provenance.js +95 -0
  65. package/dist/provenance.js.map +1 -0
  66. package/dist/query.d.ts +68 -0
  67. package/dist/query.js +127 -0
  68. package/dist/query.js.map +1 -0
  69. package/dist/redact.d.ts +30 -0
  70. package/dist/redact.js +117 -0
  71. package/dist/redact.js.map +1 -0
  72. package/dist/storage.d.ts +42 -0
  73. package/dist/storage.js +85 -0
  74. package/dist/storage.js.map +1 -0
  75. package/dist/symbols.d.ts +20 -0
  76. package/dist/symbols.js +140 -0
  77. package/dist/symbols.js.map +1 -0
  78. package/dist/validation.d.ts +9 -0
  79. package/dist/validation.js +65 -0
  80. package/dist/validation.js.map +1 -0
  81. package/dist/validators.d.ts +55 -0
  82. package/dist/validators.js +205 -0
  83. package/dist/validators.js.map +1 -0
  84. package/dist/watcher.d.ts +86 -0
  85. package/dist/watcher.js +310 -0
  86. package/dist/watcher.js.map +1 -0
  87. package/docs/architecture.md +311 -0
  88. package/docs/limitations.md +156 -0
  89. package/docs/mcp-setup.md +231 -0
  90. package/docs/quickstart.md +202 -0
  91. package/eslint.config.js +148 -0
  92. package/lefthook.yml +81 -0
  93. package/package.json +56 -0
  94. package/pnpm-workspace.yaml +6 -0
  95. package/scripts/smoke-stdio.mjs +97 -0
  96. package/src/cli.ts +171 -0
  97. package/src/edges.ts +202 -0
  98. package/src/git.ts +255 -0
  99. package/src/graph-schema.ts +229 -0
  100. package/src/impact.ts +218 -0
  101. package/src/indexer.ts +152 -0
  102. package/src/interactions.ts +0 -0
  103. package/src/mcp.ts +652 -0
  104. package/src/parser.ts +138 -0
  105. package/src/provenance.ts +115 -0
  106. package/src/query.ts +148 -0
  107. package/src/redact.ts +122 -0
  108. package/src/storage.ts +115 -0
  109. package/src/symbols.ts +173 -0
  110. package/src/validation.ts +69 -0
  111. package/src/validators.ts +253 -0
  112. package/src/watcher.ts +383 -0
  113. package/tests/edges.test.ts +175 -0
  114. package/tests/fixtures/sample.ts +32 -0
  115. package/tests/git.test.ts +303 -0
  116. package/tests/graph-schema.test.ts +321 -0
  117. package/tests/impact.test.ts +454 -0
  118. package/tests/interactions.test.ts +180 -0
  119. package/tests/lint-policy.test.ts +106 -0
  120. package/tests/mcp-stdio.test.ts +171 -0
  121. package/tests/mcp.test.ts +335 -0
  122. package/tests/parser.test.ts +31 -0
  123. package/tests/provenance.test.ts +132 -0
  124. package/tests/query.test.ts +160 -0
  125. package/tests/redact.test.ts +167 -0
  126. package/tests/security.test.ts +144 -0
  127. package/tests/symbols.test.ts +78 -0
  128. package/tests/validators.test.ts +193 -0
  129. package/tests/watcher.test.ts +250 -0
  130. package/tsconfig.json +18 -0
@@ -0,0 +1,454 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { analyzeImpact, isTestFile } from '../src/impact.js';
3
+ import { GraphIndex } from '../src/query.js';
4
+ import type { Graph } from '../src/storage.js';
5
+ import type { SymbolRecord, SymbolKind } from '../src/symbols.js';
6
+ import type { CallEdge } from '../src/edges.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Test harness — build a fake Graph + GraphIndex without touching the FS
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function sym(opts: {
13
+ id: string;
14
+ name: string;
15
+ file: string;
16
+ exported?: boolean;
17
+ kind?: SymbolKind;
18
+ }): SymbolRecord {
19
+ return {
20
+ id: opts.id,
21
+ name: opts.name,
22
+ kind: opts.kind ?? 'function',
23
+ file: opts.file,
24
+ line: 1,
25
+ column: 1,
26
+ endLine: 5,
27
+ signature: `function ${opts.name}() {}`,
28
+ exported: opts.exported ?? false,
29
+ parent: undefined,
30
+ };
31
+ }
32
+
33
+ function edge(opts: { from: string; to: string | null; toName?: string; file?: string }): CallEdge {
34
+ return {
35
+ fromId: opts.from,
36
+ toId: opts.to,
37
+ toName: opts.toName ?? (opts.to ? opts.to.split('#')[1].split('@')[0] : 'unknown'),
38
+ file: opts.file ?? 'src/x.ts',
39
+ line: 1,
40
+ column: 1,
41
+ };
42
+ }
43
+
44
+ function makeGraph(symbols: SymbolRecord[], edges: CallEdge[]): Graph {
45
+ return {
46
+ version: 1,
47
+ repoId: 'test',
48
+ rootPath: '/test',
49
+ indexedAt: new Date().toISOString(),
50
+ filesIndexed: 0,
51
+ symbolCount: symbols.length,
52
+ edgeCount: edges.length,
53
+ symbols,
54
+ edges,
55
+ };
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // isTestFile — heuristic detector
60
+ // ---------------------------------------------------------------------------
61
+
62
+ describe('isTestFile', () => {
63
+ it('matches *.test.<ext>', () => {
64
+ expect(isTestFile('src/foo.test.ts')).toBe(true);
65
+ expect(isTestFile('src/foo.test.tsx')).toBe(true);
66
+ expect(isTestFile('src/foo.test.js')).toBe(true);
67
+ expect(isTestFile('src/foo.test.jsx')).toBe(true);
68
+ expect(isTestFile('a/b/c.test.mjs')).toBe(true);
69
+ });
70
+
71
+ it('matches *.spec.<ext>', () => {
72
+ expect(isTestFile('src/foo.spec.ts')).toBe(true);
73
+ expect(isTestFile('packages/app/x.spec.tsx')).toBe(true);
74
+ });
75
+
76
+ it('matches files under __tests__/', () => {
77
+ expect(isTestFile('src/__tests__/helper.ts')).toBe(true);
78
+ expect(isTestFile('__tests__/root.ts')).toBe(true);
79
+ });
80
+
81
+ it('does NOT match a bare test/ or tests/ directory', () => {
82
+ // Deliberately conservative — these directories often contain non-test
83
+ // helpers (e.g. src/test/fixtures.ts). We require the explicit suffix.
84
+ expect(isTestFile('src/test/util.ts')).toBe(false);
85
+ expect(isTestFile('tests/parser.ts')).toBe(false);
86
+ });
87
+
88
+ it('does NOT match regular source files', () => {
89
+ expect(isTestFile('src/parser.ts')).toBe(false);
90
+ expect(isTestFile('index.ts')).toBe(false);
91
+ expect(isTestFile('Testing.ts')).toBe(false);
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // analyzeImpact — the BFS + result-shape behaviour
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe('analyzeImpact', () => {
100
+ it('returns null when the symbol does not exist', () => {
101
+ const idx = new GraphIndex(makeGraph([], []));
102
+ expect(analyzeImpact(idx, 'doesNotExist')).toBeNull();
103
+ });
104
+
105
+ it('returns empty caller arrays when nothing calls the target', () => {
106
+ const idx = new GraphIndex(
107
+ makeGraph([sym({ id: 'a.ts#target@1', name: 'target', file: 'a.ts' })], []),
108
+ );
109
+ const r = analyzeImpact(idx, 'target');
110
+ expect(r).not.toBeNull();
111
+ expect(r!.directCallers).toEqual([]);
112
+ expect(r!.transitiveCallers).toEqual([]);
113
+ expect(r!.stats.directCount).toBe(0);
114
+ expect(r!.stats.transitiveCount).toBe(0);
115
+ expect(r!.stats.sourceFileCount).toBe(0);
116
+ });
117
+
118
+ it('captures direct callers at depth 1', () => {
119
+ const symbols = [
120
+ sym({ id: 'a.ts#target@1', name: 'target', file: 'a.ts' }),
121
+ sym({ id: 'b.ts#caller1@1', name: 'caller1', file: 'b.ts' }),
122
+ sym({ id: 'c.ts#caller2@1', name: 'caller2', file: 'c.ts' }),
123
+ ];
124
+ const edges = [
125
+ edge({ from: 'b.ts#caller1@1', to: 'a.ts#target@1' }),
126
+ edge({ from: 'c.ts#caller2@1', to: 'a.ts#target@1' }),
127
+ ];
128
+ const idx = new GraphIndex(makeGraph(symbols, edges));
129
+ const r = analyzeImpact(idx, 'target')!;
130
+ expect(r.directCallers.length).toBe(2);
131
+ expect(r.directCallers.every((c) => c.depth === 1)).toBe(true);
132
+ expect(r.transitiveCallers.length).toBe(0);
133
+ });
134
+
135
+ it('captures transitive callers at depth 2..maxDepth', () => {
136
+ // A -> target
137
+ // B -> A
138
+ // C -> B
139
+ const symbols = [
140
+ sym({ id: 'f.ts#target@1', name: 'target', file: 'f.ts' }),
141
+ sym({ id: 'f.ts#A@1', name: 'A', file: 'f.ts' }),
142
+ sym({ id: 'f.ts#B@1', name: 'B', file: 'f.ts' }),
143
+ sym({ id: 'f.ts#C@1', name: 'C', file: 'f.ts' }),
144
+ ];
145
+ const edges = [
146
+ edge({ from: 'f.ts#A@1', to: 'f.ts#target@1' }),
147
+ edge({ from: 'f.ts#B@1', to: 'f.ts#A@1' }),
148
+ edge({ from: 'f.ts#C@1', to: 'f.ts#B@1' }),
149
+ ];
150
+ const idx = new GraphIndex(makeGraph(symbols, edges));
151
+ const r = analyzeImpact(idx, 'target', { depth: 3 })!;
152
+
153
+ expect(r.directCallers.map((c) => c.symbol.name)).toEqual(['A']);
154
+ const transNames = r.transitiveCallers.map((c) => c.symbol.name).sort();
155
+ expect(transNames).toEqual(['B', 'C']);
156
+ const bDepth = r.transitiveCallers.find((c) => c.symbol.name === 'B')!.depth;
157
+ const cDepth = r.transitiveCallers.find((c) => c.symbol.name === 'C')!.depth;
158
+ expect(bDepth).toBe(2);
159
+ expect(cDepth).toBe(3);
160
+ });
161
+
162
+ it('respects the depth cap', () => {
163
+ // chain of 5: D -> C -> B -> A -> target
164
+ const symbols = [
165
+ sym({ id: 'x.ts#target@1', name: 'target', file: 'x.ts' }),
166
+ sym({ id: 'x.ts#A@1', name: 'A', file: 'x.ts' }),
167
+ sym({ id: 'x.ts#B@1', name: 'B', file: 'x.ts' }),
168
+ sym({ id: 'x.ts#C@1', name: 'C', file: 'x.ts' }),
169
+ sym({ id: 'x.ts#D@1', name: 'D', file: 'x.ts' }),
170
+ ];
171
+ const edges = [
172
+ edge({ from: 'x.ts#A@1', to: 'x.ts#target@1' }),
173
+ edge({ from: 'x.ts#B@1', to: 'x.ts#A@1' }),
174
+ edge({ from: 'x.ts#C@1', to: 'x.ts#B@1' }),
175
+ edge({ from: 'x.ts#D@1', to: 'x.ts#C@1' }),
176
+ ];
177
+ const idx = new GraphIndex(makeGraph(symbols, edges));
178
+ const r = analyzeImpact(idx, 'target', { depth: 2 })!;
179
+ // Should include A (d=1) and B (d=2) only. NOT C, NOT D.
180
+ const found = [
181
+ ...r.directCallers.map((c) => c.symbol.name),
182
+ ...r.transitiveCallers.map((c) => c.symbol.name),
183
+ ].sort();
184
+ expect(found).toEqual(['A', 'B']);
185
+ });
186
+
187
+ it('caps depth to 5 even when caller requests higher', () => {
188
+ const idx = new GraphIndex(makeGraph([sym({ id: 'a.ts#t@1', name: 't', file: 'a.ts' })], []));
189
+ // depth=99 should be silently clamped. We don't expose a way to read the
190
+ // applied cap, but we can verify it doesn't throw and returns a result.
191
+ expect(() => analyzeImpact(idx, 't', { depth: 99 })).not.toThrow();
192
+ });
193
+
194
+ it('handles cycles without infinite loop', () => {
195
+ // A <-> B (mutual recursion); target is reached via A.
196
+ const symbols = [
197
+ sym({ id: 'a.ts#target@1', name: 'target', file: 'a.ts' }),
198
+ sym({ id: 'a.ts#A@1', name: 'A', file: 'a.ts' }),
199
+ sym({ id: 'a.ts#B@1', name: 'B', file: 'a.ts' }),
200
+ ];
201
+ const edges = [
202
+ edge({ from: 'a.ts#A@1', to: 'a.ts#target@1' }),
203
+ edge({ from: 'a.ts#B@1', to: 'a.ts#A@1' }),
204
+ edge({ from: 'a.ts#A@1', to: 'a.ts#B@1' }), // cycle: A also calls B
205
+ ];
206
+ const idx = new GraphIndex(makeGraph(symbols, edges));
207
+ const start = Date.now();
208
+ const r = analyzeImpact(idx, 'target', { depth: 5 })!;
209
+ expect(Date.now() - start).toBeLessThan(500);
210
+ const names = [
211
+ ...r.directCallers.map((c) => c.symbol.name),
212
+ ...r.transitiveCallers.map((c) => c.symbol.name),
213
+ ].sort();
214
+ expect(names).toEqual(['A', 'B']);
215
+ });
216
+
217
+ it('handles a self-referential symbol (direct recursion)', () => {
218
+ // Target calls itself + has one external caller.
219
+ const symbols = [
220
+ sym({ id: 'a.ts#target@1', name: 'target', file: 'a.ts' }),
221
+ sym({ id: 'a.ts#caller@1', name: 'caller', file: 'a.ts' }),
222
+ ];
223
+ const edges = [
224
+ edge({ from: 'a.ts#target@1', to: 'a.ts#target@1' }), // recursion
225
+ edge({ from: 'a.ts#caller@1', to: 'a.ts#target@1' }),
226
+ ];
227
+ const idx = new GraphIndex(makeGraph(symbols, edges));
228
+ const r = analyzeImpact(idx, 'target')!;
229
+ expect(r.directCallers.map((c) => c.symbol.name)).toEqual(['caller']);
230
+ // target should not appear as its own caller in the result
231
+ expect(
232
+ [...r.directCallers, ...r.transitiveCallers].some((c) => c.symbol.name === 'target'),
233
+ ).toBe(false);
234
+ });
235
+
236
+ it('splits test callers into testsAffected', () => {
237
+ const symbols = [
238
+ sym({ id: 'src/auth.ts#parseToken@1', name: 'parseToken', file: 'src/auth.ts' }),
239
+ sym({ id: 'src/login.ts#login@1', name: 'login', file: 'src/login.ts' }),
240
+ sym({
241
+ id: 'tests/auth.test.ts#authShouldWork@1',
242
+ name: 'authShouldWork',
243
+ file: 'tests/auth.test.ts',
244
+ }),
245
+ sym({
246
+ id: 'src/__tests__/auth.ts#authInternalTest@1',
247
+ name: 'authInternalTest',
248
+ file: 'src/__tests__/auth.ts',
249
+ }),
250
+ ];
251
+ const edges = [
252
+ edge({ from: 'src/login.ts#login@1', to: 'src/auth.ts#parseToken@1' }),
253
+ edge({
254
+ from: 'tests/auth.test.ts#authShouldWork@1',
255
+ to: 'src/auth.ts#parseToken@1',
256
+ }),
257
+ edge({
258
+ from: 'src/__tests__/auth.ts#authInternalTest@1',
259
+ to: 'src/auth.ts#parseToken@1',
260
+ }),
261
+ ];
262
+ const idx = new GraphIndex(makeGraph(symbols, edges));
263
+ const r = analyzeImpact(idx, 'parseToken')!;
264
+
265
+ expect(r.directCallers.length).toBe(3);
266
+ expect(r.testsAffected.length).toBe(2);
267
+ const testNames = r.testsAffected.map((c) => c.symbol.name).sort();
268
+ expect(testNames).toEqual(['authInternalTest', 'authShouldWork']);
269
+ });
270
+
271
+ it('reflects publicApi.exported correctly', () => {
272
+ const exportedTarget = sym({
273
+ id: 'src/x.ts#pub@1',
274
+ name: 'pub',
275
+ file: 'src/x.ts',
276
+ exported: true,
277
+ });
278
+ const privateTarget = sym({
279
+ id: 'src/y.ts#priv@1',
280
+ name: 'priv',
281
+ file: 'src/y.ts',
282
+ exported: false,
283
+ });
284
+ const idxPub = new GraphIndex(makeGraph([exportedTarget], []));
285
+ const rPub = analyzeImpact(idxPub, 'pub')!;
286
+ expect(rPub.publicApi.exported).toBe(true);
287
+ expect(rPub.publicApi.reason).toMatch(/breaking change/i);
288
+
289
+ const idxPriv = new GraphIndex(makeGraph([privateTarget], []));
290
+ const rPriv = analyzeImpact(idxPriv, 'priv')!;
291
+ expect(rPriv.publicApi.exported).toBe(false);
292
+ expect(rPriv.publicApi.reason).toMatch(/not exported/i);
293
+ });
294
+
295
+ it('stats match the actual arrays', () => {
296
+ const symbols = [
297
+ sym({ id: 'a.ts#t@1', name: 't', file: 'a.ts' }),
298
+ sym({ id: 'a.ts#c1@1', name: 'c1', file: 'a.ts' }),
299
+ sym({ id: 'b.ts#c2@1', name: 'c2', file: 'b.ts' }),
300
+ sym({
301
+ id: 'c.test.ts#tc@1',
302
+ name: 'tc',
303
+ file: 'c.test.ts',
304
+ }),
305
+ ];
306
+ const edges = [
307
+ edge({ from: 'a.ts#c1@1', to: 'a.ts#t@1' }),
308
+ edge({ from: 'b.ts#c2@1', to: 'a.ts#t@1' }),
309
+ edge({ from: 'c.test.ts#tc@1', to: 'a.ts#t@1' }),
310
+ ];
311
+ const idx = new GraphIndex(makeGraph(symbols, edges));
312
+ const r = analyzeImpact(idx, 't')!;
313
+ expect(r.stats.directCount).toBe(r.directCallers.length);
314
+ expect(r.stats.transitiveCount).toBe(r.transitiveCallers.length);
315
+ expect(r.stats.testCount).toBe(r.testsAffected.length);
316
+ // Three distinct source files for the three callers
317
+ expect(r.stats.sourceFileCount).toBe(3);
318
+ });
319
+
320
+ it('sets truncated when per-level cap is hit', () => {
321
+ // 5 callers at depth 1; limit per level = 2 should mark truncated.
322
+ const symbols = [
323
+ sym({ id: 'a.ts#t@1', name: 't', file: 'a.ts' }),
324
+ ...Array.from({ length: 5 }, (_, i) =>
325
+ sym({ id: `a.ts#c${i}@1`, name: `c${i}`, file: 'a.ts' }),
326
+ ),
327
+ ];
328
+ const edges = Array.from({ length: 5 }, (_, i) =>
329
+ edge({ from: `a.ts#c${i}@1`, to: 'a.ts#t@1' }),
330
+ );
331
+ const idx = new GraphIndex(makeGraph(symbols, edges));
332
+ const r = analyzeImpact(idx, 't', { perLevelLimit: 2 })!;
333
+ expect(r.directCallers.length).toBe(2);
334
+ expect(r.stats.truncated).toBe(true);
335
+ });
336
+
337
+ it('resolves by name via GraphIndex (same-file > global)', () => {
338
+ // Two symbols named "helper" — one in same file as caller, one elsewhere.
339
+ // Per GraphIndex.resolveSymbol, the first-found wins for ambiguous lookups.
340
+ const symbols = [
341
+ sym({ id: 'a.ts#helper@1', name: 'helper', file: 'a.ts' }),
342
+ sym({ id: 'b.ts#helper@1', name: 'helper', file: 'b.ts' }),
343
+ ];
344
+ const idx = new GraphIndex(makeGraph(symbols, []));
345
+ const r = analyzeImpact(idx, 'helper');
346
+ expect(r).not.toBeNull();
347
+ // Whichever was picked, it's one of the two ids
348
+ expect(['a.ts#helper@1', 'b.ts#helper@1']).toContain(r!.target.id);
349
+ });
350
+
351
+ it('resolves by full id when given one', () => {
352
+ const symbols = [
353
+ sym({ id: 'a.ts#helper@1', name: 'helper', file: 'a.ts' }),
354
+ sym({ id: 'b.ts#helper@1', name: 'helper', file: 'b.ts' }),
355
+ ];
356
+ const idx = new GraphIndex(makeGraph(symbols, []));
357
+ const r = analyzeImpact(idx, 'b.ts#helper@1')!;
358
+ expect(r.target.id).toBe('b.ts#helper@1');
359
+ });
360
+ });
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // changedFiles (differential `since` mode)
364
+ // ---------------------------------------------------------------------------
365
+
366
+ describe('analyzeImpact with changedFiles filter', () => {
367
+ it('filters callers to only those whose file is in changedFiles', () => {
368
+ const symbols = [
369
+ sym({ id: 'tgt.ts#target@1', name: 'target', file: 'tgt.ts' }),
370
+ sym({ id: 'a.ts#aFn@1', name: 'aFn', file: 'a.ts' }),
371
+ sym({ id: 'b.ts#bFn@1', name: 'bFn', file: 'b.ts' }),
372
+ sym({ id: 'c.ts#cFn@1', name: 'cFn', file: 'c.ts' }),
373
+ ];
374
+ const edges = [
375
+ edge({ from: 'a.ts#aFn@1', to: 'tgt.ts#target@1', file: 'a.ts' }),
376
+ edge({ from: 'b.ts#bFn@1', to: 'tgt.ts#target@1', file: 'b.ts' }),
377
+ edge({ from: 'c.ts#cFn@1', to: 'tgt.ts#target@1', file: 'c.ts' }),
378
+ ];
379
+ const idx = new GraphIndex(makeGraph(symbols, edges));
380
+
381
+ const r = analyzeImpact(idx, 'target', {
382
+ changedFiles: new Set(['a.ts', 'c.ts']),
383
+ })!;
384
+ expect(r.directCallers.map((c) => c.symbol.name).sort()).toEqual(['aFn', 'cFn']);
385
+ expect(r.stats.directCount).toBe(2);
386
+ });
387
+
388
+ it('returns no callers when changedFiles is empty', () => {
389
+ const symbols = [
390
+ sym({ id: 'tgt.ts#target@1', name: 'target', file: 'tgt.ts' }),
391
+ sym({ id: 'a.ts#aFn@1', name: 'aFn', file: 'a.ts' }),
392
+ ];
393
+ const edges = [edge({ from: 'a.ts#aFn@1', to: 'tgt.ts#target@1', file: 'a.ts' })];
394
+ const idx = new GraphIndex(makeGraph(symbols, edges));
395
+
396
+ const r = analyzeImpact(idx, 'target', { changedFiles: new Set() })!;
397
+ expect(r.directCallers).toEqual([]);
398
+ expect(r.transitiveCallers).toEqual([]);
399
+ expect(r.stats.directCount).toBe(0);
400
+ });
401
+
402
+ it('null changedFiles is identical to omitting the option', () => {
403
+ const symbols = [
404
+ sym({ id: 'tgt.ts#target@1', name: 'target', file: 'tgt.ts' }),
405
+ sym({ id: 'a.ts#aFn@1', name: 'aFn', file: 'a.ts' }),
406
+ ];
407
+ const edges = [edge({ from: 'a.ts#aFn@1', to: 'tgt.ts#target@1', file: 'a.ts' })];
408
+ const idx = new GraphIndex(makeGraph(symbols, edges));
409
+
410
+ const baseline = analyzeImpact(idx, 'target')!;
411
+ const withNull = analyzeImpact(idx, 'target', { changedFiles: null })!;
412
+ expect(withNull.stats.directCount).toBe(baseline.stats.directCount);
413
+ });
414
+
415
+ it('still resolves the target even if the target file is not in changedFiles', () => {
416
+ // The filter applies only to callers, not to the target lookup.
417
+ const symbols = [
418
+ sym({ id: 'tgt.ts#target@1', name: 'target', file: 'tgt.ts' }),
419
+ sym({ id: 'a.ts#aFn@1', name: 'aFn', file: 'a.ts' }),
420
+ ];
421
+ const edges = [edge({ from: 'a.ts#aFn@1', to: 'tgt.ts#target@1', file: 'a.ts' })];
422
+ const idx = new GraphIndex(makeGraph(symbols, edges));
423
+
424
+ const r = analyzeImpact(idx, 'target', { changedFiles: new Set(['a.ts']) })!;
425
+ expect(r).not.toBeNull();
426
+ expect(r.target.name).toBe('target');
427
+ expect(r.directCallers.length).toBe(1);
428
+ });
429
+
430
+ it('filters transitive callers too', () => {
431
+ // chain: t <- a <- b <- c
432
+ const symbols = [
433
+ sym({ id: 't.ts#t@1', name: 't', file: 't.ts' }),
434
+ sym({ id: 'a.ts#a@1', name: 'a', file: 'a.ts' }),
435
+ sym({ id: 'b.ts#b@1', name: 'b', file: 'b.ts' }),
436
+ sym({ id: 'c.ts#c@1', name: 'c', file: 'c.ts' }),
437
+ ];
438
+ const edges = [
439
+ edge({ from: 'a.ts#a@1', to: 't.ts#t@1' }),
440
+ edge({ from: 'b.ts#b@1', to: 'a.ts#a@1' }),
441
+ edge({ from: 'c.ts#c@1', to: 'b.ts#b@1' }),
442
+ ];
443
+ const idx = new GraphIndex(makeGraph(symbols, edges));
444
+
445
+ // Only c.ts changed — even though a and b are between c and t in the
446
+ // call chain, we filter callers by file, so only c survives.
447
+ const r = analyzeImpact(idx, 't', {
448
+ depth: 5,
449
+ changedFiles: new Set(['c.ts']),
450
+ })!;
451
+ expect(r.directCallers).toEqual([]);
452
+ expect(r.transitiveCallers.map((c) => c.symbol.name)).toEqual(['c']);
453
+ });
454
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, readFileSync, existsSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { logInteraction, sanitizeInput, withInteractionLog } from '../src/interactions.js';
6
+ import { repoDir } from '../src/storage.js';
7
+
8
+ const isWindows = process.platform === 'win32';
9
+
10
+ /**
11
+ * The interactions log lives under ~/.graphpilot/<id>/. We use a fresh fake
12
+ * "repo path" per test so we can locate the resulting file deterministically
13
+ * without touching real indexed repos.
14
+ */
15
+ let fakeRepoRoot: string;
16
+
17
+ function logPath(repoRoot: string): string {
18
+ return join(repoDir(repoRoot), 'interactions.jsonl');
19
+ }
20
+
21
+ beforeEach(() => {
22
+ // A path that won't collide with the user's real graphpilot dirs.
23
+ fakeRepoRoot = mkdtempSync(join(tmpdir(), 'graphpilot-log-fake-'));
24
+ // Make sure no stale log exists for this path's repoId.
25
+ const dir = repoDir(fakeRepoRoot);
26
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
27
+ });
28
+
29
+ afterEach(() => {
30
+ // Clean both the source tmpdir and the ~/.graphpilot/<id>/ dir we created.
31
+ if (existsSync(fakeRepoRoot)) rmSync(fakeRepoRoot, { recursive: true, force: true });
32
+ const dir = repoDir(fakeRepoRoot);
33
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
34
+ // Reset env between tests
35
+ delete process.env.GRAPHPILOT_NO_LOG;
36
+ });
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // sanitizeInput
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe('sanitizeInput', () => {
43
+ it('passes through plain primitives', () => {
44
+ expect(sanitizeInput({ a: 1, b: 'hi', c: true })).toEqual({
45
+ a: 1,
46
+ b: 'hi',
47
+ c: true,
48
+ });
49
+ });
50
+
51
+ it('strips control characters from strings', () => {
52
+ const raw = `before\n\tafter`;
53
+ const out = sanitizeInput({ s: raw });
54
+ // Newlines become spaces — defends against forged JSONL lines.
55
+ expect(out.s).not.toContain('\n');
56
+ expect(out.s).not.toContain('');
57
+ });
58
+
59
+ it('caps long strings', () => {
60
+ const raw = 'x'.repeat(2000);
61
+ const out = sanitizeInput({ s: raw });
62
+ expect((out.s as string).length).toBeLessThan(2000);
63
+ });
64
+
65
+ it('replaces unloggable types with a marker', () => {
66
+ const out = sanitizeInput({ fn: () => 1, obj: { nested: true } });
67
+ expect(out.fn).toMatch(/unloggable/);
68
+ expect(out.obj).toMatch(/unloggable/);
69
+ });
70
+
71
+ it('drops keys with pathologically long names', () => {
72
+ const out = sanitizeInput({ ['x'.repeat(100)]: 1 });
73
+ expect(Object.keys(out).length).toBe(0);
74
+ });
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // logInteraction
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('logInteraction', () => {
82
+ it('appends a single JSONL line per call', () => {
83
+ logInteraction(fakeRepoRoot, {
84
+ ts: '2026-05-17T20:00:00Z',
85
+ tool: 'gp_recall',
86
+ input: { query: 'parseToken' },
87
+ results: 1,
88
+ durationMs: 4,
89
+ });
90
+ logInteraction(fakeRepoRoot, {
91
+ ts: '2026-05-17T20:00:01Z',
92
+ tool: 'gp_callers',
93
+ input: { symbol: 'parseToken' },
94
+ results: 3,
95
+ durationMs: 7,
96
+ });
97
+
98
+ const text = readFileSync(logPath(fakeRepoRoot), 'utf8');
99
+ const lines = text.trim().split('\n');
100
+ expect(lines.length).toBe(2);
101
+ const a = JSON.parse(lines[0]);
102
+ const b = JSON.parse(lines[1]);
103
+ expect(a.tool).toBe('gp_recall');
104
+ expect(b.tool).toBe('gp_callers');
105
+ expect(a.input.query).toBe('parseToken');
106
+ });
107
+
108
+ it('writes the log file with mode 0600', { skip: isWindows }, () => {
109
+ logInteraction(fakeRepoRoot, {
110
+ ts: '2026-05-17T20:00:00Z',
111
+ tool: 'gp_stats',
112
+ input: {},
113
+ results: 1,
114
+ durationMs: 1,
115
+ });
116
+ const mode = statSync(logPath(fakeRepoRoot)).mode & 0o777;
117
+ expect(mode).toBe(0o600);
118
+ });
119
+
120
+ it('respects GRAPHPILOT_NO_LOG=1', () => {
121
+ process.env.GRAPHPILOT_NO_LOG = '1';
122
+ logInteraction(fakeRepoRoot, {
123
+ ts: '2026-05-17T20:00:00Z',
124
+ tool: 'gp_stats',
125
+ input: {},
126
+ results: 1,
127
+ durationMs: 1,
128
+ });
129
+ expect(existsSync(logPath(fakeRepoRoot))).toBe(false);
130
+ });
131
+
132
+ it('records errors in the entry', () => {
133
+ logInteraction(fakeRepoRoot, {
134
+ ts: '2026-05-17T20:00:00Z',
135
+ tool: 'gp_recall',
136
+ input: { query: 'x' },
137
+ results: 0,
138
+ durationMs: 2,
139
+ error: 'something broke',
140
+ });
141
+ const entry = JSON.parse(readFileSync(logPath(fakeRepoRoot), 'utf8').trim());
142
+ expect(entry.error).toBe('something broke');
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // withInteractionLog
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('withInteractionLog', () => {
151
+ it('logs a successful call and returns the value', async () => {
152
+ const out = await withInteractionLog(
153
+ fakeRepoRoot,
154
+ 'gp_recall',
155
+ { query: 'parseToken' },
156
+ async () => ({
157
+ value: { content: [{ type: 'text', text: 'ok' }] },
158
+ results: 1,
159
+ }),
160
+ );
161
+ expect(out).toEqual({ content: [{ type: 'text', text: 'ok' }] });
162
+
163
+ const entry = JSON.parse(readFileSync(logPath(fakeRepoRoot), 'utf8').trim());
164
+ expect(entry.tool).toBe('gp_recall');
165
+ expect(entry.results).toBe(1);
166
+ expect(typeof entry.durationMs).toBe('number');
167
+ });
168
+
169
+ it('logs a thrown error and re-throws', async () => {
170
+ await expect(
171
+ withInteractionLog(fakeRepoRoot, 'gp_recall', { query: 'x' }, async () => {
172
+ throw new Error('boom');
173
+ }),
174
+ ).rejects.toThrow('boom');
175
+
176
+ const entry = JSON.parse(readFileSync(logPath(fakeRepoRoot), 'utf8').trim());
177
+ expect(entry.error).toBe('boom');
178
+ expect(entry.results).toBe(0);
179
+ });
180
+ });