@sdsrs/code-graph 0.31.0 → 0.32.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/hooks/hooks.json +2 -58
- package/claude-plugin/scripts/auto-update.js +2 -1
- package/claude-plugin/scripts/doctor.js +64 -33
- package/claude-plugin/scripts/hooks.test.js +151 -0
- package/claude-plugin/scripts/lifecycle.js +145 -30
- package/claude-plugin/scripts/lifecycle.test.js +381 -6
- package/claude-plugin/scripts/mcp-launcher.js +73 -0
- package/claude-plugin/scripts/mcp-launcher.test.js +23 -3
- package/claude-plugin/scripts/pre-edit-guide.js +24 -4
- package/claude-plugin/scripts/pre-grep-guide.js +107 -9
- package/claude-plugin/scripts/pre-grep-guide.test.js +263 -1
- package/claude-plugin/scripts/pre-read-guide.js +2 -2
- package/claude-plugin/scripts/session-init.js +17 -0
- package/claude-plugin/scripts/tmp-dir.js +32 -0
- package/claude-plugin/scripts/tmp-dir.test.js +50 -0
- package/package.json +6 -6
|
@@ -266,11 +266,52 @@ test('statusline-chain CLI register/unregister/list + reserved-id guard', (t) =>
|
|
|
266
266
|
assert.match(un, /unregistered gsd/);
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
// ════════════════════════════════════════════════════════════════════
|
|
270
|
+
// v0.32.0 — settings.json hook registration (replaces the v0.8.3 strip)
|
|
271
|
+
// ════════════════════════════════════════════════════════════════════
|
|
272
|
+
|
|
273
|
+
test('install() registers PreToolUse/PostToolUse/UserPromptSubmit hooks in settings.json', (t) => {
|
|
270
274
|
const homeDir = mkHome(t);
|
|
271
275
|
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
272
276
|
writeJson(settingsPath, {
|
|
273
277
|
statusLine: { type: 'command', command: 'echo previous-status' },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
281
|
+
env: { ...process.env, HOME: homeDir },
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
285
|
+
assert.ok(after.hooks, 'install() must add hooks block');
|
|
286
|
+
assert.ok(after.hooks.PreToolUse, 'PreToolUse must be registered');
|
|
287
|
+
assert.ok(after.hooks.PostToolUse, 'PostToolUse must be registered');
|
|
288
|
+
assert.ok(after.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
|
|
289
|
+
|
|
290
|
+
// Verify the matchers we promised exist
|
|
291
|
+
const ptuMatchers = after.hooks.PreToolUse.map(e => e.matcher);
|
|
292
|
+
for (const m of ['Edit', 'Bash', 'Read']) {
|
|
293
|
+
assert.ok(ptuMatchers.includes(m), `PreToolUse matcher ${m} missing; got ${JSON.stringify(ptuMatchers)}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Every registered entry must carry the description marker for cleanup
|
|
297
|
+
for (const entries of Object.values(after.hooks)) {
|
|
298
|
+
for (const e of entries) {
|
|
299
|
+
if (e.description) {
|
|
300
|
+
assert.ok(e.description.includes('[code-graph-mcp'),
|
|
301
|
+
`entry without our marker leaked through: ${JSON.stringify(e.description)}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// statusLine composite still set
|
|
307
|
+
assert.match(after.statusLine.command, /statusline-composite/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('install() strips legacy code-graph hooks AND writes fresh ones (migration path)', (t) => {
|
|
311
|
+
const homeDir = mkHome(t);
|
|
312
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
313
|
+
// Seed with v0.8.2-era legacy entries that should be cleaned up
|
|
314
|
+
writeJson(settingsPath, {
|
|
274
315
|
hooks: legacyHooksFromPlugin(),
|
|
275
316
|
});
|
|
276
317
|
|
|
@@ -279,10 +320,344 @@ test('install() removes legacy code-graph hooks from settings.json without re-re
|
|
|
279
320
|
});
|
|
280
321
|
|
|
281
322
|
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
282
|
-
//
|
|
323
|
+
// Legacy stale paths should be gone — no `/stale/cache/0.8.2/` survivors
|
|
283
324
|
const serialized = JSON.stringify(after.hooks || {});
|
|
284
|
-
assert.ok(!serialized.includes('
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
assert.
|
|
325
|
+
assert.ok(!serialized.includes('/stale/cache/'),
|
|
326
|
+
'legacy stale paths must be evicted: ' + serialized);
|
|
327
|
+
// BUT fresh entries (v0.32.0 markers) should be present
|
|
328
|
+
assert.ok(serialized.includes('[code-graph-mcp v0.32+]'),
|
|
329
|
+
'fresh v0.32+ entries should be installed');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('install() is idempotent on settings.json (second call no-op)', (t) => {
|
|
333
|
+
const homeDir = mkHome(t);
|
|
334
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
335
|
+
|
|
336
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
337
|
+
env: { ...process.env, HOME: homeDir },
|
|
338
|
+
});
|
|
339
|
+
const first = fs.readFileSync(settingsPath, 'utf8');
|
|
340
|
+
|
|
341
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
342
|
+
env: { ...process.env, HOME: homeDir },
|
|
343
|
+
});
|
|
344
|
+
const second = fs.readFileSync(settingsPath, 'utf8');
|
|
345
|
+
|
|
346
|
+
assert.equal(first, second, 'second install() must produce byte-identical settings.json');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('install() preserves foreign plugin hooks (other plugins\' entries survive)', (t) => {
|
|
350
|
+
const homeDir = mkHome(t);
|
|
351
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
352
|
+
// Seed with an unrelated plugin's hooks alongside ours
|
|
353
|
+
writeJson(settingsPath, {
|
|
354
|
+
hooks: {
|
|
355
|
+
PreToolUse: [{
|
|
356
|
+
matcher: 'Bash',
|
|
357
|
+
description: 'some-other-plugin Bash inspector',
|
|
358
|
+
hooks: [{ type: 'command', command: 'node /opt/other-plugin/bash-check.js', timeout: 3 }],
|
|
359
|
+
}],
|
|
360
|
+
PostToolUse: [{
|
|
361
|
+
matcher: '*',
|
|
362
|
+
description: 'foreign post-tool logger',
|
|
363
|
+
hooks: [{ type: 'command', command: 'bash /opt/foreign/post.sh', timeout: 5 }],
|
|
364
|
+
}],
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
369
|
+
env: { ...process.env, HOME: homeDir },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
373
|
+
// Foreign entries must still be there
|
|
374
|
+
const ptu = after.hooks.PreToolUse;
|
|
375
|
+
const otherBash = ptu.find(e => e.description === 'some-other-plugin Bash inspector');
|
|
376
|
+
assert.ok(otherBash, 'foreign Bash hook was stripped — never strip non-code-graph entries');
|
|
377
|
+
|
|
378
|
+
const ptoFor = after.hooks.PostToolUse.find(e => e.description === 'foreign post-tool logger');
|
|
379
|
+
assert.ok(ptoFor, 'foreign PostToolUse hook was stripped');
|
|
380
|
+
|
|
381
|
+
// Ours are also there
|
|
382
|
+
assert.ok(after.hooks.PreToolUse.some(e => e.matcher === 'Edit' && e.description?.includes('[code-graph-mcp')));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('registerHooksToSettings is idempotent when called directly', () => {
|
|
386
|
+
// Pure-function direct call, no process spawn
|
|
387
|
+
const { registerHooksToSettings } = require('./lifecycle.js');
|
|
388
|
+
const settings = {};
|
|
389
|
+
const changed1 = registerHooksToSettings(settings);
|
|
390
|
+
const snapshot1 = JSON.stringify(settings);
|
|
391
|
+
const changed2 = registerHooksToSettings(settings);
|
|
392
|
+
const snapshot2 = JSON.stringify(settings);
|
|
393
|
+
assert.equal(changed1, true, 'first call must report change');
|
|
394
|
+
assert.equal(changed2, false, 'second call must report no-change (idempotent)');
|
|
395
|
+
assert.equal(snapshot1, snapshot2, 'settings must be byte-identical after second call');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('removeHooksFromSettings cleans up v0.32+ entries (uninstall path)', () => {
|
|
399
|
+
const { registerHooksToSettings, removeHooksFromSettings } = require('./lifecycle.js');
|
|
400
|
+
const settings = {};
|
|
401
|
+
registerHooksToSettings(settings);
|
|
402
|
+
// Sanity: have entries
|
|
403
|
+
assert.ok(settings.hooks.PreToolUse && settings.hooks.PreToolUse.length > 0);
|
|
404
|
+
|
|
405
|
+
const changed = removeHooksFromSettings(settings);
|
|
406
|
+
assert.equal(changed, true);
|
|
407
|
+
assert.ok(!settings.hooks || Object.keys(settings.hooks).length === 0,
|
|
408
|
+
'all our entries must be removed; got: ' + JSON.stringify(settings.hooks));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('uninstall() removes settings.json hook entries end-to-end', (t) => {
|
|
412
|
+
const homeDir = mkHome(t);
|
|
413
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
414
|
+
|
|
415
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
416
|
+
env: { ...process.env, HOME: homeDir },
|
|
417
|
+
});
|
|
418
|
+
const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
419
|
+
assert.ok(afterInstall.hooks?.PreToolUse, 'install must have created hooks');
|
|
420
|
+
|
|
421
|
+
execFileSync(process.execPath, [lifecyclePath, 'uninstall'], {
|
|
422
|
+
env: { ...process.env, HOME: homeDir },
|
|
423
|
+
});
|
|
424
|
+
const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
425
|
+
// Our hooks should be gone (foreign ones would survive but we didn't seed any)
|
|
426
|
+
const serialized = JSON.stringify(afterUninstall.hooks || {});
|
|
427
|
+
assert.ok(!serialized.includes('[code-graph-mcp'),
|
|
428
|
+
'uninstall must strip all our entries; got: ' + serialized);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('hook commands use absolute paths (no ${CLAUDE_PLUGIN_ROOT} in settings.json)', (t) => {
|
|
432
|
+
// settings.json hook commands run with env-pollution risk per
|
|
433
|
+
// feedback_plugin_env_isolation.md — they must NOT depend on
|
|
434
|
+
// ${CLAUDE_PLUGIN_ROOT} (different plugins overwrite each other's value).
|
|
435
|
+
const homeDir = mkHome(t);
|
|
436
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
437
|
+
|
|
438
|
+
execFileSync(process.execPath, [lifecyclePath, 'install'], {
|
|
439
|
+
env: { ...process.env, HOME: homeDir },
|
|
440
|
+
});
|
|
441
|
+
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
442
|
+
const serialized = JSON.stringify(after.hooks || {});
|
|
443
|
+
assert.ok(!serialized.includes('${CLAUDE_PLUGIN_ROOT}'),
|
|
444
|
+
'settings.json hook commands must not reference ${CLAUDE_PLUGIN_ROOT}: ' + serialized);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ════════════════════════════════════════════════════════════════════
|
|
448
|
+
// v0.32.2 — update() upgrade-path integration tests (reviewer Rec #2)
|
|
449
|
+
// ════════════════════════════════════════════════════════════════════
|
|
450
|
+
// Covers the actual v0.31.x → v0.32.x migration path that runs in
|
|
451
|
+
// production via session-init.js syncLifecycleConfig detecting a manifest
|
|
452
|
+
// version mismatch and calling update(). Previously only install() was
|
|
453
|
+
// tested end-to-end; the upgrade path shared the registerHooksToSettings
|
|
454
|
+
// code internally but had no integration test exercising the wiring.
|
|
455
|
+
|
|
456
|
+
test('update() from v0.31.x manifest registers fresh hooks in empty settings.json', (t) => {
|
|
457
|
+
const homeDir = mkHome(t);
|
|
458
|
+
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
459
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
460
|
+
|
|
461
|
+
// Seed v0.31.2 manifest state. updatedAt is the v0.31.2 release date.
|
|
462
|
+
writeJson(manifestPath, {
|
|
463
|
+
version: '0.31.2',
|
|
464
|
+
installedAt: '2026-03-16T18:56:17.656Z',
|
|
465
|
+
updatedAt: '2026-05-23T16:46:39.353Z',
|
|
466
|
+
config: { statusLine: false },
|
|
467
|
+
});
|
|
468
|
+
// settings.json empty (mirrors real v0.31.x state — pre-v0.32.0 strategy
|
|
469
|
+
// was "strip from settings.json, rely on plugin-cache hooks.json").
|
|
470
|
+
writeJson(settingsPath, {});
|
|
471
|
+
|
|
472
|
+
const out = execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
473
|
+
env: { ...process.env, HOME: homeDir },
|
|
474
|
+
}).toString();
|
|
475
|
+
assert.match(out, /Updated 0\.31\.2 → /, 'CLI output must show version transition');
|
|
476
|
+
|
|
477
|
+
// Manifest version was bumped to current
|
|
478
|
+
const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
479
|
+
assert.notEqual(manifestAfter.version, '0.31.2', 'manifest version must advance');
|
|
480
|
+
assert.ok(/^\d+\.\d+\.\d+$/.test(manifestAfter.version),
|
|
481
|
+
`manifest version must be semver, got ${manifestAfter.version}`);
|
|
482
|
+
|
|
483
|
+
// settings.json got the v0.32+ hook entries
|
|
484
|
+
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
485
|
+
assert.ok(settingsAfter.hooks, 'update() must populate hooks block');
|
|
486
|
+
assert.ok(settingsAfter.hooks.PreToolUse, 'PreToolUse must be registered');
|
|
487
|
+
assert.ok(settingsAfter.hooks.PostToolUse, 'PostToolUse must be registered');
|
|
488
|
+
assert.ok(settingsAfter.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered');
|
|
489
|
+
|
|
490
|
+
// Every entry must carry the v0.32+ marker
|
|
491
|
+
for (const entries of Object.values(settingsAfter.hooks)) {
|
|
492
|
+
for (const e of entries) {
|
|
493
|
+
assert.ok(e.description && e.description.includes('[code-graph-mcp v0.32+'),
|
|
494
|
+
`update() entry without v0.32+ marker: ${JSON.stringify(e.description)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('update() from v0.31.x evicts legacy v0.7/v0.8 entries with stale paths', (t) => {
|
|
500
|
+
const homeDir = mkHome(t);
|
|
501
|
+
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
502
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
503
|
+
|
|
504
|
+
writeJson(manifestPath, {
|
|
505
|
+
version: '0.31.2',
|
|
506
|
+
installedAt: '2026-03-16T18:56:17.656Z',
|
|
507
|
+
config: { statusLine: false },
|
|
508
|
+
});
|
|
509
|
+
// Seed with legacy v0.8.2-era entries that should be evicted on update.
|
|
510
|
+
writeJson(settingsPath, {
|
|
511
|
+
hooks: legacyHooksFromPlugin(),
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
515
|
+
env: { ...process.env, HOME: homeDir },
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
519
|
+
const serialized = JSON.stringify(settingsAfter.hooks || {});
|
|
520
|
+
// Stale paths must be gone
|
|
521
|
+
assert.ok(!serialized.includes('/stale/cache/'),
|
|
522
|
+
'legacy stale paths must be evicted by update(): ' + serialized);
|
|
523
|
+
// Fresh v0.32+ entries must be present
|
|
524
|
+
assert.ok(serialized.includes('[code-graph-mcp v0.32+'),
|
|
525
|
+
'fresh v0.32+ entries must be installed by update()');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('update() preserves foreign plugin hooks during upgrade', (t) => {
|
|
529
|
+
const homeDir = mkHome(t);
|
|
530
|
+
const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json');
|
|
531
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
532
|
+
|
|
533
|
+
writeJson(manifestPath, {
|
|
534
|
+
version: '0.31.2',
|
|
535
|
+
config: { statusLine: false },
|
|
536
|
+
});
|
|
537
|
+
// Seed with an unrelated plugin's hooks — must survive our update().
|
|
538
|
+
writeJson(settingsPath, {
|
|
539
|
+
hooks: {
|
|
540
|
+
PreToolUse: [{
|
|
541
|
+
matcher: 'Bash',
|
|
542
|
+
description: 'foreign-plugin Bash watcher',
|
|
543
|
+
hooks: [{ type: 'command', command: 'node /opt/foreign/bash.js', timeout: 3 }],
|
|
544
|
+
}],
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
execFileSync(process.execPath, [lifecyclePath, 'update'], {
|
|
549
|
+
env: { ...process.env, HOME: homeDir },
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
553
|
+
const ptu = settingsAfter.hooks.PreToolUse;
|
|
554
|
+
assert.ok(ptu.some(e => e.description === 'foreign-plugin Bash watcher'),
|
|
555
|
+
'foreign Bash hook must survive update() — never strip non-code-graph entries');
|
|
556
|
+
// And our own entries must coexist
|
|
557
|
+
assert.ok(ptu.some(e => e.description && e.description.includes('[code-graph-mcp v0.32+')),
|
|
558
|
+
'update() must add our v0.32+ entries alongside the foreign one');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// ════════════════════════════════════════════════════════════════════
|
|
562
|
+
// v0.32.2 — healthCheck post-repair re-verification
|
|
563
|
+
// (Reviewer M3: repaired:true was set blindly after install() without
|
|
564
|
+
// re-scanning to confirm the issues actually resolved.)
|
|
565
|
+
// ════════════════════════════════════════════════════════════════════
|
|
566
|
+
|
|
567
|
+
function runHealthCheckInChild(homeDir) {
|
|
568
|
+
const code = `
|
|
569
|
+
const lc = require(${JSON.stringify(lifecyclePath)});
|
|
570
|
+
process.stdout.write(JSON.stringify(lc.healthCheck()));
|
|
571
|
+
`;
|
|
572
|
+
const out = execFileSync(process.execPath, ['-e', code], {
|
|
573
|
+
env: { ...process.env, HOME: homeDir },
|
|
574
|
+
encoding: 'utf8',
|
|
575
|
+
});
|
|
576
|
+
return JSON.parse(out);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
test('healthCheck on a clean state returns healthy:true and never sets remaining', (t) => {
|
|
580
|
+
const homeDir = mkHome(t);
|
|
581
|
+
// No settings.json, no registry — clean slate.
|
|
582
|
+
const r = runHealthCheckInChild(homeDir);
|
|
583
|
+
assert.equal(r.healthy, true, 'fresh empty state must be healthy');
|
|
584
|
+
assert.deepEqual(r.issues, [], 'no issues on empty state');
|
|
585
|
+
assert.equal(r.repaired, false, 'no repair runs when nothing was broken');
|
|
586
|
+
assert.equal(r.remaining, undefined, 'no remaining field when no repair attempted');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('healthCheck repaired:true ONLY after post-repair re-scan returns clean', (t) => {
|
|
590
|
+
const homeDir = mkHome(t);
|
|
591
|
+
// Seed a hook entry whose path is broken AND carries our marker. install()
|
|
592
|
+
// will overwrite our entries with fresh absolute paths derived from
|
|
593
|
+
// __dirname (which is real in the test env), so the re-scan should be clean.
|
|
594
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
595
|
+
writeJson(settingsPath, {
|
|
596
|
+
hooks: {
|
|
597
|
+
PreToolUse: [{
|
|
598
|
+
matcher: 'Edit',
|
|
599
|
+
description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
|
|
600
|
+
hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
|
|
601
|
+
}],
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const r = runHealthCheckInChild(homeDir);
|
|
606
|
+
assert.equal(r.healthy, false, 'pre-repair scan must have flagged the broken path');
|
|
607
|
+
assert.ok(r.issues.length >= 1, 'pre-repair issues must list the broken hook');
|
|
608
|
+
assert.equal(r.repaired, true, 'install() rewrote our entry → post-scan clean → repaired:true');
|
|
609
|
+
assert.deepEqual(r.remaining, [], 'remaining must be empty when repair succeeded');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('healthCheck repaired:false when install() cannot resolve a flagged path', (t) => {
|
|
613
|
+
const homeDir = mkHome(t);
|
|
614
|
+
// Seed the registry with a non-`_previous` third-party provider whose path
|
|
615
|
+
// is broken. install() only manages the 'code-graph' registry entry, so
|
|
616
|
+
// the third-party entry survives untouched and the post-repair re-scan
|
|
617
|
+
// still flags it. This is the canonical "auto-repair could not fix it"
|
|
618
|
+
// path — previously the function lied and returned repaired:true anyway.
|
|
619
|
+
const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json');
|
|
620
|
+
writeJson(registryPath, [
|
|
621
|
+
{ id: 'third-party-statusline', command: 'node "/nonexistent/foreign/sl.js"', needsStdin: false },
|
|
622
|
+
]);
|
|
623
|
+
|
|
624
|
+
const r = runHealthCheckInChild(homeDir);
|
|
625
|
+
assert.equal(r.healthy, false, 'broken third-party path must be flagged on entry');
|
|
626
|
+
assert.ok(r.issues.some(i => i.type === 'registry' && i.id === 'third-party-statusline'),
|
|
627
|
+
'pre-repair issue list must contain the third-party entry');
|
|
628
|
+
assert.equal(r.repaired, false,
|
|
629
|
+
'install() does not touch third-party providers → re-scan still broken → repaired must be false');
|
|
630
|
+
assert.ok(Array.isArray(r.remaining), 'remaining must be present when install() was attempted');
|
|
631
|
+
assert.ok(r.remaining.some(i => i.id === 'third-party-statusline'),
|
|
632
|
+
'remaining must still contain the un-fixable third-party entry');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test('scanForBrokenPaths is exported and returns the issue structure', (t) => {
|
|
636
|
+
// Direct unit test of the extracted scanner — no install() side effects.
|
|
637
|
+
// Verifies the contract M3 relies on: a pure function whose return
|
|
638
|
+
// shape is what healthCheck composes its result from.
|
|
639
|
+
const homeDir = mkHome(t);
|
|
640
|
+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
|
|
641
|
+
writeJson(settingsPath, {
|
|
642
|
+
hooks: {
|
|
643
|
+
PreToolUse: [{
|
|
644
|
+
matcher: 'Edit',
|
|
645
|
+
description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)',
|
|
646
|
+
hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }],
|
|
647
|
+
}],
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const code = `
|
|
652
|
+
const lc = require(${JSON.stringify(lifecyclePath)});
|
|
653
|
+
process.stdout.write(JSON.stringify(lc.scanForBrokenPaths()));
|
|
654
|
+
`;
|
|
655
|
+
const out = execFileSync(process.execPath, ['-e', code], {
|
|
656
|
+
env: { ...process.env, HOME: homeDir },
|
|
657
|
+
encoding: 'utf8',
|
|
658
|
+
});
|
|
659
|
+
const issues = JSON.parse(out);
|
|
660
|
+
assert.ok(Array.isArray(issues));
|
|
661
|
+
assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')),
|
|
662
|
+
'scanForBrokenPaths must report the seeded broken hook entry');
|
|
288
663
|
});
|
|
@@ -15,6 +15,79 @@ const fs = require('fs');
|
|
|
15
15
|
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT can leak from other plugins
|
|
16
16
|
process.env._FIND_BINARY_ROOT = path.resolve(__dirname, '..');
|
|
17
17
|
|
|
18
|
+
// --- Tool-catalog dedup gate -----------------------------------------------
|
|
19
|
+
// If the user's project has its own .mcp.json registering a code-graph server
|
|
20
|
+
// (the recommended pattern for dev work on this repo — points at a local
|
|
21
|
+
// `target/release/code-graph-mcp` so usage telemetry lands in the project's
|
|
22
|
+
// `.code-graph/usage.jsonl`), the plugin's own MCP server adds a SECOND copy
|
|
23
|
+
// of the same 7 tools to the catalog, costing context budget and splitting
|
|
24
|
+
// the agent's choice between two equivalent namespaces.
|
|
25
|
+
//
|
|
26
|
+
// Detect that case and serve a minimal "0-tools" MCP stub so this plugin
|
|
27
|
+
// stops contributing to the catalog. Hooks, skills, agents stay registered
|
|
28
|
+
// (they live outside the MCP server). Env override
|
|
29
|
+
// `CODE_GRAPH_FORCE_PLUGIN_MCP=1` bypasses the gate.
|
|
30
|
+
function projectHasLocalCodeGraphMcp(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const p = path.join(cwd, '.mcp.json');
|
|
33
|
+
if (!fs.existsSync(p)) return false;
|
|
34
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
35
|
+
const servers = (cfg && cfg.mcpServers) || {};
|
|
36
|
+
return Object.keys(servers).some(n => /code[-_]?graph/i.test(n));
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function serveEmptyMcpStub() {
|
|
41
|
+
let buf = '';
|
|
42
|
+
process.stdin.setEncoding('utf8');
|
|
43
|
+
process.stdin.on('data', (chunk) => {
|
|
44
|
+
buf += chunk;
|
|
45
|
+
let nl;
|
|
46
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
47
|
+
const line = buf.slice(0, nl).trim();
|
|
48
|
+
buf = buf.slice(nl + 1);
|
|
49
|
+
if (!line) continue;
|
|
50
|
+
let req;
|
|
51
|
+
try { req = JSON.parse(line); } catch { continue; }
|
|
52
|
+
if (!req || typeof req.method !== 'string') continue;
|
|
53
|
+
// JSON-RPC notifications (id missing) get no response.
|
|
54
|
+
if (typeof req.id === 'undefined') continue;
|
|
55
|
+
const method = req.method;
|
|
56
|
+
let result, error;
|
|
57
|
+
if (method === 'initialize') {
|
|
58
|
+
result = {
|
|
59
|
+
protocolVersion: '2024-11-05',
|
|
60
|
+
capabilities: { tools: { listChanged: false } },
|
|
61
|
+
serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' },
|
|
62
|
+
};
|
|
63
|
+
} else if (method === 'tools/list') {
|
|
64
|
+
result = { tools: [] };
|
|
65
|
+
} else if (method === 'resources/list') {
|
|
66
|
+
result = { resources: [] };
|
|
67
|
+
} else if (method === 'prompts/list') {
|
|
68
|
+
result = { prompts: [] };
|
|
69
|
+
} else {
|
|
70
|
+
error = { code: -32601, message: 'method not found (plugin MCP is in dedup stub mode)' };
|
|
71
|
+
}
|
|
72
|
+
const resp = error
|
|
73
|
+
? { jsonrpc: '2.0', id: req.id, error }
|
|
74
|
+
: { jsonrpc: '2.0', id: req.id, result };
|
|
75
|
+
process.stdout.write(JSON.stringify(resp) + '\n');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
process.stdin.on('end', () => process.exit(0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphMcp(process.cwd())) {
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
'[code-graph] project .mcp.json registers a code-graph server; ' +
|
|
84
|
+
'plugin MCP serving 0 tools to avoid duplicate catalog entries. ' +
|
|
85
|
+
'Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n'
|
|
86
|
+
);
|
|
87
|
+
serveEmptyMcpStub();
|
|
88
|
+
return; // top-level function scope of mcp-launcher.js
|
|
89
|
+
}
|
|
90
|
+
|
|
18
91
|
const { findBinary, clearCache } = require('./find-binary');
|
|
19
92
|
|
|
20
93
|
let binary = findBinary();
|
|
@@ -33,11 +33,11 @@ function hasBuiltBinary() {
|
|
|
33
33
|
* Run the launcher, send one MCP message on stdin, collect stdout/stderr,
|
|
34
34
|
* resolve once we either see a JSON-RPC response on stdout or hit timeout.
|
|
35
35
|
*/
|
|
36
|
-
function runLauncherInitialize(timeoutMs = 15000) {
|
|
36
|
+
function runLauncherInitialize(timeoutMs = 15000, extraEnv = {}) {
|
|
37
37
|
return new Promise((resolve, reject) => {
|
|
38
38
|
const child = spawn(process.execPath, [LAUNCHER], {
|
|
39
39
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
-
env: { ...process.env },
|
|
40
|
+
env: { ...process.env, ...extraEnv },
|
|
41
41
|
cwd: REPO_ROOT,
|
|
42
42
|
});
|
|
43
43
|
|
|
@@ -80,7 +80,11 @@ test('mcp-launcher resolves dev binary and forwards MCP JSON-RPC stdin/stdout',
|
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// REPO_ROOT has its own .mcp.json registering code-graph-dev (v0.31.2
|
|
84
|
+
// landed that to capture dev session metrics), which trips the launcher's
|
|
85
|
+
// dedup gate. Force the original launch path so this test still covers
|
|
86
|
+
// it. The dedup behavior gets its own test below.
|
|
87
|
+
const { stdout, stderr } = await runLauncherInitialize(15000, { CODE_GRAPH_FORCE_PLUGIN_MCP: '1' });
|
|
84
88
|
|
|
85
89
|
// Find the JSON-RPC line in the bytes the launcher forwarded from the binary.
|
|
86
90
|
// Stderr may contain "[code-graph] ..." breadcrumbs from the launcher; those
|
|
@@ -95,6 +99,22 @@ test('mcp-launcher resolves dev binary and forwards MCP JSON-RPC stdin/stdout',
|
|
|
95
99
|
assert.equal(resp.result.serverInfo.name, 'code-graph-mcp');
|
|
96
100
|
});
|
|
97
101
|
|
|
102
|
+
test('mcp-launcher enters dedup stub when project .mcp.json registers a code-graph server', async () => {
|
|
103
|
+
// REPO_ROOT/.mcp.json registers code-graph-dev → dedup gate fires →
|
|
104
|
+
// launcher serves a 0-tools stub with a distinctive serverInfo.name.
|
|
105
|
+
// No need for the release binary; the stub is implemented in the
|
|
106
|
+
// launcher script itself.
|
|
107
|
+
const { stdout, stderr } = await runLauncherInitialize();
|
|
108
|
+
const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"'));
|
|
109
|
+
assert.ok(respLine,
|
|
110
|
+
`expected stub JSON-RPC result on stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`);
|
|
111
|
+
const resp = JSON.parse(respLine);
|
|
112
|
+
assert.match(resp.result.serverInfo.name, /stub|dedup/i,
|
|
113
|
+
`serverInfo.name should indicate stub mode, got ${JSON.stringify(resp.result.serverInfo)}`);
|
|
114
|
+
assert.match(stderr, /plugin MCP serving 0 tools/,
|
|
115
|
+
`stderr should explain the dedup, got: ${stderr.slice(0, 400)}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
98
118
|
test('mcp-launcher sets _FIND_BINARY_ROOT from __dirname (does not trust CLAUDE_PLUGIN_ROOT)', () => {
|
|
99
119
|
// Static check: the source must derive _FIND_BINARY_ROOT from __dirname so a
|
|
100
120
|
// sibling plugin's CLAUDE_PLUGIN_ROOT can't redirect us to the wrong binary.
|
|
@@ -9,12 +9,19 @@
|
|
|
9
9
|
const { execFileSync } = require('child_process');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
|
-
const
|
|
12
|
+
const { findBinary } = require('./find-binary');
|
|
13
|
+
const { cgTmpDir } = require('./tmp-dir');
|
|
13
14
|
|
|
14
15
|
const cwd = process.cwd();
|
|
15
16
|
const dbPath = path.join(cwd, '.code-graph', 'index.db');
|
|
16
17
|
if (!fs.existsSync(dbPath)) process.exit(0);
|
|
17
18
|
|
|
19
|
+
// Resolve binary the same way the other hooks do — bare PATH lookup misses
|
|
20
|
+
// npm-global installs on systems where the global bin dir isn't on PATH for
|
|
21
|
+
// non-login shells (a real failure mode reported in mem #8187).
|
|
22
|
+
const binary = findBinary();
|
|
23
|
+
if (!binary) process.exit(0);
|
|
24
|
+
|
|
18
25
|
// --- Parse tool input ---
|
|
19
26
|
let input;
|
|
20
27
|
try {
|
|
@@ -63,7 +70,7 @@ if (!symbol || symbol.length < 3) {
|
|
|
63
70
|
.sort((a, b) => b.length - a.length);
|
|
64
71
|
for (const candidate of candidates.slice(0, 5)) {
|
|
65
72
|
try {
|
|
66
|
-
const raw = execFileSync(
|
|
73
|
+
const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], {
|
|
67
74
|
cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
75
|
});
|
|
69
76
|
const grepResult = JSON.parse(raw);
|
|
@@ -98,15 +105,24 @@ function isCommonKeyword(s) {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
// --- Per-symbol cooldown: 2 minutes ---
|
|
101
|
-
const cooldownFile = path.join(
|
|
108
|
+
const cooldownFile = path.join(cgTmpDir(), `.cg-impact-${symbol}`);
|
|
102
109
|
try {
|
|
103
110
|
if (Date.now() - fs.statSync(cooldownFile).mtimeMs < 120000) process.exit(0);
|
|
104
111
|
} catch { /* first time for this symbol */ }
|
|
105
112
|
|
|
106
113
|
// --- Run impact analysis (JSON mode for programmatic parsing) ---
|
|
114
|
+
// Disambiguate via --file: file_path from tool_input is absolute, but the
|
|
115
|
+
// indexer stores files as repo-relative paths — converting here is what makes
|
|
116
|
+
// short generic symbol names (open, new, create, parse, from, init) resolve
|
|
117
|
+
// to a unique node instead of triggering the CLI's "Ambiguous symbol" error
|
|
118
|
+
// path, which previously caused silent exits for the most common edit cases.
|
|
119
|
+
const editedFile = (input.tool_input && input.tool_input.file_path) || '';
|
|
120
|
+
const relFile = editedFile ? path.relative(cwd, editedFile) : '';
|
|
107
121
|
let jsonResult;
|
|
108
122
|
try {
|
|
109
|
-
const
|
|
123
|
+
const args = ['impact', symbol, '--json'];
|
|
124
|
+
if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
|
|
125
|
+
const raw = execFileSync('code-graph-mcp', args, {
|
|
110
126
|
cwd,
|
|
111
127
|
timeout: 2500,
|
|
112
128
|
encoding: 'utf8',
|
|
@@ -118,6 +134,10 @@ try {
|
|
|
118
134
|
process.exit(0);
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
// CLI returns {"error": "..."} on ambiguous / not-found instead of throwing.
|
|
138
|
+
// Treat as silent skip — direct_callers will be undefined.
|
|
139
|
+
if (jsonResult && jsonResult.error) process.exit(0);
|
|
140
|
+
|
|
121
141
|
// --- Inject when the symbol has any caller (1+) ---
|
|
122
142
|
// Earlier gate was 2+ direct callers; reality is that editing a function with
|
|
123
143
|
// even one production caller benefits from a one-line impact summary, and the
|