@sdsrs/code-graph 0.94.0 → 0.95.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.
Files changed (30) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/scripts/auto-update.js +69 -9
  3. package/package.json +8 -7
  4. package/claude-plugin/scripts/adopt.test.js +0 -679
  5. package/claude-plugin/scripts/auto-update.test.js +0 -515
  6. package/claude-plugin/scripts/cg-answer.test.js +0 -309
  7. package/claude-plugin/scripts/claude-config.test.js +0 -58
  8. package/claude-plugin/scripts/covering-tests.test.js +0 -78
  9. package/claude-plugin/scripts/doctor.test.js +0 -215
  10. package/claude-plugin/scripts/find-binary.test.js +0 -246
  11. package/claude-plugin/scripts/hook-fire.test.js +0 -117
  12. package/claude-plugin/scripts/hooks.test.js +0 -230
  13. package/claude-plugin/scripts/incremental-index.test.js +0 -102
  14. package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
  15. package/claude-plugin/scripts/lifecycle.test.js +0 -786
  16. package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
  17. package/claude-plugin/scripts/mcp-stub.test.js +0 -207
  18. package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
  19. package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
  20. package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
  21. package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
  22. package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
  23. package/claude-plugin/scripts/project-detect.test.js +0 -95
  24. package/claude-plugin/scripts/recommendation-log.test.js +0 -79
  25. package/claude-plugin/scripts/session-init.test.js +0 -479
  26. package/claude-plugin/scripts/statusline-composite.test.js +0 -65
  27. package/claude-plugin/scripts/statusline.test.js +0 -235
  28. package/claude-plugin/scripts/tmp-dir.test.js +0 -50
  29. package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
  30. package/claude-plugin/scripts/version-utils.test.js +0 -141
@@ -1,786 +0,0 @@
1
- 'use strict';
2
- const test = require('node:test');
3
- const assert = require('node:assert/strict');
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
- const { execFileSync } = require('child_process');
8
-
9
- const lifecyclePath = path.join(__dirname, 'lifecycle.js');
10
- const statuslinePath = path.join(__dirname, 'statusline.js');
11
-
12
- function mkHome(t) {
13
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-home-'));
14
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
15
- return dir;
16
- }
17
-
18
- function writeJson(filePath, value) {
19
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
- fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n');
21
- }
22
-
23
- function seedDisabledComposite(homeDir) {
24
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
25
- const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
26
- writeJson(settingsPath, {
27
- statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
28
- enabledPlugins: { 'code-graph-mcp@code-graph-mcp': false },
29
- });
30
- writeJson(registryPath, [
31
- { id: '_previous', command: 'echo previous-status', needsStdin: true },
32
- { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
33
- ]);
34
- return { settingsPath, registryPath };
35
- }
36
-
37
- function seedOrphanedComposite(homeDir) {
38
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
39
- const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
40
- const installedPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
41
- writeJson(settingsPath, {
42
- statusLine: { type: 'command', command: 'node "/plugin/statusline-composite.js"' },
43
- enabledPlugins: {},
44
- });
45
- writeJson(installedPath, { plugins: {} });
46
- writeJson(registryPath, [
47
- { id: '_previous', command: 'echo previous-status', needsStdin: true },
48
- { id: 'code-graph', command: 'node "/plugin/statusline.js"', needsStdin: false },
49
- ]);
50
- return { settingsPath, registryPath };
51
- }
52
-
53
- test('cleanupDisabledStatusline restores previous statusline and removes registry', (t) => {
54
- const homeDir = mkHome(t);
55
- const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
56
-
57
- const out = execFileSync(process.execPath, ['-e', `
58
- const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
59
- process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
60
- `], { env: { ...process.env, HOME: homeDir } }).toString();
61
-
62
- assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
63
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
64
- assert.equal(settings.statusLine.command, 'echo previous-status');
65
- assert.equal(fs.existsSync(registryPath), false);
66
- });
67
-
68
- test('statusline exits cleanly and self-heals when plugin is disabled', (t) => {
69
- const homeDir = mkHome(t);
70
- const { settingsPath, registryPath } = seedDisabledComposite(homeDir);
71
- const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-project-'));
72
- t.after(() => fs.rmSync(projectDir, { recursive: true, force: true }));
73
- fs.mkdirSync(path.join(projectDir, '.code-graph'), { recursive: true });
74
- fs.writeFileSync(path.join(projectDir, '.code-graph', 'index.db'), '');
75
-
76
- const stdout = execFileSync(process.execPath, [statuslinePath], {
77
- env: { ...process.env, HOME: homeDir },
78
- cwd: projectDir,
79
- }).toString();
80
-
81
- assert.equal(stdout, '');
82
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
83
- assert.equal(settings.statusLine.command, 'echo previous-status');
84
- assert.equal(fs.existsSync(registryPath), false);
85
- });
86
-
87
- test('cleanupDisabledStatusline also heals orphaned statusline after uninstall', (t) => {
88
- const homeDir = mkHome(t);
89
- const { settingsPath, registryPath } = seedOrphanedComposite(homeDir);
90
-
91
- const out = execFileSync(process.execPath, ['-e', `
92
- const { cleanupDisabledStatusline } = require(${JSON.stringify(lifecyclePath)});
93
- process.stdout.write(JSON.stringify(cleanupDisabledStatusline()));
94
- `], { env: { ...process.env, HOME: homeDir } }).toString();
95
-
96
- assert.deepEqual(JSON.parse(out), { cleaned: true, settingsChanged: true });
97
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
98
- assert.equal(settings.statusLine.command, 'echo previous-status');
99
- assert.equal(fs.existsSync(registryPath), false);
100
- });
101
-
102
- test('isPluginUninstalled distinguishes a genuine uninstall from a temporary disable', (t) => {
103
- // Orphaned composite (installed_plugins exists, no code-graph record) = uninstalled.
104
- const uninstalledHome = mkHome(t);
105
- seedOrphanedComposite(uninstalledHome);
106
- // enabledPlugins[id]=false = user toggled it off; may re-enable → NOT uninstalled.
107
- const disabledHome = mkHome(t);
108
- seedDisabledComposite(disabledHome);
109
-
110
- const probe = (home) => JSON.parse(execFileSync(process.execPath, ['-e', `
111
- const { isPluginUninstalled } = require(${JSON.stringify(lifecyclePath)});
112
- process.stdout.write(JSON.stringify(isPluginUninstalled()));
113
- `], { env: { ...process.env, HOME: home } }).toString());
114
-
115
- assert.equal(probe(uninstalledHome), true, 'orphaned/no-record → uninstalled');
116
- assert.equal(probe(disabledHome), false, 'explicit disable → not uninstalled (re-enable safe)');
117
- });
118
-
119
- test('removeCacheResidue deletes ~/.cache/code-graph and is idempotent', (t) => {
120
- const homeDir = mkHome(t);
121
- const cacheDir = path.join(homeDir, '.cache', 'code-graph');
122
- writeJson(path.join(cacheDir, 'bin', 'marker.json'), { v: 1 });
123
- fs.writeFileSync(path.join(cacheDir, 'update-state.json'), '{}');
124
-
125
- const run = () => execFileSync(process.execPath, ['-e', `
126
- const { removeCacheResidue } = require(${JSON.stringify(lifecyclePath)});
127
- process.stdout.write(JSON.stringify(removeCacheResidue()));
128
- `], { env: { ...process.env, HOME: homeDir } }).toString();
129
-
130
- assert.equal(run(), 'true');
131
- assert.equal(fs.existsSync(cacheDir), false, 'cache dir removed');
132
- assert.equal(run(), 'true', 'second call is a no-op success (idempotent force-rm)');
133
- });
134
-
135
- function legacyHooksFromPlugin() {
136
- return {
137
- SessionStart: [{
138
- matcher: 'startup|clear|compact',
139
- description: 'StatusLine self-heal, lifecycle sync, project map injection',
140
- hooks: [{ type: 'command', command: 'node "/stale/cache/0.8.2/claude-plugin/scripts/session-init.js"', timeout: 5 }],
141
- }],
142
- PostToolUse: [{
143
- matcher: 'tool == "Write" || tool == "Edit"',
144
- description: 'Auto-update code graph index after file edits',
145
- hooks: [{ type: 'command', command: 'node "/stale/code-graph/incremental-index.js"', timeout: 10 }],
146
- }],
147
- };
148
- }
149
-
150
- test('isOurHookEntry matches legacy description-tagged entries', () => {
151
- const entry = legacyHooksFromPlugin().SessionStart[0];
152
- const out = execFileSync(process.execPath, ['-e', `
153
- const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
154
- process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
155
- `]).toString();
156
- assert.equal(JSON.parse(out), true);
157
- });
158
-
159
- test('isOurHookEntry matches script-name + path fallback (missing description)', () => {
160
- const entry = {
161
- matcher: 'tool == "Edit"',
162
- hooks: [{ type: 'command', command: 'node "/cache/code-graph-mcp/scripts/pre-edit-guide.js"' }],
163
- };
164
- const out = execFileSync(process.execPath, ['-e', `
165
- const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
166
- process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
167
- `]).toString();
168
- assert.equal(JSON.parse(out), true);
169
- });
170
-
171
- test('isOurHookEntry leaves unrelated entries alone', () => {
172
- const entry = {
173
- matcher: 'startup',
174
- description: 'some other plugin hook',
175
- hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
176
- };
177
- const out = execFileSync(process.execPath, ['-e', `
178
- const { isOurHookEntry } = require(${JSON.stringify(lifecyclePath)});
179
- process.stdout.write(JSON.stringify(isOurHookEntry(${JSON.stringify(entry)})));
180
- `]).toString();
181
- assert.equal(JSON.parse(out), false);
182
- });
183
-
184
- test('removeHooksFromSettings strips our entries but keeps unrelated hooks', () => {
185
- const settings = {
186
- hooks: {
187
- SessionStart: [
188
- legacyHooksFromPlugin().SessionStart[0],
189
- {
190
- matcher: 'startup',
191
- description: 'some other plugin hook',
192
- hooks: [{ type: 'command', command: 'node /some/other/script.js' }],
193
- },
194
- ],
195
- PostToolUse: [legacyHooksFromPlugin().PostToolUse[0]],
196
- },
197
- };
198
-
199
- const out = execFileSync(process.execPath, ['-e', `
200
- const { removeHooksFromSettings } = require(${JSON.stringify(lifecyclePath)});
201
- const s = ${JSON.stringify(settings)};
202
- const changed = removeHooksFromSettings(s);
203
- process.stdout.write(JSON.stringify({ changed, s }));
204
- `]).toString();
205
-
206
- const { changed, s } = JSON.parse(out);
207
- assert.equal(changed, true);
208
- // Only the unrelated SessionStart entry remains; PostToolUse removed entirely.
209
- assert.equal(s.hooks.SessionStart.length, 1);
210
- assert.equal(s.hooks.SessionStart[0].description, 'some other plugin hook');
211
- assert.ok(!s.hooks.PostToolUse, 'empty event key should be deleted');
212
- });
213
-
214
- test('writeRegistry mirrors entries to durable backup outside ~/.cache/', (t) => {
215
- const homeDir = mkHome(t);
216
- const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
217
- const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
218
-
219
- execFileSync(process.execPath, ['-e', `
220
- const { registerStatuslineProvider } = require(${JSON.stringify(lifecyclePath)});
221
- registerStatuslineProvider('_previous', 'echo prev', true);
222
- registerStatuslineProvider('code-graph', 'node /cg.js', false);
223
- `], { env: { ...process.env, HOME: homeDir } });
224
-
225
- const primary = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
226
- const backup = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
227
- assert.deepEqual(primary, backup);
228
- assert.equal(primary.length, 2);
229
- });
230
-
231
- test('readRegistry self-heals primary from durable backup after cache wipe', (t) => {
232
- const homeDir = mkHome(t);
233
- const cacheDir = path.join(homeDir, '.cache', 'code-graph');
234
- const registryPath = path.join(cacheDir, 'statusline-registry.json');
235
- const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
236
-
237
- // Seed both files, then simulate user wiping ~/.cache/code-graph/
238
- writeJson(registryPath, [
239
- { id: '_previous', command: 'echo gsd', needsStdin: true },
240
- { id: 'code-graph', command: 'node /cg.js', needsStdin: false },
241
- ]);
242
- writeJson(backupPath, [
243
- { id: '_previous', command: 'echo gsd', needsStdin: true },
244
- { id: 'code-graph', command: 'node /cg.js', needsStdin: false },
245
- ]);
246
- fs.rmSync(cacheDir, { recursive: true, force: true });
247
- assert.equal(fs.existsSync(registryPath), false);
248
-
249
- const out = execFileSync(process.execPath, ['-e', `
250
- const { readRegistry } = require(${JSON.stringify(lifecyclePath)});
251
- process.stdout.write(JSON.stringify(readRegistry()));
252
- `], { env: { ...process.env, HOME: homeDir } }).toString();
253
-
254
- const restored = JSON.parse(out);
255
- assert.equal(restored.length, 2);
256
- assert.equal(restored[0].id, '_previous');
257
- // Primary file rebuilt from backup
258
- assert.equal(fs.existsSync(registryPath), true);
259
- });
260
-
261
- test('writeRegistry([]) clears both primary and backup', (t) => {
262
- const homeDir = mkHome(t);
263
- const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
264
- const backupPath = path.join(homeDir, '.claude', 'statusline-providers.json');
265
-
266
- execFileSync(process.execPath, ['-e', `
267
- const { registerStatuslineProvider, unregisterStatuslineProvider } = require(${JSON.stringify(lifecyclePath)});
268
- registerStatuslineProvider('code-graph', 'node /cg.js', false);
269
- unregisterStatuslineProvider('code-graph');
270
- `], { env: { ...process.env, HOME: homeDir } });
271
-
272
- assert.equal(fs.existsSync(registryPath), false);
273
- assert.equal(fs.existsSync(backupPath), false);
274
- });
275
-
276
- test('statusline-chain CLI register/unregister/list + reserved-id guard', (t) => {
277
- const homeDir = mkHome(t);
278
- const chainPath = path.join(__dirname, 'statusline-chain.js');
279
- const env = { ...process.env, HOME: homeDir };
280
-
281
- const reg = execFileSync(process.execPath, [chainPath, 'register', 'gsd', 'node /gsd.cjs', '--stdin'], { env }).toString();
282
- assert.match(reg, /registered gsd/);
283
-
284
- const reRun = execFileSync(process.execPath, [chainPath, 'register', 'gsd', 'node /gsd.cjs', '--stdin'], { env }).toString();
285
- assert.match(reRun, /unchanged gsd/);
286
-
287
- const list = execFileSync(process.execPath, [chainPath, 'list'], { env }).toString();
288
- assert.match(list, /gsd \[stdin\]: node \/gsd\.cjs/);
289
-
290
- // Reserved ids rejected — both should exit 2 with stderr "reserved"
291
- const { spawnSync } = require('child_process');
292
- for (const rid of ['_previous', 'code-graph']) {
293
- const r = spawnSync(process.execPath, [chainPath, 'register', rid, 'x'], { env });
294
- assert.equal(r.status, 2, `${rid} should exit 2`);
295
- assert.match(r.stderr.toString(), /reserved/);
296
- }
297
-
298
- const un = execFileSync(process.execPath, [chainPath, 'unregister', 'gsd'], { env }).toString();
299
- assert.match(un, /unregistered gsd/);
300
- });
301
-
302
- // ════════════════════════════════════════════════════════════════════
303
- // v0.32.0 — settings.json hook registration (replaces the v0.8.3 strip)
304
- // ════════════════════════════════════════════════════════════════════
305
-
306
- test('install() registers PreToolUse/PostToolUse/UserPromptSubmit hooks in settings.json', (t) => {
307
- const homeDir = mkHome(t);
308
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
309
- writeJson(settingsPath, {
310
- statusLine: { type: 'command', command: 'echo previous-status' },
311
- });
312
-
313
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
314
- env: { ...process.env, HOME: homeDir },
315
- });
316
-
317
- const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
318
- assert.ok(after.hooks, 'install() must add hooks block');
319
- assert.ok(after.hooks.PreToolUse, 'PreToolUse must be registered');
320
- assert.ok(after.hooks.PostToolUse, 'PostToolUse must be registered');
321
- assert.ok(after.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
322
-
323
- // Verify the matchers we promised exist
324
- const ptuMatchers = after.hooks.PreToolUse.map(e => e.matcher);
325
- for (const m of ['Edit', 'Bash', 'Read']) {
326
- assert.ok(ptuMatchers.includes(m), `PreToolUse matcher ${m} missing; got ${JSON.stringify(ptuMatchers)}`);
327
- }
328
-
329
- // Every registered entry must carry the description marker for cleanup
330
- for (const entries of Object.values(after.hooks)) {
331
- for (const e of entries) {
332
- if (e.description) {
333
- assert.ok(e.description.includes('[code-graph-mcp'),
334
- `entry without our marker leaked through: ${JSON.stringify(e.description)}`);
335
- }
336
- }
337
- }
338
-
339
- // statusLine composite still set
340
- assert.match(after.statusLine.command, /statusline-composite/);
341
- });
342
-
343
- test('install() strips legacy code-graph hooks AND writes fresh ones (migration path)', (t) => {
344
- const homeDir = mkHome(t);
345
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
346
- // Seed with v0.8.2-era legacy entries that should be cleaned up
347
- writeJson(settingsPath, {
348
- hooks: legacyHooksFromPlugin(),
349
- });
350
-
351
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
352
- env: { ...process.env, HOME: homeDir },
353
- });
354
-
355
- const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
356
- // Legacy stale paths should be gone — no `/stale/cache/0.8.2/` survivors
357
- const serialized = JSON.stringify(after.hooks || {});
358
- assert.ok(!serialized.includes('/stale/cache/'),
359
- 'legacy stale paths must be evicted: ' + serialized);
360
- // BUT fresh entries (v0.32.0 markers) should be present
361
- assert.ok(serialized.includes('[code-graph-mcp v0.32+]'),
362
- 'fresh v0.32+ entries should be installed');
363
- });
364
-
365
- test('install() is idempotent on settings.json (second call no-op)', (t) => {
366
- const homeDir = mkHome(t);
367
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
368
-
369
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
370
- env: { ...process.env, HOME: homeDir },
371
- });
372
- const first = fs.readFileSync(settingsPath, 'utf8');
373
-
374
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
375
- env: { ...process.env, HOME: homeDir },
376
- });
377
- const second = fs.readFileSync(settingsPath, 'utf8');
378
-
379
- assert.equal(first, second, 'second install() must produce byte-identical settings.json');
380
- });
381
-
382
- test('install() preserves foreign plugin hooks (other plugins\' entries survive)', (t) => {
383
- const homeDir = mkHome(t);
384
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
385
- // Seed with an unrelated plugin's hooks alongside ours
386
- writeJson(settingsPath, {
387
- hooks: {
388
- PreToolUse: [{
389
- matcher: 'Bash',
390
- description: 'some-other-plugin Bash inspector',
391
- hooks: [{ type: 'command', command: 'node /opt/other-plugin/bash-check.js', timeout: 3 }],
392
- }],
393
- PostToolUse: [{
394
- matcher: '*',
395
- description: 'foreign post-tool logger',
396
- hooks: [{ type: 'command', command: 'bash /opt/foreign/post.sh', timeout: 5 }],
397
- }],
398
- },
399
- });
400
-
401
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
402
- env: { ...process.env, HOME: homeDir },
403
- });
404
-
405
- const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
406
- // Foreign entries must still be there
407
- const ptu = after.hooks.PreToolUse;
408
- const otherBash = ptu.find(e => e.description === 'some-other-plugin Bash inspector');
409
- assert.ok(otherBash, 'foreign Bash hook was stripped — never strip non-code-graph entries');
410
-
411
- const ptoFor = after.hooks.PostToolUse.find(e => e.description === 'foreign post-tool logger');
412
- assert.ok(ptoFor, 'foreign PostToolUse hook was stripped');
413
-
414
- // Ours are also there
415
- assert.ok(after.hooks.PreToolUse.some(e => e.matcher === 'Edit' && e.description?.includes('[code-graph-mcp')));
416
- });
417
-
418
- test('registerHooksToSettings is idempotent when called directly', () => {
419
- // Pure-function direct call, no process spawn
420
- const { registerHooksToSettings } = require('./lifecycle.js');
421
- const settings = {};
422
- const changed1 = registerHooksToSettings(settings);
423
- const snapshot1 = JSON.stringify(settings);
424
- const changed2 = registerHooksToSettings(settings);
425
- const snapshot2 = JSON.stringify(settings);
426
- assert.equal(changed1, true, 'first call must report change');
427
- assert.equal(changed2, false, 'second call must report no-change (idempotent)');
428
- assert.equal(snapshot1, snapshot2, 'settings must be byte-identical after second call');
429
- });
430
-
431
- test('removeHooksFromSettings cleans up v0.32+ entries (uninstall path)', () => {
432
- const { registerHooksToSettings, removeHooksFromSettings } = require('./lifecycle.js');
433
- const settings = {};
434
- registerHooksToSettings(settings);
435
- // Sanity: have entries
436
- assert.ok(settings.hooks.PreToolUse && settings.hooks.PreToolUse.length > 0);
437
-
438
- const changed = removeHooksFromSettings(settings);
439
- assert.equal(changed, true);
440
- assert.ok(!settings.hooks || Object.keys(settings.hooks).length === 0,
441
- 'all our entries must be removed; got: ' + JSON.stringify(settings.hooks));
442
- });
443
-
444
- test('uninstall() removes settings.json hook entries end-to-end', (t) => {
445
- const homeDir = mkHome(t);
446
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
447
-
448
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
449
- env: { ...process.env, HOME: homeDir },
450
- });
451
- const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
452
- assert.ok(afterInstall.hooks?.PreToolUse, 'install must have created hooks');
453
-
454
- execFileSync(process.execPath, [lifecyclePath, 'uninstall'], {
455
- env: { ...process.env, HOME: homeDir },
456
- });
457
- const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
458
- // Our hooks should be gone (foreign ones would survive but we didn't seed any)
459
- const serialized = JSON.stringify(afterUninstall.hooks || {});
460
- assert.ok(!serialized.includes('[code-graph-mcp'),
461
- 'uninstall must strip all our entries; got: ' + serialized);
462
- });
463
-
464
- test('hook commands use absolute paths (no ${CLAUDE_PLUGIN_ROOT} in settings.json)', (t) => {
465
- // settings.json hook commands run with env-pollution risk per
466
- // feedback_plugin_env_isolation.md — they must NOT depend on
467
- // ${CLAUDE_PLUGIN_ROOT} (different plugins overwrite each other's value).
468
- const homeDir = mkHome(t);
469
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
470
-
471
- execFileSync(process.execPath, [lifecyclePath, 'install'], {
472
- env: { ...process.env, HOME: homeDir },
473
- });
474
- const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
475
- const serialized = JSON.stringify(after.hooks || {});
476
- assert.ok(!serialized.includes('${CLAUDE_PLUGIN_ROOT}'),
477
- 'settings.json hook commands must not reference ${CLAUDE_PLUGIN_ROOT}: ' + serialized);
478
- });
479
-
480
- // ════════════════════════════════════════════════════════════════════
481
- // v0.32.2 — update() upgrade-path integration tests (reviewer Rec #2)
482
- // ════════════════════════════════════════════════════════════════════
483
- // Covers the actual v0.31.x → v0.32.x migration path that runs in
484
- // production via session-init.js syncLifecycleConfig detecting a manifest
485
- // version mismatch and calling update(). Previously only install() was
486
- // tested end-to-end; the upgrade path shared the registerHooksToSettings
487
- // code internally but had no integration test exercising the wiring.
488
-
489
- test('update() from v0.31.x manifest registers fresh hooks in empty settings.json', (t) => {
490
- const homeDir = mkHome(t);
491
- const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
492
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
493
-
494
- // Seed v0.31.2 manifest state. updatedAt is the v0.31.2 release date.
495
- writeJson(manifestPath, {
496
- version: '0.31.2',
497
- installedAt: '2026-03-16T18:56:17.656Z',
498
- updatedAt: '2026-05-23T16:46:39.353Z',
499
- config: { statusLine: false },
500
- });
501
- // settings.json empty (mirrors real v0.31.x state — pre-v0.32.0 strategy
502
- // was "strip from settings.json, rely on plugin-cache hooks.json").
503
- writeJson(settingsPath, {});
504
-
505
- const out = execFileSync(process.execPath, [lifecyclePath, 'update'], {
506
- env: { ...process.env, HOME: homeDir },
507
- }).toString();
508
- assert.match(out, /Updated 0\.31\.2 → /, 'CLI output must show version transition');
509
-
510
- // Manifest version was bumped to current
511
- const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
512
- assert.notEqual(manifestAfter.version, '0.31.2', 'manifest version must advance');
513
- assert.ok(/^\d+\.\d+\.\d+$/.test(manifestAfter.version),
514
- `manifest version must be semver, got ${manifestAfter.version}`);
515
-
516
- // settings.json got the v0.32+ hook entries
517
- const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
518
- assert.ok(settingsAfter.hooks, 'update() must populate hooks block');
519
- assert.ok(settingsAfter.hooks.PreToolUse, 'PreToolUse must be registered');
520
- assert.ok(settingsAfter.hooks.PostToolUse, 'PostToolUse must be registered');
521
- assert.ok(settingsAfter.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
522
-
523
- // Every entry must carry the v0.32+ marker
524
- for (const entries of Object.values(settingsAfter.hooks)) {
525
- for (const e of entries) {
526
- assert.ok(e.description && e.description.includes('[code-graph-mcp v0.32+'),
527
- `update() entry without v0.32+ marker: ${JSON.stringify(e.description)}`);
528
- }
529
- }
530
- });
531
-
532
- test('update() from v0.31.x evicts legacy v0.7/v0.8 entries with stale paths', (t) => {
533
- const homeDir = mkHome(t);
534
- const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
535
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
536
-
537
- writeJson(manifestPath, {
538
- version: '0.31.2',
539
- installedAt: '2026-03-16T18:56:17.656Z',
540
- config: { statusLine: false },
541
- });
542
- // Seed with legacy v0.8.2-era entries that should be evicted on update.
543
- writeJson(settingsPath, {
544
- hooks: legacyHooksFromPlugin(),
545
- });
546
-
547
- execFileSync(process.execPath, [lifecyclePath, 'update'], {
548
- env: { ...process.env, HOME: homeDir },
549
- });
550
-
551
- const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
552
- const serialized = JSON.stringify(settingsAfter.hooks || {});
553
- // Stale paths must be gone
554
- assert.ok(!serialized.includes('/stale/cache/'),
555
- 'legacy stale paths must be evicted by update(): ' + serialized);
556
- // Fresh v0.32+ entries must be present
557
- assert.ok(serialized.includes('[code-graph-mcp v0.32+'),
558
- 'fresh v0.32+ entries must be installed by update()');
559
- });
560
-
561
- test('update() preserves foreign plugin hooks during upgrade', (t) => {
562
- const homeDir = mkHome(t);
563
- const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
564
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
565
-
566
- writeJson(manifestPath, {
567
- version: '0.31.2',
568
- config: { statusLine: false },
569
- });
570
- // Seed with an unrelated plugin's hooks — must survive our update().
571
- writeJson(settingsPath, {
572
- hooks: {
573
- PreToolUse: [{
574
- matcher: 'Bash',
575
- description: 'foreign-plugin Bash watcher',
576
- hooks: [{ type: 'command', command: 'node /opt/foreign/bash.js', timeout: 3 }],
577
- }],
578
- },
579
- });
580
-
581
- execFileSync(process.execPath, [lifecyclePath, 'update'], {
582
- env: { ...process.env, HOME: homeDir },
583
- });
584
-
585
- const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
586
- const ptu = settingsAfter.hooks.PreToolUse;
587
- assert.ok(ptu.some(e => e.description === 'foreign-plugin Bash watcher'),
588
- 'foreign Bash hook must survive update() — never strip non-code-graph entries');
589
- // And our own entries must coexist
590
- assert.ok(ptu.some(e => e.description && e.description.includes('[code-graph-mcp v0.32+')),
591
- 'update() must add our v0.32+ entries alongside the foreign one');
592
- });
593
-
594
- // ════════════════════════════════════════════════════════════════════
595
- // v0.32.2 — healthCheck post-repair re-verification
596
- // (Reviewer M3: repaired:true was set blindly after install() without
597
- // re-scanning to confirm the issues actually resolved.)
598
- // ════════════════════════════════════════════════════════════════════
599
-
600
- function runHealthCheckInChild(homeDir) {
601
- const code = `
602
- const lc = require(${JSON.stringify(lifecyclePath)});
603
- process.stdout.write(JSON.stringify(lc.healthCheck()));
604
- `;
605
- const out = execFileSync(process.execPath, ['-e', code], {
606
- env: { ...process.env, HOME: homeDir },
607
- encoding: 'utf8',
608
- });
609
- return JSON.parse(out);
610
- }
611
-
612
- test('healthCheck on a clean state returns healthy:true and never sets remaining', (t) => {
613
- const homeDir = mkHome(t);
614
- // No settings.json, no registry — clean slate.
615
- const r = runHealthCheckInChild(homeDir);
616
- assert.equal(r.healthy, true, 'fresh empty state must be healthy');
617
- assert.deepEqual(r.issues, [], 'no issues on empty state');
618
- assert.equal(r.repaired, false, 'no repair runs when nothing was broken');
619
- assert.equal(r.remaining, undefined, 'no remaining field when no repair attempted');
620
- });
621
-
622
- test('healthCheck repaired:true ONLY after post-repair re-scan returns clean', (t) => {
623
- const homeDir = mkHome(t);
624
- // Seed a hook entry whose path is broken AND carries our marker. install()
625
- // will overwrite our entries with fresh absolute paths derived from
626
- // __dirname (which is real in the test env), so the re-scan should be clean.
627
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
628
- writeJson(settingsPath, {
629
- hooks: {
630
- PreToolUse: [{
631
- matcher: 'Edit',
632
- description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
633
- hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
634
- }],
635
- },
636
- });
637
-
638
- const r = runHealthCheckInChild(homeDir);
639
- assert.equal(r.healthy, false, 'pre-repair scan must have flagged the broken path');
640
- assert.ok(r.issues.length >= 1, 'pre-repair issues must list the broken hook');
641
- assert.equal(r.repaired, true, 'install() rewrote our entry → post-scan clean → repaired:true');
642
- assert.deepEqual(r.remaining, [], 'remaining must be empty when repair succeeded');
643
- });
644
-
645
- test('healthCheck repaired:false when install() cannot resolve a flagged path', (t) => {
646
- const homeDir = mkHome(t);
647
- // Seed the registry with a non-`_previous` third-party provider whose path
648
- // is broken. install() only manages the 'code-graph' registry entry, so
649
- // the third-party entry survives untouched and the post-repair re-scan
650
- // still flags it. This is the canonical "auto-repair could not fix it"
651
- // path — previously the function lied and returned repaired:true anyway.
652
- const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
653
- writeJson(registryPath, [
654
- { id: 'third-party-statusline', command: 'node "/nonexistent/foreign/sl.js"', needsStdin: false },
655
- ]);
656
-
657
- const r = runHealthCheckInChild(homeDir);
658
- assert.equal(r.healthy, false, 'broken third-party path must be flagged on entry');
659
- assert.ok(r.issues.some(i => i.type === 'registry' && i.id === 'third-party-statusline'),
660
- 'pre-repair issue list must contain the third-party entry');
661
- assert.equal(r.repaired, false,
662
- 'install() does not touch third-party providers → re-scan still broken → repaired must be false');
663
- assert.ok(Array.isArray(r.remaining), 'remaining must be present when install() was attempted');
664
- assert.ok(r.remaining.some(i => i.id === 'third-party-statusline'),
665
- 'remaining must still contain the un-fixable third-party entry');
666
- });
667
-
668
- test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
669
- // Direct unit test of the extracted scanner — no install() side effects.
670
- // Verifies the contract M3 relies on: a pure function whose return
671
- // shape is what healthCheck composes its result from.
672
- const homeDir = mkHome(t);
673
- const settingsPath = path.join(homeDir, '.claude', 'settings.json');
674
- writeJson(settingsPath, {
675
- hooks: {
676
- PreToolUse: [{
677
- matcher: 'Edit',
678
- description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
679
- hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
680
- }],
681
- },
682
- });
683
-
684
- const code = `
685
- const lc = require(${JSON.stringify(lifecyclePath)});
686
- process.stdout.write(JSON.stringify(lc.scanForBrokenPaths()));
687
- `;
688
- const out = execFileSync(process.execPath, ['-e', code], {
689
- env: { ...process.env, HOME: homeDir },
690
- encoding: 'utf8',
691
- });
692
- const issues = JSON.parse(out);
693
- assert.ok(Array.isArray(issues));
694
- assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
695
- 'scanForBrokenPaths must report the seeded broken hook entry');
696
- });
697
- // ── isStaleRelicContext (v0.49.1 downgrade-war guard) ──────────────────────
698
-
699
- test('isStaleRelicContext: relic in plugins cache defers to a different active install', (t) => {
700
- const { isStaleRelicContext } = require('./lifecycle');
701
- const cacheRoot = '/home/u/.claude/plugins/cache';
702
- const relicRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.48.0`;
703
- const activeRoot = `${cacheRoot}/code-graph-mcp/code-graph-mcp/0.49.0`;
704
-
705
- // The downgrade-war case: running from old cache dir, active points elsewhere.
706
- assert.equal(isStaleRelicContext({
707
- pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
708
- existsSync: () => true,
709
- }), true);
710
-
711
- // Running FROM the active install → full self-heal rights.
712
- assert.equal(isStaleRelicContext({
713
- pluginRoot: activeRoot, cacheRoot, activePath: activeRoot,
714
- existsSync: () => true,
715
- }), false);
716
-
717
- // Dev checkout / npm install (pluginRoot outside the plugins cache) → exempt.
718
- assert.equal(isStaleRelicContext({
719
- pluginRoot: '/repo/code-graph-mcp/claude-plugin', cacheRoot, activePath: activeRoot,
720
- existsSync: () => true,
721
- }), false);
722
-
723
- // No installed_plugins record → exempt (nothing authoritative to defer to).
724
- assert.equal(isStaleRelicContext({
725
- pluginRoot: relicRoot, cacheRoot, activePath: null,
726
- existsSync: () => true,
727
- }), false);
728
-
729
- // Active path recorded but its lifecycle.js is gone (cache wiped) → the
730
- // relic is the only working copy left; keep self-heal rights.
731
- assert.equal(isStaleRelicContext({
732
- pluginRoot: relicRoot, cacheRoot, activePath: activeRoot,
733
- existsSync: () => false,
734
- }), false);
735
- });
736
-
737
- test('cleanupOldCacheVersions keeps an in-use version even beyond the keep window', (t) => {
738
- const { cleanupOldCacheVersions } = require('./lifecycle.js');
739
- const cacheParent = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cache-'));
740
- t.after(() => fs.rmSync(cacheParent, { recursive: true, force: true }));
741
- const pluginDir = path.join(cacheParent, 'code-graph-mcp');
742
- // Seven versions, oldest -> newest by mtime.
743
- const vers = ['0.78.0', '0.80.2', '0.80.3', '0.81.0', '0.81.1', '0.81.2', '0.81.3'];
744
- vers.forEach((v, i) => {
745
- const scripts = path.join(pluginDir, v, 'scripts');
746
- fs.mkdirSync(scripts, { recursive: true });
747
- fs.writeFileSync(path.join(scripts, 'mcp-launcher.js'), '// stub');
748
- const ts = (i + 1) * 3600; // distinct, increasing mtimes
749
- fs.utimesSync(path.join(pluginDir, v), ts, ts);
750
- });
751
- // A live MCP server is running from the OLDEST version (beyond keep=5) — this
752
- // is the v0.80.2 reconnect-(-32000) scenario.
753
- const inUse = path.join(pluginDir, '0.78.0');
754
- const fakeCmdlines = [`node ${path.join(inUse, 'scripts', 'mcp-launcher.js')} `];
755
-
756
- cleanupOldCacheVersions(5, () => fakeCmdlines, cacheParent);
757
-
758
- assert.equal(fs.existsSync(inUse), true,
759
- 'in-use version must survive prune even when it is the oldest');
760
- assert.equal(fs.existsSync(path.join(pluginDir, '0.80.2')), false,
761
- 'a non-in-use version beyond the keep window is still pruned');
762
- assert.equal(fs.existsSync(path.join(pluginDir, '0.81.3')), true,
763
- 'newest version (within keep window) is kept');
764
- });
765
-
766
- test('cleanupOldCacheVersions prunes beyond keep when nothing is in use', (t) => {
767
- const { cleanupOldCacheVersions } = require('./lifecycle.js');
768
- const cacheParent = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cache-'));
769
- t.after(() => fs.rmSync(cacheParent, { recursive: true, force: true }));
770
- const pluginDir = path.join(cacheParent, 'code-graph-mcp');
771
- const vers = ['0.78.0', '0.80.2', '0.80.3', '0.81.0', '0.81.1', '0.81.2', '0.81.3'];
772
- vers.forEach((v, i) => {
773
- fs.mkdirSync(path.join(pluginDir, v), { recursive: true });
774
- const ts = (i + 1) * 3600;
775
- fs.utimesSync(path.join(pluginDir, v), ts, ts);
776
- });
777
- // No live process references any version → recency-only pruning (pre-guard).
778
- cleanupOldCacheVersions(5, () => [], cacheParent);
779
-
780
- assert.equal(fs.existsSync(path.join(pluginDir, '0.78.0')), false, 'oldest pruned');
781
- assert.equal(fs.existsSync(path.join(pluginDir, '0.80.2')), false, '2nd-oldest pruned');
782
- assert.equal(fs.existsSync(path.join(pluginDir, '0.80.3')), true, 'within keep window kept');
783
- assert.equal(
784
- fs.readdirSync(pluginDir).filter(n => fs.statSync(path.join(pluginDir, n)).isDirectory()).length,
785
- 5, 'exactly keep=5 versions remain');
786
- });