@sdsrs/code-graph
Advanced tools
| 'use strict'; | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| // Regression gate for v0.31.1: hooks.json matchers must be Claude Code's | ||
| // literal/regex form, NOT the expression DSL `tool == "X"`. The earlier | ||
| // matchers parsed as regex against tool names, never matched anything, | ||
| // and left every PreToolUse hook silently inert from v0.25.0 through | ||
| // v0.31.0. The bug was invisible to the existing unit tests because they | ||
| // spawn the hook scripts directly via stdin, bypassing Claude Code's | ||
| // matcher dispatch. | ||
| const HOOKS_JSON = path.resolve(__dirname, '..', 'hooks', 'hooks.json'); | ||
| function loadHooks() { | ||
| const raw = fs.readFileSync(HOOKS_JSON, 'utf8'); | ||
| return JSON.parse(raw); | ||
| } | ||
| function* iterMatchers(hooksByEvent) { | ||
| for (const [event, entries] of Object.entries(hooksByEvent || {})) { | ||
| if (!Array.isArray(entries)) continue; | ||
| for (let i = 0; i < entries.length; i++) { | ||
| const e = entries[i]; | ||
| yield { event, idx: i, matcher: e && e.matcher }; | ||
| } | ||
| } | ||
| } | ||
| test('hooks.json: file parses as JSON', () => { | ||
| assert.doesNotThrow(loadHooks); | ||
| }); | ||
| test('hooks.json: every entry has a string matcher', () => { | ||
| const cfg = loadHooks(); | ||
| let count = 0; | ||
| for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) { | ||
| assert.equal(typeof matcher, 'string', | ||
| `hooks.${event}[${idx}].matcher should be a string, got ${typeof matcher}`); | ||
| count++; | ||
| } | ||
| assert.ok(count > 0, 'expected at least one matcher in hooks.json'); | ||
| }); | ||
| // The actual regression gate. Each banned token reflects a specific | ||
| // failure mode we hit and want to keep out forever. | ||
| const BANNED_TOKENS = [ | ||
| // The original v0.25.0 → v0.31.0 bug: expression-style matcher treated | ||
| // as regex against tool name → never matched. | ||
| { token: '==', why: 'expression DSL (e.g. `tool == "Edit"`) is not supported; use literal tool name' }, | ||
| // `tool ==` or `tool name == "X"` — same family, different spelling. | ||
| { token: 'tool ', why: 'expression DSL with `tool` variable is not supported' }, | ||
| // Boolean ORs as expression operators (regex uses `|`, not `||`). | ||
| { token: '||', why: 'use `|` for pipe-list (e.g. `Write|Edit`), not `||`' }, | ||
| // Boolean AND has no meaning in tool-name matching. | ||
| { token: '&&', why: '`&&` has no meaning in matchers' }, | ||
| // Double-quotes inside the matcher are a strong hint of expression DSL | ||
| // (the broken syntax was `"tool == \"Edit\""`). | ||
| { token: '"', why: 'literal double-quote in matcher is almost always a copy-paste of expression DSL' }, | ||
| ]; | ||
| test('hooks.json: matchers avoid banned expression-DSL tokens', () => { | ||
| const cfg = loadHooks(); | ||
| const offenders = []; | ||
| for (const { event, idx, matcher } of iterMatchers(cfg.hooks)) { | ||
| for (const { token, why } of BANNED_TOKENS) { | ||
| if (matcher.includes(token)) { | ||
| offenders.push(`hooks.${event}[${idx}].matcher = ${JSON.stringify(matcher)} — contains banned ${JSON.stringify(token)} (${why})`); | ||
| } | ||
| } | ||
| } | ||
| assert.deepEqual(offenders, [], | ||
| 'hooks.json matcher syntax regression — see v0.31.1 CHANGELOG:\n ' + offenders.join('\n ')); | ||
| }); | ||
| // v0.32.0 architecture: plugin-cache hooks.json ONLY carries SessionStart. | ||
| // PreToolUse / PostToolUse / UserPromptSubmit are registered into | ||
| // ~/.claude/settings.json by lifecycle.js (current Claude Code silently | ||
| // ignores plugin-cache hooks.json entries for those events — confirmed | ||
| // 2026-05-24 via session jsonl, see feedback_pretooluse_dark_under_green_health.md). | ||
| test('hooks.json: contains SessionStart only (v0.32.0)', () => { | ||
| const cfg = loadHooks(); | ||
| assert.deepEqual(Object.keys(cfg.hooks || {}), ['SessionStart'], | ||
| 'plugin-cache hooks.json must contain only SessionStart; other events go via settings.json. ' + | ||
| 'Adding entries here for PreToolUse/PostToolUse/UserPromptSubmit would be dead config — CC does not load them.'); | ||
| }); | ||
| test('hooks.json: SessionStart wires session-init.js', () => { | ||
| const cfg = loadHooks(); | ||
| const entries = (cfg.hooks && cfg.hooks.SessionStart) || []; | ||
| assert.ok(entries.length > 0, 'SessionStart entry missing'); | ||
| const cmd = entries[0].hooks && entries[0].hooks[0] && entries[0].hooks[0].command; | ||
| assert.match(cmd || '', /session-init\.js/); | ||
| }); | ||
| // Cross-validate that lifecycle.js's buildSettingsHookEntries covers the | ||
| // matchers we removed from hooks.json — keeps the migration whole. If a | ||
| // future refactor accidentally drops a matcher in one place, this fails. | ||
| test('lifecycle.buildSettingsHookEntries covers PreToolUse Edit/Bash/Read', () => { | ||
| const { buildSettingsHookEntries } = require('./lifecycle'); | ||
| const desired = buildSettingsHookEntries(); | ||
| const ptu = (desired.PreToolUse || []).map(e => e.matcher); | ||
| for (const tool of ['Edit', 'Bash', 'Read']) { | ||
| assert.ok(ptu.includes(tool), `lifecycle.js PreToolUse missing matcher: ${tool}; got ${JSON.stringify(ptu)}`); | ||
| } | ||
| }); | ||
| test('lifecycle.buildSettingsHookEntries covers PostToolUse Write|Edit + UserPromptSubmit', () => { | ||
| const { buildSettingsHookEntries } = require('./lifecycle'); | ||
| const desired = buildSettingsHookEntries(); | ||
| const postMatchers = (desired.PostToolUse || []).map(e => e.matcher); | ||
| assert.ok(postMatchers.some(m => m === 'Write|Edit'), | ||
| `PostToolUse must have 'Write|Edit' matcher; got ${JSON.stringify(postMatchers)}`); | ||
| const upsMatchers = (desired.UserPromptSubmit || []).map(e => e.matcher); | ||
| assert.ok(upsMatchers.length > 0, 'UserPromptSubmit must have at least one matcher'); | ||
| }); | ||
| test('lifecycle.buildSettingsHookEntries: every entry carries description marker', () => { | ||
| // Description marker is the primary cleanup discriminator (immune to | ||
| // path/env pollution per feedback_plugin_env_isolation.md). If an entry | ||
| // lacks a description, isOurHookEntry falls back to path-fragment match | ||
| // which is less reliable. Force every entry to have one. | ||
| const { buildSettingsHookEntries } = require('./lifecycle'); | ||
| const desired = buildSettingsHookEntries(); | ||
| for (const [event, entries] of Object.entries(desired)) { | ||
| for (let i = 0; i < entries.length; i++) { | ||
| assert.ok(entries[i].description && entries[i].description.includes('[code-graph-mcp'), | ||
| `${event}[${i}] missing or malformed description marker`); | ||
| } | ||
| } | ||
| }); | ||
| test('lifecycle.buildSettingsHookEntries: hook commands use absolute paths (no env vars)', () => { | ||
| // settings.json hook commands run with env pollution risk | ||
| // (feedback_plugin_env_isolation.md). Paths MUST be absolute, derived | ||
| // from __dirname, never from ${CLAUDE_PLUGIN_ROOT}. | ||
| const { buildSettingsHookEntries } = require('./lifecycle'); | ||
| const desired = buildSettingsHookEntries(); | ||
| for (const entries of Object.values(desired)) { | ||
| for (const e of entries) { | ||
| for (const h of e.hooks) { | ||
| assert.ok(!h.command.includes('${CLAUDE_PLUGIN_ROOT}'), | ||
| `command must not use \${CLAUDE_PLUGIN_ROOT}: ${h.command}`); | ||
| assert.ok(h.command.startsWith('node "/') || h.command.match(/node "[A-Z]:\\/), | ||
| `command path must be absolute: ${h.command}`); | ||
| } | ||
| } | ||
| } | ||
| }); |
| #!/usr/bin/env node | ||
| 'use strict'; | ||
| // Shared temp-dir helper for hook + auto-update scripts. | ||
| // | ||
| // Why this exists: Claude Code overrides $TMPDIR to ~/.claude/tmp/ so it can | ||
| // capture process stdout for transcript replay. That makes `os.tmpdir()` | ||
| // resolve to the same directory that holds 9000+ transcript subdirs. Putting | ||
| // hook cooldown flags directly there has two failure modes: | ||
| // | ||
| // 1. Diagnostic blindness — every doc / memory / debug query that checks | ||
| // `/tmp/.code-graph-bash-*` for hook firing returns empty even when the | ||
| // hook is working perfectly. v0.32.0's "PreToolUse dark under green | ||
| // health" investigation chased this red herring for ~2 hours before the | ||
| // $TMPDIR override was identified. | ||
| // 2. §8 SAFETY recursive-traversal trap — `~/.claude/tmp/<id>.output` is | ||
| // where CC writes captured process output; scattering 0-byte flag files | ||
| // alongside them amplifies the "grep -r ~/.claude/tmp/" footgun. | ||
| // | ||
| // Fix: pin all hook + auto-update artifacts to a `code-graph-mcp/` subdir of | ||
| // whatever `os.tmpdir()` resolves to. Contained, deterministic, easy to GC. | ||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const CG_TMP_DIR = path.join(os.tmpdir(), 'code-graph-mcp'); | ||
| function cgTmpDir() { | ||
| try { fs.mkdirSync(CG_TMP_DIR, { recursive: true }); } catch { /* ok */ } | ||
| return CG_TMP_DIR; | ||
| } | ||
| module.exports = { cgTmpDir, CG_TMP_DIR }; |
| 'use strict'; | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert'); | ||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const { cgTmpDir, CG_TMP_DIR } = require('./tmp-dir'); | ||
| test('CG_TMP_DIR is a "code-graph-mcp" subdir of os.tmpdir()', () => { | ||
| assert.strictEqual(path.basename(CG_TMP_DIR), 'code-graph-mcp'); | ||
| assert.strictEqual(path.dirname(CG_TMP_DIR), os.tmpdir()); | ||
| }); | ||
| test('cgTmpDir() returns the same path and creates the directory', () => { | ||
| // Pre-condition: nuke it if it exists from a prior run, to prove cgTmpDir() | ||
| // actually creates it on demand (not just reports a pre-existing path). | ||
| try { fs.rmSync(CG_TMP_DIR, { recursive: true, force: true }); } catch { /* ok */ } | ||
| assert.ok(!fs.existsSync(CG_TMP_DIR), 'pre-condition: dir must be absent'); | ||
| const p = cgTmpDir(); | ||
| assert.strictEqual(p, CG_TMP_DIR); | ||
| assert.ok(fs.existsSync(p), 'cgTmpDir() must create the directory'); | ||
| assert.ok(fs.statSync(p).isDirectory(), 'created entry must be a directory'); | ||
| }); | ||
| test('cgTmpDir() is idempotent — second call does not throw on existing dir', () => { | ||
| cgTmpDir(); | ||
| // Should not throw even though dir now exists. | ||
| assert.doesNotThrow(() => cgTmpDir()); | ||
| }); | ||
| test('cgTmpDir() does not leak files into os.tmpdir() root', () => { | ||
| // Regression guard: the v0.32.x bug was hook artifacts landing directly | ||
| // in os.tmpdir() (= ~/.claude/tmp/ under Claude Code's $TMPDIR override), | ||
| // colliding with transcript subdirs. After the fix, no `.code-graph-bash-*` | ||
| // / `.cg-impact-*` / `.code-graph-readfan-*` filename should ever appear | ||
| // outside CG_TMP_DIR — only inside it. | ||
| const dir = cgTmpDir(); | ||
| const flag = path.join(dir, '.code-graph-bash-test'); | ||
| fs.writeFileSync(flag, ''); | ||
| try { | ||
| // The sibling of CG_TMP_DIR (= os.tmpdir()) must NOT now contain the flag. | ||
| const parent = path.dirname(dir); | ||
| const stray = path.join(parent, '.code-graph-bash-test'); | ||
| assert.ok(!fs.existsSync(stray), 'flag must not exist in os.tmpdir() root'); | ||
| } finally { | ||
| try { fs.unlinkSync(flag); } catch { /* ok */ } | ||
| } | ||
| }); |
@@ -7,3 +7,3 @@ { | ||
| }, | ||
| "version": "0.31.0", | ||
| "version": "0.32.2", | ||
| "keywords": [ | ||
@@ -10,0 +10,0 @@ "code-graph", |
| { | ||
| "description": "code-graph-mcp hooks — loaded directly by Claude Code from the plugin cache.", | ||
| "_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.", | ||
| "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+).", | ||
| "_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.", | ||
| "hooks": { | ||
@@ -16,60 +16,4 @@ "SessionStart": [ | ||
| } | ||
| ], | ||
| "PreToolUse": [ | ||
| { | ||
| "matcher": "tool == \"Edit\"", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-edit-guide.js\"", | ||
| "timeout": 4 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "matcher": "tool == \"Bash\"", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-grep-guide.js\"", | ||
| "timeout": 3 | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "matcher": "tool == \"Read\"", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-read-guide.js\"", | ||
| "timeout": 3 | ||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "PostToolUse": [ | ||
| { | ||
| "matcher": "tool == \"Write\" || tool == \"Edit\"", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/incremental-index.js\"", | ||
| "timeout": 10 | ||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "UserPromptSubmit": [ | ||
| { | ||
| "matcher": "", | ||
| "hooks": [ | ||
| { | ||
| "type": "command", | ||
| "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-context.js\"", | ||
| "timeout": 5 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| } |
@@ -11,2 +11,3 @@ #!/usr/bin/env node | ||
| const { readBinaryVersion, isDevMode } = require('./version-utils'); | ||
| const { cgTmpDir } = require('./tmp-dir'); | ||
@@ -255,3 +256,3 @@ // ── Environment Checks ──────────────────────────────────── | ||
| const tmpDir = path.join(os.tmpdir(), `code-graph-update-${Date.now()}`); | ||
| const tmpDir = path.join(cgTmpDir(), `update-${Date.now()}`); | ||
| let pluginUpdated = false; | ||
@@ -258,0 +259,0 @@ let binaryUpdated = false; |
@@ -10,4 +10,3 @@ #!/usr/bin/env node | ||
| getPluginVersion, readJson, healthCheck, CACHE_DIR, | ||
| removeHooksFromSettings, isOurHookEntry, writeJsonAtomic, | ||
| settingsPath, | ||
| isOurHookEntry, settingsPath, buildSettingsHookEntries, | ||
| } = require('./lifecycle'); | ||
@@ -177,30 +176,48 @@ const { findBinary, clearCache: clearBinaryCache } = require('./find-binary'); | ||
| // 6. Hook paths validity | ||
| // healthCheck() auto-attempts install() when broken paths are detected and | ||
| // re-scans to verify; repaired:true is now contingent on the re-scan | ||
| // returning clean. If repaired:false despite install() running, the | ||
| // re-scan still found broken paths — surfacing 'remaining' makes that | ||
| // honest instead of telling the user we fixed nothing. | ||
| const hookResult = healthCheck(); | ||
| if (hookResult.healthy) { | ||
| results.push({ name: 'Hooks', status: 'ok', detail: 'all paths valid' }); | ||
| } else if (hookResult.repaired) { | ||
| results.push({ | ||
| name: 'Hooks', | ||
| status: 'ok', | ||
| detail: `${hookResult.issues.length} issue(s) auto-repaired`, | ||
| }); | ||
| } else { | ||
| const remainingCount = Array.isArray(hookResult.remaining) | ||
| ? hookResult.remaining.length | ||
| : hookResult.issues.length; | ||
| results.push({ | ||
| name: 'Hooks', | ||
| status: hookResult.repaired ? 'ok' : 'warn', | ||
| detail: hookResult.repaired | ||
| ? `${hookResult.issues.length} issue(s) auto-repaired` | ||
| : `${hookResult.issues.length} invalid path(s)`, | ||
| fixId: hookResult.repaired ? undefined : 'hooks-invalid', | ||
| status: 'warn', | ||
| detail: `${remainingCount} invalid path(s) — auto-repair did not resolve`, | ||
| fixId: 'hooks-invalid', | ||
| }); | ||
| } | ||
| // 7. Legacy hooks in settings.json — v0.8.2 and earlier wrote hooks there; | ||
| // cache/<ver>/hooks/hooks.json is now authoritative. Duplicates cause | ||
| // every hook to fire twice until settings.json is cleaned. | ||
| // 7. settings.json hook coverage — v0.32.0 inversion. Current Claude Code | ||
| // silently ignores plugin-cache hooks.json for PreToolUse/PostToolUse/ | ||
| // UserPromptSubmit. lifecycle.js install/update is responsible for | ||
| // registering them in settings.json. "Missing" is the bug (previously | ||
| // "present" was treated as legacy debris — that was wrong). | ||
| try { | ||
| const settings = readJson(settingsPath()) || {}; | ||
| const legacyCount = countLegacyHookEntries(settings); | ||
| if (legacyCount === 0) { | ||
| results.push({ name: 'Legacy hooks', status: 'ok', detail: 'settings.json is clean' }); | ||
| const cov = surveyHookCoverage(settings); | ||
| if (cov.missing.length === 0) { | ||
| results.push({ | ||
| name: 'Hook coverage', | ||
| status: 'ok', | ||
| detail: `settings.json has all ${cov.expected.length} expected entries`, | ||
| }); | ||
| } else { | ||
| results.push({ | ||
| name: 'Legacy hooks', | ||
| name: 'Hook coverage', | ||
| status: 'warn', | ||
| detail: `${legacyCount} entries in settings.json (fire twice per event)`, | ||
| fixId: 'legacy-hooks-in-settings', | ||
| detail: `missing ${cov.missing.length}/${cov.expected.length} settings.json entries: ${cov.missing.join(', ')}`, | ||
| fixId: 'missing-hooks-in-settings', | ||
| }); | ||
@@ -213,12 +230,27 @@ } | ||
| function countLegacyHookEntries(settings) { | ||
| if (!settings || !settings.hooks) return 0; | ||
| let count = 0; | ||
| for (const entries of Object.values(settings.hooks)) { | ||
| if (!Array.isArray(entries)) continue; | ||
| for (const entry of entries) { | ||
| if (isOurHookEntry(entry)) count++; | ||
| // Inventory of (event, matcher) tuples we expect to find in settings.json after | ||
| // install. Used by doctor to detect missing entries. | ||
| function surveyHookCoverage(settings) { | ||
| const desired = buildSettingsHookEntries(); | ||
| const expected = []; | ||
| for (const [event, entries] of Object.entries(desired)) { | ||
| for (const e of entries) { | ||
| expected.push(`${event}:${e.matcher || '*'}`); | ||
| } | ||
| } | ||
| return count; | ||
| const present = new Set(); | ||
| if (settings && settings.hooks) { | ||
| for (const [event, entries] of Object.entries(settings.hooks)) { | ||
| if (!Array.isArray(entries)) continue; | ||
| for (const entry of entries) { | ||
| if (isOurHookEntry(entry)) { | ||
| present.add(`${event}:${entry.matcher || '*'}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| const missing = expected.filter(k => !present.has(k)); | ||
| return { expected, present: [...present], missing }; | ||
| } | ||
@@ -376,3 +408,3 @@ | ||
| install(); | ||
| console.log(' \u2705 Hooks repaired'); | ||
| console.log(' \u2705 Hooks repaired \u2014 restart Claude Code to apply'); | ||
| fixed++; | ||
@@ -382,12 +414,11 @@ break; | ||
| case 'legacy-hooks-in-settings': { | ||
| console.log('\n Removing legacy code-graph hooks from settings.json...'); | ||
| const settingsFile = settingsPath(); | ||
| const settings = readJson(settingsFile) || {}; | ||
| if (removeHooksFromSettings(settings)) { | ||
| writeJsonAtomic(settingsFile, settings); | ||
| console.log(' \u2705 settings.json cleaned — restart Claude Code to apply'); | ||
| case 'missing-hooks-in-settings': { | ||
| console.log('\n Registering code-graph hooks in settings.json...'); | ||
| const { install } = require('./lifecycle'); | ||
| const r = install(); | ||
| if (r.hooksRegistered) { | ||
| console.log(' \u2705 settings.json updated — restart Claude Code to apply'); | ||
| fixed++; | ||
| } else { | ||
| console.log(' \u2796 No legacy entries found'); | ||
| console.log(' \u2796 install reported no change (settings already had entries)'); | ||
| } | ||
@@ -394,0 +425,0 @@ break; |
@@ -248,10 +248,38 @@ #!/usr/bin/env node | ||
| // --- Hook identity --- | ||
| // Claude Code loads hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json — | ||
| // that file is the authoritative source. Any entries matching our hooks | ||
| // inside settings.json are legacy migration debris (v0.8.2 and earlier wrote | ||
| // there) and must be stripped on every install/update/session-init so events | ||
| // don't fire twice. | ||
| // | ||
| // v0.32.0 ARCHITECTURE CORRECTION (see project_hooks_settings.md / feedback_pretooluse_dark_under_green_health.md): | ||
| // | ||
| // Empirical finding 2026-05-24: current Claude Code only loads SessionStart | ||
| // hooks from cache/<mp>/<plugin>/<ver>/hooks/hooks.json. PreToolUse, PostToolUse, | ||
| // UserPromptSubmit, Stop, SessionEnd entries in plugin-cache hooks.json are | ||
| // SILENTLY IGNORED. Only ~/.claude/settings.json entries reach CC for those events. | ||
| // | ||
| // Therefore lifecycle.js now ACTIVELY WRITES non-SessionStart hook entries to | ||
| // settings.json (with description markers for cleanup), and the shipped | ||
| // claude-plugin/hooks/hooks.json carries only SessionStart. SessionStart entries | ||
| // in claude-plugin/hooks/hooks.json continue to be CC-loaded as before. | ||
| // | ||
| // Pattern mirrors claude-mem-lite's install.mjs (cache hooks.json cleared | ||
| // to prevent duplicate registration). | ||
| const OUR_HOOK_SCRIPTS = ['session-init.js', 'incremental-index.js', 'user-prompt-context.js', 'pre-edit-guide.js']; | ||
| const OUR_HOOK_SCRIPTS = [ | ||
| 'session-init.js', | ||
| 'incremental-index.js', | ||
| 'user-prompt-context.js', | ||
| 'pre-edit-guide.js', | ||
| 'pre-grep-guide.js', // v0.32.0 — was in plugin-cache only, never fired | ||
| 'pre-read-guide.js', // v0.32.0 — was in plugin-cache only, never fired | ||
| ]; | ||
| // Description markers — primary cleanup discriminator (immune to env/path | ||
| // pollution per feedback_plugin_env_isolation.md). New v0.32.0 markers carry | ||
| // the version so older lifecycle.js still recognizes them as ours. | ||
| const SETTINGS_HOOK_DESC = { | ||
| preToolUse: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)', | ||
| postToolUseEdit: '[code-graph-mcp v0.32+] PostToolUse Write|Edit incremental-index update', | ||
| userPromptSubmit: '[code-graph-mcp v0.32+] UserPromptSubmit context push', | ||
| }; | ||
| const OUR_DESCRIPTIONS = [ | ||
| // Legacy v0.7.x / 0.8.x descriptions — kept so very-old installs still get cleaned up. | ||
| 'StatusLine self-heal, lifecycle sync, project map injection', | ||
@@ -261,2 +289,6 @@ 'Auto-inject impact analysis when editing functions with 2+ callers', | ||
| 'Inject code-graph structural context based on user intent', | ||
| // v0.32.0 — new re-route markers | ||
| SETTINGS_HOOK_DESC.preToolUse, | ||
| SETTINGS_HOOK_DESC.postToolUseEdit, | ||
| SETTINGS_HOOK_DESC.userPromptSubmit, | ||
| ]; | ||
@@ -266,8 +298,11 @@ | ||
| if (!entry || !entry.hooks) return false; | ||
| // Primary: match by description (legacy v0.7.x/0.8.x registrations). | ||
| // Primary: match by description (immune to path pollution). | ||
| if (entry.description && OUR_DESCRIPTIONS.includes(entry.description)) return true; | ||
| // Fallback: match by script name + 'code-graph' in path. | ||
| // Fallback: script name + MARKETPLACE_NAME in path. v0.32.1: tightened from | ||
| // bare 'code-graph' (which would claim a user's own ~/code-graph/foo.js) to | ||
| // the actual marketplace dir name 'code-graph-mcp' — Requirement 3 says | ||
| // foreign-entry strip is unacceptable, so be conservative. | ||
| return entry.hooks.some(h => | ||
| h.command && OUR_HOOK_SCRIPTS.some(s => h.command.includes(s)) && | ||
| h.command.includes('code-graph') | ||
| h.command.includes(MARKETPLACE_NAME) | ||
| ); | ||
@@ -292,2 +327,58 @@ } | ||
| // --- v0.32.0: settings.json hook registration --- | ||
| // PLUGIN_ROOT (module-level, line 18) is the canonical __dirname-derived | ||
| // absolute path — never CLAUDE_PLUGIN_ROOT env (env leaks across plugins | ||
| // in settings.json hook execution context per feedback_plugin_env_isolation.md). | ||
| function buildSettingsHookEntries() { | ||
| const root = PLUGIN_ROOT; | ||
| const scriptCmd = (name, timeout) => ({ | ||
| type: 'command', | ||
| command: `node "${path.join(root, 'scripts', name)}"`, | ||
| timeout, | ||
| }); | ||
| return { | ||
| PreToolUse: [ | ||
| { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js', 4)] }, | ||
| { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js', 3)] }, | ||
| { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js', 3)] }, | ||
| ], | ||
| PostToolUse: [ | ||
| { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js', 10)] }, | ||
| ], | ||
| UserPromptSubmit: [ | ||
| { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js', 5)] }, | ||
| ], | ||
| }; | ||
| } | ||
| // Idempotent two-pass: (1) evict ALL our entries (legacy v0.7+/0.8+ markers | ||
| // AND v0.32+ markers) across EVERY event — catches legacy SessionStart/ | ||
| // PostToolUse entries in settings.json pointing to stale plugin-cache paths; | ||
| // (2) write fresh v0.32+ entries for the events we own. SessionStart stays | ||
| // in plugin-cache hooks.json (it's still loaded from there), so we don't | ||
| // re-write it to settings.json. | ||
| function registerHooksToSettings(settings) { | ||
| settings.hooks = settings.hooks || {}; | ||
| const before = JSON.stringify(settings.hooks); | ||
| // Pass 1: evict our entries across every event. | ||
| for (const event of Object.keys(settings.hooks)) { | ||
| if (!Array.isArray(settings.hooks[event])) continue; | ||
| settings.hooks[event] = settings.hooks[event].filter(e => !isOurHookEntry(e)); | ||
| if (settings.hooks[event].length === 0) delete settings.hooks[event]; | ||
| } | ||
| // Pass 2: write fresh entries for our desired events. | ||
| const desired = buildSettingsHookEntries(); | ||
| for (const [event, desiredEntries] of Object.entries(desired)) { | ||
| const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : []; | ||
| settings.hooks[event] = [...existing, ...desiredEntries]; | ||
| } | ||
| return before !== JSON.stringify(settings.hooks); | ||
| } | ||
| // --- Install (idempotent) --- | ||
@@ -331,7 +422,8 @@ | ||
| // 2. Hooks — cache/<ver>/hooks/hooks.json is authoritative. Strip any legacy | ||
| // entries from settings.json that v0.8.2 or earlier registered, so events | ||
| // don't fire twice. | ||
| const legacyHooksRemoved = removeHooksFromSettings(settings); | ||
| if (legacyHooksRemoved) settingsChanged = true; | ||
| // 2. Hooks — v0.32.0: actively write PreToolUse/PostToolUse/UserPromptSubmit | ||
| // to settings.json. Plugin-cache hooks.json is silently ignored by current | ||
| // Claude Code for these events (SessionStart still loads from cache). | ||
| // registerHooksToSettings is idempotent: strips priors then appends fresh. | ||
| const hooksRegistered = registerHooksToSettings(settings); | ||
| if (hooksRegistered) settingsChanged = true; | ||
@@ -353,3 +445,3 @@ // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle. | ||
| return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, legacyHooksRemoved }; | ||
| return { version, settingsChanged, statusLineClaimed: manifest.config.statusLine, hooksRegistered }; | ||
| } | ||
@@ -446,6 +538,6 @@ | ||
| // 3. Hooks — strip any legacy entries from settings.json. cache hooks.json | ||
| // is the new authoritative source and always has the up-to-date paths. | ||
| const legacyHooksRemoved = removeHooksFromSettings(settings); | ||
| if (legacyHooksRemoved) settingsChanged = true; | ||
| // 3. Hooks — v0.32.0: register PreToolUse/PostToolUse/UserPromptSubmit in | ||
| // settings.json (idempotent; absolute paths re-anchor on every update). | ||
| const hooksRegistered = registerHooksToSettings(settings); | ||
| if (hooksRegistered) settingsChanged = true; | ||
@@ -473,3 +565,3 @@ // NOTE: enabledPlugins is managed by Claude Code's plugin system, not by lifecycle. | ||
| return { oldVersion, version, settingsChanged, legacyHooksRemoved }; | ||
| return { oldVersion, version, settingsChanged, hooksRegistered }; | ||
| } | ||
@@ -514,5 +606,11 @@ | ||
| // Validates all registered paths in settings.json point to existing scripts. | ||
| // Returns { healthy, issues, repaired }. | ||
| // Returns { healthy, issues, repaired, remaining }. | ||
| // issues: pre-repair detection list (what was wrong on entry) | ||
| // repaired: true only after a post-repair re-scan returned zero issues | ||
| // (was previously set blindly to true whenever install() ran, | ||
| // which would lie if install() couldn't actually fix something) | ||
| // remaining: post-repair detection list — present iff install() was invoked; | ||
| // empty array means repair succeeded | ||
| function healthCheck() { | ||
| function scanForBrokenPaths() { | ||
| const settings = readJson(settingsPath()) || {}; | ||
@@ -555,14 +653,28 @@ const issues = []; | ||
| // Auto-repair if issues found | ||
| let repaired = false; | ||
| if (issues.length > 0) { | ||
| install(); | ||
| repaired = true; | ||
| return issues; | ||
| } | ||
| function healthCheck() { | ||
| const issues = scanForBrokenPaths(); | ||
| if (issues.length === 0) { | ||
| return { healthy: true, issues, repaired: false }; | ||
| } | ||
| return { healthy: issues.length === 0, issues, repaired }; | ||
| // Attempt auto-repair, then re-scan to confirm the issues actually went | ||
| // away. install() may legitimately fail to resolve a problem (binary path | ||
| // permanently gone, registry corrupted, etc.) and the previous code lied | ||
| // by always returning repaired:true. | ||
| install(); | ||
| const remaining = scanForBrokenPaths(); | ||
| return { | ||
| healthy: false, | ||
| issues, | ||
| repaired: remaining.length === 0, | ||
| remaining, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| install, uninstall, update, healthCheck, checkScopeConflict, | ||
| install, uninstall, update, healthCheck, scanForBrokenPaths, checkScopeConflict, | ||
| isPluginExplicitlyDisabled, isPluginInactive, cleanupDisabledStatusline, | ||
@@ -573,2 +685,5 @@ readManifest, readJson, writeJsonAtomic, | ||
| removeHooksFromSettings, isOurHookEntry, | ||
| registerHooksToSettings, buildSettingsHookEntries, // v0.32.0 | ||
| SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests | ||
| PLUGIN_ROOT, // v0.32.1 — for tests / consumers | ||
| registerStatuslineProvider, unregisterStatuslineProvider, | ||
@@ -575,0 +690,0 @@ PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE, |
@@ -269,3 +269,7 @@ 'use strict'; | ||
| test('install() removes legacy code-graph hooks from settings.json without re-registering', (t) => { | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // v0.32.0 — settings.json hook registration (replaces the v0.8.3 strip) | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| test('install() registers PreToolUse/PostToolUse/UserPromptSubmit hooks in settings.json', (t) => { | ||
| const homeDir = mkHome(t); | ||
@@ -275,2 +279,39 @@ const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| statusLine: { type: 'command', command: 'echo previous-status' }, | ||
| }); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| assert.ok(after.hooks, 'install() must add hooks block'); | ||
| assert.ok(after.hooks.PreToolUse, 'PreToolUse must be registered'); | ||
| assert.ok(after.hooks.PostToolUse, 'PostToolUse must be registered'); | ||
| assert.ok(after.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered'); | ||
| // Verify the matchers we promised exist | ||
| const ptuMatchers = after.hooks.PreToolUse.map(e => e.matcher); | ||
| for (const m of ['Edit', 'Bash', 'Read']) { | ||
| assert.ok(ptuMatchers.includes(m), `PreToolUse matcher ${m} missing; got ${JSON.stringify(ptuMatchers)}`); | ||
| } | ||
| // Every registered entry must carry the description marker for cleanup | ||
| for (const entries of Object.values(after.hooks)) { | ||
| for (const e of entries) { | ||
| if (e.description) { | ||
| assert.ok(e.description.includes('[code-graph-mcp'), | ||
| `entry without our marker leaked through: ${JSON.stringify(e.description)}`); | ||
| } | ||
| } | ||
| } | ||
| // statusLine composite still set | ||
| assert.match(after.statusLine.command, /statusline-composite/); | ||
| }); | ||
| test('install() strips legacy code-graph hooks AND writes fresh ones (migration path)', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| // Seed with v0.8.2-era legacy entries that should be cleaned up | ||
| writeJson(settingsPath, { | ||
| hooks: legacyHooksFromPlugin(), | ||
@@ -284,8 +325,342 @@ }); | ||
| const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| // No code-graph hook entries should remain — cache hooks.json is authoritative now. | ||
| // Legacy stale paths should be gone — no `/stale/cache/0.8.2/` survivors | ||
| const serialized = JSON.stringify(after.hooks || {}); | ||
| assert.ok(!serialized.includes('code-graph'), 'settings.json must not retain code-graph hook entries'); | ||
| assert.ok(!serialized.includes('session-init.js'), 'settings.json must not retain session-init.js paths'); | ||
| // StatusLine composite is still registered (only channel available). | ||
| assert.match(after.statusLine.command, /statusline-composite/); | ||
| assert.ok(!serialized.includes('/stale/cache/'), | ||
| 'legacy stale paths must be evicted: ' + serialized); | ||
| // BUT fresh entries (v0.32.0 markers) should be present | ||
| assert.ok(serialized.includes('[code-graph-mcp v0.32+]'), | ||
| 'fresh v0.32+ entries should be installed'); | ||
| }); | ||
| test('install() is idempotent on settings.json (second call no-op)', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const first = fs.readFileSync(settingsPath, 'utf8'); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const second = fs.readFileSync(settingsPath, 'utf8'); | ||
| assert.equal(first, second, 'second install() must produce byte-identical settings.json'); | ||
| }); | ||
| test('install() preserves foreign plugin hooks (other plugins\' entries survive)', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| // Seed with an unrelated plugin's hooks alongside ours | ||
| writeJson(settingsPath, { | ||
| hooks: { | ||
| PreToolUse: [{ | ||
| matcher: 'Bash', | ||
| description: 'some-other-plugin Bash inspector', | ||
| hooks: [{ type: 'command', command: 'node /opt/other-plugin/bash-check.js', timeout: 3 }], | ||
| }], | ||
| PostToolUse: [{ | ||
| matcher: '*', | ||
| description: 'foreign post-tool logger', | ||
| hooks: [{ type: 'command', command: 'bash /opt/foreign/post.sh', timeout: 5 }], | ||
| }], | ||
| }, | ||
| }); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| // Foreign entries must still be there | ||
| const ptu = after.hooks.PreToolUse; | ||
| const otherBash = ptu.find(e => e.description === 'some-other-plugin Bash inspector'); | ||
| assert.ok(otherBash, 'foreign Bash hook was stripped — never strip non-code-graph entries'); | ||
| const ptoFor = after.hooks.PostToolUse.find(e => e.description === 'foreign post-tool logger'); | ||
| assert.ok(ptoFor, 'foreign PostToolUse hook was stripped'); | ||
| // Ours are also there | ||
| assert.ok(after.hooks.PreToolUse.some(e => e.matcher === 'Edit' && e.description?.includes('[code-graph-mcp'))); | ||
| }); | ||
| test('registerHooksToSettings is idempotent when called directly', () => { | ||
| // Pure-function direct call, no process spawn | ||
| const { registerHooksToSettings } = require('./lifecycle.js'); | ||
| const settings = {}; | ||
| const changed1 = registerHooksToSettings(settings); | ||
| const snapshot1 = JSON.stringify(settings); | ||
| const changed2 = registerHooksToSettings(settings); | ||
| const snapshot2 = JSON.stringify(settings); | ||
| assert.equal(changed1, true, 'first call must report change'); | ||
| assert.equal(changed2, false, 'second call must report no-change (idempotent)'); | ||
| assert.equal(snapshot1, snapshot2, 'settings must be byte-identical after second call'); | ||
| }); | ||
| test('removeHooksFromSettings cleans up v0.32+ entries (uninstall path)', () => { | ||
| const { registerHooksToSettings, removeHooksFromSettings } = require('./lifecycle.js'); | ||
| const settings = {}; | ||
| registerHooksToSettings(settings); | ||
| // Sanity: have entries | ||
| assert.ok(settings.hooks.PreToolUse && settings.hooks.PreToolUse.length > 0); | ||
| const changed = removeHooksFromSettings(settings); | ||
| assert.equal(changed, true); | ||
| assert.ok(!settings.hooks || Object.keys(settings.hooks).length === 0, | ||
| 'all our entries must be removed; got: ' + JSON.stringify(settings.hooks)); | ||
| }); | ||
| test('uninstall() removes settings.json hook entries end-to-end', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| assert.ok(afterInstall.hooks?.PreToolUse, 'install must have created hooks'); | ||
| execFileSync(process.execPath, [lifecyclePath, 'uninstall'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| // Our hooks should be gone (foreign ones would survive but we didn't seed any) | ||
| const serialized = JSON.stringify(afterUninstall.hooks || {}); | ||
| assert.ok(!serialized.includes('[code-graph-mcp'), | ||
| 'uninstall must strip all our entries; got: ' + serialized); | ||
| }); | ||
| test('hook commands use absolute paths (no ${CLAUDE_PLUGIN_ROOT} in settings.json)', (t) => { | ||
| // settings.json hook commands run with env-pollution risk per | ||
| // feedback_plugin_env_isolation.md — they must NOT depend on | ||
| // ${CLAUDE_PLUGIN_ROOT} (different plugins overwrite each other's value). | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| execFileSync(process.execPath, [lifecyclePath, 'install'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| const serialized = JSON.stringify(after.hooks || {}); | ||
| assert.ok(!serialized.includes('${CLAUDE_PLUGIN_ROOT}'), | ||
| 'settings.json hook commands must not reference ${CLAUDE_PLUGIN_ROOT}: ' + serialized); | ||
| }); | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // v0.32.2 — update() upgrade-path integration tests (reviewer Rec #2) | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // Covers the actual v0.31.x → v0.32.x migration path that runs in | ||
| // production via session-init.js syncLifecycleConfig detecting a manifest | ||
| // version mismatch and calling update(). Previously only install() was | ||
| // tested end-to-end; the upgrade path shared the registerHooksToSettings | ||
| // code internally but had no integration test exercising the wiring. | ||
| test('update() from v0.31.x manifest registers fresh hooks in empty settings.json', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json'); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| // Seed v0.31.2 manifest state. updatedAt is the v0.31.2 release date. | ||
| writeJson(manifestPath, { | ||
| version: '0.31.2', | ||
| installedAt: '2026-03-16T18:56:17.656Z', | ||
| updatedAt: '2026-05-23T16:46:39.353Z', | ||
| config: { statusLine: false }, | ||
| }); | ||
| // settings.json empty (mirrors real v0.31.x state — pre-v0.32.0 strategy | ||
| // was "strip from settings.json, rely on plugin-cache hooks.json"). | ||
| writeJson(settingsPath, {}); | ||
| const out = execFileSync(process.execPath, [lifecyclePath, 'update'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }).toString(); | ||
| assert.match(out, /Updated 0\.31\.2 → /, 'CLI output must show version transition'); | ||
| // Manifest version was bumped to current | ||
| const manifestAfter = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); | ||
| assert.notEqual(manifestAfter.version, '0.31.2', 'manifest version must advance'); | ||
| assert.ok(/^\d+\.\d+\.\d+$/.test(manifestAfter.version), | ||
| `manifest version must be semver, got ${manifestAfter.version}`); | ||
| // settings.json got the v0.32+ hook entries | ||
| const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| assert.ok(settingsAfter.hooks, 'update() must populate hooks block'); | ||
| assert.ok(settingsAfter.hooks.PreToolUse, 'PreToolUse must be registered'); | ||
| assert.ok(settingsAfter.hooks.PostToolUse, 'PostToolUse must be registered'); | ||
| assert.ok(settingsAfter.hooks.UserPromptSubmit, 'UserPromptSubmit must be registered'); | ||
| // Every entry must carry the v0.32+ marker | ||
| for (const entries of Object.values(settingsAfter.hooks)) { | ||
| for (const e of entries) { | ||
| assert.ok(e.description && e.description.includes('[code-graph-mcp v0.32+'), | ||
| `update() entry without v0.32+ marker: ${JSON.stringify(e.description)}`); | ||
| } | ||
| } | ||
| }); | ||
| test('update() from v0.31.x evicts legacy v0.7/v0.8 entries with stale paths', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json'); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| writeJson(manifestPath, { | ||
| version: '0.31.2', | ||
| installedAt: '2026-03-16T18:56:17.656Z', | ||
| config: { statusLine: false }, | ||
| }); | ||
| // Seed with legacy v0.8.2-era entries that should be evicted on update. | ||
| writeJson(settingsPath, { | ||
| hooks: legacyHooksFromPlugin(), | ||
| }); | ||
| execFileSync(process.execPath, [lifecyclePath, 'update'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| const serialized = JSON.stringify(settingsAfter.hooks || {}); | ||
| // Stale paths must be gone | ||
| assert.ok(!serialized.includes('/stale/cache/'), | ||
| 'legacy stale paths must be evicted by update(): ' + serialized); | ||
| // Fresh v0.32+ entries must be present | ||
| assert.ok(serialized.includes('[code-graph-mcp v0.32+'), | ||
| 'fresh v0.32+ entries must be installed by update()'); | ||
| }); | ||
| test('update() preserves foreign plugin hooks during upgrade', (t) => { | ||
| const homeDir = mkHome(t); | ||
| const manifestPath = path.join(homeDir, '.cache', 'code-graph', 'install-manifest.json'); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| writeJson(manifestPath, { | ||
| version: '0.31.2', | ||
| config: { statusLine: false }, | ||
| }); | ||
| // Seed with an unrelated plugin's hooks — must survive our update(). | ||
| writeJson(settingsPath, { | ||
| hooks: { | ||
| PreToolUse: [{ | ||
| matcher: 'Bash', | ||
| description: 'foreign-plugin Bash watcher', | ||
| hooks: [{ type: 'command', command: 'node /opt/foreign/bash.js', timeout: 3 }], | ||
| }], | ||
| }, | ||
| }); | ||
| execFileSync(process.execPath, [lifecyclePath, 'update'], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| }); | ||
| const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); | ||
| const ptu = settingsAfter.hooks.PreToolUse; | ||
| assert.ok(ptu.some(e => e.description === 'foreign-plugin Bash watcher'), | ||
| 'foreign Bash hook must survive update() — never strip non-code-graph entries'); | ||
| // And our own entries must coexist | ||
| assert.ok(ptu.some(e => e.description && e.description.includes('[code-graph-mcp v0.32+')), | ||
| 'update() must add our v0.32+ entries alongside the foreign one'); | ||
| }); | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // v0.32.2 — healthCheck post-repair re-verification | ||
| // (Reviewer M3: repaired:true was set blindly after install() without | ||
| // re-scanning to confirm the issues actually resolved.) | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| function runHealthCheckInChild(homeDir) { | ||
| const code = ` | ||
| const lc = require(${JSON.stringify(lifecyclePath)}); | ||
| process.stdout.write(JSON.stringify(lc.healthCheck())); | ||
| `; | ||
| const out = execFileSync(process.execPath, ['-e', code], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| encoding: 'utf8', | ||
| }); | ||
| return JSON.parse(out); | ||
| } | ||
| test('healthCheck on a clean state returns healthy:true and never sets remaining', (t) => { | ||
| const homeDir = mkHome(t); | ||
| // No settings.json, no registry — clean slate. | ||
| const r = runHealthCheckInChild(homeDir); | ||
| assert.equal(r.healthy, true, 'fresh empty state must be healthy'); | ||
| assert.deepEqual(r.issues, [], 'no issues on empty state'); | ||
| assert.equal(r.repaired, false, 'no repair runs when nothing was broken'); | ||
| assert.equal(r.remaining, undefined, 'no remaining field when no repair attempted'); | ||
| }); | ||
| test('healthCheck repaired:true ONLY after post-repair re-scan returns clean', (t) => { | ||
| const homeDir = mkHome(t); | ||
| // Seed a hook entry whose path is broken AND carries our marker. install() | ||
| // will overwrite our entries with fresh absolute paths derived from | ||
| // __dirname (which is real in the test env), so the re-scan should be clean. | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| writeJson(settingsPath, { | ||
| hooks: { | ||
| PreToolUse: [{ | ||
| matcher: 'Edit', | ||
| description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)', | ||
| hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }], | ||
| }], | ||
| }, | ||
| }); | ||
| const r = runHealthCheckInChild(homeDir); | ||
| assert.equal(r.healthy, false, 'pre-repair scan must have flagged the broken path'); | ||
| assert.ok(r.issues.length >= 1, 'pre-repair issues must list the broken hook'); | ||
| assert.equal(r.repaired, true, 'install() rewrote our entry → post-scan clean → repaired:true'); | ||
| assert.deepEqual(r.remaining, [], 'remaining must be empty when repair succeeded'); | ||
| }); | ||
| test('healthCheck repaired:false when install() cannot resolve a flagged path', (t) => { | ||
| const homeDir = mkHome(t); | ||
| // Seed the registry with a non-`_previous` third-party provider whose path | ||
| // is broken. install() only manages the 'code-graph' registry entry, so | ||
| // the third-party entry survives untouched and the post-repair re-scan | ||
| // still flags it. This is the canonical "auto-repair could not fix it" | ||
| // path — previously the function lied and returned repaired:true anyway. | ||
| const registryPath = path.join(homeDir, '.cache', 'code-graph', 'statusline-registry.json'); | ||
| writeJson(registryPath, [ | ||
| { id: 'third-party-statusline', command: 'node "/nonexistent/foreign/sl.js"', needsStdin: false }, | ||
| ]); | ||
| const r = runHealthCheckInChild(homeDir); | ||
| assert.equal(r.healthy, false, 'broken third-party path must be flagged on entry'); | ||
| assert.ok(r.issues.some(i => i.type === 'registry' && i.id === 'third-party-statusline'), | ||
| 'pre-repair issue list must contain the third-party entry'); | ||
| assert.equal(r.repaired, false, | ||
| 'install() does not touch third-party providers → re-scan still broken → repaired must be false'); | ||
| assert.ok(Array.isArray(r.remaining), 'remaining must be present when install() was attempted'); | ||
| assert.ok(r.remaining.some(i => i.id === 'third-party-statusline'), | ||
| 'remaining must still contain the un-fixable third-party entry'); | ||
| }); | ||
| test('scanForBrokenPaths is exported and returns the issue structure', (t) => { | ||
| // Direct unit test of the extracted scanner — no install() side effects. | ||
| // Verifies the contract M3 relies on: a pure function whose return | ||
| // shape is what healthCheck composes its result from. | ||
| const homeDir = mkHome(t); | ||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| writeJson(settingsPath, { | ||
| hooks: { | ||
| PreToolUse: [{ | ||
| matcher: 'Edit', | ||
| description: '[code-graph-mcp v0.32+] PreToolUse re-routed via settings.json (cache hooks.json silently ignored for this event by current CC)', | ||
| hooks: [{ type: 'command', command: 'node "/nonexistent/code-graph-mcp/pre-edit-guide.js"' }], | ||
| }], | ||
| }, | ||
| }); | ||
| const code = ` | ||
| const lc = require(${JSON.stringify(lifecyclePath)}); | ||
| process.stdout.write(JSON.stringify(lc.scanForBrokenPaths())); | ||
| `; | ||
| const out = execFileSync(process.execPath, ['-e', code], { | ||
| env: { ...process.env, HOME: homeDir }, | ||
| encoding: 'utf8', | ||
| }); | ||
| const issues = JSON.parse(out); | ||
| assert.ok(Array.isArray(issues)); | ||
| assert.ok(issues.some(i => i.type === 'hook' && i.event === 'PreToolUse' && i.path.includes('/nonexistent/')), | ||
| 'scanForBrokenPaths must report the seeded broken hook entry'); | ||
| }); |
@@ -18,2 +18,75 @@ #!/usr/bin/env node | ||
| // --- Tool-catalog dedup gate ----------------------------------------------- | ||
| // If the user's project has its own .mcp.json registering a code-graph server | ||
| // (the recommended pattern for dev work on this repo — points at a local | ||
| // `target/release/code-graph-mcp` so usage telemetry lands in the project's | ||
| // `.code-graph/usage.jsonl`), the plugin's own MCP server adds a SECOND copy | ||
| // of the same 7 tools to the catalog, costing context budget and splitting | ||
| // the agent's choice between two equivalent namespaces. | ||
| // | ||
| // Detect that case and serve a minimal "0-tools" MCP stub so this plugin | ||
| // stops contributing to the catalog. Hooks, skills, agents stay registered | ||
| // (they live outside the MCP server). Env override | ||
| // `CODE_GRAPH_FORCE_PLUGIN_MCP=1` bypasses the gate. | ||
| function projectHasLocalCodeGraphMcp(cwd) { | ||
| try { | ||
| const p = path.join(cwd, '.mcp.json'); | ||
| if (!fs.existsSync(p)) return false; | ||
| const cfg = JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| const servers = (cfg && cfg.mcpServers) || {}; | ||
| return Object.keys(servers).some(n => /code[-_]?graph/i.test(n)); | ||
| } catch { return false; } | ||
| } | ||
| function serveEmptyMcpStub() { | ||
| let buf = ''; | ||
| process.stdin.setEncoding('utf8'); | ||
| process.stdin.on('data', (chunk) => { | ||
| buf += chunk; | ||
| let nl; | ||
| while ((nl = buf.indexOf('\n')) >= 0) { | ||
| const line = buf.slice(0, nl).trim(); | ||
| buf = buf.slice(nl + 1); | ||
| if (!line) continue; | ||
| let req; | ||
| try { req = JSON.parse(line); } catch { continue; } | ||
| if (!req || typeof req.method !== 'string') continue; | ||
| // JSON-RPC notifications (id missing) get no response. | ||
| if (typeof req.id === 'undefined') continue; | ||
| const method = req.method; | ||
| let result, error; | ||
| if (method === 'initialize') { | ||
| result = { | ||
| protocolVersion: '2024-11-05', | ||
| capabilities: { tools: { listChanged: false } }, | ||
| serverInfo: { name: 'code-graph-mcp (plugin stub, dedup)', version: '0.31.1' }, | ||
| }; | ||
| } else if (method === 'tools/list') { | ||
| result = { tools: [] }; | ||
| } else if (method === 'resources/list') { | ||
| result = { resources: [] }; | ||
| } else if (method === 'prompts/list') { | ||
| result = { prompts: [] }; | ||
| } else { | ||
| error = { code: -32601, message: 'method not found (plugin MCP is in dedup stub mode)' }; | ||
| } | ||
| const resp = error | ||
| ? { jsonrpc: '2.0', id: req.id, error } | ||
| : { jsonrpc: '2.0', id: req.id, result }; | ||
| process.stdout.write(JSON.stringify(resp) + '\n'); | ||
| } | ||
| }); | ||
| process.stdin.on('end', () => process.exit(0)); | ||
| } | ||
| if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && projectHasLocalCodeGraphMcp(process.cwd())) { | ||
| process.stderr.write( | ||
| '[code-graph] project .mcp.json registers a code-graph server; ' + | ||
| 'plugin MCP serving 0 tools to avoid duplicate catalog entries. ' + | ||
| 'Set CODE_GRAPH_FORCE_PLUGIN_MCP=1 to override.\n' | ||
| ); | ||
| serveEmptyMcpStub(); | ||
| return; // top-level function scope of mcp-launcher.js | ||
| } | ||
| const { findBinary, clearCache } = require('./find-binary'); | ||
@@ -20,0 +93,0 @@ |
@@ -36,7 +36,7 @@ #!/usr/bin/env node | ||
| */ | ||
| function runLauncherInitialize(timeoutMs = 15000) { | ||
| function runLauncherInitialize(timeoutMs = 15000, extraEnv = {}) { | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn(process.execPath, [LAUNCHER], { | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| env: { ...process.env }, | ||
| env: { ...process.env, ...extraEnv }, | ||
| cwd: REPO_ROOT, | ||
@@ -84,3 +84,7 @@ }); | ||
| const { stdout, stderr } = await runLauncherInitialize(); | ||
| // REPO_ROOT has its own .mcp.json registering code-graph-dev (v0.31.2 | ||
| // landed that to capture dev session metrics), which trips the launcher's | ||
| // dedup gate. Force the original launch path so this test still covers | ||
| // it. The dedup behavior gets its own test below. | ||
| const { stdout, stderr } = await runLauncherInitialize(15000, { CODE_GRAPH_FORCE_PLUGIN_MCP: '1' }); | ||
@@ -100,2 +104,18 @@ // Find the JSON-RPC line in the bytes the launcher forwarded from the binary. | ||
| test('mcp-launcher enters dedup stub when project .mcp.json registers a code-graph server', async () => { | ||
| // REPO_ROOT/.mcp.json registers code-graph-dev → dedup gate fires → | ||
| // launcher serves a 0-tools stub with a distinctive serverInfo.name. | ||
| // No need for the release binary; the stub is implemented in the | ||
| // launcher script itself. | ||
| const { stdout, stderr } = await runLauncherInitialize(); | ||
| const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"')); | ||
| assert.ok(respLine, | ||
| `expected stub JSON-RPC result on stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`); | ||
| const resp = JSON.parse(respLine); | ||
| assert.match(resp.result.serverInfo.name, /stub|dedup/i, | ||
| `serverInfo.name should indicate stub mode, got ${JSON.stringify(resp.result.serverInfo)}`); | ||
| assert.match(stderr, /plugin MCP serving 0 tools/, | ||
| `stderr should explain the dedup, got: ${stderr.slice(0, 400)}`); | ||
| }); | ||
| test('mcp-launcher sets _FIND_BINARY_ROOT from __dirname (does not trust CLAUDE_PLUGIN_ROOT)', () => { | ||
@@ -102,0 +122,0 @@ // Static check: the source must derive _FIND_BINARY_ROOT from __dirname so a |
@@ -12,3 +12,4 @@ #!/usr/bin/env node | ||
| const path = require('path'); | ||
| const os = require('os'); | ||
| const { findBinary } = require('./find-binary'); | ||
| const { cgTmpDir } = require('./tmp-dir'); | ||
@@ -19,2 +20,8 @@ const cwd = process.cwd(); | ||
| // Resolve binary the same way the other hooks do — bare PATH lookup misses | ||
| // npm-global installs on systems where the global bin dir isn't on PATH for | ||
| // non-login shells (a real failure mode reported in mem #8187). | ||
| const binary = findBinary(); | ||
| if (!binary) process.exit(0); | ||
| // --- Parse tool input --- | ||
@@ -68,3 +75,3 @@ let input; | ||
| try { | ||
| const raw = execFileSync('code-graph-mcp', ['grep', candidate, filePath, '--json'], { | ||
| const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], { | ||
| cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], | ||
@@ -104,3 +111,3 @@ }); | ||
| // --- Per-symbol cooldown: 2 minutes --- | ||
| const cooldownFile = path.join(os.tmpdir(), `.cg-impact-${symbol}`); | ||
| const cooldownFile = path.join(cgTmpDir(), `.cg-impact-${symbol}`); | ||
| try { | ||
@@ -111,5 +118,14 @@ if (Date.now() - fs.statSync(cooldownFile).mtimeMs < 120000) process.exit(0); | ||
| // --- Run impact analysis (JSON mode for programmatic parsing) --- | ||
| // Disambiguate via --file: file_path from tool_input is absolute, but the | ||
| // indexer stores files as repo-relative paths — converting here is what makes | ||
| // short generic symbol names (open, new, create, parse, from, init) resolve | ||
| // to a unique node instead of triggering the CLI's "Ambiguous symbol" error | ||
| // path, which previously caused silent exits for the most common edit cases. | ||
| const editedFile = (input.tool_input && input.tool_input.file_path) || ''; | ||
| const relFile = editedFile ? path.relative(cwd, editedFile) : ''; | ||
| let jsonResult; | ||
| try { | ||
| const raw = execFileSync('code-graph-mcp', ['impact', symbol, '--json'], { | ||
| const args = ['impact', symbol, '--json']; | ||
| if (relFile && !relFile.startsWith('..')) args.push('--file', relFile); | ||
| const raw = execFileSync('code-graph-mcp', args, { | ||
| cwd, | ||
@@ -126,2 +142,6 @@ timeout: 2500, | ||
| // CLI returns {"error": "..."} on ambiguous / not-found instead of throwing. | ||
| // Treat as silent skip — direct_callers will be undefined. | ||
| if (jsonResult && jsonResult.error) process.exit(0); | ||
| // --- Inject when the symbol has any caller (1+) --- | ||
@@ -128,0 +148,0 @@ // Earlier gate was 2+ direct callers; reality is that editing a function with |
| #!/usr/bin/env node | ||
| 'use strict'; | ||
| // PreToolUse(Bash) hook: detect raw `grep`/`rg`/`ag` on the indexed source tree | ||
| // and suggest code-graph CLI alternatives. Closes the "Bash comfort zone" leak — | ||
| // pre-training bias has Claude reach for `grep -rn` ~13× more than the indexed | ||
| // CLI on bash-heavy days (15-day baseline: 429 raw grep vs 191 functional CLI). | ||
| // and either BLOCK with suggestion (v0.32+) or HINT (legacy path). Closes the | ||
| // "Bash comfort zone" leak — pre-training bias has Claude reach for `grep -rn` | ||
| // ~13× more than the indexed CLI on bash-heavy days (15-day baseline: 429 raw | ||
| // grep vs 191 functional CLI). v0.25.0 hint-only had ~0% transfer rate; v0.32.0 | ||
| // upgrades the narrowest "I'm searching for a symbol" subset to block-with-reason. | ||
| // | ||
| // Fires when ALL conditions met: | ||
| // HINT fires when ALL conditions met (shouldHint): | ||
| // 1. Command HEAD is grep/rg/ag (NOT piped — pipe-greps are output filters) | ||
@@ -16,2 +18,9 @@ // 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...) | ||
| // | ||
| // BLOCK fires when shouldHint AND (shouldBlock): | ||
| // 7. No precision flag in the command (-l / -A / -B / -C / --include / --exclude) | ||
| // 8. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or | ||
| // a declaration anchor like `fn X` / `class X` / `def X`) | ||
| // 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE) | ||
| // 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS) | ||
| // | ||
| // Exits silently otherwise — zero noise for build greps, log filters, config | ||
@@ -22,4 +31,4 @@ // lookups, or the rare legitimate use of raw grep on indexed source. | ||
| const path = require('path'); | ||
| const os = require('os'); | ||
| const crypto = require('crypto'); | ||
| const { cgTmpDir } = require('./tmp-dir'); | ||
@@ -61,2 +70,48 @@ // --- Pure logic (testable) --- | ||
| // v0.32.0 block tier — strictly narrower than shouldHint. The disqualifying | ||
| // flags (-l, -A, -B, -C, --include, --exclude) mean the user is already doing | ||
| // precise filtering and a blanket "use cg" suggestion would be wrong. The | ||
| // identifier-like check restricts blocks to "I'm looking for a symbol" — the | ||
| // exact use case cg replaces. Marker-only patterns (TODO/FIXME) are legit raw | ||
| // text scans with no cg equivalent. | ||
| // Match any short-flag cluster containing l/L/A/B/C (e.g. `-l`, `-rl`, `-rln`, | ||
| // `-A`, `-rA3`). Combined flag clusters are common in real-world usage and the | ||
| // "precision intent" applies as soon as ANY of these letters appears. | ||
| const BLOCK_DISQUALIFYING_FLAGS = | ||
| /(?:^|\s)-[a-zA-Z]*[lLABC][a-zA-Z]*(?:\s|=|\d|$)|--(?:files-with-matches|files-without-match|include|exclude|exclude-dir|after-context|before-context|context)\b/; | ||
| // v0.32.1: drop the `type` declaration keyword (too common in English prose | ||
| // like "# type checking") and anchor declaration anchors to pattern start | ||
| // (otherwise `"some type X"` matches). CamelCase and snake_case still match | ||
| // anywhere — they're distinctive enough on their own. | ||
| const IDENTIFIER_LIKE = | ||
| /[A-Z][a-zA-Z0-9]{3,}|[a-z][a-z0-9]*_[a-z0-9_]+|^\s*(?:fn|def|class|function|struct|impl|trait)\s+\w/; | ||
| const MARKER_ONLY = | ||
| /^[^"']*["']\s*(?:TODO|FIXME|XXX|HACK|WARN|WARNING|ERROR|NOTE)\s*["']/i; | ||
| // v0.32.1: pull the pattern argument(s) out of the command before running | ||
| // IDENTIFIER_LIKE — testing the full cmd false-positives on CamelCase / | ||
| // snake_case in PATH ARGUMENTS like `src/EmbeddingModel.rs` or | ||
| // `src/some_module/`. The pattern arg is what the user is actually searching | ||
| // for, and that's the only thing we should evaluate against "is this a | ||
| // symbol-shaped target". | ||
| function extractPatterns(cmd) { | ||
| if (!cmd || typeof cmd !== 'string') return []; | ||
| // Strip leading verb + env prefix | ||
| const stripped = cmd.replace(/^\s*(?:env\s+\S+=\S+\s+)*(?:grep|rg|ag)\s+/, ''); | ||
| // Collect every quoted argument — first one is the pattern in standard grep | ||
| // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter | ||
| // expressions and worth screening too. | ||
| const matches = [...stripped.matchAll(/"([^"]+)"|'([^']+)'/g)]; | ||
| return matches.map(m => m[1] !== undefined ? m[1] : m[2]); | ||
| } | ||
| function shouldBlock(cmd) { | ||
| if (!shouldHint(cmd)) return false; // narrower than hint | ||
| if (BLOCK_DISQUALIFYING_FLAGS.test(cmd)) return false; | ||
| if (MARKER_ONLY.test(cmd)) return false; // bare TODO/FIXME — no cg equivalent | ||
| const patterns = extractPatterns(cmd); | ||
| if (patterns.length === 0) return false; // unquoted pattern — conservative, hint | ||
| return patterns.some(p => IDENTIFIER_LIKE.test(p)); | ||
| } | ||
| function commandHash(cmd) { | ||
@@ -66,6 +121,9 @@ return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12); | ||
| function flagPath(cmd) { | ||
| return path.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`); | ||
| } | ||
| function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) { | ||
| const flag = path.join(os.tmpdir(), `.code-graph-bash-${commandHash(cmd)}`); | ||
| try { | ||
| return now - fs.statSync(flag).mtimeMs < windowMs; | ||
| return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs; | ||
| } catch { return false; } | ||
@@ -75,4 +133,3 @@ } | ||
| function markCooldown(cmd) { | ||
| const flag = path.join(os.tmpdir(), `.code-graph-bash-${commandHash(cmd)}`); | ||
| try { fs.writeFileSync(flag, ''); } catch { /* ok */ } | ||
| try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ } | ||
| } | ||
@@ -92,2 +149,15 @@ | ||
| function buildBlockReason() { | ||
| // Shown to Claude via PreToolUse `decision: block` reason. Must give a | ||
| // concrete alternate command Claude can re-issue without further thinking. | ||
| return [ | ||
| '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.', | ||
| 'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):', | ||
| ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit', | ||
| ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type', | ||
| ' code-graph-mcp callgraph SYMBOL # callers + callees', | ||
| 'For raw-text scans (log/comment/marker), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.', | ||
| ].join('\n'); | ||
| } | ||
| // --- Main execution (only when run directly) --- | ||
@@ -103,2 +173,9 @@ | ||
| // v0.32.0 — independent of QUIET_HOOKS. =1 downgrades block tier to hint | ||
| // (legacy v0.25.0–v0.31 behavior). Useful when raw-text scan is intentional | ||
| // but the user still wants the hint for future commands. | ||
| function isBlockDisabled(env = process.env) { | ||
| return env.CODE_GRAPH_NO_BLOCK_GREP === '1'; | ||
| } | ||
| function runMain() { | ||
@@ -120,2 +197,19 @@ if (isSilenced()) return; | ||
| markCooldown(cmd); | ||
| if (!isBlockDisabled() && shouldBlock(cmd)) { | ||
| // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`). | ||
| // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was | ||
| // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form | ||
| // is the documented modern path. Exit 0 — this is a routing decision, not | ||
| // a hook failure (exit 2 would mark the tool call as "hook errored"). | ||
| process.stdout.write(JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: 'PreToolUse', | ||
| permissionDecision: 'deny', | ||
| permissionDecisionReason: buildBlockReason(), | ||
| }, | ||
| }) + '\n'); | ||
| return; | ||
| } | ||
| process.stdout.write(buildHint() + '\n'); | ||
@@ -130,3 +224,6 @@ } | ||
| shouldHint, | ||
| shouldBlock, | ||
| extractPatterns, // v0.32.1 — exposed for tests | ||
| buildHint, | ||
| buildBlockReason, | ||
| commandHash, | ||
@@ -136,2 +233,3 @@ isOnCooldown, | ||
| isSilenced, | ||
| isBlockDisabled, | ||
| }; |
| 'use strict'; | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const { shouldHint, buildHint, commandHash, isSilenced } = require('./pre-grep-guide'); | ||
| const { | ||
| shouldHint, | ||
| shouldBlock, | ||
| extractPatterns, | ||
| buildHint, | ||
| buildBlockReason, | ||
| commandHash, | ||
| isSilenced, | ||
| isBlockDisabled, | ||
| } = require('./pre-grep-guide'); | ||
@@ -265,1 +274,254 @@ // ── Should fire: bare grep/rg/ag on indexed source tree ───────────── | ||
| }); | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // v0.32.0 — Block tier (shouldBlock, buildBlockReason, isBlockDisabled) | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // ── shouldBlock: SHOULD block — identifier-shaped symbol scan ─────── | ||
| test('shouldBlock: CamelCase identifier on src/', () => { | ||
| assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true); | ||
| }); | ||
| test('shouldBlock: snake_case identifier on src/', () => { | ||
| assert.equal(shouldBlock('grep -rn "fts5_search" src/storage/'), true); | ||
| }); | ||
| test('shouldBlock: fn declaration anchor on src/', () => { | ||
| assert.equal(shouldBlock('grep -rn "fn fts5_search" src/storage/'), true); | ||
| }); | ||
| test('shouldBlock: alternation with identifiers on src/', () => { | ||
| assert.equal(shouldBlock('grep -rn "fn fts5_search\\|MATCH" src/storage/'), true); | ||
| }); | ||
| test('shouldBlock: class declaration on src/', () => { | ||
| assert.equal(shouldBlock('grep -rn "class UserService" src/'), true); | ||
| }); | ||
| test('shouldBlock: def declaration on backend/app/', () => { | ||
| assert.equal(shouldBlock('grep -rn "def fetch_user" backend/app/services/'), true); | ||
| }); | ||
| test('shouldBlock: rg with CamelCase on lib/', () => { | ||
| assert.equal(shouldBlock('rg "AuthHandler" lib/'), true); | ||
| }); | ||
| // ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─ | ||
| test('shouldBlock: grep -l (files-with-matches) → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: --include=*.rs → user already filtering, hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: --exclude-dir=tests → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: -A 3 context flag → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: -B 2 context flag → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: -C 5 context flag → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false); | ||
| }); | ||
| // ── shouldBlock: should NOT block — marker-only patterns ──────────── | ||
| test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => { | ||
| assert.equal(shouldBlock('grep -rn "TODO" src/'), false); | ||
| }); | ||
| test('shouldBlock: bare FIXME marker → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn "FIXME" src/'), false); | ||
| }); | ||
| test('shouldBlock: bare XXX marker → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn "XXX" src/'), false); | ||
| }); | ||
| test('shouldBlock: bare HACK marker → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn "HACK" src/'), false); | ||
| }); | ||
| // ── shouldBlock: should NOT block — non-identifier text ───────────── | ||
| test('shouldBlock: short lowercase word "foo" → hint only', () => { | ||
| // No CamelCase, no _, no declaration anchor → not symbol-shaped | ||
| assert.equal(shouldBlock('grep -rn "foo" src/'), false); | ||
| }); | ||
| test('shouldBlock: short alphanumeric "v1" → hint only', () => { | ||
| assert.equal(shouldBlock('grep -rn "v1" src/'), false); | ||
| }); | ||
| // ── shouldBlock: should NOT block — inherits shouldHint=false ────── | ||
| test('shouldBlock: pipe-grep → false (already shouldHint=false)', () => { | ||
| assert.equal(shouldBlock('cargo test 2>&1 | grep "EmbeddingModel"'), false); | ||
| }); | ||
| test('shouldBlock: code-graph-mcp already used → false', () => { | ||
| assert.equal(shouldBlock('code-graph-mcp grep "EmbeddingModel" src/'), false); | ||
| }); | ||
| test('shouldBlock: empty / non-string → false', () => { | ||
| assert.equal(shouldBlock(''), false); | ||
| assert.equal(shouldBlock(null), false); | ||
| }); | ||
| test('shouldBlock: grep on Cargo.toml only → false', () => { | ||
| assert.equal(shouldBlock('grep "EmbeddingModel" Cargo.toml'), false); | ||
| }); | ||
| // ── buildBlockReason content ──────────────────────────────────────── | ||
| test('buildBlockReason: includes "denied"', () => { | ||
| assert.match(buildBlockReason(), /denied/i); | ||
| }); | ||
| test('buildBlockReason: lists cg grep + ast-search + callgraph', () => { | ||
| const out = buildBlockReason(); | ||
| assert.match(out, /code-graph-mcp grep/); | ||
| assert.match(out, /code-graph-mcp ast-search/); | ||
| assert.match(out, /code-graph-mcp callgraph/); | ||
| }); | ||
| test('buildBlockReason: documents the escape hatch env var', () => { | ||
| assert.match(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP=1/); | ||
| }); | ||
| test('buildBlockReason: under 700-byte budget (single CC message)', () => { | ||
| const out = buildBlockReason(); | ||
| assert.ok(out.length < 700, `reason length ${out.length} exceeds budget`); | ||
| }); | ||
| // ── isBlockDisabled escape hatch ──────────────────────────────────── | ||
| test('isBlockDisabled: default (no env) → false (block enabled)', () => { | ||
| assert.equal(isBlockDisabled({}), false); | ||
| }); | ||
| test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=1 → true', () => { | ||
| assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), true); | ||
| }); | ||
| test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=0 → false', () => { | ||
| assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '0' }), false); | ||
| }); | ||
| test('isBlockDisabled: independent of CODE_GRAPH_QUIET_HOOKS', () => { | ||
| // QUIET_HOOKS=1 silences entirely (no block, no hint). | ||
| // NO_BLOCK_GREP=1 downgrades block to hint only. | ||
| // The two flags must be orthogonal — neither implies the other. | ||
| assert.equal(isBlockDisabled({ CODE_GRAPH_QUIET_HOOKS: '1' }), false); | ||
| assert.equal(isSilenced({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), false); | ||
| }); | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // v0.32.1 — extractPatterns + I1/I4 false-positive regressions | ||
| // ════════════════════════════════════════════════════════════════════ | ||
| // ── extractPatterns: pulls quoted args from grep/rg/ag commands ────── | ||
| test('extractPatterns: single double-quoted pattern', () => { | ||
| assert.deepEqual(extractPatterns('grep -rn "EmbeddingModel" src/'), ['EmbeddingModel']); | ||
| }); | ||
| test('extractPatterns: single-quoted pattern', () => { | ||
| assert.deepEqual(extractPatterns("grep -rn 'fts5_search' src/"), ['fts5_search']); | ||
| }); | ||
| test('extractPatterns: env-prefixed verb', () => { | ||
| assert.deepEqual(extractPatterns('env LANG=C grep -rn "Foo" src/'), ['Foo']); | ||
| }); | ||
| test('extractPatterns: multiple -e patterns', () => { | ||
| // Multi-pattern grep: both quoted args should be returned. | ||
| const got = extractPatterns('grep -rn -e "first" -e "second" src/'); | ||
| assert.deepEqual(got, ['first', 'second']); | ||
| }); | ||
| test('extractPatterns: pattern with alternation', () => { | ||
| assert.deepEqual( | ||
| extractPatterns('grep -rn "fn fts5_search\\|MATCH" src/storage/'), | ||
| ['fn fts5_search\\|MATCH'] | ||
| ); | ||
| }); | ||
| test('extractPatterns: no quotes at all → empty array', () => { | ||
| // Unquoted pattern (`grep foo src/`) — we deliberately don't try to parse | ||
| // shell tokenization; shouldBlock falls back to hint in this case. | ||
| assert.deepEqual(extractPatterns('grep -rn foo src/'), []); | ||
| }); | ||
| test('extractPatterns: empty / non-string → empty array', () => { | ||
| assert.deepEqual(extractPatterns(''), []); | ||
| assert.deepEqual(extractPatterns(null), []); | ||
| assert.deepEqual(extractPatterns(undefined), []); | ||
| }); | ||
| test('extractPatterns: rg / ag head also stripped', () => { | ||
| assert.deepEqual(extractPatterns('rg "Foo" lib/'), ['Foo']); | ||
| assert.deepEqual(extractPatterns('ag "Bar" src/'), ['Bar']); | ||
| }); | ||
| // ── I1 regression: identifier-shaped PATHS no longer trigger block ── | ||
| test('I1: grep -rn "abc" src/EmbeddingModel.rs → HINT (path has CamelCase, pattern doesn\'t)', () => { | ||
| // CamelCase is in the FILENAME, not the pattern. v0.32.0 false-blocked | ||
| // this. Pattern "abc" has no identifier shape → must downgrade to hint. | ||
| assert.equal(shouldBlock('grep -rn "abc" src/EmbeddingModel.rs'), false); | ||
| }); | ||
| test('I1: grep -rn "x" src/some_module/file.rs → HINT (path has snake_case)', () => { | ||
| assert.equal(shouldBlock('grep -rn "x" src/some_module/file.rs'), false); | ||
| }); | ||
| test('I1: grep -rn "the quick brown fox" src/EmbeddingModel.rs → HINT (English prose pattern)', () => { | ||
| assert.equal(shouldBlock('grep -rn "the quick brown fox" src/EmbeddingModel.rs'), false); | ||
| }); | ||
| test('I1: unquoted pattern grep -rn foo src/ → HINT (conservative fallback)', () => { | ||
| // Without quotes we can't safely identify the pattern arg via shell rules | ||
| // alone. Conservative: hint only. | ||
| assert.equal(shouldBlock('grep -rn foo src/'), false); | ||
| }); | ||
| test('I1: identifier pattern still blocks even with non-identifier path', () => { | ||
| // Sanity check the inverse — block tier shouldn't get over-relaxed. | ||
| // Path is plain `src/` but pattern is CamelCase → still block. | ||
| assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true); | ||
| }); | ||
| // ── I4 regression: declaration-anchor + `type` keyword fixes ───────── | ||
| test('I4: grep -rn "# type checking" src/ → HINT (comment scan, "type" not a decl keyword anymore)', () => { | ||
| assert.equal(shouldBlock('grep -rn "# type checking" src/'), false); | ||
| }); | ||
| test('I4: grep -rn "some type X" src/ → HINT (type not at pattern start, no longer over-matches)', () => { | ||
| assert.equal(shouldBlock('grep -rn "some type X" src/'), false); | ||
| }); | ||
| test('I4: grep -rn "the def keyword" src/ → HINT (def not at pattern start)', () => { | ||
| // "the def keyword" had `\bdef\s+\w` match `def k` previously. | ||
| // ^\s*(?:fn|def|...) anchor stops this. | ||
| assert.equal(shouldBlock('grep -rn "the def keyword" src/'), false); | ||
| }); | ||
| test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)', () => { | ||
| // Real declaration search — still blocks correctly. | ||
| assert.equal(shouldBlock('grep -rn "def calc_total" src/'), true); | ||
| }); | ||
| test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => { | ||
| assert.equal(shouldBlock('grep -rn "fn render" src/'), true); | ||
| }); |
@@ -27,4 +27,4 @@ #!/usr/bin/env node | ||
| const path = require('path'); | ||
| const os = require('os'); | ||
| const crypto = require('crypto'); | ||
| const { cgTmpDir } = require('./tmp-dir'); | ||
@@ -70,3 +70,3 @@ // --- Configuration --- | ||
| function statePath(cwd) { | ||
| return path.join(os.tmpdir(), `.code-graph-readfan-${cwdHash(cwd)}.json`); | ||
| return path.join(cgTmpDir(), `.code-graph-readfan-${cwdHash(cwd)}.json`); | ||
| } | ||
@@ -73,0 +73,0 @@ |
@@ -89,2 +89,19 @@ #!/usr/bin/env node | ||
| } | ||
| // v0.32.0: self-heal if our settings.json hook coverage is incomplete | ||
| // (e.g. user manually edited settings.json, or settings.json got rewritten | ||
| // by another tool that didn't preserve our entries). Without this, the | ||
| // user silently loses PreToolUse/PostToolUse hooks until next plugin update. | ||
| const { isOurHookEntry, buildSettingsHookEntries } = require('./lifecycle'); | ||
| const desired = buildSettingsHookEntries(); | ||
| for (const [event, desiredEntries] of Object.entries(desired)) { | ||
| const presentMatchers = new Set( | ||
| (settings.hooks?.[event] || []).filter(isOurHookEntry).map(e => e.matcher || '*') | ||
| ); | ||
| for (const e of desiredEntries) { | ||
| if (!presentMatchers.has(e.matcher || '*')) { | ||
| install(); | ||
| return 'self-healed-missing-settings-hook'; | ||
| } | ||
| } | ||
| } | ||
| return 'noop'; | ||
@@ -91,0 +108,0 @@ } |
+6
-6
| { | ||
| "name": "@sdsrs/code-graph", | ||
| "version": "0.31.0", | ||
| "version": "0.32.2", | ||
| "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing", | ||
@@ -38,8 +38,8 @@ "license": "MIT", | ||
| "optionalDependencies": { | ||
| "@sdsrs/code-graph-linux-x64": "0.31.0", | ||
| "@sdsrs/code-graph-linux-arm64": "0.31.0", | ||
| "@sdsrs/code-graph-darwin-x64": "0.31.0", | ||
| "@sdsrs/code-graph-darwin-arm64": "0.31.0", | ||
| "@sdsrs/code-graph-win32-x64": "0.31.0" | ||
| "@sdsrs/code-graph-linux-x64": "0.32.2", | ||
| "@sdsrs/code-graph-linux-arm64": "0.32.2", | ||
| "@sdsrs/code-graph-darwin-x64": "0.32.2", | ||
| "@sdsrs/code-graph-darwin-arm64": "0.32.2", | ||
| "@sdsrs/code-graph-win32-x64": "0.32.2" | ||
| } | ||
| } |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
399189
16.4%46
6.98%7972
14.84%125
15.74%