@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/hooks/hooks.json +2 -58
- package/claude-plugin/scripts/adopt.js +15 -2
- package/claude-plugin/scripts/adopt.test.js +38 -0
- package/claude-plugin/scripts/auto-update.js +5 -4
- package/claude-plugin/scripts/claude-config.js +21 -0
- package/claude-plugin/scripts/claude-config.test.js +58 -0
- package/claude-plugin/scripts/doctor.js +65 -34
- package/claude-plugin/scripts/hooks.test.js +151 -0
- package/claude-plugin/scripts/lifecycle.e2e.test.js +46 -0
- package/claude-plugin/scripts/lifecycle.js +185 -62
- 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 +19 -2
- package/claude-plugin/scripts/tmp-dir.js +32 -0
- package/claude-plugin/scripts/tmp-dir.test.js +50 -0
- package/package.json +6 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "code-graph-mcp hooks — loaded
|
|
3
|
-
"_note": "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
182
|
-
detail:
|
|
183
|
-
|
|
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.
|
|
190
|
-
// cache
|
|
191
|
-
//
|
|
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
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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: '
|
|
217
|
+
name: 'Hook coverage',
|
|
201
218
|
status: 'warn',
|
|
202
|
-
detail:
|
|
203
|
-
fixId: '
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 '
|
|
379
|
-
console.log('\n
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
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
|
|
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
|
+
|