@sdsrs/code-graph 0.69.0 → 0.70.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.69.0",
7
+ "version": "0.70.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -166,6 +166,12 @@ function classifyBlock(cmd) {
166
166
  if (symbols.length === 0) return null; // context read without named decls
167
167
  return { mode: 'show', symbols: symbols.slice(0, 3) };
168
168
  }
169
+ // v0.70 — only DENY when the inline grep answer can cover the SAME scope. It scopes to one
170
+ // path (extractSearchPath = first src-prefixed token), so a grep naming ≥2 file paths gets a
171
+ // first-path-only answer (the rest silently dropped) — an incomplete substitute that
172
+ // rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade to hint: the model's complete
173
+ // grep runs and the hint still nudges. (show mode above is symbol-scoped, not path → unaffected.)
174
+ if (countNamedPaths(cmd, patterns) >= 2) return null;
169
175
  return { mode: 'grep' };
170
176
  }
171
177
 
@@ -303,6 +309,37 @@ function extractSearchPath(cmd) {
303
309
  return undefined;
304
310
  }
305
311
 
312
+ // v0.70 — count the explicit file/dir path arguments a grep names (excluding flags and
313
+ // the quoted search pattern). The deny's inline answer scopes to ONE path
314
+ // (extractSearchPath returns only the first source-prefixed token), so a grep naming ≥2
315
+ // paths gets an answer covering only the first — an incomplete substitute that rationally
316
+ // drives CODE_GRAPH_NO_BLOCK_GREP bypass (2026-06-23: the dominant observed bypass was a
317
+ // multi-file named grep whose deny silently dropped the other files). classifyBlock uses
318
+ // this to downgrade those denies to a hint (which still nudges) so the complete grep runs.
319
+ function countNamedPaths(cmd, patterns) {
320
+ if (!cmd || typeof cmd !== 'string') return 0;
321
+ const pats = new Set(patterns || []);
322
+ // Only the grep's OWN path args count. Stop at the first top-level command separator so a
323
+ // path in a compound tail (`grep X src/a.py | sed … src/b.py`) is NOT mistaken for a second
324
+ // grep target — that would wrongly downgrade a complete single-file grep to a hint.
325
+ let seg = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
326
+ let quote = null;
327
+ for (let i = 0; i < seg.length; i++) {
328
+ const c = seg[i];
329
+ if (quote) { if (c === quote) quote = null; continue; }
330
+ if (c === '"' || c === "'") { quote = c; continue; }
331
+ if (c === ';' || c === '|' || c === '&' || c === '>' || c === '<' || c === '\n') { seg = seg.slice(0, i); break; }
332
+ }
333
+ let n = 0;
334
+ for (const raw of seg.split(/\s+/)) {
335
+ const tok = raw.replace(/^["']|["']$/g, '');
336
+ if (!tok || tok.startsWith('-')) continue; // a flag
337
+ if (pats.has(tok)) continue; // the search pattern, not a path
338
+ if (tok.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(tok)) n++; // dir-sep or file extension
339
+ }
340
+ return n;
341
+ }
342
+
306
343
  // v0.47.0 — the pattern that justified the block: first identifier-like one.
307
344
  function pickBlockPattern(cmd) {
308
345
  return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
@@ -607,6 +644,7 @@ module.exports = {
607
644
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
608
645
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
609
646
  extractPatterns, // v0.32.1 — exposed for tests
647
+ countNamedPaths, // v0.70 — multi-path deny→hint downgrade
610
648
  extractSearchPath, // v0.47.0 — deny-with-answer
611
649
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
612
650
  resolveProjectRoot, // v0.48 — subdir-cwd dark fix
@@ -5,6 +5,7 @@ const {
5
5
  shouldHint,
6
6
  shouldBlock,
7
7
  classifyBlock,
8
+ countNamedPaths,
8
9
  extractDeclSymbols,
9
10
  translateBreToRg,
10
11
  buildShowDenyReason,
@@ -372,6 +373,45 @@ test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)
372
373
  assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
373
374
  });
374
375
 
376
+ // ── B (v0.70): deny only when the inline answer covers the FULL scope ──
377
+ // The deny scopes to ONE path (extractSearchPath = first src-prefixed token). A grep naming
378
+ // ≥2 file paths would get a first-path-only answer (the rest silently dropped) — an incomplete
379
+ // substitute that rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade those to HINT;
380
+ // single file / directory greps (which the answer fully covers) still deny.
381
+
382
+ test('B: ≥2 named files downgrade deny→hint (deny would drop all but the first)', () => {
383
+ const cmd = 'grep -n "CLAUDE_MEM_DIR" scripts/setup.sh hook-shared.mjs';
384
+ assert.equal(shouldHint(cmd), true); // still nudges
385
+ assert.equal(shouldBlock(cmd), false); // but does NOT deny (answer can't cover hook-shared.mjs)
386
+ assert.equal(classifyBlock(cmd), null);
387
+ });
388
+
389
+ test('B: two source files also downgrade (deny would cover only the first)', () => {
390
+ assert.equal(shouldBlock('grep -rn "set_hook" src/main.rs src/lib.rs'), false);
391
+ assert.equal(shouldHint('grep -rn "set_hook" src/main.rs src/lib.rs'), true);
392
+ });
393
+
394
+ test('B: single file still DENIES (inline answer fully covers it)', () => {
395
+ assert.deepEqual(classifyBlock('grep -n "handleMessage" src/server.mjs'), { mode: 'grep' });
396
+ });
397
+
398
+ test('B: single directory (recursive) still DENIES (cg grep covers the whole dir)', () => {
399
+ assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
400
+ });
401
+
402
+ test('B: --include on a single dir still DENIES (one path, fully scoped)', () => {
403
+ assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
404
+ });
405
+
406
+ test('countNamedPaths: counts paths, excludes flags and the quoted pattern', () => {
407
+ assert.equal(countNamedPaths('grep -n "Foo" src/a.rs src/b.rs', ['Foo']), 2);
408
+ assert.equal(countNamedPaths('grep -rn "Foo" src/', ['Foo']), 1);
409
+ // a path-shaped pattern is the pattern, not a second path token
410
+ assert.equal(countNamedPaths('grep "config.json" src/app.rs', ['config.json']), 1);
411
+ // a path in a compound tail (sed/pipe target) is NOT a 2nd grep target → stays 1 (deny)
412
+ assert.equal(countNamedPaths("grep -n \"Foo\" src/foo.rs | head; sed -n '1,5p' src/bar.rs", ['Foo']), 1);
413
+ });
414
+
375
415
  test('shouldBlock: -L / -v inverted intents → hint only', () => {
376
416
  assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
377
417
  assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.69.0",
3
+ "version": "0.70.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.69.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.69.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.69.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.69.0",
42
- "@sdsrs/code-graph-win32-x64": "0.69.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.70.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.70.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.70.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.70.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.70.0"
43
43
  }
44
44
  }