@sdsrs/code-graph 0.64.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.
- 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/pre-grep-guide.js +12 -2
- package/claude-plugin/scripts/pre-grep-guide.test.js +39 -0
- package/claude-plugin/scripts/recommendation-log.js +8 -0
- package/claude-plugin/scripts/recommendation-log.test.js +13 -0
- package/claude-plugin/scripts/session-init.js +72 -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
|
}
|
|
@@ -484,6 +484,13 @@ function runMain() {
|
|
|
484
484
|
if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
|
|
485
485
|
if (!shouldHint(cmd)) return;
|
|
486
486
|
|
|
487
|
+
// v0.64 — fingerprint the grep's pattern once, shared by the emit points below.
|
|
488
|
+
// The funnel (aggregate_recommendations_jsonl) uses it to tell a verbatim re-grep
|
|
489
|
+
// of an answered deny (inline answer ignored → fall-through) from a deeper
|
|
490
|
+
// drill-down. undefined when there's no identifier-like pattern (unquoted / prose
|
|
491
|
+
// grep) → omitted from the event, so the funnel stays back-compatible.
|
|
492
|
+
const grepPattern = translateBreToRg(cmd, pickBlockPattern(cmd));
|
|
493
|
+
|
|
487
494
|
// v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
|
|
488
495
|
// Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
|
|
489
496
|
if (commandHasBypass(rawCmd)) {
|
|
@@ -495,7 +502,7 @@ function runMain() {
|
|
|
495
502
|
// Outcome proxy: a source grep re-issued within the cooldown window runs
|
|
496
503
|
// silently (no deny/hint). Record it so `stats` sees the model's grep
|
|
497
504
|
// fan-out — especially a re-grep right after cg answered the same query.
|
|
498
|
-
recordRecommendation(root, { hook: 'grep', action: 'observe' });
|
|
505
|
+
recordRecommendation(root, { hook: 'grep', action: 'observe', ...(grepPattern ? { pattern: grepPattern } : {}) });
|
|
499
506
|
return;
|
|
500
507
|
}
|
|
501
508
|
|
|
@@ -510,7 +517,7 @@ function runMain() {
|
|
|
510
517
|
// v0.49 — intent-aware: declaration+context greps get `show` bodies,
|
|
511
518
|
// falling back to the grep answer, then the static deny.
|
|
512
519
|
let answer = { status: 'unavailable' };
|
|
513
|
-
const pattern =
|
|
520
|
+
const pattern = grepPattern; // computed once above; reused for the answer + deny fingerprint
|
|
514
521
|
// v0.48 — glob-truncated once, shared by the run and the deny message
|
|
515
522
|
// (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
|
|
516
523
|
const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
|
|
@@ -544,6 +551,9 @@ function runMain() {
|
|
|
544
551
|
const unansweredTail = extractUnansweredTail(rawCmd);
|
|
545
552
|
recordRecommendation(root, {
|
|
546
553
|
hook: 'grep', action: 'deny', answered,
|
|
554
|
+
// pattern fingerprints the denied search so the funnel can score a verbatim
|
|
555
|
+
// re-grep of it (the inline answer was ignored) as fall-through, not a win.
|
|
556
|
+
...(pattern ? { pattern } : {}),
|
|
547
557
|
// mode segments which answer type converts (show=bodies, grep=hits).
|
|
548
558
|
...(answered ? { mode: answeredMode } : {}),
|
|
549
559
|
// reason segments WHY an unanswered deny fell back to the static copy:
|
|
@@ -909,6 +909,45 @@ test('e2e: denied grep with stub hits → deny JSON embeds the answer + records
|
|
|
909
909
|
}
|
|
910
910
|
});
|
|
911
911
|
|
|
912
|
+
test('e2e: denied grep records the denied pattern (fingerprint for verbatim re-grep detection)', () => {
|
|
913
|
+
// The Rust funnel (aggregate_recommendations_jsonl) scores a follow-up search
|
|
914
|
+
// carrying the SAME pattern as the armed answered deny as fall-through, not a
|
|
915
|
+
// sustained drill-down. That needs the pattern on the deny event.
|
|
916
|
+
const uniq = `StubPat${Date.now()}`;
|
|
917
|
+
const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
|
|
918
|
+
const cmd = `grep -rn "${uniq}" src/`;
|
|
919
|
+
try {
|
|
920
|
+
const res = runHook(cmd, fixture);
|
|
921
|
+
assert.equal(res.status, 0);
|
|
922
|
+
const rec = JSON.parse(fsE2e.readFileSync(
|
|
923
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim().split('\n').pop());
|
|
924
|
+
assert.equal(rec.action, 'deny');
|
|
925
|
+
assert.equal(rec.pattern, uniq, 'deny event carries the denied pattern as a fingerprint');
|
|
926
|
+
} finally {
|
|
927
|
+
cleanupFixture(fixture, cmd);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test('e2e: re-grep within cooldown → observe carries the same pattern (answer-ignored fingerprint)', () => {
|
|
932
|
+
// First grep denies + marks the cooldown; the verbatim re-grep within the
|
|
933
|
+
// window runs silently as an observe. It must carry the same pattern so the
|
|
934
|
+
// funnel can tell "ignored the inline answer" from "drilled into something new".
|
|
935
|
+
const uniq = `StubCool${Date.now()}`;
|
|
936
|
+
const fixture = e2eFixture(`process.stdout.write('src/foo.rs:7 hit\\n');`);
|
|
937
|
+
const cmd = `grep -rn "${uniq}" src/`;
|
|
938
|
+
try {
|
|
939
|
+
runHook(cmd, fixture); // 1st → deny + markCooldown
|
|
940
|
+
const res2 = runHook(cmd, fixture); // 2nd within window → observe
|
|
941
|
+
assert.equal(res2.status, 0);
|
|
942
|
+
const last = JSON.parse(fsE2e.readFileSync(
|
|
943
|
+
pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim().split('\n').pop());
|
|
944
|
+
assert.equal(last.action, 'observe');
|
|
945
|
+
assert.equal(last.pattern, uniq, 'cooldown observe carries the re-grepped pattern');
|
|
946
|
+
} finally {
|
|
947
|
+
cleanupFixture(fixture, cmd);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
912
951
|
test('e2e: stub reports no matches → grep allowed with FYI + records fallthrough', () => {
|
|
913
952
|
const uniq = `StubMiss${Date.now()}`;
|
|
914
953
|
const fixture = e2eFixture(
|
|
@@ -17,6 +17,9 @@ const fs = require('fs');
|
|
|
17
17
|
const path = require('path');
|
|
18
18
|
|
|
19
19
|
const REC_FILE = 'recommendations.jsonl';
|
|
20
|
+
// Opt-in per-project metrics-silence sentinel (under .code-graph/). Mirror of the
|
|
21
|
+
// Rust `domain::NO_METRICS_SENTINEL` — keep the literal in sync.
|
|
22
|
+
const NO_METRICS_FILE = '.no-metrics';
|
|
20
23
|
|
|
21
24
|
// Bounded growth: recommendations.jsonl is append-only and written per-event
|
|
22
25
|
// from BOTH here and the Rust CLI (cli::record_cli_use). Keep these constants in
|
|
@@ -57,6 +60,11 @@ function recordRecommendation(cwd, event = {}) {
|
|
|
57
60
|
// Append-only: do NOT create .code-graph. Its absence means "not an indexed
|
|
58
61
|
// project" — recording there would pollute non-project cwds.
|
|
59
62
|
if (!fs.existsSync(dir)) return false;
|
|
63
|
+
// Opt-in metrics silence for dev/dogfood checkouts: when the project marks
|
|
64
|
+
// itself with `.code-graph/.no-metrics`, the tool's own hook/CLI runs (sims,
|
|
65
|
+
// functionality testing) must not self-pollute its adoption metrics. Mirrors
|
|
66
|
+
// the Rust cli::record_cli_use guard. Reversible: delete the file to re-enable.
|
|
67
|
+
if (fs.existsSync(path.join(dir, NO_METRICS_FILE))) return false;
|
|
60
68
|
const file = path.join(dir, REC_FILE);
|
|
61
69
|
rotateIfNeeded(file); // rotate-before-append so the file never exceeds ~max + one line
|
|
62
70
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
|
|
@@ -32,6 +32,19 @@ test('recordRecommendation is a no-op (no dir created) when .code-graph absent',
|
|
|
32
32
|
assert.equal(fs.existsSync(path.join(cwd, '.code-graph')), false);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test('recordRecommendation is a no-op when .code-graph/.no-metrics sentinel present', (t) => {
|
|
36
|
+
const cwd = tmpProject(t, true);
|
|
37
|
+
// Without the sentinel it records normally...
|
|
38
|
+
assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'deny' }), true);
|
|
39
|
+
const before = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8');
|
|
40
|
+
// ...then the project marks itself metrics-silent (a dev/dogfood checkout)...
|
|
41
|
+
fs.writeFileSync(path.join(cwd, '.code-graph', '.no-metrics'), '');
|
|
42
|
+
// ...and subsequent recordings are suppressed, leaving the file byte-unchanged.
|
|
43
|
+
assert.equal(recordRecommendation(cwd, { hook: 'grep', action: 'hint' }), false);
|
|
44
|
+
const after = fs.readFileSync(path.join(cwd, '.code-graph', REC_FILE), 'utf8');
|
|
45
|
+
assert.equal(after, before, 'sentinel must suppress further recordings');
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
test('recordRecommendation appends across calls (one line each)', (t) => {
|
|
36
49
|
const cwd = tmpProject(t, true);
|
|
37
50
|
recordRecommendation(cwd, { hook: 'grep', action: 'hint' });
|
|
@@ -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.
|
|
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.
|
|
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.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
|
}
|