@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,193 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ validateGpIndex,
4
+ validateGpRecall,
5
+ validateGpCallers,
6
+ validateGpImpact,
7
+ validateGpStats,
8
+ } from '../src/validators.js';
9
+
10
+ describe('validateGpStats', () => {
11
+ it('accepts empty object', () => {
12
+ expect(validateGpStats({})).toEqual({ ok: true, value: { path: undefined } });
13
+ });
14
+
15
+ it('accepts a path', () => {
16
+ expect(validateGpStats({ path: '/x' })).toEqual({
17
+ ok: true,
18
+ value: { path: '/x' },
19
+ });
20
+ });
21
+
22
+ it('rejects non-object', () => {
23
+ expect(validateGpStats('nope').ok).toBe(false);
24
+ expect(validateGpStats(42).ok).toBe(false);
25
+ expect(validateGpStats([]).ok).toBe(false);
26
+ });
27
+
28
+ it('rejects extra keys', () => {
29
+ const r = validateGpStats({ path: '/x', sneaky: true });
30
+ expect(r.ok).toBe(false);
31
+ if (!r.ok) expect(r.error).toMatch(/sneaky/);
32
+ });
33
+
34
+ it('rejects non-string path', () => {
35
+ const r = validateGpStats({ path: 42 });
36
+ expect(r.ok).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe('validateGpIndex', () => {
41
+ it('accepts empty (path defaults to cwd later)', () => {
42
+ expect(validateGpIndex({}).ok).toBe(true);
43
+ });
44
+ it('accepts path', () => {
45
+ const r = validateGpIndex({ path: '/repo' });
46
+ expect(r.ok).toBe(true);
47
+ if (r.ok) expect(r.value.path).toBe('/repo');
48
+ });
49
+ it('rejects extra keys', () => {
50
+ expect(validateGpIndex({ path: '/x', force: true }).ok).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('validateGpRecall', () => {
55
+ it('requires a non-empty query', () => {
56
+ expect(validateGpRecall({}).ok).toBe(false);
57
+ expect(validateGpRecall({ query: '' }).ok).toBe(false);
58
+ expect(validateGpRecall({ query: ' ' }).ok).toBe(false);
59
+ });
60
+
61
+ it('accepts minimal valid input', () => {
62
+ const r = validateGpRecall({ query: 'parseToken' });
63
+ expect(r.ok).toBe(true);
64
+ if (r.ok) {
65
+ expect(r.value.query).toBe('parseToken');
66
+ expect(r.value.limit).toBeUndefined();
67
+ expect(r.value.substring).toBeUndefined();
68
+ }
69
+ });
70
+
71
+ it('accepts full input', () => {
72
+ const r = validateGpRecall({
73
+ query: 'parse',
74
+ limit: 25,
75
+ substring: true,
76
+ path: '/x',
77
+ });
78
+ expect(r.ok).toBe(true);
79
+ if (r.ok) {
80
+ expect(r.value.limit).toBe(25);
81
+ expect(r.value.substring).toBe(true);
82
+ }
83
+ });
84
+
85
+ it('caps limit range', () => {
86
+ expect(validateGpRecall({ query: 'x', limit: 0 }).ok).toBe(false);
87
+ expect(validateGpRecall({ query: 'x', limit: 51 }).ok).toBe(false);
88
+ expect(validateGpRecall({ query: 'x', limit: 1.5 }).ok).toBe(false);
89
+ expect(validateGpRecall({ query: 'x', limit: -1 }).ok).toBe(false);
90
+ });
91
+
92
+ it('rejects extra keys', () => {
93
+ expect(validateGpRecall({ query: 'x', shellInjection: 'oops' }).ok).toBe(false);
94
+ });
95
+
96
+ it('rejects wrong types', () => {
97
+ expect(validateGpRecall({ query: 42 }).ok).toBe(false);
98
+ expect(validateGpRecall({ query: 'x', substring: 'yes' }).ok).toBe(false);
99
+ });
100
+
101
+ it('caps query length', () => {
102
+ const r = validateGpRecall({ query: 'a'.repeat(500) });
103
+ expect(r.ok).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('validateGpCallers', () => {
108
+ it('requires a non-empty symbol', () => {
109
+ expect(validateGpCallers({}).ok).toBe(false);
110
+ expect(validateGpCallers({ symbol: '' }).ok).toBe(false);
111
+ });
112
+
113
+ it('accepts minimal valid input', () => {
114
+ const r = validateGpCallers({ symbol: 'parseToken' });
115
+ expect(r.ok).toBe(true);
116
+ });
117
+
118
+ it('enforces direction enum', () => {
119
+ expect(validateGpCallers({ symbol: 'x', direction: 'callers' }).ok).toBe(true);
120
+ expect(validateGpCallers({ symbol: 'x', direction: 'callees' }).ok).toBe(true);
121
+ expect(validateGpCallers({ symbol: 'x', direction: 'both' }).ok).toBe(false);
122
+ expect(validateGpCallers({ symbol: 'x', direction: 42 }).ok).toBe(false);
123
+ });
124
+
125
+ it('caps limit at 100', () => {
126
+ expect(validateGpCallers({ symbol: 'x', limit: 100 }).ok).toBe(true);
127
+ expect(validateGpCallers({ symbol: 'x', limit: 101 }).ok).toBe(false);
128
+ });
129
+
130
+ it('rejects extra keys', () => {
131
+ expect(validateGpCallers({ symbol: 'x', sql: 'drop table' }).ok).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('validateGpImpact', () => {
136
+ it('requires a non-empty symbol', () => {
137
+ expect(validateGpImpact({}).ok).toBe(false);
138
+ expect(validateGpImpact({ symbol: '' }).ok).toBe(false);
139
+ expect(validateGpImpact({ symbol: ' ' }).ok).toBe(false);
140
+ });
141
+
142
+ it('accepts a minimal valid input', () => {
143
+ const r = validateGpImpact({ symbol: 'parseToken' });
144
+ expect(r.ok).toBe(true);
145
+ if (r.ok) {
146
+ expect(r.value.symbol).toBe('parseToken');
147
+ expect(r.value.depth).toBeUndefined();
148
+ }
149
+ });
150
+
151
+ it('accepts depth in range 1..5', () => {
152
+ expect(validateGpImpact({ symbol: 'x', depth: 1 }).ok).toBe(true);
153
+ expect(validateGpImpact({ symbol: 'x', depth: 3 }).ok).toBe(true);
154
+ expect(validateGpImpact({ symbol: 'x', depth: 5 }).ok).toBe(true);
155
+ });
156
+
157
+ it('rejects depth out of range', () => {
158
+ expect(validateGpImpact({ symbol: 'x', depth: 0 }).ok).toBe(false);
159
+ expect(validateGpImpact({ symbol: 'x', depth: 6 }).ok).toBe(false);
160
+ expect(validateGpImpact({ symbol: 'x', depth: -1 }).ok).toBe(false);
161
+ });
162
+
163
+ it('rejects non-integer depth', () => {
164
+ expect(validateGpImpact({ symbol: 'x', depth: 2.5 }).ok).toBe(false);
165
+ });
166
+
167
+ it('accepts path', () => {
168
+ const r = validateGpImpact({ symbol: 'x', path: '/tmp/repo' });
169
+ expect(r.ok).toBe(true);
170
+ });
171
+
172
+ it('rejects extra keys', () => {
173
+ expect(validateGpImpact({ symbol: 'x', surprise: 'hello' }).ok).toBe(false);
174
+ });
175
+
176
+ it('rejects wrong types', () => {
177
+ expect(validateGpImpact({ symbol: 42 }).ok).toBe(false);
178
+ expect(validateGpImpact({ symbol: 'x', depth: '3' }).ok).toBe(false);
179
+ });
180
+
181
+ it('accepts a non-empty `since` (commit/branch/tag)', () => {
182
+ const r = validateGpImpact({ symbol: 'x', since: 'main' });
183
+ expect(r.ok).toBe(true);
184
+ if (r.ok) expect(r.value.since).toBe('main');
185
+ expect(validateGpImpact({ symbol: 'x', since: 'abc1234' }).ok).toBe(true);
186
+ });
187
+
188
+ it('rejects empty / whitespace / wrong-typed `since`', () => {
189
+ expect(validateGpImpact({ symbol: 'x', since: '' }).ok).toBe(false);
190
+ expect(validateGpImpact({ symbol: 'x', since: ' ' }).ok).toBe(false);
191
+ expect(validateGpImpact({ symbol: 'x', since: 42 }).ok).toBe(false);
192
+ });
193
+ });
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs';
3
+ import { tmpdir, homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { GraphWatcher } from '../src/watcher.js';
6
+ import { repoDir, loadGraph } from '../src/storage.js';
7
+
8
+ /**
9
+ * Tests drive applyUpdate / applyDeletion directly. We do NOT spin chokidar
10
+ * up in tests — it's timing-dependent and racy on CI. The chokidar wiring is
11
+ * exercised manually with `graphpilot watch <path>` and observed in dev.
12
+ */
13
+
14
+ let workDir: string;
15
+
16
+ function silentLog(): (s: string) => void {
17
+ return () => undefined;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ workDir = mkdtempSync(join(tmpdir(), 'graphpilot-watch-'));
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (workDir && existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
26
+ // Clean the index dir this workDir would have produced
27
+ const dir = repoDir(workDir);
28
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
29
+ });
30
+
31
+ describe('GraphWatcher — initial state', () => {
32
+ it('starts with empty graph when no index exists yet', () => {
33
+ const w = new GraphWatcher(workDir, { log: silentLog() });
34
+ expect(w.currentGraph.symbols.length).toBe(0);
35
+ expect(w.currentGraph.edges.length).toBe(0);
36
+ expect(w.currentGraph.rootPath).toBe(workDir);
37
+ });
38
+
39
+ it('loads existing graph from disk if present', async () => {
40
+ writeFileSync(join(workDir, 'hello.ts'), 'export function hello() { return 1; }\n');
41
+ const w = new GraphWatcher(workDir, { log: silentLog() });
42
+ await w.fullReindex();
43
+ expect(w.currentGraph.symbols.length).toBeGreaterThan(0);
44
+
45
+ // New watcher should pick the existing graph from disk.
46
+ const w2 = new GraphWatcher(workDir, { log: silentLog() });
47
+ expect(w2.currentGraph.symbols.length).toBe(w.currentGraph.symbols.length);
48
+ });
49
+
50
+ it('refuses to construct on a dangerous root path', () => {
51
+ expect(() => new GraphWatcher(homedir(), { log: silentLog() })).toThrow(
52
+ /home directory|system path/i,
53
+ );
54
+ expect(() => new GraphWatcher('/', { log: silentLog() })).toThrow(/system path/i);
55
+ });
56
+ });
57
+
58
+ describe('GraphWatcher — applyUpdate (single-file change)', () => {
59
+ it('adds symbols when a previously-unseen file appears', async () => {
60
+ const w = new GraphWatcher(workDir, { log: silentLog() });
61
+ await w.fullReindex(); // empty graph
62
+
63
+ const newFile = join(workDir, 'new.ts');
64
+ writeFileSync(newFile, 'export function appeared() { return 42; }\n');
65
+ const r = await w.applyUpdate(newFile, 'add');
66
+
67
+ expect(r).not.toBeNull();
68
+ expect(r!.symbolsAfter).toBeGreaterThan(r!.symbolsBefore);
69
+ const names = w.currentGraph.symbols.map((s) => s.name);
70
+ expect(names).toContain('appeared');
71
+ });
72
+
73
+ it("replaces a file's symbols when it changes (rename function)", async () => {
74
+ const file = join(workDir, 'a.ts');
75
+ writeFileSync(file, 'export function oldName() { return 1; }\n');
76
+ const w = new GraphWatcher(workDir, { log: silentLog() });
77
+ await w.fullReindex();
78
+ expect(w.currentGraph.symbols.some((s) => s.name === 'oldName')).toBe(true);
79
+
80
+ // Rewrite the file with a different function name
81
+ writeFileSync(file, 'export function newName() { return 2; }\n');
82
+ await w.applyUpdate(file, 'change');
83
+
84
+ const names = w.currentGraph.symbols.map((s) => s.name);
85
+ expect(names).toContain('newName');
86
+ expect(names).not.toContain('oldName');
87
+ });
88
+
89
+ it('updates edges when a caller appears that resolves a previously-unresolved name', async () => {
90
+ // Start with a self-contained caller that calls a not-yet-existing function
91
+ writeFileSync(join(workDir, 'consumer.ts'), `function consume() { helper(); return 1; }\n`);
92
+ const w = new GraphWatcher(workDir, { log: silentLog() });
93
+ await w.fullReindex();
94
+ const unresolvedBefore = w.currentGraph.edges.filter(
95
+ (e) => e.toName === 'helper' && e.toId === null,
96
+ );
97
+ expect(unresolvedBefore.length).toBe(1);
98
+
99
+ // Add the helper — the edge should newly resolve
100
+ const helperFile = join(workDir, 'helper.ts');
101
+ writeFileSync(helperFile, 'export function helper() { return 7; }\n');
102
+ await w.applyUpdate(helperFile, 'add');
103
+
104
+ const resolved = w.currentGraph.edges.find((e) => e.toName === 'helper' && e.toId !== null);
105
+ expect(resolved).toBeDefined();
106
+ expect(resolved!.toId).toMatch(/helper/);
107
+ });
108
+
109
+ it('drops edges for a removed caller', async () => {
110
+ writeFileSync(join(workDir, 'target.ts'), 'export function target() { return 1; }\n');
111
+ writeFileSync(
112
+ join(workDir, 'caller.ts'),
113
+ 'import { target } from "./target";\nfunction call() { return target(); }\n',
114
+ );
115
+ const w = new GraphWatcher(workDir, { log: silentLog() });
116
+ await w.fullReindex();
117
+ const edgesBefore = w.currentGraph.edges.length;
118
+ expect(edgesBefore).toBeGreaterThan(0);
119
+
120
+ // Remove the caller's body — should drop the edge
121
+ const callerFile = join(workDir, 'caller.ts');
122
+ writeFileSync(callerFile, 'import { target } from "./target";\n');
123
+ await w.applyUpdate(callerFile, 'change');
124
+
125
+ const callTargetEdges = w.currentGraph.edges.filter((e) => e.toName === 'target');
126
+ expect(callTargetEdges.length).toBe(0);
127
+ });
128
+
129
+ it('ignores non-watchable files', async () => {
130
+ const w = new GraphWatcher(workDir, { log: silentLog() });
131
+ await w.fullReindex();
132
+ const txtFile = join(workDir, 'note.txt');
133
+ writeFileSync(txtFile, 'just text');
134
+ const r = await w.applyUpdate(txtFile, 'add');
135
+ expect(r).toBeNull();
136
+ });
137
+
138
+ it('ignores files outside the watched root', async () => {
139
+ const w = new GraphWatcher(workDir, { log: silentLog() });
140
+ await w.fullReindex();
141
+ // A path outside the root
142
+ const outside = '/tmp/some-other-file.ts';
143
+ const r = await w.applyUpdate(outside, 'change');
144
+ expect(r).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe('GraphWatcher — applyDeletion', () => {
149
+ it('removes all symbols + edges that came from the deleted file', async () => {
150
+ const a = join(workDir, 'a.ts');
151
+ const b = join(workDir, 'b.ts');
152
+ writeFileSync(a, 'export function fromA() { return 1; }\n');
153
+ writeFileSync(b, 'import { fromA } from "./a";\nexport function fromB() { return fromA(); }\n');
154
+
155
+ const w = new GraphWatcher(workDir, { log: silentLog() });
156
+ await w.fullReindex();
157
+ expect(w.currentGraph.symbols.some((s) => s.name === 'fromA')).toBe(true);
158
+
159
+ unlinkSync(a);
160
+ const r = await w.applyDeletion(a);
161
+ expect(r).not.toBeNull();
162
+ expect(w.currentGraph.symbols.some((s) => s.name === 'fromA')).toBe(false);
163
+ // The fromA-call edge in b.ts is now unresolved
164
+ const stillThere = w.currentGraph.edges.find((e) => e.toName === 'fromA');
165
+ expect(stillThere?.toId).toBeNull();
166
+ });
167
+
168
+ it('is a no-op if the file was not in the index', async () => {
169
+ const w = new GraphWatcher(workDir, { log: silentLog() });
170
+ await w.fullReindex();
171
+ const r = await w.applyDeletion(join(workDir, 'never-existed.ts'));
172
+ expect(r).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe('GraphWatcher — on-disk side effects', () => {
177
+ it('persists each applyUpdate atomically (graph.json never partial)', async () => {
178
+ writeFileSync(join(workDir, 'x.ts'), 'export function x() {}\n');
179
+ const w = new GraphWatcher(workDir, { log: silentLog() });
180
+ await w.fullReindex();
181
+
182
+ // After a change, on-disk graph.json must be parseable JSON matching
183
+ // what's in memory. (Atomic .tmp + rename in storage.saveGraph.)
184
+ writeFileSync(join(workDir, 'x.ts'), 'export function x() { return 9; }\n');
185
+ await w.applyUpdate(join(workDir, 'x.ts'), 'change');
186
+
187
+ const onDisk = loadGraph(workDir);
188
+ expect(onDisk).not.toBeNull();
189
+ expect(onDisk!.symbolCount).toBe(w.currentGraph.symbols.length);
190
+ });
191
+
192
+ it('recomputes filesIndexed from surviving symbols', async () => {
193
+ writeFileSync(join(workDir, 'one.ts'), 'export function one() {}\n');
194
+ writeFileSync(join(workDir, 'two.ts'), 'export function two() {}\n');
195
+ const w = new GraphWatcher(workDir, { log: silentLog() });
196
+ await w.fullReindex();
197
+ expect(w.currentGraph.filesIndexed).toBe(2);
198
+
199
+ unlinkSync(join(workDir, 'one.ts'));
200
+ await w.applyDeletion(join(workDir, 'one.ts'));
201
+ expect(w.currentGraph.filesIndexed).toBe(1);
202
+ });
203
+
204
+ it('serializes concurrent updates via the chain (no torn graph)', async () => {
205
+ // Drive multiple applyUpdates in parallel — the watcher should still end
206
+ // up consistent. (The internal chain only protects chokidar-triggered
207
+ // events, but applyUpdate is awaitable from the outside; here we test
208
+ // that two awaits in sequence produce a deterministic outcome.)
209
+ const a = join(workDir, 'a.ts');
210
+ const b = join(workDir, 'b.ts');
211
+ writeFileSync(a, 'export function a1() {}\n');
212
+ writeFileSync(b, 'export function b1() {}\n');
213
+ const w = new GraphWatcher(workDir, { log: silentLog() });
214
+ await w.fullReindex();
215
+
216
+ writeFileSync(a, 'export function a2() {}\n');
217
+ writeFileSync(b, 'export function b2() {}\n');
218
+ await Promise.all([w.applyUpdate(a, 'change'), w.applyUpdate(b, 'change')]);
219
+
220
+ const names = w.currentGraph.symbols.map((s) => s.name).sort();
221
+ expect(names).toEqual(['a2', 'b2']);
222
+ });
223
+ });
224
+
225
+ describe('GraphWatcher — diagnostic output', () => {
226
+ it('writes one diagnostic line per applyUpdate', async () => {
227
+ writeFileSync(join(workDir, 'x.ts'), 'export function x() {}\n');
228
+ const lines: string[] = [];
229
+ const w = new GraphWatcher(workDir, { log: (s) => lines.push(s) });
230
+ // fullReindex is silent by design; only event-driven updates log.
231
+ await w.fullReindex();
232
+ const before = lines.length;
233
+ writeFileSync(join(workDir, 'x.ts'), 'export function x() { return 1; }\n');
234
+ await w.applyUpdate(join(workDir, 'x.ts'), 'change');
235
+ expect(lines.length).toBe(before + 1);
236
+ expect(lines[lines.length - 1]).toMatch(/x\.ts/);
237
+ });
238
+
239
+ it('on-disk graph contains the new symbols after change', async () => {
240
+ writeFileSync(join(workDir, 'x.ts'), 'export function oldOne() {}\n');
241
+ const w = new GraphWatcher(workDir, { log: silentLog() });
242
+ await w.fullReindex();
243
+ writeFileSync(join(workDir, 'x.ts'), 'export function newOne() {}\n');
244
+ await w.applyUpdate(join(workDir, 'x.ts'), 'change');
245
+
246
+ const raw = readFileSync(join(repoDir(workDir), 'graph.json'), 'utf8');
247
+ expect(raw).toContain('newOne');
248
+ expect(raw).not.toContain('oldOne');
249
+ });
250
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "sourceMap": true,
14
+ "resolveJsonModule": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "tests"]
18
+ }