Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@sdsrs/code-graph

Package Overview
Dependencies
Maintainers
1
Versions
154
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sdsrs/code-graph - npm Package Compare versions

Comparing version
0.31.0
to
0.32.2
+151
claude-plugin/scripts/hooks.test.js
'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 */ }
}
});
+1
-1

@@ -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 @@ }

{
"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"
}
}