@sdsrs/code-graph 0.31.0 → 0.32.3

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.
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  // PreToolUse(Bash) hook: detect raw `grep`/`rg`/`ag` on the indexed source tree
4
- // and suggest code-graph CLI alternatives. Closes the "Bash comfort zone" leak —
5
- // pre-training bias has Claude reach for `grep -rn` ~13× more than the indexed
6
- // CLI on bash-heavy days (15-day baseline: 429 raw grep vs 191 functional CLI).
4
+ // and either BLOCK with suggestion (v0.32+) or HINT (legacy path). Closes the
5
+ // "Bash comfort zone" leak — pre-training bias has Claude reach for `grep -rn`
6
+ // ~13× more than the indexed CLI on bash-heavy days (15-day baseline: 429 raw
7
+ // grep vs 191 functional CLI). v0.25.0 hint-only had ~0% transfer rate; v0.32.0
8
+ // upgrades the narrowest "I'm searching for a symbol" subset to block-with-reason.
7
9
  //
8
- // Fires when ALL conditions met:
10
+ // HINT fires when ALL conditions met (shouldHint):
9
11
  // 1. Command HEAD is grep/rg/ag (NOT piped — pipe-greps are output filters)
10
12
  // 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...)
11
13
  // 3. Not searching only a config/lockfile (Cargo.toml/.gitignore/*.md/*.json)
@@ -13,13 +15,20 @@
13
15
  // 5. .code-graph/index.db exists in CWD
14
16
  // 6. Same command-hash not hinted within last 60s (per-command cooldown)
15
17
  //
18
+ // BLOCK fires when shouldHint AND (shouldBlock):
19
+ // 7. No precision flag in the command (-l / -A / -B / -C / --include / --exclude)
20
+ // 8. Pattern looks identifier-like (CamelCase ≥4ch, or snake_case with _, or
21
+ // a declaration anchor like `fn X` / `class X` / `def X`)
22
+ // 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
23
+ // 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
24
+ //
16
25
  // Exits silently otherwise — zero noise for build greps, log filters, config
17
26
  // lookups, or the rare legitimate use of raw grep on indexed source.
18
27
 
19
28
  const fs = require('fs');
20
29
  const path = require('path');
21
- const os = require('os');
22
30
  const crypto = require('crypto');
31
+ const { cgTmpDir } = require('./tmp-dir');
23
32
 
24
33
  // --- Pure logic (testable) ---
25
34
 
@@ -56,20 +65,68 @@ function shouldHint(cmd) {
56
65
  return true;
57
66
  }
58
67
 
68
+ // v0.32.0 block tier — strictly narrower than shouldHint. The disqualifying
69
+ // flags (-l, -A, -B, -C, --include, --exclude) mean the user is already doing
70
+ // precise filtering and a blanket "use cg" suggestion would be wrong. The
71
+ // identifier-like check restricts blocks to "I'm looking for a symbol" — the
72
+ // exact use case cg replaces. Marker-only patterns (TODO/FIXME) are legit raw
73
+ // text scans with no cg equivalent.
74
+ // Match any short-flag cluster containing l/L/A/B/C (e.g. `-l`, `-rl`, `-rln`,
75
+ // `-A`, `-rA3`). Combined flag clusters are common in real-world usage and the
76
+ // "precision intent" applies as soon as ANY of these letters appears.
77
+ const BLOCK_DISQUALIFYING_FLAGS =
78
+ /(?:^|\s)-[a-zA-Z]*[lLABC][a-zA-Z]*(?:\s|=|\d|$)|--(?:files-with-matches|files-without-match|include|exclude|exclude-dir|after-context|before-context|context)\b/;
79
+ // v0.32.1: drop the `type` declaration keyword (too common in English prose
80
+ // like "# type checking") and anchor declaration anchors to pattern start
81
+ // (otherwise `"some type X"` matches). CamelCase and snake_case still match
82
+ // anywhere — they're distinctive enough on their own.
83
+ const IDENTIFIER_LIKE =
84
+ /[A-Z][a-zA-Z0-9]{3,}|[a-z][a-z0-9]*_[a-z0-9_]+|^\s*(?:fn|def|class|function|struct|impl|trait)\s+\w/;
85
+ const MARKER_ONLY =
86
+ /^[^"']*["']\s*(?:TODO|FIXME|XXX|HACK|WARN|WARNING|ERROR|NOTE)\s*["']/i;
87
+
88
+ // v0.32.1: pull the pattern argument(s) out of the command before running
89
+ // IDENTIFIER_LIKE — testing the full cmd false-positives on CamelCase /
90
+ // snake_case in PATH ARGUMENTS like `src/EmbeddingModel.rs` or
91
+ // `src/some_module/`. The pattern arg is what the user is actually searching
92
+ // for, and that's the only thing we should evaluate against "is this a
93
+ // symbol-shaped target".
94
+ function extractPatterns(cmd) {
95
+ if (!cmd || typeof cmd !== 'string') return [];
96
+ // Strip leading verb + env prefix
97
+ const stripped = cmd.replace(/^\s*(?:env\s+\S+=\S+\s+)*(?:grep|rg|ag)\s+/, '');
98
+ // Collect every quoted argument — first one is the pattern in standard grep
99
+ // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
100
+ // expressions and worth screening too.
101
+ const matches = [...stripped.matchAll(/"([^"]+)"|'([^']+)'/g)];
102
+ return matches.map(m => m[1] !== undefined ? m[1] : m[2]);
103
+ }
104
+
105
+ function shouldBlock(cmd) {
106
+ if (!shouldHint(cmd)) return false; // narrower than hint
107
+ if (BLOCK_DISQUALIFYING_FLAGS.test(cmd)) return false;
108
+ if (MARKER_ONLY.test(cmd)) return false; // bare TODO/FIXME — no cg equivalent
109
+ const patterns = extractPatterns(cmd);
110
+ if (patterns.length === 0) return false; // unquoted pattern — conservative, hint
111
+ return patterns.some(p => IDENTIFIER_LIKE.test(p));
112
+ }
113
+
59
114
  function commandHash(cmd) {
60
115
  return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
61
116
  }
62
117
 
118
+ function flagPath(cmd) {
119
+ return path.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`);
120
+ }
121
+
63
122
  function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
64
- const flag = path.join(os.tmpdir(), `.code-graph-bash-${commandHash(cmd)}`);
65
123
  try {
66
- return now - fs.statSync(flag).mtimeMs < windowMs;
124
+ return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
67
125
  } catch { return false; }
68
126
  }
69
127
 
70
128
  function markCooldown(cmd) {
71
- const flag = path.join(os.tmpdir(), `.code-graph-bash-${commandHash(cmd)}`);
72
- try { fs.writeFileSync(flag, ''); } catch { /* ok */ }
129
+ try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
73
130
  }
74
131
 
75
132
  function buildHint() {
@@ -84,6 +141,19 @@ function buildHint() {
84
141
  ].join('\n');
85
142
  }
86
143
 
144
+ function buildBlockReason() {
145
+ // Shown to Claude via PreToolUse `decision: block` reason. Must give a
146
+ // concrete alternate command Claude can re-issue without further thinking.
147
+ return [
148
+ '[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
149
+ 'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
150
+ ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
151
+ ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
152
+ ' code-graph-mcp callgraph SYMBOL # callers + callees',
153
+ 'For raw-text scans (log/comment/marker), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
154
+ ].join('\n');
155
+ }
156
+
87
157
  // --- Main execution (only when run directly) ---
88
158
 
89
159
  // Kill switch: matches user-prompt-context.js convention. =1 forces silence
@@ -94,6 +164,13 @@ function isSilenced(env = process.env) {
94
164
  return env.CODE_GRAPH_QUIET_HOOKS === '1';
95
165
  }
96
166
 
167
+ // v0.32.0 — independent of QUIET_HOOKS. =1 downgrades block tier to hint
168
+ // (legacy v0.25.0–v0.31 behavior). Useful when raw-text scan is intentional
169
+ // but the user still wants the hint for future commands.
170
+ function isBlockDisabled(env = process.env) {
171
+ return env.CODE_GRAPH_NO_BLOCK_GREP === '1';
172
+ }
173
+
97
174
  function runMain() {
98
175
  if (isSilenced()) return;
99
176
  const cwd = process.cwd();
@@ -110,6 +187,23 @@ function runMain() {
110
187
  if (isOnCooldown(cmd)) return;
111
188
 
112
189
  markCooldown(cmd);
190
+
191
+ if (!isBlockDisabled() && shouldBlock(cmd)) {
192
+ // PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`).
193
+ // Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was
194
+ // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
195
+ // is the documented modern path. Exit 0 — this is a routing decision, not
196
+ // a hook failure (exit 2 would mark the tool call as "hook errored").
197
+ process.stdout.write(JSON.stringify({
198
+ hookSpecificOutput: {
199
+ hookEventName: 'PreToolUse',
200
+ permissionDecision: 'deny',
201
+ permissionDecisionReason: buildBlockReason(),
202
+ },
203
+ }) + '\n');
204
+ return;
205
+ }
206
+
113
207
  process.stdout.write(buildHint() + '\n');
114
208
  }
115
209
 
@@ -119,9 +213,13 @@ if (require.main === module) {
119
213
 
120
214
  module.exports = {
121
215
  shouldHint,
216
+ shouldBlock,
217
+ extractPatterns, // v0.32.1 — exposed for tests
122
218
  buildHint,
219
+ buildBlockReason,
123
220
  commandHash,
124
221
  isOnCooldown,
125
222
  markCooldown,
126
223
  isSilenced,
224
+ isBlockDisabled,
127
225
  };
@@ -1,7 +1,16 @@
1
1
  'use strict';
2
2
  const test = require('node:test');
3
3
  const assert = require('node:assert/strict');
4
- const { shouldHint, buildHint, commandHash, isSilenced } = require('./pre-grep-guide');
4
+ const {
5
+ shouldHint,
6
+ shouldBlock,
7
+ extractPatterns,
8
+ buildHint,
9
+ buildBlockReason,
10
+ commandHash,
11
+ isSilenced,
12
+ isBlockDisabled,
13
+ } = require('./pre-grep-guide');
5
14
 
6
15
  // ── Should fire: bare grep/rg/ag on indexed source tree ─────────────
7
16
 
@@ -262,3 +271,256 @@ test('regression: cargo test pipe filter NOT fires (sess 45691293)', () => {
262
271
  test('regression: grep -m1 "^version" Cargo.toml NOT fires', () => {
263
272
  assert.equal(shouldHint('grep -m1 "^version" Cargo.toml'), false);
264
273
  });
274
+
275
+ // ════════════════════════════════════════════════════════════════════
276
+ // v0.32.0 — Block tier (shouldBlock, buildBlockReason, isBlockDisabled)
277
+ // ════════════════════════════════════════════════════════════════════
278
+
279
+ // ── shouldBlock: SHOULD block — identifier-shaped symbol scan ───────
280
+
281
+ test('shouldBlock: CamelCase identifier on src/', () => {
282
+ assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
283
+ });
284
+
285
+ test('shouldBlock: snake_case identifier on src/', () => {
286
+ assert.equal(shouldBlock('grep -rn "fts5_search" src/storage/'), true);
287
+ });
288
+
289
+ test('shouldBlock: fn declaration anchor on src/', () => {
290
+ assert.equal(shouldBlock('grep -rn "fn fts5_search" src/storage/'), true);
291
+ });
292
+
293
+ test('shouldBlock: alternation with identifiers on src/', () => {
294
+ assert.equal(shouldBlock('grep -rn "fn fts5_search\\|MATCH" src/storage/'), true);
295
+ });
296
+
297
+ test('shouldBlock: class declaration on src/', () => {
298
+ assert.equal(shouldBlock('grep -rn "class UserService" src/'), true);
299
+ });
300
+
301
+ test('shouldBlock: def declaration on backend/app/', () => {
302
+ assert.equal(shouldBlock('grep -rn "def fetch_user" backend/app/services/'), true);
303
+ });
304
+
305
+ test('shouldBlock: rg with CamelCase on lib/', () => {
306
+ assert.equal(shouldBlock('rg "AuthHandler" lib/'), true);
307
+ });
308
+
309
+ // ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
310
+
311
+ test('shouldBlock: grep -l (files-with-matches) → hint only', () => {
312
+ assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), false);
313
+ });
314
+
315
+ test('shouldBlock: --include=*.rs → user already filtering, hint only', () => {
316
+ assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), false);
317
+ });
318
+
319
+ test('shouldBlock: --exclude-dir=tests → hint only', () => {
320
+ assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
321
+ });
322
+
323
+ test('shouldBlock: -A 3 context flag → hint only', () => {
324
+ assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
325
+ });
326
+
327
+ test('shouldBlock: -B 2 context flag → hint only', () => {
328
+ assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
329
+ });
330
+
331
+ test('shouldBlock: -C 5 context flag → hint only', () => {
332
+ assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
333
+ });
334
+
335
+ // ── shouldBlock: should NOT block — marker-only patterns ────────────
336
+
337
+ test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
338
+ assert.equal(shouldBlock('grep -rn "TODO" src/'), false);
339
+ });
340
+
341
+ test('shouldBlock: bare FIXME marker → hint only', () => {
342
+ assert.equal(shouldBlock('grep -rn "FIXME" src/'), false);
343
+ });
344
+
345
+ test('shouldBlock: bare XXX marker → hint only', () => {
346
+ assert.equal(shouldBlock('grep -rn "XXX" src/'), false);
347
+ });
348
+
349
+ test('shouldBlock: bare HACK marker → hint only', () => {
350
+ assert.equal(shouldBlock('grep -rn "HACK" src/'), false);
351
+ });
352
+
353
+ // ── shouldBlock: should NOT block — non-identifier text ─────────────
354
+
355
+ test('shouldBlock: short lowercase word "foo" → hint only', () => {
356
+ // No CamelCase, no _, no declaration anchor → not symbol-shaped
357
+ assert.equal(shouldBlock('grep -rn "foo" src/'), false);
358
+ });
359
+
360
+ test('shouldBlock: short alphanumeric "v1" → hint only', () => {
361
+ assert.equal(shouldBlock('grep -rn "v1" src/'), false);
362
+ });
363
+
364
+ // ── shouldBlock: should NOT block — inherits shouldHint=false ──────
365
+
366
+ test('shouldBlock: pipe-grep → false (already shouldHint=false)', () => {
367
+ assert.equal(shouldBlock('cargo test 2>&1 | grep "EmbeddingModel"'), false);
368
+ });
369
+
370
+ test('shouldBlock: code-graph-mcp already used → false', () => {
371
+ assert.equal(shouldBlock('code-graph-mcp grep "EmbeddingModel" src/'), false);
372
+ });
373
+
374
+ test('shouldBlock: empty / non-string → false', () => {
375
+ assert.equal(shouldBlock(''), false);
376
+ assert.equal(shouldBlock(null), false);
377
+ });
378
+
379
+ test('shouldBlock: grep on Cargo.toml only → false', () => {
380
+ assert.equal(shouldBlock('grep "EmbeddingModel" Cargo.toml'), false);
381
+ });
382
+
383
+ // ── buildBlockReason content ────────────────────────────────────────
384
+
385
+ test('buildBlockReason: includes "denied"', () => {
386
+ assert.match(buildBlockReason(), /denied/i);
387
+ });
388
+
389
+ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
390
+ const out = buildBlockReason();
391
+ assert.match(out, /code-graph-mcp grep/);
392
+ assert.match(out, /code-graph-mcp ast-search/);
393
+ assert.match(out, /code-graph-mcp callgraph/);
394
+ });
395
+
396
+ test('buildBlockReason: documents the escape hatch env var', () => {
397
+ assert.match(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP=1/);
398
+ });
399
+
400
+ test('buildBlockReason: under 700-byte budget (single CC message)', () => {
401
+ const out = buildBlockReason();
402
+ assert.ok(out.length < 700, `reason length ${out.length} exceeds budget`);
403
+ });
404
+
405
+ // ── isBlockDisabled escape hatch ────────────────────────────────────
406
+
407
+ test('isBlockDisabled: default (no env) → false (block enabled)', () => {
408
+ assert.equal(isBlockDisabled({}), false);
409
+ });
410
+
411
+ test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=1 → true', () => {
412
+ assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), true);
413
+ });
414
+
415
+ test('isBlockDisabled: CODE_GRAPH_NO_BLOCK_GREP=0 → false', () => {
416
+ assert.equal(isBlockDisabled({ CODE_GRAPH_NO_BLOCK_GREP: '0' }), false);
417
+ });
418
+
419
+ test('isBlockDisabled: independent of CODE_GRAPH_QUIET_HOOKS', () => {
420
+ // QUIET_HOOKS=1 silences entirely (no block, no hint).
421
+ // NO_BLOCK_GREP=1 downgrades block to hint only.
422
+ // The two flags must be orthogonal — neither implies the other.
423
+ assert.equal(isBlockDisabled({ CODE_GRAPH_QUIET_HOOKS: '1' }), false);
424
+ assert.equal(isSilenced({ CODE_GRAPH_NO_BLOCK_GREP: '1' }), false);
425
+ });
426
+
427
+ // ════════════════════════════════════════════════════════════════════
428
+ // v0.32.1 — extractPatterns + I1/I4 false-positive regressions
429
+ // ════════════════════════════════════════════════════════════════════
430
+
431
+ // ── extractPatterns: pulls quoted args from grep/rg/ag commands ──────
432
+
433
+ test('extractPatterns: single double-quoted pattern', () => {
434
+ assert.deepEqual(extractPatterns('grep -rn "EmbeddingModel" src/'), ['EmbeddingModel']);
435
+ });
436
+
437
+ test('extractPatterns: single-quoted pattern', () => {
438
+ assert.deepEqual(extractPatterns("grep -rn 'fts5_search' src/"), ['fts5_search']);
439
+ });
440
+
441
+ test('extractPatterns: env-prefixed verb', () => {
442
+ assert.deepEqual(extractPatterns('env LANG=C grep -rn "Foo" src/'), ['Foo']);
443
+ });
444
+
445
+ test('extractPatterns: multiple -e patterns', () => {
446
+ // Multi-pattern grep: both quoted args should be returned.
447
+ const got = extractPatterns('grep -rn -e "first" -e "second" src/');
448
+ assert.deepEqual(got, ['first', 'second']);
449
+ });
450
+
451
+ test('extractPatterns: pattern with alternation', () => {
452
+ assert.deepEqual(
453
+ extractPatterns('grep -rn "fn fts5_search\\|MATCH" src/storage/'),
454
+ ['fn fts5_search\\|MATCH']
455
+ );
456
+ });
457
+
458
+ test('extractPatterns: no quotes at all → empty array', () => {
459
+ // Unquoted pattern (`grep foo src/`) — we deliberately don't try to parse
460
+ // shell tokenization; shouldBlock falls back to hint in this case.
461
+ assert.deepEqual(extractPatterns('grep -rn foo src/'), []);
462
+ });
463
+
464
+ test('extractPatterns: empty / non-string → empty array', () => {
465
+ assert.deepEqual(extractPatterns(''), []);
466
+ assert.deepEqual(extractPatterns(null), []);
467
+ assert.deepEqual(extractPatterns(undefined), []);
468
+ });
469
+
470
+ test('extractPatterns: rg / ag head also stripped', () => {
471
+ assert.deepEqual(extractPatterns('rg "Foo" lib/'), ['Foo']);
472
+ assert.deepEqual(extractPatterns('ag "Bar" src/'), ['Bar']);
473
+ });
474
+
475
+ // ── I1 regression: identifier-shaped PATHS no longer trigger block ──
476
+
477
+ test('I1: grep -rn "abc" src/EmbeddingModel.rs → HINT (path has CamelCase, pattern doesn\'t)', () => {
478
+ // CamelCase is in the FILENAME, not the pattern. v0.32.0 false-blocked
479
+ // this. Pattern "abc" has no identifier shape → must downgrade to hint.
480
+ assert.equal(shouldBlock('grep -rn "abc" src/EmbeddingModel.rs'), false);
481
+ });
482
+
483
+ test('I1: grep -rn "x" src/some_module/file.rs → HINT (path has snake_case)', () => {
484
+ assert.equal(shouldBlock('grep -rn "x" src/some_module/file.rs'), false);
485
+ });
486
+
487
+ test('I1: grep -rn "the quick brown fox" src/EmbeddingModel.rs → HINT (English prose pattern)', () => {
488
+ assert.equal(shouldBlock('grep -rn "the quick brown fox" src/EmbeddingModel.rs'), false);
489
+ });
490
+
491
+ test('I1: unquoted pattern grep -rn foo src/ → HINT (conservative fallback)', () => {
492
+ // Without quotes we can't safely identify the pattern arg via shell rules
493
+ // alone. Conservative: hint only.
494
+ assert.equal(shouldBlock('grep -rn foo src/'), false);
495
+ });
496
+
497
+ test('I1: identifier pattern still blocks even with non-identifier path', () => {
498
+ // Sanity check the inverse — block tier shouldn't get over-relaxed.
499
+ // Path is plain `src/` but pattern is CamelCase → still block.
500
+ assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
501
+ });
502
+
503
+ // ── I4 regression: declaration-anchor + `type` keyword fixes ─────────
504
+
505
+ test('I4: grep -rn "# type checking" src/ → HINT (comment scan, "type" not a decl keyword anymore)', () => {
506
+ assert.equal(shouldBlock('grep -rn "# type checking" src/'), false);
507
+ });
508
+
509
+ test('I4: grep -rn "some type X" src/ → HINT (type not at pattern start, no longer over-matches)', () => {
510
+ assert.equal(shouldBlock('grep -rn "some type X" src/'), false);
511
+ });
512
+
513
+ test('I4: grep -rn "the def keyword" src/ → HINT (def not at pattern start)', () => {
514
+ // "the def keyword" had `\bdef\s+\w` match `def k` previously.
515
+ // ^\s*(?:fn|def|...) anchor stops this.
516
+ assert.equal(shouldBlock('grep -rn "the def keyword" src/'), false);
517
+ });
518
+
519
+ test('I4: grep -rn "def calc_total" src/ → BLOCK (def at start + snake_case)', () => {
520
+ // Real declaration search — still blocks correctly.
521
+ assert.equal(shouldBlock('grep -rn "def calc_total" src/'), true);
522
+ });
523
+
524
+ test('I4: grep -rn "fn render" src/ → BLOCK (decl anchor at start)', () => {
525
+ assert.equal(shouldBlock('grep -rn "fn render" src/'), true);
526
+ });
@@ -24,8 +24,8 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
- const os = require('os');
28
27
  const crypto = require('crypto');
28
+ const { cgTmpDir } = require('./tmp-dir');
29
29
 
30
30
  // --- Configuration ---
31
31
 
@@ -66,7 +66,7 @@ function cwdHash(cwd) {
66
66
  }
67
67
 
68
68
  function statePath(cwd) {
69
- return path.join(os.tmpdir(), `.code-graph-readfan-${cwdHash(cwd)}.json`);
69
+ return path.join(cgTmpDir(), `.code-graph-readfan-${cwdHash(cwd)}.json`);
70
70
  }
71
71
 
72
72
  function loadState(cwd, now = Date.now()) {
@@ -86,6 +86,23 @@ function syncLifecycleConfig() {
86
86
  }
87
87
  }
88
88
  }
89
+ // v0.32.0: self-heal if our settings.json hook coverage is incomplete
90
+ // (e.g. user manually edited settings.json, or settings.json got rewritten
91
+ // by another tool that didn't preserve our entries). Without this, the
92
+ // user silently loses PreToolUse/PostToolUse hooks until next plugin update.
93
+ const { isOurHookEntry, buildSettingsHookEntries } = require('./lifecycle');
94
+ const desired = buildSettingsHookEntries();
95
+ for (const [event, desiredEntries] of Object.entries(desired)) {
96
+ const presentMatchers = new Set(
97
+ (settings.hooks?.[event] || []).filter(isOurHookEntry).map(e => e.matcher || '*')
98
+ );
99
+ for (const e of desiredEntries) {
100
+ if (!presentMatchers.has(e.matcher || '*')) {
101
+ install();
102
+ return 'self-healed-missing-settings-hook';
103
+ }
104
+ }
105
+ }
89
106
  return 'noop';
90
107
  }
91
108
 
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Shared temp-dir helper for hook + auto-update scripts.
4
+ //
5
+ // Why this exists: Claude Code overrides $TMPDIR to ~/.claude/tmp/ so it can
6
+ // capture process stdout for transcript replay. That makes `os.tmpdir()`
7
+ // resolve to the same directory that holds 9000+ transcript subdirs. Putting
8
+ // hook cooldown flags directly there has two failure modes:
9
+ //
10
+ // 1. Diagnostic blindness — every doc / memory / debug query that checks
11
+ // `/tmp/.code-graph-bash-*` for hook firing returns empty even when the
12
+ // hook is working perfectly. v0.32.0's "PreToolUse dark under green
13
+ // health" investigation chased this red herring for ~2 hours before the
14
+ // $TMPDIR override was identified.
15
+ // 2. §8 SAFETY recursive-traversal trap — `~/.claude/tmp/<id>.output` is
16
+ // where CC writes captured process output; scattering 0-byte flag files
17
+ // alongside them amplifies the "grep -r ~/.claude/tmp/" footgun.
18
+ //
19
+ // Fix: pin all hook + auto-update artifacts to a `code-graph-mcp/` subdir of
20
+ // whatever `os.tmpdir()` resolves to. Contained, deterministic, easy to GC.
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ const CG_TMP_DIR = path.join(os.tmpdir(), 'code-graph-mcp');
26
+
27
+ function cgTmpDir() {
28
+ try { fs.mkdirSync(CG_TMP_DIR, { recursive: true }); } catch { /* ok */ }
29
+ return CG_TMP_DIR;
30
+ }
31
+
32
+ module.exports = { cgTmpDir, CG_TMP_DIR };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const { cgTmpDir, CG_TMP_DIR } = require('./tmp-dir');
9
+
10
+ test('CG_TMP_DIR is a "code-graph-mcp" subdir of os.tmpdir()', () => {
11
+ assert.strictEqual(path.basename(CG_TMP_DIR), 'code-graph-mcp');
12
+ assert.strictEqual(path.dirname(CG_TMP_DIR), os.tmpdir());
13
+ });
14
+
15
+ test('cgTmpDir() returns the same path and creates the directory', () => {
16
+ // Pre-condition: nuke it if it exists from a prior run, to prove cgTmpDir()
17
+ // actually creates it on demand (not just reports a pre-existing path).
18
+ try { fs.rmSync(CG_TMP_DIR, { recursive: true, force: true }); } catch { /* ok */ }
19
+ assert.ok(!fs.existsSync(CG_TMP_DIR), 'pre-condition: dir must be absent');
20
+
21
+ const p = cgTmpDir();
22
+ assert.strictEqual(p, CG_TMP_DIR);
23
+ assert.ok(fs.existsSync(p), 'cgTmpDir() must create the directory');
24
+ assert.ok(fs.statSync(p).isDirectory(), 'created entry must be a directory');
25
+ });
26
+
27
+ test('cgTmpDir() is idempotent — second call does not throw on existing dir', () => {
28
+ cgTmpDir();
29
+ // Should not throw even though dir now exists.
30
+ assert.doesNotThrow(() => cgTmpDir());
31
+ });
32
+
33
+ test('cgTmpDir() does not leak files into os.tmpdir() root', () => {
34
+ // Regression guard: the v0.32.x bug was hook artifacts landing directly
35
+ // in os.tmpdir() (= ~/.claude/tmp/ under Claude Code's $TMPDIR override),
36
+ // colliding with transcript subdirs. After the fix, no `.code-graph-bash-*`
37
+ // / `.cg-impact-*` / `.code-graph-readfan-*` filename should ever appear
38
+ // outside CG_TMP_DIR — only inside it.
39
+ const dir = cgTmpDir();
40
+ const flag = path.join(dir, '.code-graph-bash-test');
41
+ fs.writeFileSync(flag, '');
42
+ try {
43
+ // The sibling of CG_TMP_DIR (= os.tmpdir()) must NOT now contain the flag.
44
+ const parent = path.dirname(dir);
45
+ const stray = path.join(parent, '.code-graph-bash-test');
46
+ assert.ok(!fs.existsSync(stray), 'flag must not exist in os.tmpdir() root');
47
+ } finally {
48
+ try { fs.unlinkSync(flag); } catch { /* ok */ }
49
+ }
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.31.0",
3
+ "version": "0.32.3",
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.31.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.31.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.31.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.31.0",
42
- "@sdsrs/code-graph-win32-x64": "0.31.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.32.3",
39
+ "@sdsrs/code-graph-linux-arm64": "0.32.3",
40
+ "@sdsrs/code-graph-darwin-x64": "0.32.3",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.32.3",
42
+ "@sdsrs/code-graph-win32-x64": "0.32.3"
43
43
  }
44
44
  }