@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.
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/doctor.js +18 -0
- package/claude-plugin/scripts/hook-fire.test.js +117 -0
- package/claude-plugin/scripts/hooks.test.js +79 -0
- package/claude-plugin/scripts/lifecycle.js +126 -1
- package/claude-plugin/scripts/session-init.js +75 -0
- package/claude-plugin/scripts/session-init.test.js +12 -0
- package/claude-plugin/scripts/user-prompt-context.js +11 -1
- package/claude-plugin/scripts/user-prompt-context.test.js +28 -0
- package/package.json +6 -6
|
@@ -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.
|
|
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.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "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
|
}
|