@sdsrs/code-graph 0.69.0 → 0.71.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.71.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -48,7 +48,15 @@ const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answe
48
48
  // v0.48: also match bare `KEY=VALUE grep` prefixes (no `env` verb) — the shape
49
49
  // the deny message itself teaches (`CODE_GRAPH_NO_BLOCK_GREP=1 grep …`). With
50
50
  // the old `env`-only form those commands failed gate 1 and were invisible.
51
- const GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(grep|rg|ag)\b/;
51
+ // v0.71: `git grep` shares the verb set — its head is `git`, so it leaked past
52
+ // the matcher until folded in here. cg grep is a SUPERSET (covers tracked AND
53
+ // gitignored files), so routing `git grep` to it is sound. GREP_VERB is the
54
+ // single source of truth for every parse site that recognizes the search verb.
55
+ const GREP_VERB = 'git\\s+grep|grep|rg|ag';
56
+ const GREP_HEAD = new RegExp(`^\\s*(?:env\\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(${GREP_VERB})\\b`);
57
+ // Verb + prefix strip (kept in sync with GREP_HEAD via GREP_VERB; non-capturing).
58
+ // Shared by extractPatterns and countNamedPaths so the verb is removed identically.
59
+ const VERB_STRIP = new RegExp(`^\\s*(?:env\\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:${GREP_VERB})\\s+`);
52
60
  // Source-tree prefix list. Expanded v0.27+ Phase C: original `src/tests/lib/...`
53
61
  // missed real-world backend conventions where the prefix list term is preceded
54
62
  // by something else (`backend/app/...` — `app/` doesn't match because `/` isn't
@@ -63,7 +71,7 @@ const SRC_PREFIXES =
63
71
  const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
64
72
  // Anchored variant for whole-token matching in extractSearchPath.
65
73
  const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
66
- const PIPE_INTO_GREP = /\|\s*(?:grep|rg|ag)\b/;
74
+ const PIPE_INTO_GREP = new RegExp(`\\|\\s*(?:${GREP_VERB})\\b`);
67
75
  const CG_INVOKED = /\bcode-graph-mcp\b/;
68
76
  // File argument(s) that end in a config/lockfile/data extension. If, after removing
69
77
  // ALL of them, no source-tree path remains, the grep is searching config/data not code.
@@ -80,12 +88,41 @@ const CONFIG_TARGET_ONLY = new RegExp(`(?:^|\\s)[^\\s|<>]*\\.(?:${NON_SOURCE_EXT
80
88
  // data-file tokens both match; global so every one is peeled before the SRC_PATH re-check.
81
89
  const CONFIG_TARGET_STRIP = new RegExp(`(?:^|\\s)[^\\s|<>]*\\.(?:${NON_SOURCE_EXTS})(?=\\s|$)`, 'gi');
82
90
 
91
+ // v0.71 — `git grep --cached`/`--staged` searches the STAGED index, and a treeish
92
+ // ref (`git grep "X" HEAD~3 -- src/`, `git grep "X" main -- src/`) searches another
93
+ // commit/branch — a scope the working-tree inline answer (`code-graph-mcp grep`)
94
+ // CANNOT honor. Folding them would substitute current-tree hits for a different
95
+ // revision with no signal. These are NOT the working-tree source searches this hook
96
+ // folds, so it stays out entirely (no hint, no deny) and the real git grep runs.
97
+ // (`--no-index` is working-tree scope → cg covers it → NOT excluded; plain grep/rg/ag
98
+ // have no revision concept.) A bare treeish without `--` (`git grep X main src/`) is
99
+ // genuinely ambiguous with a pathspec → left as the residual minority.
100
+ const GIT_GREP_HEAD = /^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*git\s+grep\b/;
101
+ const GIT_GREP_STAGED = /(?:^|\s)--(?:cached|staged)(?:\s|$)/;
102
+
103
+ function isRevisionScopedGitGrep(cmd) {
104
+ if (typeof cmd !== 'string' || !GIT_GREP_HEAD.test(cmd)) return false;
105
+ if (GIT_GREP_STAGED.test(cmd)) return true;
106
+ // treeish before the `--` pathspec separator: git grep [flags] PATTERN <ref>... -- <path>
107
+ const sep = cmd.indexOf(' -- ');
108
+ if (sep === -1) return false;
109
+ const afterVerb = cmd.slice(0, sep).replace(GIT_GREP_HEAD, '').trimStart();
110
+ let seenPattern = false;
111
+ for (const tok of afterVerb.split(/\s+/)) {
112
+ if (!tok || tok.startsWith('-')) continue; // a flag
113
+ if (!seenPattern) { seenPattern = true; continue; } // the search pattern
114
+ return true; // a 2nd non-flag token before `--` = treeish
115
+ }
116
+ return false;
117
+ }
118
+
83
119
  function shouldHint(cmd) {
84
120
  if (!cmd || typeof cmd !== 'string') return false;
85
121
  if (cmd.length > 1000) return false; // sanity — oversize commands are noise
86
122
  if (CG_INVOKED.test(cmd)) return false; // already using cg
87
123
  if (PIPE_INTO_GREP.test(cmd)) return false; // `cargo test | grep FAILED` is output filter
88
124
  if (!GREP_HEAD.test(cmd)) return false; // not a search command
125
+ if (isRevisionScopedGitGrep(cmd)) return false; // v0.71 — git grep --cached/treeish: scope cg can't honor
89
126
  if (!SRC_PATH.test(cmd)) return false; // not against indexed source tree
90
127
  // If a config file appears AND no source path remains after stripping it, skip.
91
128
  if (CONFIG_TARGET_ONLY.test(cmd)) {
@@ -128,7 +165,7 @@ const MARKER_ONLY =
128
165
  function extractPatterns(cmd) {
129
166
  if (!cmd || typeof cmd !== 'string') return [];
130
167
  // Strip leading verb + env/assignment prefix (kept in sync with GREP_HEAD)
131
- const stripped = cmd.replace(/^\s*(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:grep|rg|ag)\s+/, '');
168
+ const stripped = cmd.replace(VERB_STRIP, '');
132
169
  // Collect every quoted argument — first one is the pattern in standard grep
133
170
  // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
134
171
  // expressions and worth screening too.
@@ -166,6 +203,12 @@ function classifyBlock(cmd) {
166
203
  if (symbols.length === 0) return null; // context read without named decls
167
204
  return { mode: 'show', symbols: symbols.slice(0, 3) };
168
205
  }
206
+ // v0.70 — only DENY when the inline grep answer can cover the SAME scope. It scopes to one
207
+ // path (extractSearchPath = first src-prefixed token), so a grep naming ≥2 file paths gets a
208
+ // first-path-only answer (the rest silently dropped) — an incomplete substitute that
209
+ // rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade to hint: the model's complete
210
+ // grep runs and the hint still nudges. (show mode above is symbol-scoped, not path → unaffected.)
211
+ if (countNamedPaths(cmd, patterns) >= 2) return null;
169
212
  return { mode: 'grep' };
170
213
  }
171
214
 
@@ -303,6 +346,37 @@ function extractSearchPath(cmd) {
303
346
  return undefined;
304
347
  }
305
348
 
349
+ // v0.70 — count the explicit file/dir path arguments a grep names (excluding flags and
350
+ // the quoted search pattern). The deny's inline answer scopes to ONE path
351
+ // (extractSearchPath returns only the first source-prefixed token), so a grep naming ≥2
352
+ // paths gets an answer covering only the first — an incomplete substitute that rationally
353
+ // drives CODE_GRAPH_NO_BLOCK_GREP bypass (2026-06-23: the dominant observed bypass was a
354
+ // multi-file named grep whose deny silently dropped the other files). classifyBlock uses
355
+ // this to downgrade those denies to a hint (which still nudges) so the complete grep runs.
356
+ function countNamedPaths(cmd, patterns) {
357
+ if (!cmd || typeof cmd !== 'string') return 0;
358
+ const pats = new Set(patterns || []);
359
+ // Only the grep's OWN path args count. Stop at the first top-level command separator so a
360
+ // path in a compound tail (`grep X src/a.py | sed … src/b.py`) is NOT mistaken for a second
361
+ // grep target — that would wrongly downgrade a complete single-file grep to a hint.
362
+ let seg = cmd.replace(VERB_STRIP, '');
363
+ let quote = null;
364
+ for (let i = 0; i < seg.length; i++) {
365
+ const c = seg[i];
366
+ if (quote) { if (c === quote) quote = null; continue; }
367
+ if (c === '"' || c === "'") { quote = c; continue; }
368
+ if (c === ';' || c === '|' || c === '&' || c === '>' || c === '<' || c === '\n') { seg = seg.slice(0, i); break; }
369
+ }
370
+ let n = 0;
371
+ for (const raw of seg.split(/\s+/)) {
372
+ const tok = raw.replace(/^["']|["']$/g, '');
373
+ if (!tok || tok.startsWith('-')) continue; // a flag
374
+ if (pats.has(tok)) continue; // the search pattern, not a path
375
+ if (tok.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(tok)) n++; // dir-sep or file extension
376
+ }
377
+ return n;
378
+ }
379
+
306
380
  // v0.47.0 — the pattern that justified the block: first identifier-like one.
307
381
  function pickBlockPattern(cmd) {
308
382
  return extractPatterns(cmd).find(p => IDENTIFIER_LIKE.test(p));
@@ -413,7 +487,8 @@ function buildShowDenyReason(answer, unansweredTail) {
413
487
  function translateBreToRg(cmd, pattern) {
414
488
  if (typeof pattern !== 'string' || !pattern) return pattern;
415
489
  const verb = (cmd.match(GREP_HEAD) || [])[1];
416
- if (verb !== 'grep') return pattern;
490
+ // git grep speaks BRE like plain grep; rg/ag are already extended-regex.
491
+ if (!verb || !/grep$/.test(verb)) return pattern;
417
492
  if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(cmd)) {
418
493
  return pattern;
419
494
  }
@@ -607,6 +682,8 @@ module.exports = {
607
682
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
608
683
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
609
684
  extractPatterns, // v0.32.1 — exposed for tests
685
+ countNamedPaths, // v0.70 — multi-path deny→hint downgrade
686
+ isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
610
687
  extractSearchPath, // v0.47.0 — deny-with-answer
611
688
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
612
689
  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,
@@ -62,6 +63,71 @@ test('shouldHint: env-prefixed grep on src/', () => {
62
63
  assert.equal(shouldHint('env LANG=C grep -rn "Foo" src/'), true);
63
64
  });
64
65
 
66
+ // ── git grep coverage (v0.71): `git grep` is raw BRE search on the tracked
67
+ // source tree — same foldable intent as `grep`, but its command HEAD is
68
+ // `git`, so it leaked past GREP_HEAD until v0.71. cg grep is a superset
69
+ // (tracked AND gitignored), so folding `git grep` into it is sound. The verb
70
+ // set is shared across GREP_HEAD / VERB_STRIP / PIPE_INTO_GREP — these lock
71
+ // each parse site that touches the verb.
72
+
73
+ test('git grep: shouldHint fires on `git grep` against src/', () => {
74
+ assert.equal(shouldHint('git grep -n "fts5_search" src/storage/'), true);
75
+ });
76
+
77
+ test('git grep: shouldHint fires with the `--` pathspec separator', () => {
78
+ assert.equal(shouldHint('git grep "FooBar" -- src/lib.rs'), true);
79
+ });
80
+
81
+ test('git grep: identifier search is a deny (block tier, same as grep)', () => {
82
+ assert.equal(shouldBlock('git grep "FooBar" src/'), true);
83
+ });
84
+
85
+ test('git grep: context flag + decl anchor → show mode', () => {
86
+ assert.deepEqual(
87
+ classifyBlock('git grep "fn handle_message" -A 5 src/'),
88
+ { mode: 'show', symbols: ['handle_message'] });
89
+ });
90
+
91
+ test('git grep: multi-file named search downgrades to hint (v0.70 parity)', () => {
92
+ // inline answer scopes to ONE path; ≥2 named files → hint so the full grep runs.
93
+ assert.equal(classifyBlock('git grep "FooBar" src/a.rs src/b.rs'), null);
94
+ });
95
+
96
+ test('git grep: BRE alternation is translated to rust-regex dialect', () => {
97
+ // git grep speaks BRE like plain grep → an escaped \| must unescape for cg grep.
98
+ assert.equal(translateBreToRg('git grep "a\\|b" src/', 'a\\|b'), 'a|b');
99
+ });
100
+
101
+ test('git grep: `| git grep` is an output-filter pipe (no fire)', () => {
102
+ assert.equal(shouldHint('grep -rn "Foo" src/ | git grep "Bar"'), false);
103
+ });
104
+
105
+ test('git grep: rebaseRelativePaths rebases the real subdir path, not the `grep` word', () => {
106
+ // shell sits in backend/; `app` is subdir-relative → rebased. `grep` is the
107
+ // git subcommand and is existence-gated so it never masquerades as a path.
108
+ const exists = (p) => p.endsWith('/root/backend/app');
109
+ const out = rebaseRelativePaths('git grep "Foo" app', 'backend', '/root', exists);
110
+ assert.match(out, /git grep "Foo" backend\/app/);
111
+ });
112
+
113
+ // v0.71 — git grep at a scope the working-tree cg answer can't honor (staged
114
+ // index / another revision) must NOT deny: folding it would substitute
115
+ // current-tree hits for a different revision. The hook stays out entirely.
116
+ test('git grep: --cached (staged index) is not denied — cg cannot honor that scope', () => {
117
+ assert.equal(shouldHint('git grep --cached "FooBar" src/'), false);
118
+ assert.equal(shouldBlock('git grep --cached "FooBar" src/'), false);
119
+ });
120
+
121
+ test('git grep: a treeish ref before `--` (another revision) is not denied', () => {
122
+ assert.equal(shouldHint('git grep "FooBar" HEAD~3 -- src/'), false);
123
+ assert.equal(shouldBlock('git grep "cascade_failure" main -- src/'), false);
124
+ });
125
+
126
+ test('git grep: a bare `-- path` (no ref, working-tree scope) STILL denies', () => {
127
+ // guard: the revision-scope exclusion must not over-catch a plain pathspec sep.
128
+ assert.equal(shouldBlock('git grep "FooBar" -- src/lib.rs'), true);
129
+ });
130
+
65
131
  // ── Should NOT fire: pipe-grep (output filter, not search) ──────────
66
132
 
67
133
  test('shouldHint: pipe-grep on cargo test output', () => {
@@ -372,6 +438,45 @@ test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)
372
438
  assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
373
439
  });
374
440
 
441
+ // ── B (v0.70): deny only when the inline answer covers the FULL scope ──
442
+ // The deny scopes to ONE path (extractSearchPath = first src-prefixed token). A grep naming
443
+ // ≥2 file paths would get a first-path-only answer (the rest silently dropped) — an incomplete
444
+ // substitute that rationally teaches CODE_GRAPH_NO_BLOCK_GREP bypass. Downgrade those to HINT;
445
+ // single file / directory greps (which the answer fully covers) still deny.
446
+
447
+ test('B: ≥2 named files downgrade deny→hint (deny would drop all but the first)', () => {
448
+ const cmd = 'grep -n "CLAUDE_MEM_DIR" scripts/setup.sh hook-shared.mjs';
449
+ assert.equal(shouldHint(cmd), true); // still nudges
450
+ assert.equal(shouldBlock(cmd), false); // but does NOT deny (answer can't cover hook-shared.mjs)
451
+ assert.equal(classifyBlock(cmd), null);
452
+ });
453
+
454
+ test('B: two source files also downgrade (deny would cover only the first)', () => {
455
+ assert.equal(shouldBlock('grep -rn "set_hook" src/main.rs src/lib.rs'), false);
456
+ assert.equal(shouldHint('grep -rn "set_hook" src/main.rs src/lib.rs'), true);
457
+ });
458
+
459
+ test('B: single file still DENIES (inline answer fully covers it)', () => {
460
+ assert.deepEqual(classifyBlock('grep -n "handleMessage" src/server.mjs'), { mode: 'grep' });
461
+ });
462
+
463
+ test('B: single directory (recursive) still DENIES (cg grep covers the whole dir)', () => {
464
+ assert.equal(shouldBlock('grep -rn "EmbeddingModel" src/'), true);
465
+ });
466
+
467
+ test('B: --include on a single dir still DENIES (one path, fully scoped)', () => {
468
+ assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
469
+ });
470
+
471
+ test('countNamedPaths: counts paths, excludes flags and the quoted pattern', () => {
472
+ assert.equal(countNamedPaths('grep -n "Foo" src/a.rs src/b.rs', ['Foo']), 2);
473
+ assert.equal(countNamedPaths('grep -rn "Foo" src/', ['Foo']), 1);
474
+ // a path-shaped pattern is the pattern, not a second path token
475
+ assert.equal(countNamedPaths('grep "config.json" src/app.rs', ['config.json']), 1);
476
+ // a path in a compound tail (sed/pipe target) is NOT a 2nd grep target → stays 1 (deny)
477
+ assert.equal(countNamedPaths("grep -n \"Foo\" src/foo.rs | head; sed -n '1,5p' src/bar.rs", ['Foo']), 1);
478
+ });
479
+
375
480
  test('shouldBlock: -L / -v inverted intents → hint only', () => {
376
481
  assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
377
482
  assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
@@ -945,6 +1050,22 @@ test('e2e: denied grep with stub hits → deny JSON embeds the answer + records
945
1050
  }
946
1051
  });
947
1052
 
1053
+ test('e2e: `git grep` identifier on src/ → deny with the embedded answer', () => {
1054
+ const uniq = `GitHit${Date.now()}`;
1055
+ const fixture = e2eFixture(
1056
+ `process.stdout.write('src/foo.rs:9 fn ' + process.argv[3] + '()\\n');`);
1057
+ const cmd = `git grep -n "${uniq}" src/`;
1058
+ try {
1059
+ const res = runHook(cmd, fixture);
1060
+ assert.equal(res.status, 0);
1061
+ const out = JSON.parse(res.stdout);
1062
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1063
+ assert.match(out.hookSpecificOutput.permissionDecisionReason, new RegExp(uniq));
1064
+ } finally {
1065
+ cleanupFixture(fixture, cmd);
1066
+ }
1067
+ });
1068
+
948
1069
  test('e2e: denied grep records the denied pattern (fingerprint for verbatim re-grep detection)', () => {
949
1070
  // The Rust funnel (aggregate_recommendations_jsonl) scores a follow-up search
950
1071
  // carrying the SAME pattern as the armed answered deny as fall-through, not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.69.0",
3
+ "version": "0.71.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.71.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.71.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.71.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.71.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.71.0"
43
43
  }
44
44
  }