@sdsrs/code-graph 0.66.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.66.0",
7
+ "version": "0.68.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -260,6 +260,24 @@ function runDiagnostics() {
260
260
  }
261
261
  } catch { /* probe failed — skip */ }
262
262
 
263
+ // 8. Hook firing (v0.67.0) — coverage (#7) proves the hook is WIRED into
264
+ // settings.json; this proves the script actually RUNS. Spawns each
265
+ // registered hook with a synthetic CC payload in a throwaway fixture and
266
+ // checks it exits 0. Catches the registered-but-inert class (broken
267
+ // require-chain / incompatible node / corrupt install) that a string/path
268
+ // check cannot see. (It does NOT prove CC dispatches to it — that needs a
269
+ // live session; the dispatch canary in session-init.js covers that.)
270
+ try {
271
+ const { verifyHooksFire } = require('./lifecycle');
272
+ const fire = verifyHooksFire();
273
+ if (fire.ok) {
274
+ results.push({ name: 'Hook firing', status: 'ok', detail: `${fire.results.length} hooks fire cleanly` });
275
+ } else {
276
+ const failed = fire.results.filter(r => !r.ok).map(r => r.label).join(', ') || fire.error || 'unknown';
277
+ results.push({ name: 'Hook firing', status: 'warn', detail: `did not fire: ${failed}` });
278
+ }
279
+ } catch { /* probe failed — skip */ }
280
+
263
281
  return results;
264
282
  }
265
283
 
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ // Layer-A "does the hook really fire" smoke test (v0.67.0). Distinct from
3
+ // hooks.test.js (which inspects registration STRINGS) and the per-hook unit
4
+ // tests (which import predicates): this spawns each REGISTERED hook script the
5
+ // way Claude Code would — node + a synthetic CC stdin payload — and asserts it
6
+ // runs end-to-end without erroring. Catches the "registered but inert on this
7
+ // machine" class (broken require-chain, node-version, corrupt install) that
8
+ // string/predicate tests can't see. See feedback_pretooluse_dark_under_green_health.md.
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+ const { verifyHooksFire } = require('./lifecycle');
16
+ const { hookFireWarning, analyzeHookDark } = require('./session-init');
17
+
18
+ test('verifyHooksFire: all real registered hooks run cleanly (exit 0)', () => {
19
+ const { ok, results } = verifyHooksFire();
20
+ // 3 PreToolUse + 1 PostToolUse + 1 UserPromptSubmit = 5 settings.json hooks
21
+ assert.ok(results.length >= 5, `expected >=5 hook probes, got ${results.length}`);
22
+ for (const r of results) {
23
+ assert.ok(r.ok, `hook ${r.label} (${r.script}) did not fire cleanly: code=${r.code} err=${r.error}`);
24
+ }
25
+ assert.equal(ok, true);
26
+ });
27
+
28
+ test('verifyHooksFire: the grep hook actually engages (emits a decision)', () => {
29
+ const { results } = verifyHooksFire();
30
+ const grep = results.find(r => /pre-grep-guide/.test(r.script));
31
+ assert.ok(grep, 'no grep hook probe found');
32
+ assert.ok(grep.emitted,
33
+ 'pre-grep-guide produced no output on an engaging grep payload — the firing path did not engage');
34
+ });
35
+
36
+ test('verifyHooksFire: reports a broken hook script (teeth)', () => {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hookfire-teeth-'));
38
+ const broken = path.join(dir, 'broken-hook.js');
39
+ fs.writeFileSync(broken, 'throw new Error("boom at runtime");\n');
40
+ try {
41
+ const { ok, results } = verifyHooksFire({ hooks: [{ label: 'broken', script: broken, payload: {} }] });
42
+ assert.equal(ok, false, 'a hook that throws must make ok=false');
43
+ assert.equal(results[0].ok, false);
44
+ } finally {
45
+ fs.rmSync(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('verifyHooksFire: missing hook script is reported, not thrown (teeth)', () => {
50
+ const { ok, results } = verifyHooksFire({
51
+ hooks: [{ label: 'gone', script: path.join(os.tmpdir(), 'definitely-not-here-xyz.js'), payload: {} }],
52
+ });
53
+ assert.equal(ok, false);
54
+ assert.equal(results[0].ok, false);
55
+ });
56
+
57
+ // ── Layer A surface: hookFireWarning (pure interpreter of cached state) ──
58
+
59
+ test('hookFireWarning: ok / absent state → no warning', () => {
60
+ assert.equal(hookFireWarning({ ok: true, failures: [] }), null);
61
+ assert.equal(hookFireWarning(null), null);
62
+ assert.equal(hookFireWarning({ ok: false, failures: [] }), null); // no names → nothing to say
63
+ });
64
+
65
+ test('hookFireWarning: failed state names the failed hook + points to doctor', () => {
66
+ const w = hookFireWarning({ ok: false, failures: ['PreToolUse:Bash'] });
67
+ assert.match(w, /PreToolUse:Bash/);
68
+ assert.match(w, /doctor/);
69
+ });
70
+
71
+ // ── Layer B dispatch canary: analyzeHookDark (pure) ──
72
+
73
+ test('analyzeHookDark: edit fires repeatedly but grep/read never → warns', () => {
74
+ const lines = ['{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"edit"}'].join('\n');
75
+ assert.match(analyzeHookDark(lines), /grep\/read/);
76
+ });
77
+
78
+ test('analyzeHookDark: any grep/read event present → no warning', () => {
79
+ const lines = ['{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"edit"}', '{"hook":"read","action":"observe"}'].join('\n');
80
+ assert.equal(analyzeHookDark(lines), null);
81
+ });
82
+
83
+ test('analyzeHookDark: below the edit threshold / empty → no warning (low false-positive)', () => {
84
+ assert.equal(analyzeHookDark('{"hook":"edit"}\n{"hook":"edit"}'), null);
85
+ assert.equal(analyzeHookDark(''), null);
86
+ assert.equal(analyzeHookDark('garbage\n{not json}'), null);
87
+ });
88
+
89
+ // ── CLI wiring: `lifecycle.js verify-hooks-fire` writes the state file ──
90
+
91
+ test('CLI verify-hooks-fire runs and writes hook-fire-state.json (HOME-redirected)', () => {
92
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-hf-home-'));
93
+ try {
94
+ const r = spawnSync(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
95
+ env: { ...process.env, HOME: home }, encoding: 'utf8', timeout: 30000,
96
+ });
97
+ assert.equal(r.status, 0, `CLI exit ${r.status}: ${r.stderr}`);
98
+ assert.match(r.stdout, /Hook firing: (OK|FAIL)/);
99
+ const statePath = path.join(home, '.cache', 'code-graph', 'hook-fire-state.json');
100
+ assert.ok(fs.existsSync(statePath), 'hook-fire-state.json was not written');
101
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
102
+ assert.equal(typeof state.ok, 'boolean');
103
+ assert.ok(state.ts, 'state missing timestamp');
104
+ } finally {
105
+ fs.rmSync(home, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ // ── doctor wiring: runDiagnostics surfaces a "Hook firing" check ──
110
+
111
+ test('doctor runDiagnostics includes a Hook firing check', () => {
112
+ const { runDiagnostics } = require('./doctor');
113
+ const results = runDiagnostics();
114
+ const hf = results.find(r => r.name === 'Hook firing');
115
+ assert.ok(hf, 'doctor did not report a "Hook firing" check');
116
+ assert.ok(['ok', 'warn'].includes(hf.status), `unexpected status ${hf.status}`);
117
+ });
@@ -3,6 +3,7 @@ const test = require('node:test');
3
3
  const assert = require('node:assert/strict');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { execFileSync } = require('child_process');
6
7
 
7
8
  // Regression gate for v0.31.1: hooks.json matchers must be Claude Code's
8
9
  // literal/regex form, NOT the expression DSL `tool == "X"`. The earlier
@@ -149,3 +150,81 @@ test('lifecycle.buildSettingsHookEntries: hook commands use absolute paths (no e
149
150
  }
150
151
  }
151
152
  });
153
+
154
+ // v0.67.0 hook-reliability Layer 1 (static firing invariants):
155
+ // The tests above inspect matcher STRINGS but never the target script file. A
156
+ // renamed/typo'd/moved hook script makes Claude Code unable to run it → the hook
157
+ // is SILENTLY inert (the "dark hook" class — feedback_pretooluse_dark_under_green_health.md).
158
+ // This collects every script CC will actually load — both registration channels —
159
+ // and asserts each exists and parses. Cheapest possible guard against silent dark.
160
+ const PLUGIN_ROOT = path.resolve(__dirname, '..'); // claude-plugin/
161
+
162
+ function resolveHookScript(cmd) {
163
+ // command form: node "<path>" (<path> may contain ${CLAUDE_PLUGIN_ROOT})
164
+ const m = (cmd || '').match(/"([^"]+\.js)"/);
165
+ return m ? m[1].replace('${CLAUDE_PLUGIN_ROOT}', PLUGIN_ROOT) : null;
166
+ }
167
+
168
+ function allRegisteredHookCommands() {
169
+ const commands = [];
170
+ // (1) settings.json side — lifecycle.buildSettingsHookEntries (PreToolUse/PostToolUse/UserPromptSubmit)
171
+ const { buildSettingsHookEntries } = require('./lifecycle');
172
+ for (const entries of Object.values(buildSettingsHookEntries())) {
173
+ for (const e of entries) for (const h of e.hooks || []) commands.push(h.command);
174
+ }
175
+ // (2) plugin-cache hooks.json side — SessionStart (the only event CC loads from here)
176
+ for (const entries of Object.values(loadHooks().hooks || {})) {
177
+ if (!Array.isArray(entries)) continue;
178
+ for (const e of entries) for (const h of e.hooks || []) commands.push(h.command);
179
+ }
180
+ return commands;
181
+ }
182
+
183
+ test('every registered hook script exists on disk', () => {
184
+ const commands = allRegisteredHookCommands();
185
+ // 3 PreToolUse + 1 PostToolUse + 1 UserPromptSubmit + 1 SessionStart = 6
186
+ assert.ok(commands.length >= 6, `expected >=6 registered hook commands, got ${commands.length}`);
187
+ for (const cmd of commands) {
188
+ const p = resolveHookScript(cmd);
189
+ assert.ok(p, `could not extract a .js path from hook command: ${JSON.stringify(cmd)}`);
190
+ assert.ok(fs.existsSync(p),
191
+ `hook script missing on disk: ${p}\n (from command ${JSON.stringify(cmd)})\n` +
192
+ ` A renamed/typo'd/moved script makes the hook silently inert — Claude Code cannot run a missing file.`);
193
+ }
194
+ });
195
+
196
+ test('every registered hook script parses (node --check)', () => {
197
+ for (const cmd of allRegisteredHookCommands()) {
198
+ const p = resolveHookScript(cmd);
199
+ assert.doesNotThrow(
200
+ () => execFileSync(process.execPath, ['--check', p], { stdio: 'pipe' }),
201
+ `hook script has a syntax error (node --check failed): ${p}`);
202
+ }
203
+ });
204
+
205
+ // Pin the EXACT matcher surface, not just "covers". The earlier tests assert the
206
+ // set INCLUDES Edit/Bash/Read etc.; this asserts it EQUALS the intended set, so
207
+ // adding/dropping a matcher must update this test — a deliberate decision, never a
208
+ // silent coverage drift. A PreToolUse hook fires only on the literal tool name.
209
+ // Deliberate exclusions (verified 2026-06-23; revisit if either premise changes):
210
+ // - MultiEdit: NOT a tool in current Claude Code (absent from the tool surface;
211
+ // the plugin targets recent CC per the v0.32.0 settings.json architecture), so
212
+ // a matcher for it would be dead config. Re-add only if CC (re)introduces it.
213
+ // - NotebookEdit: a real tool, but code-graph does NOT parse .ipynb (no jupyter
214
+ // support in the parser / supported-language set), so both pre-edit-guide
215
+ // (needs graph symbols) and incremental-index (needs to re-index the file)
216
+ // would no-op on a notebook. Prerequisite is .ipynb PARSING support (a parser
217
+ // feature); add the matcher as PART of that work, never before it.
218
+ test('buildSettingsHookEntries: matcher surface is exactly the intended set', () => {
219
+ const { buildSettingsHookEntries } = require('./lifecycle');
220
+ const desired = buildSettingsHookEntries();
221
+ const setOf = (event) => (desired[event] || []).map(e => e.matcher).sort();
222
+ assert.deepEqual(setOf('PreToolUse'), ['Bash', 'Edit', 'Read'],
223
+ 'PreToolUse matcher set changed — update this gate intentionally (does the new tool need a guide hook?)');
224
+ assert.deepEqual(setOf('PostToolUse'), ['Write|Edit'],
225
+ 'PostToolUse matcher set changed — incremental-index re-index trigger surface must be deliberate');
226
+ assert.deepEqual(setOf('UserPromptSubmit'), [''],
227
+ 'UserPromptSubmit matcher set changed unexpectedly');
228
+ assert.deepEqual(Object.keys(desired).sort(), ['PostToolUse', 'PreToolUse', 'UserPromptSubmit'],
229
+ 'a new top-level hook event is registered into settings.json — confirm it is intended (SessionStart belongs in hooks.json)');
230
+ });
@@ -463,6 +463,118 @@ function surveyHookCoverage(settings) {
463
463
  return { expected, present: [...present], missing, stale };
464
464
  }
465
465
 
466
+ // --- Firing self-test (v0.67.0) ---
467
+ //
468
+ // surveyHookCoverage proves a hook is WIRED; it does NOT prove the script RUNS.
469
+ // A renamed sibling module, an incompatible node, or a corrupt install leaves a
470
+ // hook registered-but-inert — invisible to every string/path check. verifyHooksFire
471
+ // spawns each registered hook the way Claude Code does (node + a synthetic CC
472
+ // stdin payload) inside a throwaway fixture and asserts it exits 0. This is the
473
+ // "does it really fire" check. What it CANNOT prove is that CC *dispatches* real
474
+ // tool-calls to it (only a live session shows that — see the Layer-B dispatch
475
+ // canary in session-init.js). Best-effort; never throws.
476
+
477
+ // Representative CC stdin payload that drives each matcher's path. The Bash
478
+ // payload is engaging (a source-tree search → a deny/hint IS emitted); the Edit
479
+ // payload's short old_string short-circuits before any binary spawn; the rest
480
+ // just exercise the require-chain + stdin parse.
481
+ function hookFirePayload(matcher) {
482
+ switch (matcher) {
483
+ case 'Bash':
484
+ return { tool_name: 'Bash', tool_input: { command: 'grep -rn someUniqueSymbol src/' } };
485
+ case 'Read':
486
+ return { tool_name: 'Read', tool_input: { file_path: 'src/example.rs' } };
487
+ case 'Edit':
488
+ case 'Write|Edit':
489
+ return { tool_name: 'Edit', tool_input: { file_path: 'src/example.rs', old_string: 'a', new_string: 'b' } };
490
+ case '': // UserPromptSubmit
491
+ return { prompt: 'where is the parse function defined' };
492
+ default:
493
+ return { tool_name: 'Unknown', tool_input: {} };
494
+ }
495
+ }
496
+
497
+ // The hooks CC actually loads from settings.json (PreToolUse/PostToolUse/
498
+ // UserPromptSubmit). SessionStart (hooks.json) runs every session → its own
499
+ // liveness proof → excluded here.
500
+ function defaultHookFireProbes() {
501
+ const probes = [];
502
+ for (const [event, entries] of Object.entries(buildSettingsHookEntries())) {
503
+ for (const e of entries) {
504
+ const cmd = e.hooks && e.hooks[0] && e.hooks[0].command;
505
+ const m = (cmd || '').match(/"([^"]+\.js)"/);
506
+ if (!m) continue;
507
+ probes.push({ label: `${event}:${e.matcher || '*'}`, script: m[1], payload: hookFirePayload(e.matcher || '') });
508
+ }
509
+ }
510
+ return probes;
511
+ }
512
+
513
+ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
514
+ const { spawnSync } = require('child_process');
515
+ const probes = hooks || defaultHookFireProbes();
516
+
517
+ // Throwaway fixture carrying the .code-graph/index.db marker so resolveProjectRoot
518
+ // resolves (otherwise the hooks early-return before exercising anything).
519
+ // Base is os.tmpdir() directly (a UNIQUE mkdtemp), NOT the shared cgTmpDir()
520
+ // subdir — a concurrent process clearing `<tmp>/code-graph-mcp` mid-run would
521
+ // otherwise yank this fixture out from under an in-flight spawn (cwd ENOENT).
522
+ let fixture = null;
523
+ try {
524
+ const base = tmpBase || os.tmpdir();
525
+ fixture = fs.mkdtempSync(path.join(base, 'cg-hookfire-'));
526
+ fs.mkdirSync(path.join(fixture, '.code-graph'), { recursive: true });
527
+ fs.writeFileSync(path.join(fixture, '.code-graph', 'index.db'), '');
528
+ } catch (e) {
529
+ return { ok: false, results: [], error: `fixture: ${e && e.message}` };
530
+ }
531
+
532
+ // Force a non-silenced, no-binary-spawn firing config: the smoke tests the
533
+ // machinery regardless of the user's silence prefs and never invokes the binary
534
+ // (CODE_GRAPH_NO_ANSWER_IN_DENY keeps cg-answer out of the deny path).
535
+ // Redirect the hooks' tmp state (per-command grep cooldown, read-fanout state)
536
+ // into the throwaway fixture via TMPDIR so the smoke neither collides with a
537
+ // real 60s cooldown (which would suppress the deny → false "didn't fire") nor
538
+ // pollutes the user's real cooldown/state.
539
+ const baseEnv = {
540
+ ...process.env,
541
+ CODE_GRAPH_QUIET_HOOKS: '0',
542
+ CODE_GRAPH_NO_ANSWER_IN_DENY: '1',
543
+ TMPDIR: fixture, TMP: fixture, TEMP: fixture,
544
+ ...(env || {}),
545
+ };
546
+
547
+ const results = [];
548
+ for (const h of probes) {
549
+ let error = null, ok = false, emitted = false, code = null, signal = null;
550
+ try {
551
+ const r = spawnSync(process.execPath, [h.script], {
552
+ input: JSON.stringify(h.payload || {}),
553
+ cwd: fixture,
554
+ env: baseEnv,
555
+ timeout: timeoutMs,
556
+ encoding: 'utf8',
557
+ });
558
+ code = r.status;
559
+ signal = r.signal;
560
+ ok = !r.error && r.status === 0;
561
+ emitted = !!(r.stdout && r.stdout.trim());
562
+ if (!ok) {
563
+ error = r.error
564
+ ? String(r.error.message || r.error)
565
+ : ((r.stderr || '').trim().slice(0, 200) || `exit ${r.status}`);
566
+ }
567
+ } catch (e) {
568
+ error = String((e && e.message) || e);
569
+ }
570
+ results.push({ label: h.label, script: h.script, ok, code, signal, emitted, error });
571
+ }
572
+
573
+ try { fs.rmSync(fixture, { recursive: true, force: true }); } catch { /* ok */ }
574
+
575
+ return { ok: results.length > 0 && results.every(r => r.ok), results };
576
+ }
577
+
466
578
  // --- Install (idempotent) ---
467
579
 
468
580
  function install() {
@@ -760,6 +872,7 @@ module.exports = {
760
872
  removeHooksFromSettings, isOurHookEntry,
761
873
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
762
874
  surveyHookCoverage, compositeCommand, // v0.49.1 — version-aware self-heal
875
+ verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
763
876
  activeInstallPath, isStaleRelicContext, // v0.49.1 — stale-relic downgrade guard
764
877
  SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
765
878
  PLUGIN_ROOT, // v0.32.1 — for tests / consumers
@@ -796,8 +909,20 @@ if (require.main === module) {
796
909
  const checkOnly = process.argv.includes('--check-only');
797
910
  const { issueCount } = runDoctor({ checkOnly });
798
911
  process.exit(issueCount > 0 ? 1 : 0);
912
+ } else if (cmd === 'verify-hooks-fire') {
913
+ // v0.67.0 — Layer-A firing self-test. Spawned detached by session-init
914
+ // (off the SessionStart budget); writes a small state file the next
915
+ // SessionStart reads to surface failures.
916
+ const res = verifyHooksFire();
917
+ const failures = res.results.filter(r => !r.ok).map(r => r.label);
918
+ try {
919
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
920
+ writeJsonAtomic(path.join(CACHE_DIR, 'hook-fire-state.json'),
921
+ { ts: new Date().toISOString(), ok: res.ok, failures });
922
+ } catch { /* best-effort telemetry */ }
923
+ console.log(`Hook firing: ${res.ok ? 'OK' : 'FAIL'} (${res.results.length} probed${failures.length ? ', failed: ' + failures.join(', ') : ''})`);
799
924
  } else {
800
- console.error('Usage: lifecycle.js <install|uninstall|update|health|doctor>');
925
+ console.error('Usage: lifecycle.js <install|uninstall|update|health|doctor|verify-hooks-fire>');
801
926
  process.exit(1);
802
927
  }
803
928
  }
@@ -493,10 +493,18 @@ function runSessionInit({ source } = {}) {
493
493
  const consistencyIssues = binaryCheck.available
494
494
  ? consistencyCheck(binaryCheck.binary)
495
495
  : [];
496
+ // v0.67.0 — hook reliability (active-project path only): Layer A pushes a
497
+ // FAILED firing self-test result (and refreshes it in the background, off the
498
+ // 5s budget); Layer B is the runtime dispatch-dark canary. Both best-effort.
499
+ const hookFireWarn = checkHookFiring();
500
+ if (hookFireWarn) process.stderr.write(hookFireWarn);
501
+ const hookDarkWarn = detectHookDark();
502
+ if (hookDarkWarn) process.stderr.write(hookDarkWarn);
496
503
  return {
497
504
  inactive: false, lifecycle,
498
505
  autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
499
506
  quietHooks, adopted, autoAdopted: autoAdopt.attempted,
507
+ hookFireWarn: !!hookFireWarn, hookDarkWarn: !!hookDarkWarn,
500
508
  };
501
509
  }
502
510
 
@@ -523,6 +531,9 @@ function injectProjectMap() {
523
531
  timeout: 5000,
524
532
  encoding: 'utf8',
525
533
  stdio: ['pipe', 'pipe', 'pipe'],
534
+ // Hook-internal delivery, not a model conversion — keep record_cli_use from
535
+ // logging this `map` run as a phantom `use` (mirror injectRecentImpact's affected call).
536
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
526
537
  });
527
538
 
528
539
  if (output && output.trim()) {
@@ -611,6 +622,69 @@ function injectRecentImpact({ source } = {}) {
611
622
  }
612
623
  }
613
624
 
625
+ // ── v0.67.0 hook-reliability surfaces ─────────────────────────────────
626
+ //
627
+ // Layer A (firing): surface the cached `verify-hooks-fire` result. PUSH — the
628
+ // model/user learns of a broken hook without running doctor. Pure interpreter
629
+ // + an I/O wrapper that background-refreshes the cache off the SessionStart budget.
630
+ function hookFireWarning(state) {
631
+ if (state && state.ok === false && Array.isArray(state.failures) && state.failures.length) {
632
+ return `[code-graph] Hook firing self-test failed for: ${state.failures.join(', ')}.\n` +
633
+ ` Registered but did not run on this machine — run: code-graph-mcp doctor\n`;
634
+ }
635
+ return null;
636
+ }
637
+
638
+ function checkHookFiring({ now = Date.now() } = {}) {
639
+ const STALE_MS = 24 * 60 * 60 * 1000;
640
+ let warn = null;
641
+ try {
642
+ const state = readJson(path.join(CACHE_DIR, 'hook-fire-state.json'));
643
+ warn = hookFireWarning(state);
644
+ const fresh = state && state.ts && (now - new Date(state.ts).getTime() < STALE_MS);
645
+ if (!fresh) {
646
+ // Background refresh — detached + unref'd + stdio ignore → zero budget impact.
647
+ // First session after install has no cache → schedules a check; failures
648
+ // surface from the next start. Re-checks daily (catches post-install drift,
649
+ // e.g. a node upgrade that breaks a hook).
650
+ try {
651
+ const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
652
+ detached: true, stdio: 'ignore',
653
+ });
654
+ if (child && typeof child.unref === 'function') child.unref();
655
+ } catch { /* ok */ }
656
+ }
657
+ } catch { /* best-effort */ }
658
+ return warn;
659
+ }
660
+
661
+ // Layer B (dispatch): verifyHooksFire proves the script RUNS; only a live session
662
+ // proves Claude Code DISPATCHES tool-calls to it. Pure: given recommendations.jsonl
663
+ // text, the grep/read matchers are "dark" when the edit hook has fired repeatedly
664
+ // (dispatch demonstrably works) but grep AND read never have. Relative sibling
665
+ // comparison → low false-positive (full darkness leaves no file → no claim).
666
+ function analyzeHookDark(recText) {
667
+ let edit = 0, grepOrRead = 0;
668
+ for (const line of (recText || '').split('\n')) {
669
+ if (!line) continue;
670
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
671
+ if (ev.hook === 'grep' || ev.hook === 'read') grepOrRead++;
672
+ else if (ev.hook === 'edit') edit++;
673
+ }
674
+ if (edit >= 3 && grepOrRead === 0) {
675
+ return `[code-graph] The grep/read PreToolUse hooks have recorded nothing in this project,\n` +
676
+ ` though the edit hook fired ${edit}× — grep/read interception may be dark. Run: code-graph-mcp doctor\n`;
677
+ }
678
+ return null;
679
+ }
680
+
681
+ function detectHookDark() {
682
+ try {
683
+ const recPath = path.join(process.cwd(), '.code-graph', 'recommendations.jsonl');
684
+ return analyzeHookDark(fs.readFileSync(recPath, 'utf8'));
685
+ } catch { return null; } // no recommendations.jsonl → nothing to conclude
686
+ }
687
+
614
688
  module.exports = {
615
689
  launchBackgroundAutoUpdate,
616
690
  syncLifecycleConfig,
@@ -627,6 +701,7 @@ module.exports = {
627
701
  filterSourceFiles,
628
702
  parseGitStatusPaths,
629
703
  formatRecentImpact,
704
+ hookFireWarning, checkHookFiring, analyzeHookDark, detectHookDark, // v0.67.0
630
705
  };
631
706
 
632
707
  if (require.main === module) {
@@ -347,3 +347,15 @@ test('consistencyCheck returns version-mismatch when versions differ', (t) => {
347
347
  assert.ok(versionIssue.msg.includes('0.0.1'));
348
348
  });
349
349
 
350
+ test('injectProjectMap map call carries CODE_GRAPH_INTERNAL (delivery, not a model conversion)', () => {
351
+ // injectProjectMap runs `code-graph-mcp map --compact` to inject the project map.
352
+ // That run is a hook-internal delivery — it must carry the internal marker so
353
+ // record_cli_use (src/cli.rs) does not log it as a phantom model `use` event
354
+ // (the 2026-06-23 mem audit found this leak class; the sibling affected call was
355
+ // already guarded). Asserted at source level because injectProjectMap is not exported.
356
+ const src = fs.readFileSync(path.join(__dirname, 'session-init.js'), 'utf8');
357
+ const i = src.indexOf("['map', '--compact']");
358
+ assert.ok(i >= 0, 'map injection present');
359
+ assert.match(src.slice(i, i + 420), /CODE_GRAPH_INTERNAL:\s*'1'/);
360
+ });
361
+
@@ -374,6 +374,15 @@ function determineQueryType(intents, symbols, filePaths, isCoolingDownFn, messag
374
374
  return null;
375
375
  }
376
376
 
377
+ // Hook-internal CLI runs are PUSH deliveries, not model-initiated conversions.
378
+ // Tagging them CODE_GRAPH_INTERNAL=1 keeps record_cli_use (src/cli.rs) from
379
+ // logging them as `use` events — otherwise this hook's own injected callgraph/
380
+ // overview/search results read back as genuine consumer adoption (2026-06-23 mem
381
+ // audit: 100 phantom "model CLI calls" were this hook crediting its own deliveries).
382
+ function buildRunEnv(base = process.env) {
383
+ return { ...base, CODE_GRAPH_INTERNAL: '1' };
384
+ }
385
+
377
386
  // --- Main execution (only when run directly) ---
378
387
  // All exit-on-condition checks (manifest, computeQuietHooks, message length,
379
388
  // db presence) live INSIDE this guard so `require()` from tests doesn't
@@ -454,6 +463,7 @@ function runMain() {
454
463
  timeout: 3000,
455
464
  encoding: 'utf8',
456
465
  stdio: ['pipe', 'pipe', 'pipe'],
466
+ env: buildRunEnv(),
457
467
  });
458
468
  }
459
469
 
@@ -477,4 +487,4 @@ if (require.main === module) {
477
487
  runMain();
478
488
  }
479
489
 
480
- module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS };
490
+ module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS, buildRunEnv };
@@ -14,6 +14,7 @@ const {
14
14
  INTENT_THRESHOLD,
15
15
  determineQueryType,
16
16
  computeQuietHooks,
17
+ buildRunEnv,
17
18
  } = require('./user-prompt-context');
18
19
 
19
20
  // ── shouldSkip ──────────────────────────────────────────
@@ -713,3 +714,30 @@ test('integration: Why does this not work? → symptom-hint', () => {
713
714
  const r = analyze('Why does this not work?');
714
715
  assert.equal(r.query && r.query.type, 'symptom-hint');
715
716
  });
717
+
718
+ // ── buildRunEnv: hook-internal delivery marker (anti phantom-conversion) ──
719
+
720
+ test('buildRunEnv: tags CODE_GRAPH_INTERNAL=1 so deliveries are not logged as model `use`', () => {
721
+ const env = buildRunEnv({ PATH: '/usr/bin', HOME: '/home/x' });
722
+ assert.equal(env.CODE_GRAPH_INTERNAL, '1');
723
+ // preserves the base env (binary still resolves on PATH, cwd inherited, etc.)
724
+ assert.equal(env.PATH, '/usr/bin');
725
+ assert.equal(env.HOME, '/home/x');
726
+ });
727
+
728
+ test('buildRunEnv: defaults to process.env when no base given', () => {
729
+ const env = buildRunEnv();
730
+ assert.equal(env.CODE_GRAPH_INTERNAL, '1');
731
+ });
732
+
733
+ test('run() wires buildRunEnv() into execFileSync (no phantom use-event leak)', () => {
734
+ // run() lives inside runMain() (the file top-level-executes on require), so assert
735
+ // the wiring at the source level: every code-graph-mcp invocation this hook makes
736
+ // must carry the internal marker, else its PUSH injections read back as model
737
+ // adoption (the 2026-06-23 mem audit: 100 phantom "model CLI calls"). Mirrors the
738
+ // cg-answer.js / pre-edit-guide.js internal-env guard.
739
+ const src = fs.readFileSync(path.join(__dirname, 'user-prompt-context.js'), 'utf8');
740
+ const i = src.indexOf('function run(');
741
+ assert.ok(i >= 0, 'run() helper present');
742
+ assert.match(src.slice(i, i + 320), /env:\s*buildRunEnv\(\)/);
743
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.66.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.66.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.66.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.66.0",
42
- "@sdsrs/code-graph-win32-x64": "0.66.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.68.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.68.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.68.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.68.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.68.0"
43
43
  }
44
44
  }