@sdsrs/code-graph 0.47.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.47.1",
7
+ "version": "0.48.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -48,6 +48,24 @@ function truncateAtLine(text, maxBytes) {
48
48
  return { text: buf.subarray(0, maxBytes).toString('latin1'), truncated: true };
49
49
  }
50
50
 
51
+ /**
52
+ * v0.48 — drop glob segments from a search path. The hook extracts path tokens
53
+ * verbatim from the denied command, and spawnSync runs WITHOUT a shell, so a
54
+ * literal `backend/…/llm_engine/*.py` reaches rg as a nonexistent file →
55
+ * exit 1 → `unavailable` → static deny with no answer (daagu 2026-06-11: the
56
+ * night's only deny failed exactly this way). Truncate at the first segment
57
+ * containing a glob metacharacter; widening the scope to the parent dir is
58
+ * always safe. A leading glob (`*.py`) drops the scope entirely (repo-wide).
59
+ */
60
+ function sanitizeSearchPath(searchPath) {
61
+ if (!searchPath || typeof searchPath !== 'string') return undefined;
62
+ const segs = searchPath.split('/');
63
+ const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
64
+ if (i === -1) return searchPath;
65
+ const kept = segs.slice(0, i).join('/');
66
+ return kept || undefined;
67
+ }
68
+
51
69
  /**
52
70
  * Run `code-graph-mcp grep <pattern> [searchPath]` synchronously.
53
71
  *
@@ -81,8 +99,11 @@ function runGrepAnswer(opts = {}) {
81
99
  }
82
100
  if (!binary) return { status: 'unavailable' };
83
101
 
102
+ // Defensive re-sanitize: callers should pass a clean path, but a glob
103
+ // reaching argv is a guaranteed nonzero exit (see sanitizeSearchPath).
104
+ const scope = sanitizeSearchPath(searchPath);
84
105
  const args = ['grep', pattern];
85
- if (searchPath) args.push(searchPath);
106
+ if (scope) args.push(scope);
86
107
  const res = spawnSync(binary, args, {
87
108
  cwd,
88
109
  timeout: timeoutMs,
@@ -104,4 +125,4 @@ function runGrepAnswer(opts = {}) {
104
125
  }
105
126
  }
106
127
 
107
- module.exports = { runGrepAnswer, truncateAtLine };
128
+ module.exports = { runGrepAnswer, truncateAtLine, sanitizeSearchPath };
@@ -133,3 +133,30 @@ test('truncateAtLine: single oversized line → hard cut', () => {
133
133
  assert.equal(truncated, true);
134
134
  assert.equal(Buffer.byteLength(text, 'utf8'), 10);
135
135
  });
136
+
137
+ // ── v0.48 sanitizeSearchPath: glob args reach rg literally (no shell) ──
138
+
139
+ test('sanitizeSearchPath: truncates at first glob segment (daagu denied command)', () => {
140
+ const { sanitizeSearchPath } = require('./cg-answer');
141
+ assert.equal(
142
+ sanitizeSearchPath('backend/app/services/llm_engine/*.py'),
143
+ 'backend/app/services/llm_engine');
144
+ });
145
+
146
+ test('sanitizeSearchPath: clean path unchanged; leading glob drops scope; falsy → undefined', () => {
147
+ const { sanitizeSearchPath } = require('./cg-answer');
148
+ assert.equal(sanitizeSearchPath('src/storage/'), 'src/storage/');
149
+ assert.equal(sanitizeSearchPath('*.py'), undefined);
150
+ assert.equal(sanitizeSearchPath('src/**/x.rs'), 'src');
151
+ assert.equal(sanitizeSearchPath('src/file[1].rs'), 'src');
152
+ assert.equal(sanitizeSearchPath(''), undefined);
153
+ assert.equal(sanitizeSearchPath(undefined), undefined);
154
+ });
155
+
156
+ test('runGrepAnswer: glob searchPath is truncated before spawn (defensive layer)', () => {
157
+ const r = runGrepAnswer({
158
+ cwd: stubDir, pattern: 'fts5_search', searchPath: 'src/storage/*.rs', binary: stubBinary(),
159
+ });
160
+ assert.equal(r.status, 'hits');
161
+ assert.match(r.text, /args=\["grep","fts5_search","src\/storage"\]/);
162
+ });
@@ -12,7 +12,10 @@
12
12
  // 2. Args include an indexed source-tree path (src/ tests/ lib/ scripts/ ...)
13
13
  // 3. Not searching only a config/lockfile (Cargo.toml/.gitignore/*.md/*.json)
14
14
  // 4. Command doesn't already invoke code-graph-mcp (no double-suggest)
15
- // 5. .code-graph/index.db exists in CWD
15
+ // 5. .code-graph/index.db exists in CWD or a parent up to $HOME (v0.48: the
16
+ // hook's cwd follows the persistent shell — after `cd backend/` every
17
+ // gate used to fail silently for the rest of the session; daagu
18
+ // 2026-06-11 replay: 38/40 head-greps dark to this)
16
19
  // 6. Same command-hash not hinted within last 60s (per-command cooldown)
17
20
  //
18
21
  // BLOCK fires when shouldHint AND (shouldBlock):
@@ -22,19 +25,28 @@
22
25
  // 9. Pattern is not a bare marker word (TODO/FIXME/XXX/HACK/WARN/ERROR/NOTE)
23
26
  // 10. CODE_GRAPH_NO_BLOCK_GREP != "1" (block escape, independent of QUIET_HOOKS)
24
27
  //
28
+ // A `CODE_GRAPH_NO_BLOCK_GREP=1`-prefixed grep that would otherwise hint is
29
+ // recorded as `action:'bypass'` and allowed silently (v0.48) — previously the
30
+ // bare KEY=VALUE prefix failed GREP_HEAD and the escape was invisible to the
31
+ // conversion funnel (daagu 2026-06-11: 14 bypassed greps, 0 recorded).
32
+ //
25
33
  // Exits silently otherwise — zero noise for build greps, log filters, config
26
34
  // lookups, or the rare legitimate use of raw grep on indexed source.
27
35
 
28
36
  const fs = require('fs');
37
+ const os = require('os');
29
38
  const path = require('path');
30
39
  const crypto = require('crypto');
31
40
  const { cgTmpDir } = require('./tmp-dir');
32
41
  const { recordRecommendation } = require('./recommendation-log');
33
- const { runGrepAnswer } = require('./cg-answer');
42
+ const { runGrepAnswer, sanitizeSearchPath } = require('./cg-answer');
34
43
 
35
44
  // --- Pure logic (testable) ---
36
45
 
37
- const GREP_HEAD = /^\s*(?:env\s+\S+=\S+\s+)*(grep|rg|ag)\b/;
46
+ // v0.48: also match bare `KEY=VALUE grep` prefixes (no `env` verb) — the shape
47
+ // the deny message itself teaches (`CODE_GRAPH_NO_BLOCK_GREP=1 grep …`). With
48
+ // the old `env`-only form those commands failed gate 1 and were invisible.
49
+ const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(grep|rg|ag)\b/;
38
50
  // Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
39
51
  // missed real-world backend conventions where the prefix list term is preceded
40
52
  // by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
@@ -99,8 +111,8 @@ const MARKER_ONLY =
99
111
  // symbol-shaped target".
100
112
  function extractPatterns(cmd) {
101
113
  if (!cmd || typeof cmd !== 'string') return [];
102
- // Strip leading verb + env prefix
103
- const stripped = cmd.replace(/^\s*(?:env\s+\S+=\S+\s+)*(?:grep|rg|ag)\s+/, '');
114
+ // Strip leading verb + env/assignment prefix (kept in sync with GREP_HEAD)
115
+ const stripped = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
104
116
  // Collect every quoted argument — first one is the pattern in standard grep
105
117
  // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
106
118
  // expressions and worth screening too.
@@ -131,6 +143,69 @@ function normalizeCommandPaths(cmd, cwd) {
131
143
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
132
144
  }
133
145
 
146
+ // v0.48 — the hook's process.cwd() follows the PERSISTENT shell, not the
147
+ // project root: after the model runs `cd backend/`, every later bare grep used
148
+ // to fail the index.db gate silently for the rest of the session (daagu
149
+ // 2026-06-11: 38/40 head-greps dark). Walk up to the nearest ancestor holding
150
+ // `.code-graph/index.db`; stop at $HOME (checked, not crossed) and fs root.
151
+ function resolveProjectRoot(startDir, opts = {}) {
152
+ const home = opts.home !== undefined ? opts.home : os.homedir();
153
+ const exists = opts.exists || fs.existsSync;
154
+ let dir = path.resolve(startDir || '.');
155
+ for (;;) {
156
+ if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
157
+ if (dir === home) return null;
158
+ const parent = path.dirname(dir);
159
+ if (parent === dir) return null;
160
+ dir = parent;
161
+ }
162
+ }
163
+
164
+ // v0.48 — companion to resolveProjectRoot: when the shell sits in a subdir,
165
+ // bare relative path args (`app --include=*.py` from backend/) are
166
+ // subdir-relative and never match the root-relative SRC_PATH prefixes. Rebase
167
+ // each candidate token onto the project root; a token only counts as a path
168
+ // when the rebased form EXISTS under the root — quoted patterns, flags,
169
+ // operators, absolute and traversal tokens are never touched. Existence is the
170
+ // workhorse gate: it keeps unquoted pattern words from masquerading as paths
171
+ // (the exact shape that would re-create the answered:false glob failure).
172
+ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
173
+ if (!cmd || typeof cmd !== 'string' || !relPrefix || !rootDir) return cmd;
174
+ const prefix = relPrefix.split(path.sep).join('/');
175
+ // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
176
+ if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
177
+ let verbSeen = false;
178
+ return cmd.split(/(\s+)/).map((tok) => {
179
+ if (!tok || /^\s+$/.test(tok)) return tok;
180
+ if (!verbSeen) {
181
+ if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
182
+ verbSeen = true; // the verb itself (grep/rg/ag) — never a path
183
+ return tok;
184
+ }
185
+ if (/^["']/.test(tok)) return tok; // quoted → pattern
186
+ if (tok.startsWith('-')) return tok; // flag
187
+ if (tok.startsWith('/')) return tok; // absolute (foreign — root strip already ran)
188
+ if (tok.includes('..')) return tok; // traversal
189
+ if (/[|;&<>=\\$`'"]/.test(tok)) return tok; // operators / redirects / assignments / escapes
190
+ const candidate = prefix + '/' + tok;
191
+ // Probe existence on the glob-truncated form: `app/…/llm_engine/*.py`
192
+ // must still rebase (its dir exists) or the deny-answer would run a
193
+ // subdir-relative path from the root and fail (answered:false again).
194
+ const probe = sanitizeSearchPath(candidate);
195
+ try {
196
+ if (!probe || !exists(path.join(rootDir, probe))) return tok;
197
+ } catch { return tok; }
198
+ return candidate;
199
+ }).join('');
200
+ }
201
+
202
+ // v0.48 — bypass detection on the RAW command. The deny message documents
203
+ // `CODE_GRAPH_NO_BLOCK_GREP=1` as a per-command escape; record its use so the
204
+ // conversion funnel can see escape adoption instead of going dark.
205
+ function commandHasBypass(cmd) {
206
+ return typeof cmd === 'string' && /(?:^|\s)CODE_GRAPH_NO_BLOCK_GREP=1(?:\s|$)/.test(cmd);
207
+ }
208
+
134
209
  // v0.47.0 — pull the first source-tree path token out of the denied command so
135
210
  // the inline answer can scope its search the same way the raw grep would have.
136
211
  function extractSearchPath(cmd) {
@@ -188,14 +263,18 @@ function buildBlockReason() {
188
263
  ' code-graph-mcp grep "<pattern>" <path> # FTS + AST context per hit',
189
264
  ' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
190
265
  ' code-graph-mcp callgraph SYMBOL # callers + callees',
191
- 'For raw-text scans (log/comment/marker), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
266
+ 'If this specific search truly needs raw-text regex (log/comment scan), prepend',
267
+ '`CODE_GRAPH_NO_BLOCK_GREP=1` to THIS command only — a per-command escape, not a default prefix.',
192
268
  ].join('\n');
193
269
  }
194
270
 
195
271
  // v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
196
272
  // deny still asks the model to initiate a new tool call; embedding the actual
197
- // results removes that choice entirely. Keep the escape hatch line — raw-text
198
- // regex (BRE alternation, log scans) remains a legitimate need.
273
+ // results removes that choice entirely.
274
+ // v0.48 NO escape-hatch line here: the model already has the results, and
275
+ // advertising the bypass taught it a permanent prefix within 5 seconds (daagu
276
+ // 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
277
+ // escape because there we give no answer.
199
278
  function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
200
279
  const cmdShown = `code-graph-mcp grep "${pattern}"${searchPath ? ` ${searchPath}` : ''}`;
201
280
  const lines = [
@@ -208,7 +287,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer) {
208
287
  }
209
288
  lines.push(
210
289
  'Each hit shows its containing fn/module — use these results directly instead of re-running the search.',
211
- 'For raw-text regex (alternation, log/comment scans), re-run with `CODE_GRAPH_NO_BLOCK_GREP=1` prepended.',
212
290
  );
213
291
  return lines.join('\n');
214
292
  }
@@ -245,9 +323,11 @@ function isAnswerDisabled(env = process.env) {
245
323
 
246
324
  function runMain() {
247
325
  if (isSilenced()) return;
248
- const cwd = process.cwd();
249
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
250
- if (!fs.existsSync(dbPath)) return; // no index — no hint
326
+ // v0.48 process.cwd() follows the persistent shell; resolve the project
327
+ // root by walking up so `cd backend/` no longer darkens the whole session.
328
+ const shellCwd = process.cwd();
329
+ const root = resolveProjectRoot(shellCwd);
330
+ if (root === null) return; // no index anywhere up to $HOME — no hint
251
331
 
252
332
  let input;
253
333
  try {
@@ -258,11 +338,22 @@ function runMain() {
258
338
  } catch { return; }
259
339
 
260
340
  const rawCmd = (input.tool_input && input.tool_input.command) || '';
261
- // v0.47.1 — match against the cwd-stripped form so absolute paths under the
262
- // project root behave exactly like their relative spelling. Cooldown stays
263
- // keyed on the raw command (what Claude actually sent).
264
- const cmd = normalizeCommandPaths(rawCmd, cwd);
341
+ // v0.47.1 — match against the root-stripped form so absolute paths under the
342
+ // project root behave exactly like their relative spelling. v0.48 — then
343
+ // rebase bare subdir-relative tokens onto the root. Cooldown stays keyed on
344
+ // the raw command (what Claude actually sent).
345
+ let cmd = normalizeCommandPaths(rawCmd, root);
346
+ const relPrefix = path.relative(root, shellCwd);
347
+ if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
265
348
  if (!shouldHint(cmd)) return;
349
+
350
+ // v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
351
+ // Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
352
+ if (commandHasBypass(rawCmd)) {
353
+ recordRecommendation(root, { hook: 'grep', action: 'bypass' });
354
+ return;
355
+ }
356
+
266
357
  if (isOnCooldown(rawCmd)) return;
267
358
 
268
359
  markCooldown(rawCmd);
@@ -274,12 +365,15 @@ function runMain() {
274
365
  // (regex-dialect differences mean 0 hits ≠ proof of absence).
275
366
  let answer = { status: 'unavailable' };
276
367
  const pattern = pickBlockPattern(cmd);
368
+ // v0.48 — glob-truncated once, shared by the run and the deny message
369
+ // (a literal `dir/*.py` argv made rg exit 1 → answered:false static deny).
370
+ const searchPath = sanitizeSearchPath(extractSearchPath(cmd));
277
371
  if (!isAnswerDisabled() && pattern) {
278
- answer = runGrepAnswer({ cwd, pattern, searchPath: extractSearchPath(cmd) });
372
+ answer = runGrepAnswer({ cwd: root, pattern, searchPath });
279
373
  }
280
374
 
281
375
  if (answer.status === 'no-hits') {
282
- recordRecommendation(cwd, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
376
+ recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: 'no-hits' });
283
377
  process.stdout.write(buildNoHitsFyi(pattern) + '\n');
284
378
  return;
285
379
  }
@@ -289,7 +383,7 @@ function runMain() {
289
383
  // ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
290
384
  // is the documented modern path. Exit 0 — this is a routing decision, not
291
385
  // a hook failure (exit 2 would mark the tool call as "hook errored").
292
- recordRecommendation(cwd, {
386
+ recordRecommendation(root, {
293
387
  hook: 'grep', action: 'deny', answered: answer.status === 'hits',
294
388
  });
295
389
  process.stdout.write(JSON.stringify({
@@ -297,14 +391,14 @@ function runMain() {
297
391
  hookEventName: 'PreToolUse',
298
392
  permissionDecision: 'deny',
299
393
  permissionDecisionReason: answer.status === 'hits'
300
- ? buildBlockReasonWithAnswer(pattern, extractSearchPath(cmd), answer)
394
+ ? buildBlockReasonWithAnswer(pattern, searchPath, answer)
301
395
  : buildBlockReason(),
302
396
  },
303
397
  }) + '\n');
304
398
  return;
305
399
  }
306
400
 
307
- recordRecommendation(cwd, { hook: 'grep', action: 'hint' });
401
+ recordRecommendation(root, { hook: 'grep', action: 'hint' });
308
402
  process.stdout.write(buildHint() + '\n');
309
403
  }
310
404
 
@@ -318,6 +412,9 @@ module.exports = {
318
412
  extractPatterns, // v0.32.1 — exposed for tests
319
413
  extractSearchPath, // v0.47.0 — deny-with-answer
320
414
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
415
+ resolveProjectRoot, // v0.48 — subdir-cwd dark fix
416
+ rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
417
+ commandHasBypass, // v0.48 — bypass funnel visibility
321
418
  pickBlockPattern,
322
419
  buildHint,
323
420
  buildBlockReason,
@@ -7,6 +7,9 @@ const {
7
7
  extractPatterns,
8
8
  extractSearchPath,
9
9
  normalizeCommandPaths,
10
+ resolveProjectRoot,
11
+ rebaseRelativePaths,
12
+ commandHasBypass,
10
13
  pickBlockPattern,
11
14
  buildHint,
12
15
  buildBlockReason,
@@ -651,17 +654,30 @@ test('pickBlockPattern: no identifier-like pattern → undefined', () => {
651
654
 
652
655
  // ── v0.47.0 deny-with-answer: message builders + env gate ───────────
653
656
 
654
- test('buildBlockReasonWithAnswer: embeds results, command, and escape hatch', () => {
657
+ test('buildBlockReasonWithAnswer: embeds results and command', () => {
655
658
  const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
656
659
  status: 'hits', text: 'src/storage/db.rs:42 fn fts5_search()', truncated: false,
657
660
  });
658
661
  assert.match(reason, /already ran/);
659
662
  assert.match(reason, /code-graph-mcp grep "fts5_search" src\/storage\//);
660
663
  assert.match(reason, /src\/storage\/db\.rs:42/);
661
- assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
662
664
  assert.doesNotMatch(reason, /truncated/);
663
665
  });
664
666
 
667
+ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one deny taught a 14-grep permanent prefix)', () => {
668
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/storage/', {
669
+ status: 'hits', text: 'hit', truncated: false,
670
+ });
671
+ assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
672
+ });
673
+
674
+ test('buildBlockReason: scopes the escape to THIS command only', () => {
675
+ const reason = buildBlockReason();
676
+ assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
677
+ assert.match(reason, /THIS command only/);
678
+ assert.match(reason, /not a default/);
679
+ });
680
+
665
681
  test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
666
682
  const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
667
683
  status: 'hits', text: 'hit', truncated: false,
@@ -706,9 +722,9 @@ function e2eFixture(stubBody) {
706
722
  return { dir, stub };
707
723
  }
708
724
 
709
- function runHook(cmd, fixture) {
725
+ function runHook(cmd, fixture, cwdOverride) {
710
726
  const res = spawnHook(process.execPath, [pathE2e.join(__dirname, 'pre-grep-guide.js')], {
711
- cwd: fixture.dir,
727
+ cwd: cwdOverride || fixture.dir,
712
728
  input: JSON.stringify({ tool_input: { command: cmd } }),
713
729
  encoding: 'utf8',
714
730
  env: {
@@ -837,3 +853,190 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
837
853
  cleanupFixture(fixture, cmd);
838
854
  }
839
855
  });
856
+
857
+ // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
858
+ // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
859
+ // head-greps for the rest of the night — gate 5 checked process.cwd() only.
860
+
861
+ const { sanitizeSearchPath } = require('./cg-answer');
862
+
863
+ test('resolveProjectRoot: index at start dir', () => {
864
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
865
+ try {
866
+ fsE2e.mkdirSync(pathE2e.join(base, 'proj', '.code-graph'), { recursive: true });
867
+ fsE2e.writeFileSync(pathE2e.join(base, 'proj', '.code-graph', 'index.db'), '');
868
+ assert.equal(
869
+ resolveProjectRoot(pathE2e.join(base, 'proj'), { home: base }),
870
+ pathE2e.join(base, 'proj'));
871
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
872
+ });
873
+
874
+ test('resolveProjectRoot: walks up from nested subdir to the indexed root', () => {
875
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
876
+ try {
877
+ const proj = pathE2e.join(base, 'proj');
878
+ fsE2e.mkdirSync(pathE2e.join(proj, '.code-graph'), { recursive: true });
879
+ fsE2e.writeFileSync(pathE2e.join(proj, '.code-graph', 'index.db'), '');
880
+ const deep = pathE2e.join(proj, 'backend', 'app', 'services');
881
+ fsE2e.mkdirSync(deep, { recursive: true });
882
+ assert.equal(resolveProjectRoot(deep, { home: base }), proj);
883
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
884
+ });
885
+
886
+ test('resolveProjectRoot: no index up to $HOME → null (home itself still checked)', () => {
887
+ const base = fsE2e.mkdtempSync(pathE2e.join(osE2e.tmpdir(), 'cg-root-'));
888
+ try {
889
+ const deep = pathE2e.join(base, 'somewhere', 'deep');
890
+ fsE2e.mkdirSync(deep, { recursive: true });
891
+ assert.equal(resolveProjectRoot(deep, { home: base }), null);
892
+ // home itself holding an index is honored
893
+ fsE2e.mkdirSync(pathE2e.join(base, '.code-graph'), { recursive: true });
894
+ fsE2e.writeFileSync(pathE2e.join(base, '.code-graph', 'index.db'), '');
895
+ assert.equal(resolveProjectRoot(deep, { home: base }), base);
896
+ } finally { fsE2e.rmSync(base, { recursive: true, force: true }); }
897
+ });
898
+
899
+ test('rebaseRelativePaths: daagu shape — bare `app` from backend/ cwd', () => {
900
+ const exists = (p) => p.endsWith(pathE2e.join('backend', 'app'));
901
+ const cmd = 'grep -rn "rr_source\\|max_retries" app --include=*.py';
902
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
903
+ assert.equal(rebased, 'grep -rn "rr_source\\|max_retries" backend/app --include=*.py');
904
+ assert.equal(shouldHint(rebased), true);
905
+ assert.equal(extractSearchPath(rebased), 'backend/app');
906
+ });
907
+
908
+ test('rebaseRelativePaths: deep relPrefix, multiple file args', () => {
909
+ const exists = (p) => p.endsWith('.py');
910
+ const rel = 'backend/app/services/scheduler/tasks';
911
+ const cmd = 'grep -n "except Exception" asr_preload.py xuanlun_pro_scan.py';
912
+ const rebased = rebaseRelativePaths(cmd, rel, '/proj', exists);
913
+ assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/asr_preload\.py/);
914
+ assert.match(rebased, /backend\/app\/services\/scheduler\/tasks\/xuanlun_pro_scan\.py/);
915
+ assert.equal(shouldHint(rebased), true);
916
+ });
917
+
918
+ test('rebaseRelativePaths: quoted patterns are never rebased even if a same-named path exists', () => {
919
+ const exists = () => true; // adversarial: everything "exists"
920
+ const cmd = 'grep -rn "retry" app';
921
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
922
+ assert.equal(rebased, 'grep -rn "retry" backend/app');
923
+ });
924
+
925
+ test('rebaseRelativePaths: flags, absolute, traversal, operators untouched', () => {
926
+ const exists = () => true;
927
+ const cmd = 'grep -rn "X" /etc/hosts ../up --include=*.py 2>/dev/null';
928
+ assert.equal(rebaseRelativePaths(cmd, 'backend', '/proj', exists), cmd);
929
+ });
930
+
931
+ test('rebaseRelativePaths: non-source relPrefix (docs/) → unchanged', () => {
932
+ const exists = () => true;
933
+ const cmd = 'grep -rn "X" app';
934
+ assert.equal(rebaseRelativePaths(cmd, 'docs', '/proj', exists), cmd);
935
+ });
936
+
937
+ test('rebaseRelativePaths: unquoted pattern word does not exist → untouched', () => {
938
+ const exists = (p) => p.endsWith('/backend/app');
939
+ const cmd = 'grep -rn retry_count app';
940
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
941
+ assert.equal(rebased, 'grep -rn retry_count backend/app');
942
+ });
943
+
944
+ // ── v0.48 bypass visibility: GREP_HEAD bare-prefix + commandHasBypass ──
945
+
946
+ test('shouldHint: bare KEY=VALUE prefixed grep now matches GREP_HEAD', () => {
947
+ assert.equal(shouldHint('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "fts5_search" src/'), true);
948
+ });
949
+
950
+ test('extractPatterns: bare KEY=VALUE prefix stripped with the verb', () => {
951
+ assert.deepEqual(
952
+ extractPatterns('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "split_identifier" src/'),
953
+ ['split_identifier']);
954
+ });
955
+
956
+ test('commandHasBypass: =1 prefix detected, other values / absence are not', () => {
957
+ assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "X" src/'), true);
958
+ assert.equal(commandHasBypass('FOO=1 CODE_GRAPH_NO_BLOCK_GREP=1 grep "X" src/'), true);
959
+ assert.equal(commandHasBypass('CODE_GRAPH_NO_BLOCK_GREP=0 grep -rn "X" src/'), false);
960
+ assert.equal(commandHasBypass('grep -rn "CODE_GRAPH_NO_BLOCK_GREP=1" src/'), false);
961
+ assert.equal(commandHasBypass('grep -rn "X" src/'), false);
962
+ });
963
+
964
+ // ── v0.48 replay: the exact command behind the night's only deny ──
965
+ // (answered:false — glob path reached rg literally and exited 1)
966
+
967
+ test('replay: daagu denied glob command → block + sanitized search path', () => {
968
+ const cmd = 'grep -rn "async def chat\\|def chat\\|retry\\|rate.limit\\|rate-limit\\|RateLimit\\|429\\|max_retries\\|backoff\\|fallback_model\\|temporarily" backend/app/services/llm_engine/*.py | head -40';
969
+ assert.equal(shouldHint(cmd), true);
970
+ assert.equal(shouldBlock(cmd), true);
971
+ const raw = extractSearchPath(cmd);
972
+ assert.equal(raw, 'backend/app/services/llm_engine/*.py');
973
+ assert.equal(sanitizeSearchPath(raw), 'backend/app/services/llm_engine');
974
+ });
975
+
976
+ // ── v0.48 e2e: hook process spawned exactly as CC does ──
977
+
978
+ test('e2e: subdir cwd — hook resolves root, rebases path, records at root', () => {
979
+ const uniq = `sub_dir_fix_${Date.now()}`;
980
+ const fixture = e2eFixture(
981
+ `process.stdout.write('backend/app/x.py:1 fn hit()\\n');`);
982
+ const cmd = `grep -rn "${uniq}\\|max_retries" app`;
983
+ try {
984
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'backend', 'app'), { recursive: true });
985
+ const res = runHook(cmd, fixture, pathE2e.join(fixture.dir, 'backend'));
986
+ const out = JSON.parse(res.stdout);
987
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
988
+ const recs = fsE2e.readFileSync(
989
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
990
+ assert.match(recs, /"action":"deny"/);
991
+ // never creates .code-graph in the subdir
992
+ assert.equal(fsE2e.existsSync(pathE2e.join(fixture.dir, 'backend', '.code-graph')), false);
993
+ } finally {
994
+ cleanupFixture(fixture, cmd);
995
+ }
996
+ });
997
+
998
+ test('e2e: bypassed grep is silent but recorded as action:bypass', () => {
999
+ const uniq = `bypass_vis_${Date.now()}`;
1000
+ const fixture = e2eFixture(`process.stdout.write('never called\\n');`);
1001
+ const cmd = `CODE_GRAPH_NO_BLOCK_GREP=1 grep -rn "${uniq}\\|fts5_search" src/`;
1002
+ try {
1003
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1004
+ const res = runHook(cmd, fixture);
1005
+ assert.equal(res.stdout, '');
1006
+ const recs = fsE2e.readFileSync(
1007
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
1008
+ assert.match(recs, /"action":"bypass"/);
1009
+ } finally {
1010
+ cleanupFixture(fixture, cmd);
1011
+ }
1012
+ });
1013
+
1014
+ test('e2e: glob path arg → answer runs against the glob-truncated dir', () => {
1015
+ const uniq = `GlobTrunc${Date.now()}`;
1016
+ const fixture = e2eFixture(
1017
+ `process.stdout.write('args=' + JSON.stringify(process.argv.slice(2)) + '\\n');`);
1018
+ const cmd = `grep -rn "${uniq}" src/storage/*.rs`;
1019
+ try {
1020
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src', 'storage'), { recursive: true });
1021
+ const res = runHook(cmd, fixture);
1022
+ const out = JSON.parse(res.stdout);
1023
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1024
+ assert.match(out.hookSpecificOutput.permissionDecisionReason,
1025
+ new RegExp(`args=\\["grep","${uniq}","src/storage"\\]`));
1026
+ } finally {
1027
+ cleanupFixture(fixture, cmd);
1028
+ }
1029
+ });
1030
+
1031
+ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists', () => {
1032
+ // daagu shape: shell in backend/, command scopes a glob under it. Without
1033
+ // the truncated probe the token stayed subdir-relative while the answer ran
1034
+ // from the root → rg ENOENT → answered:false (the original night bug).
1035
+ const exists = (p) => p.endsWith(pathE2e.join('backend', 'app', 'services', 'llm_engine'));
1036
+ const cmd = 'grep -rn "def chat\\|max_retries" app/services/llm_engine/*.py';
1037
+ const rebased = rebaseRelativePaths(cmd, 'backend', '/proj', exists);
1038
+ assert.equal(
1039
+ extractSearchPath(rebased), 'backend/app/services/llm_engine/*.py');
1040
+ assert.equal(
1041
+ sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
1042
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.47.1",
3
+ "version": "0.48.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.47.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.47.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.47.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.47.1",
42
- "@sdsrs/code-graph-win32-x64": "0.47.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.48.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.48.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.48.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.48.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.48.0"
43
43
  }
44
44
  }