@sdsrs/code-graph 0.30.0 → 0.32.2

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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.30.0",
7
+ "version": "0.32.2",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -1,6 +1,6 @@
1
1
  {
2
- "description": "code-graph-mcp hooks — loaded directly by Claude Code from the plugin cache.",
3
- "_note": "Authoritative source. settings.json is no longer used for hook registration as of v0.8.3 session-init.js actively removes any legacy code-graph entries it finds there. Paths use ${CLAUDE_PLUGIN_ROOT} so they follow version directory updates automatically.",
2
+ "description": "code-graph-mcp hooks — only SessionStart is loaded by Claude Code from plugin-cache. All other event types are registered by lifecycle.js into ~/.claude/settings.json (v0.32.0+).",
3
+ "_note": "Empirical 2026-05-24: current Claude Code only loads SessionStart entries from plugin-cache hooks.json. PreToolUse / PostToolUse / UserPromptSubmit / Stop / SessionEnd entries here are SILENTLY IGNORED. lifecycle.js install/update writes those to ~/.claude/settings.json instead (pattern from claude-mem-lite). Re-adding non-SessionStart entries here would NOT make them fire they would just be dead config. See feedback_pretooluse_dark_under_green_health.md for the jsonl evidence chain.",
4
4
  "hooks": {
5
5
  "SessionStart": [
6
6
  {
@@ -13,62 +13,6 @@
13
13
  }
14
14
  ]
15
15
  }
16
- ],
17
- "PreToolUse": [
18
- {
19
- "matcher": "tool == \"Edit\"",
20
- "hooks": [
21
- {
22
- "type": "command",
23
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit-guide.js\"",
24
- "timeout": 4
25
- }
26
- ]
27
- },
28
- {
29
- "matcher": "tool == \"Bash\"",
30
- "hooks": [
31
- {
32
- "type": "command",
33
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-grep-guide.js\"",
34
- "timeout": 3
35
- }
36
- ]
37
- },
38
- {
39
- "matcher": "tool == \"Read\"",
40
- "hooks": [
41
- {
42
- "type": "command",
43
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"",
44
- "timeout": 3
45
- }
46
- ]
47
- }
48
- ],
49
- "PostToolUse": [
50
- {
51
- "matcher": "tool == \"Write\" || tool == \"Edit\"",
52
- "hooks": [
53
- {
54
- "type": "command",
55
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/incremental-index.js\"",
56
- "timeout": 10
57
- }
58
- ]
59
- }
60
- ],
61
- "UserPromptSubmit": [
62
- {
63
- "matcher": "",
64
- "hooks": [
65
- {
66
- "type": "command",
67
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-context.js\"",
68
- "timeout": 5
69
- }
70
- ]
71
- }
72
16
  ]
73
17
  }
74
18
  }
@@ -269,9 +269,14 @@ const TARGET_NAME = 'plugin_code_graph_mcp.md';
269
269
  // Claude Code slug convention: every non-alphanumeric-non-hyphen char → `-`.
270
270
  // `/mnt/data_ssd/dev/proj` → `-mnt-data-ssd-dev-proj`
271
271
  // `/home/sds/.claude/x` → `-home-sds--claude-x` (double-dash from `/.`)
272
+ //
273
+ // `home` is the OS home dir (default `os.homedir()`). When `CLAUDE_CONFIG_DIR`
274
+ // is set it overrides `home/.claude`, so multi-account users (personal vs work)
275
+ // land in the directory Claude Code itself is using for `projects/`.
272
276
  function memoryDir(cwd = process.cwd(), home = os.homedir()) {
273
277
  const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
274
- return path.join(home, '.claude', 'projects', slug, 'memory');
278
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
279
+ return path.join(claudeDir, 'projects', slug, 'memory');
275
280
  }
276
281
 
277
282
  function escapeRegex(s) {
@@ -421,9 +426,17 @@ function needsRefresh({ cwd, home, templatePath } = {}) {
421
426
  // 检测脚本是否从 Claude Code 插件 cache 运行。
422
427
  // 走 __dirname 而非 CLAUDE_PLUGIN_ROOT — 后者在多插件共存时会互相污染
423
428
  // (见 feedback_plugin_env_isolation.md)。
429
+ // 默认匹配 `.claude/plugins/` 路径;CLAUDE_CONFIG_DIR 自定义目录时
430
+ // 走 startsWith(CLAUDE_CONFIG_DIR/plugins/) 兜底。
424
431
  function isPluginModeInstall(scriptPath = __dirname) {
425
432
  const sep = path.sep;
426
- return scriptPath.includes(`${sep}.claude${sep}plugins${sep}`);
433
+ if (scriptPath.includes(`${sep}.claude${sep}plugins${sep}`)) return true;
434
+ const envDir = process.env.CLAUDE_CONFIG_DIR;
435
+ if (envDir) {
436
+ const marker = path.join(envDir, 'plugins') + sep;
437
+ if (scriptPath.startsWith(marker)) return true;
438
+ }
439
+ return false;
427
440
  }
428
441
 
429
442
  // C' 上下文感知默认(v0.9.0):插件模式下首次 SessionStart 静默 adopt。
@@ -278,6 +278,44 @@ test('isPluginModeInstall rejects npx cache paths', () => {
278
278
  assert.strictEqual(isPluginModeInstall(npxPath), false);
279
279
  });
280
280
 
281
+ test('memoryDir honors CLAUDE_CONFIG_DIR override (multi-account isolation)', () => {
282
+ const prev = process.env.CLAUDE_CONFIG_DIR;
283
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
284
+ try {
285
+ // home arg is irrelevant when env var is set — projects live under the
286
+ // configured claude dir, not home/.claude.
287
+ assert.strictEqual(
288
+ memoryDir('/home/alice/proj', '/home/alice'),
289
+ '/home/alice/work-claude/projects/-home-alice-proj/memory'
290
+ );
291
+ } finally {
292
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
293
+ else process.env.CLAUDE_CONFIG_DIR = prev;
294
+ }
295
+ });
296
+
297
+ test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
298
+ const prev = process.env.CLAUDE_CONFIG_DIR;
299
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
300
+ try {
301
+ const pluginPath = '/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts';
302
+ assert.strictEqual(isPluginModeInstall(pluginPath), true);
303
+ // Legacy ~/.claude/plugins/ path still works even with env var set.
304
+ assert.strictEqual(
305
+ isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'),
306
+ true
307
+ );
308
+ // Unrelated path under same prefix is still rejected.
309
+ assert.strictEqual(
310
+ isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'),
311
+ false
312
+ );
313
+ } finally {
314
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
315
+ else process.env.CLAUDE_CONFIG_DIR = prev;
316
+ }
317
+ });
318
+
281
319
  test('maybeAutoAdopt skips when CODE_GRAPH_NO_AUTO_ADOPT=1', () => {
282
320
  const sb = makeSandbox();
283
321
  try {
@@ -5,9 +5,10 @@ const fs = require('fs');
5
5
  const https = require('https');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic } = require('./lifecycle');
8
+ const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
9
9
  const { clearCache: clearBinaryCache } = require('./find-binary');
10
10
  const { readBinaryVersion, isDevMode } = require('./version-utils');
11
+ const { cgTmpDir } = require('./tmp-dir');
11
12
 
12
13
  // ── Environment Checks ────────────────────────────────────
13
14
 
@@ -251,7 +252,7 @@ async function downloadAndInstall(latest) {
251
252
  return { pluginUpdated: false, binaryUpdated: false };
252
253
  }
253
254
 
254
- const tmpDir = path.join(os.tmpdir(), `code-graph-update-${Date.now()}`);
255
+ const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`);
255
256
  let pluginUpdated = false;
256
257
  let binaryUpdated = false;
257
258
 
@@ -272,7 +273,7 @@ async function downloadAndInstall(latest) {
272
273
 
273
274
  const pluginSrc = path.join(tmpDir, 'claude-plugin');
274
275
  const pluginDst = path.join(
275
- os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME, 'code-graph-mcp', latest.version
276
+ pluginsCacheDir(), MARKETPLACE_NAME, 'code-graph-mcp', latest.version
276
277
  );
277
278
 
278
279
  if (fs.existsSync(pluginSrc) && getExtractedPluginVersion(pluginSrc) === latest.version) {
@@ -282,7 +283,7 @@ async function downloadAndInstall(latest) {
282
283
  }
283
284
 
284
285
  // Update installed_plugins.json to point to new version
285
- const installedPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
286
+ const installedPath = installedPluginsPath();
286
287
  try {
287
288
  const installed = readJson(installedPath);
288
289
  if (installed && installed.plugins && installed.plugins[PLUGIN_ID]) {
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Resolve Claude Code's config directory. Honors CLAUDE_CONFIG_DIR — when set
6
+ // (commonly used to keep multiple accounts isolated, e.g. personal vs work),
7
+ // Claude Code reads `settings.json`, `plugins/`, `projects/`, etc. from there
8
+ // instead of `~/.claude/`. Our plugin must follow the same override so its
9
+ // hook registrations, statusline, adoption files, and cache cleanup land in
10
+ // the directory Claude Code is actually using.
11
+ //
12
+ // Read fresh on every call so per-process env mutation (tests, child procs
13
+ // spawned with a different env) takes effect immediately. Unlike
14
+ // CLAUDE_PLUGIN_ROOT (which leaks across plugins — see
15
+ // feedback_plugin_env_isolation.md), CLAUDE_CONFIG_DIR is user-set and
16
+ // process-wide, so reading it is safe.
17
+ function claudeHome() {
18
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
19
+ }
20
+
21
+ module.exports = { claudeHome };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { claudeHome } = require('./claude-config');
7
+
8
+ test('claudeHome defaults to ~/.claude when CLAUDE_CONFIG_DIR unset', () => {
9
+ const prev = process.env.CLAUDE_CONFIG_DIR;
10
+ delete process.env.CLAUDE_CONFIG_DIR;
11
+ try {
12
+ assert.strictEqual(claudeHome(), path.join(os.homedir(), '.claude'));
13
+ } finally {
14
+ if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
15
+ }
16
+ });
17
+
18
+ test('claudeHome honors CLAUDE_CONFIG_DIR when set', () => {
19
+ const prev = process.env.CLAUDE_CONFIG_DIR;
20
+ process.env.CLAUDE_CONFIG_DIR = '/tmp/work-claude';
21
+ try {
22
+ assert.strictEqual(claudeHome(), '/tmp/work-claude');
23
+ } finally {
24
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
25
+ else process.env.CLAUDE_CONFIG_DIR = prev;
26
+ }
27
+ });
28
+
29
+ test('claudeHome re-reads env on every call (not cached)', () => {
30
+ const prev = process.env.CLAUDE_CONFIG_DIR;
31
+ delete process.env.CLAUDE_CONFIG_DIR;
32
+ try {
33
+ const before = claudeHome();
34
+ process.env.CLAUDE_CONFIG_DIR = '/tmp/account-A';
35
+ const during = claudeHome();
36
+ delete process.env.CLAUDE_CONFIG_DIR;
37
+ const after = claudeHome();
38
+ assert.strictEqual(before, path.join(os.homedir(), '.claude'));
39
+ assert.strictEqual(during, '/tmp/account-A');
40
+ assert.strictEqual(after, path.join(os.homedir(), '.claude'));
41
+ } finally {
42
+ if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
43
+ }
44
+ });
45
+
46
+ test('claudeHome ignores empty CLAUDE_CONFIG_DIR (falls back to ~/.claude)', () => {
47
+ // Empty string is falsy in JS — sanity-check the `||` fallback path so an
48
+ // accidentally `CLAUDE_CONFIG_DIR=` (unset-style) shell line does not strand
49
+ // us writing to the literal repository root `/`.
50
+ const prev = process.env.CLAUDE_CONFIG_DIR;
51
+ process.env.CLAUDE_CONFIG_DIR = '';
52
+ try {
53
+ assert.strictEqual(claudeHome(), path.join(os.homedir(), '.claude'));
54
+ } finally {
55
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
56
+ else process.env.CLAUDE_CONFIG_DIR = prev;
57
+ }
58
+ });
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
8
8
  const {
9
9
  getPluginVersion, readJson, healthCheck, CACHE_DIR,
10
- removeHooksFromSettings, isOurHookEntry, writeJsonAtomic,
10
+ isOurHookEntry, settingsPath, buildSettingsHookEntries,
11
11
  } = require('./lifecycle');
12
12
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
13
 
@@ -172,35 +172,52 @@ function runDiagnostics() {
172
172
  }
173
173
 
174
174
  // 6. Hook paths validity
175
+ // healthCheck() auto-attempts install() when broken paths are detected and
176
+ // re-scans to verify; repaired:true is now contingent on the re-scan
177
+ // returning clean. If repaired:false despite install() running, the
178
+ // re-scan still found broken paths — surfacing 'remaining' makes that
179
+ // honest instead of telling the user we fixed nothing.
175
180
  const hookResult = healthCheck();
176
181
  if (hookResult.healthy) {
177
182
  results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' });
183
+ } else if (hookResult.repaired) {
184
+ results.push({
185
+ name: 'Hooks',
186
+ status: 'ok',
187
+ detail: `${hookResult.issues.length} issue(s) auto-repaired`,
188
+ });
178
189
  } else {
190
+ const remainingCount = Array.isArray(hookResult.remaining)
191
+ ? hookResult.remaining.length
192
+ : hookResult.issues.length;
179
193
  results.push({
180
194
  name: 'Hooks',
181
- status: hookResult.repaired ? 'ok' : 'warn',
182
- detail: hookResult.repaired
183
- ? `${hookResult.issues.length} issue(s) auto-repaired`
184
- : `${hookResult.issues.length} invalid path(s)`,
185
- fixId: hookResult.repaired ? undefined : 'hooks-invalid',
195
+ status: 'warn',
196
+ detail: `${remainingCount} invalid path(s) — auto-repair did not resolve`,
197
+ fixId: 'hooks-invalid',
186
198
  });
187
199
  }
188
200
 
189
- // 7. Legacy hooks in settings.json — v0.8.2 and earlier wrote hooks there;
190
- // cache/<ver>/hooks/hooks.json is now authoritative. Duplicates cause
191
- // every hook to fire twice until settings.json is cleaned.
201
+ // 7. settings.json hook coverage — v0.32.0 inversion. Current Claude Code
202
+ // silently ignores plugin-cache hooks.json for PreToolUse/PostToolUse/
203
+ // UserPromptSubmit. lifecycle.js install/update is responsible for
204
+ // registering them in settings.json. "Missing" is the bug (previously
205
+ // "present" was treated as legacy debris — that was wrong).
192
206
  try {
193
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
194
- const settings = readJson(SETTINGS_PATH) || {};
195
- const legacyCount = countLegacyHookEntries(settings);
196
- if (legacyCount === 0) {
197
- results.push({ name: 'Legacy hooks', status: 'ok', detail: 'settings.json is clean' });
207
+ const settings = readJson(settingsPath()) || {};
208
+ const cov = surveyHookCoverage(settings);
209
+ if (cov.missing.length === 0) {
210
+ results.push({
211
+ name: 'Hook coverage',
212
+ status: 'ok',
213
+ detail: `settings.json has all ${cov.expected.length} expected entries`,
214
+ });
198
215
  } else {
199
216
  results.push({
200
- name: 'Legacy hooks',
217
+ name: 'Hook coverage',
201
218
  status: 'warn',
202
- detail: `${legacyCount} entries in settings.json (fire twice per event)`,
203
- fixId: 'legacy-hooks-in-settings',
219
+ detail: `missing ${cov.missing.length}/${cov.expected.length} settings.json entries: ${cov.missing.join(', ')}`,
220
+ fixId: 'missing-hooks-in-settings',
204
221
  });
205
222
  }
206
223
  } catch { /* probe failed — skip */ }
@@ -208,16 +225,31 @@ function runDiagnostics() {
208
225
  return results;
209
226
  }
210
227
 
211
- function countLegacyHookEntries(settings) {
212
- if (!settings || !settings.hooks) return 0;
213
- let count = 0;
214
- for (const entries of Object.values(settings.hooks)) {
215
- if (!Array.isArray(entries)) continue;
216
- for (const entry of entries) {
217
- if (isOurHookEntry(entry)) count++;
228
+ // Inventory of (event, matcher) tuples we expect to find in settings.json after
229
+ // install. Used by doctor to detect missing entries.
230
+ function surveyHookCoverage(settings) {
231
+ const desired = buildSettingsHookEntries();
232
+ const expected = [];
233
+ for (const [event, entries] of Object.entries(desired)) {
234
+ for (const e of entries) {
235
+ expected.push(`${event}:${e.matcher || '*'}`);
236
+ }
237
+ }
238
+
239
+ const present = new Set();
240
+ if (settings && settings.hooks) {
241
+ for (const [event, entries] of Object.entries(settings.hooks)) {
242
+ if (!Array.isArray(entries)) continue;
243
+ for (const entry of entries) {
244
+ if (isOurHookEntry(entry)) {
245
+ present.add(`${event}:${entry.matcher || '*'}`);
246
+ }
247
+ }
218
248
  }
219
249
  }
220
- return count;
250
+
251
+ const missing = expected.filter(k => !present.has(k));
252
+ return { expected, present: [...present], missing };
221
253
  }
222
254
 
223
255
  // ── Report Formatting ─────────────────────────────────────
@@ -370,21 +402,20 @@ function runRepairs(results) {
370
402
  console.log('\n Repairing hooks...');
371
403
  const { install } = require('./lifecycle');
372
404
  install();
373
- console.log(' \u2705 Hooks repaired');
405
+ console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply');
374
406
  fixed++;
375
407
  break;
376
408
  }
377
409
 
378
- case 'legacy-hooks-in-settings': {
379
- console.log('\n Removing legacy code-graph hooks from settings.json...');
380
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
381
- const settings = readJson(SETTINGS_PATH) || {};
382
- if (removeHooksFromSettings(settings)) {
383
- writeJsonAtomic(SETTINGS_PATH, settings);
384
- console.log(' \u2705 settings.json cleaned — restart Claude Code to apply');
410
+ case 'missing-hooks-in-settings': {
411
+ console.log('\n Registering code-graph hooks in settings.json...');
412
+ const { install } = require('./lifecycle');
413
+ const r = install();
414
+ if (r.hooksRegistered) {
415
+ console.log(' \u2705 settings.json updated — restart Claude Code to apply');
385
416
  fixed++;
386
417
  } else {
387
- console.log(' \u2796 No legacy entries found');
418
+ console.log(' \u2796 install reported no change (settings already had entries)');
388
419
  }
389
420
  break;
390
421
  }
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // Regression gate for v0.31.1: hooks.json matchers must be Claude Code's
8
+ // literal/regex form, NOT the expression DSL `tool == "X"`. The earlier
9
+ // matchers parsed as regex against tool names, never matched anything,
10
+ // and left every PreToolUse hook silently inert from v0.25.0 through
11
+ // v0.31.0. The bug was invisible to the existing unit tests because they
12
+ // spawn the hook scripts directly via stdin, bypassing Claude Code's
13
+ // matcher dispatch.
14
+
15
+ const HOOKS_JSON = path.resolve(__dirname, '..', 'hooks', 'hooks.json');
16
+
17
+ function loadHooks() {
18
+ const raw = fs.readFileSync(HOOKS_JSON, 'utf8');
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ function* iterMatchers(hooksByEvent) {
23
+ for (const [event, entries] of Object.entries(hooksByEvent || {})) {
24
+ if (!Array.isArray(entries)) continue;
25
+ for (let i = 0; i < entries.length; i++) {
26
+ const e = entries[i];
27
+ yield { event, idx: i, matcher: e && e.matcher };
28
+ }
29
+ }
30
+ }
31
+
32
+ test('hooks.json: file parses as JSON', () => {
33
+ assert.doesNotThrow(loadHooks);
34
+ });
35
+
36
+ test('hooks.json: every entry has a string matcher', () => {
37
+ const cfg = loadHooks();
38
+ let count = 0;
39
+ for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
40
+ assert.equal(typeof matcher, 'string',
41
+ `hooks.${event}[${idx}].matcher should be a string, got ${typeof matcher}`);
42
+ count++;
43
+ }
44
+ assert.ok(count > 0, 'expected at least one matcher in hooks.json');
45
+ });
46
+
47
+ // The actual regression gate. Each banned token reflects a specific
48
+ // failure mode we hit and want to keep out forever.
49
+ const BANNED_TOKENS = [
50
+ // The original v0.25.0 → v0.31.0 bug: expression-style matcher treated
51
+ // as regex against tool name → never matched.
52
+ { token: '==', why: 'expression DSL (e.g. `tool == "Edit"`) is not supported; use literal tool name' },
53
+ // `tool ==` or `tool name == "X"` — same family, different spelling.
54
+ { token: 'tool ', why: 'expression DSL with `tool` variable is not supported' },
55
+ // Boolean ORs as expression operators (regex uses `|`, not `||`).
56
+ { token: '||', why: 'use `|` for pipe-list (e.g. `Write|Edit`), not `||`' },
57
+ // Boolean AND has no meaning in tool-name matching.
58
+ { token: '&&', why: '`&&` has no meaning in matchers' },
59
+ // Double-quotes inside the matcher are a strong hint of expression DSL
60
+ // (the broken syntax was `"tool == \"Edit\""`).
61
+ { token: '"', why: 'literal double-quote in matcher is almost always a copy-paste of expression DSL' },
62
+ ];
63
+
64
+ test('hooks.json: matchers avoid banned expression-DSL tokens', () => {
65
+ const cfg = loadHooks();
66
+ const offenders = [];
67
+ for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) {
68
+ for (const { token, why } of BANNED_TOKENS) {
69
+ if (matcher.includes(token)) {
70
+ offenders.push(`hooks.${event}[${idx}].matcher = ${JSON.stringify(matcher)} — contains banned ${JSON.stringify(token)} (${why})`);
71
+ }
72
+ }
73
+ }
74
+ assert.deepEqual(offenders, [],
75
+ 'hooks.json matcher syntax regression — see v0.31.1 CHANGELOG:\n ' + offenders.join('\n '));
76
+ });
77
+
78
+ // v0.32.0 architecture: plugin-cache hooks.json ONLY carries SessionStart.
79
+ // PreToolUse / PostToolUse / UserPromptSubmit are registered into
80
+ // ~/.claude/settings.json by lifecycle.js (current Claude Code silently
81
+ // ignores plugin-cache hooks.json entries for those events — confirmed
82
+ // 2026-05-24 via session jsonl, see feedback_pretooluse_dark_under_green_health.md).
83
+ test('hooks.json: contains SessionStart only (v0.32.0)', () => {
84
+ const cfg = loadHooks();
85
+ assert.deepEqual(Object.keys(cfg.hooks || {}), ['SessionStart'],
86
+ 'plugin-cache hooks.json must contain only SessionStart; other events go via settings.json. ' +
87
+ 'Adding entries here for PreToolUse/PostToolUse/UserPromptSubmit would be dead config — CC does not load them.');
88
+ });
89
+
90
+ test('hooks.json: SessionStart wires session-init.js', () => {
91
+ const cfg = loadHooks();
92
+ const entries = (cfg.hooks && cfg.hooks.SessionStart) || [];
93
+ assert.ok(entries.length > 0, 'SessionStart entry missing');
94
+ const cmd = entries[0].hooks && entries[0].hooks[0] && entries[0].hooks[0].command;
95
+ assert.match(cmd || '', /session-init\.js/);
96
+ });
97
+
98
+ // Cross-validate that lifecycle.js's buildSettingsHookEntries covers the
99
+ // matchers we removed from hooks.json — keeps the migration whole. If a
100
+ // future refactor accidentally drops a matcher in one place, this fails.
101
+ test('lifecycle.buildSettingsHookEntries covers PreToolUse Edit/Bash/Read', () => {
102
+ const { buildSettingsHookEntries } = require('./lifecycle');
103
+ const desired = buildSettingsHookEntries();
104
+ const ptu = (desired.PreToolUse || []).map(e => e.matcher);
105
+ for (const tool of ['Edit', 'Bash', 'Read']) {
106
+ assert.ok(ptu.includes(tool), `lifecycle.js PreToolUse missing matcher: ${tool}; got ${JSON.stringify(ptu)}`);
107
+ }
108
+ });
109
+
110
+ test('lifecycle.buildSettingsHookEntries covers PostToolUse Write|Edit + UserPromptSubmit', () => {
111
+ const { buildSettingsHookEntries } = require('./lifecycle');
112
+ const desired = buildSettingsHookEntries();
113
+ const postMatchers = (desired.PostToolUse || []).map(e => e.matcher);
114
+ assert.ok(postMatchers.some(m => m === 'Write|Edit'),
115
+ `PostToolUse must have 'Write|Edit' matcher; got ${JSON.stringify(postMatchers)}`);
116
+ const upsMatchers = (desired.UserPromptSubmit || []).map(e => e.matcher);
117
+ assert.ok(upsMatchers.length > 0, 'UserPromptSubmit must have at least one matcher');
118
+ });
119
+
120
+ test('lifecycle.buildSettingsHookEntries: every entry carries description marker', () => {
121
+ // Description marker is the primary cleanup discriminator (immune to
122
+ // path/env pollution per feedback_plugin_env_isolation.md). If an entry
123
+ // lacks a description, isOurHookEntry falls back to path-fragment match
124
+ // which is less reliable. Force every entry to have one.
125
+ const { buildSettingsHookEntries } = require('./lifecycle');
126
+ const desired = buildSettingsHookEntries();
127
+ for (const [event, entries] of Object.entries(desired)) {
128
+ for (let i = 0; i < entries.length; i++) {
129
+ assert.ok(entries[i].description && entries[i].description.includes('[code-graph-mcp'),
130
+ `${event}[${i}] missing or malformed description marker`);
131
+ }
132
+ }
133
+ });
134
+
135
+ test('lifecycle.buildSettingsHookEntries: hook commands use absolute paths (no env vars)', () => {
136
+ // settings.json hook commands run with env pollution risk
137
+ // (feedback_plugin_env_isolation.md). Paths MUST be absolute, derived
138
+ // from __dirname, never from ${CLAUDE_PLUGIN_ROOT}.
139
+ const { buildSettingsHookEntries } = require('./lifecycle');
140
+ const desired = buildSettingsHookEntries();
141
+ for (const entries of Object.values(desired)) {
142
+ for (const e of entries) {
143
+ for (const h of e.hooks) {
144
+ assert.ok(!h.command.includes('${CLAUDE_PLUGIN_ROOT}'),
145
+ `command must not use \${CLAUDE_PLUGIN_ROOT}: ${h.command}`);
146
+ assert.ok(h.command.startsWith('node "/') || h.command.match(/node "[A-Z]:\\/),
147
+ `command path must be absolute: ${h.command}`);
148
+ }
149
+ }
150
+ }
151
+ });
@@ -95,3 +95,49 @@ test('lifecycle CLI handles install, disable self-heal, re-enable, and uninstall
95
95
  assert.equal(fs.existsSync(cacheDir), false);
96
96
  });
97
97
 
98
+ test('lifecycle install writes to CLAUDE_CONFIG_DIR instead of ~/.claude when set', (t) => {
99
+ // Multi-account isolation: a user with CLAUDE_CONFIG_DIR=~/work-claude
100
+ // expects all plugin config (settings.json, installed_plugins.json,
101
+ // statusline-providers backup) to land under that directory, not the
102
+ // default ~/.claude. Default path must remain untouched.
103
+ const homeDir = mkHome(t);
104
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cfgdir-'));
105
+ t.after(() => fs.rmSync(configDir, { recursive: true, force: true }));
106
+
107
+ const cfgSettings = path.join(configDir, 'settings.json');
108
+ const cfgInstalled = path.join(configDir, 'plugins', 'installed_plugins.json');
109
+ const cfgBackup = path.join(configDir, 'statusline-providers.json');
110
+ const defaultSettings = path.join(homeDir, '.claude', 'settings.json');
111
+
112
+ writeJson(cfgSettings, {
113
+ statusLine: { type: 'command', command: 'echo prior-work-status' },
114
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': true },
115
+ });
116
+ writeJson(cfgInstalled, {
117
+ plugins: {
118
+ 'code-graph-mcp@code-graph-mcp': [{
119
+ installPath: pluginRoot,
120
+ version: currentVersion,
121
+ scope: 'user',
122
+ }],
123
+ },
124
+ });
125
+
126
+ // Run install with CLAUDE_CONFIG_DIR set; HOME points elsewhere.
127
+ const env = { ...process.env, HOME: homeDir, CLAUDE_CONFIG_DIR: configDir };
128
+ delete env.CLAUDE_PLUGIN_ROOT;
129
+ execFileSync(process.execPath, [lifecycleCli, 'install'], {
130
+ cwd: repoRoot, env, stdio: ['pipe', 'pipe', 'pipe'],
131
+ });
132
+
133
+ // Config landed in the override dir...
134
+ const settings = readJson(cfgSettings);
135
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
136
+ assert.equal(fs.existsSync(cfgBackup), true,
137
+ 'statusline-providers backup should land in CLAUDE_CONFIG_DIR');
138
+
139
+ // ...and default ~/.claude was never touched.
140
+ assert.equal(fs.existsSync(defaultSettings), false,
141
+ 'default ~/.claude/settings.json must not be written when override is set');
142
+ });
143
+