@sdsrs/code-graph 0.66.0 → 0.67.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.67.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
 
@@ -611,6 +619,69 @@ function injectRecentImpact({ source } = {}) {
611
619
  }
612
620
  }
613
621
 
622
+ // ── v0.67.0 hook-reliability surfaces ─────────────────────────────────
623
+ //
624
+ // Layer A (firing): surface the cached `verify-hooks-fire` result. PUSH — the
625
+ // model/user learns of a broken hook without running doctor. Pure interpreter
626
+ // + an I/O wrapper that background-refreshes the cache off the SessionStart budget.
627
+ function hookFireWarning(state) {
628
+ if (state && state.ok === false && Array.isArray(state.failures) && state.failures.length) {
629
+ return `[code-graph] Hook firing self-test failed for: ${state.failures.join(', ')}.\n` +
630
+ ` Registered but did not run on this machine — run: code-graph-mcp doctor\n`;
631
+ }
632
+ return null;
633
+ }
634
+
635
+ function checkHookFiring({ now = Date.now() } = {}) {
636
+ const STALE_MS = 24 * 60 * 60 * 1000;
637
+ let warn = null;
638
+ try {
639
+ const state = readJson(path.join(CACHE_DIR, 'hook-fire-state.json'));
640
+ warn = hookFireWarning(state);
641
+ const fresh = state && state.ts && (now - new Date(state.ts).getTime() < STALE_MS);
642
+ if (!fresh) {
643
+ // Background refresh — detached + unref'd + stdio ignore → zero budget impact.
644
+ // First session after install has no cache → schedules a check; failures
645
+ // surface from the next start. Re-checks daily (catches post-install drift,
646
+ // e.g. a node upgrade that breaks a hook).
647
+ try {
648
+ const child = spawn(process.execPath, [path.join(__dirname, 'lifecycle.js'), 'verify-hooks-fire'], {
649
+ detached: true, stdio: 'ignore',
650
+ });
651
+ if (child && typeof child.unref === 'function') child.unref();
652
+ } catch { /* ok */ }
653
+ }
654
+ } catch { /* best-effort */ }
655
+ return warn;
656
+ }
657
+
658
+ // Layer B (dispatch): verifyHooksFire proves the script RUNS; only a live session
659
+ // proves Claude Code DISPATCHES tool-calls to it. Pure: given recommendations.jsonl
660
+ // text, the grep/read matchers are "dark" when the edit hook has fired repeatedly
661
+ // (dispatch demonstrably works) but grep AND read never have. Relative sibling
662
+ // comparison → low false-positive (full darkness leaves no file → no claim).
663
+ function analyzeHookDark(recText) {
664
+ let edit = 0, grepOrRead = 0;
665
+ for (const line of (recText || '').split('\n')) {
666
+ if (!line) continue;
667
+ let ev; try { ev = JSON.parse(line); } catch { continue; }
668
+ if (ev.hook === 'grep' || ev.hook === 'read') grepOrRead++;
669
+ else if (ev.hook === 'edit') edit++;
670
+ }
671
+ if (edit >= 3 && grepOrRead === 0) {
672
+ return `[code-graph] The grep/read PreToolUse hooks have recorded nothing in this project,\n` +
673
+ ` though the edit hook fired ${edit}× — grep/read interception may be dark. Run: code-graph-mcp doctor\n`;
674
+ }
675
+ return null;
676
+ }
677
+
678
+ function detectHookDark() {
679
+ try {
680
+ const recPath = path.join(process.cwd(), '.code-graph', 'recommendations.jsonl');
681
+ return analyzeHookDark(fs.readFileSync(recPath, 'utf8'));
682
+ } catch { return null; } // no recommendations.jsonl → nothing to conclude
683
+ }
684
+
614
685
  module.exports = {
615
686
  launchBackgroundAutoUpdate,
616
687
  syncLifecycleConfig,
@@ -627,6 +698,7 @@ module.exports = {
627
698
  filterSourceFiles,
628
699
  parseGitStatusPaths,
629
700
  formatRecentImpact,
701
+ hookFireWarning, checkHookFiring, analyzeHookDark, detectHookDark, // v0.67.0
630
702
  };
631
703
 
632
704
  if (require.main === module) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.66.0",
3
+ "version": "0.67.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.67.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.67.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.67.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.67.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.67.0"
43
43
  }
44
44
  }