@sdsrs/code-graph 0.31.0 → 0.32.2
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/hooks/hooks.json +2 -58
- package/claude-plugin/scripts/auto-update.js +2 -1
- package/claude-plugin/scripts/doctor.js +64 -33
- package/claude-plugin/scripts/hooks.test.js +151 -0
- package/claude-plugin/scripts/lifecycle.js +145 -30
- package/claude-plugin/scripts/lifecycle.test.js +381 -6
- package/claude-plugin/scripts/mcp-launcher.js +73 -0
- package/claude-plugin/scripts/mcp-launcher.test.js +23 -3
- package/claude-plugin/scripts/pre-edit-guide.js +24 -4
- package/claude-plugin/scripts/pre-grep-guide.js +107 -9
- package/claude-plugin/scripts/pre-grep-guide.test.js +263 -1
- package/claude-plugin/scripts/pre-read-guide.js +2 -2
- package/claude-plugin/scripts/session-init.js +17 -0
- package/claude-plugin/scripts/tmp-dir.js +32 -0
- package/claude-plugin/scripts/tmp-dir.test.js +50 -0
- package/package.json +6 -6
|
@@ -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
|
|
5
|
-
// pre-training bias has Claude reach for `grep -rn`
|
|
6
|
-
// CLI on bash-heavy days (15-day baseline: 429 raw
|
|
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
|
-
//
|
|
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(
|
|
124
|
+
return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
|
|
67
125
|
} catch { return false; }
|
|
68
126
|
}
|
|
69
127
|
|
|
70
128
|
function markCooldown(cmd) {
|
|
71
|
-
|
|
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 {
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.32.2",
|
|
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.32.2",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.32.2",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.32.2",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.32.2",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.32.2"
|
|
43
43
|
}
|
|
44
44
|
}
|