@sdsrs/code-graph 0.160.0 → 0.161.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.160.0",
7
+ "version": "0.161.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -347,10 +347,45 @@ function runGrepAnswer(opts = {}) {
347
347
  * `no-binary` distinguishes a missing/unlocatable binary from a runtime
348
348
  * `unavailable`, so the deny funnel can see a dark flagship answer-in-deny.
349
349
  */
350
+ /**
351
+ * The files of the definitions a `show` printed: each definition opens with an
352
+ * unindented header `<kind> <name> <path>:<start>-<end> <signature>`, its body
353
+ * indented below. null when an unindented line is not such a header, so a
354
+ * caller that must account for every definition can refuse.
355
+ */
356
+ function showDefinitionFiles(out) {
357
+ const defs = [];
358
+ for (const line of out.split('\n')) {
359
+ if (line === '' || /^\s/.test(line)) continue;
360
+ const m = /^(\S+) \S+ {2}(\S+):\d+-\d+(?: |$)/.exec(line);
361
+ if (!m) return null;
362
+ defs.push({ kind: m[1], file: m[2] });
363
+ }
364
+ return defs.length ? defs : null;
365
+ }
366
+
367
+ /** Whether a root-relative file lies under a grep path ('' = the whole root). */
368
+ function pathWithin(file, within) {
369
+ const w = within.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
370
+ return w === '' || w === '.' || file === w || file.startsWith(w + '/');
371
+ }
372
+
350
373
  function runShowAnswer(opts = {}) {
351
374
  const {
352
375
  cwd,
353
376
  symbols,
377
+ // A grep path the answer must stay inside (D#125 #2): `show` prints every
378
+ // same-named definition in the project. With `within`, a symbol is answered
379
+ // only when EVERY definition `show` prints lies inside the path and is one
380
+ // the grep's declaration keyword matches (`kinds`: symbol → the label and
381
+ // file-extension shapes it matches); a symbol with no definition inside is
382
+ // skipped (the grep finds none either), and any other mix refuses the whole
383
+ // answer. Narrowing with `show --file` was tried and removed: the label is
384
+ // coarser than the keyword, so a narrowed answer dropped real matches, and
385
+ // the inject printed the unnarrowed text (review of D#125, round 2).
386
+ // undefined: unscoped, as before.
387
+ within,
388
+ kinds,
354
389
  timeoutMs = DEFAULT_TIMEOUT_MS,
355
390
  maxBytes = DEFAULT_MAX_BYTES,
356
391
  } = opts;
@@ -365,6 +400,8 @@ function runShowAnswer(opts = {}) {
365
400
  // Which symbols actually resolved — the rewrite re-runs exactly these, so a
366
401
  // symbol that printed nothing here cannot turn into an exit-1 in the Bash call.
367
402
  const resolved = [];
403
+ // The argv that re-runs each resolved symbol, scoped when `within` is set.
404
+ const argvs = [];
368
405
  for (const sym of symbols.slice(0, 3)) {
369
406
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
370
407
  const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
@@ -383,12 +420,24 @@ function runShowAnswer(opts = {}) {
383
420
  if (classifyRun(res, { exitOneIsNoHits: false }) !== 'ok') continue;
384
421
  const out = (res.stdout || '').trim();
385
422
  if (isEmptyAnswer(out)) continue;
386
- parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
423
+ const argv = ['show', sym];
424
+ if (within !== undefined) {
425
+ const defs = showDefinitionFiles(out);
426
+ if (!defs) return { status: 'unavailable', reason: 'scope' };
427
+ if (!defs.some((d) => pathWithin(d.file, within))) continue;
428
+ const shapes = (kinds && kinds[sym]) || [];
429
+ const ext = (f) => (f.match(/\.([A-Za-z0-9]+)$/) || [])[1];
430
+ const matches = (d) => pathWithin(d.file, within)
431
+ && shapes.some((k) => k.labels.includes(d.kind) && k.exts.includes(ext(d.file)));
432
+ if (!defs.every(matches)) return { status: 'unavailable', reason: 'scope' };
433
+ }
434
+ parts.push(`$ code-graph-mcp ${argv.join(' ')}\n${out}`);
387
435
  resolved.push(sym);
436
+ argvs.push(argv);
388
437
  }
389
438
  if (parts.length === 0) return { status: 'no-hits' };
390
439
  const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
391
- return { status: 'hits', text, truncated, symbols: resolved };
440
+ return { status: 'hits', text, truncated, symbols: resolved, argvs };
392
441
  } catch {
393
442
  return { status: 'unavailable' };
394
443
  }
@@ -52,6 +52,11 @@ const {
52
52
  operandMatches,
53
53
  firstShellClause,
54
54
  normalizeCommandPaths,
55
+ rootOnlyInSearchPath,
56
+ grepSearchesNoDirectory,
57
+ isDirectory,
58
+ declKindsBySymbol,
59
+ extractPatterns,
55
60
  rebaseRelativePaths,
56
61
  resolveProjectRoot,
57
62
  } = require('./pre-grep-guide');
@@ -288,6 +293,11 @@ function runMain() {
288
293
  if (segCwd === null) return;
289
294
  if ((segCwd !== root || relPrefix)
290
295
  && !operandMatches(firstShellClause(rawSegs[idx]), extractSearchPath(segment), root, segCwd)) return;
296
+ // D#125 #1 — the root strip rewrote a pattern that held the root.
297
+ if (!rootOnlyInSearchPath(rawSegs[idx], root, extractSearchPath(segment))) return;
298
+ // D#125 #3 — a plain grep without -r searched no directory.
299
+ if (grepSearchesNoDirectory(firstShellClause(segment), extractSearchPath(segment),
300
+ (t) => isDirectory(path.resolve(root, t)))) return;
291
301
  // Run the answer exactly like the deny path.
292
302
  const rawPattern = pickBlockPattern(segment);
293
303
  // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
@@ -382,7 +392,14 @@ function runMain() {
382
392
  if (answer.status !== 'hits') {
383
393
  if (block.mode === 'show') {
384
394
  answeredMode = 'show';
385
- answer = runShowAnswer({ cwd: root, symbols: block.symbols });
395
+ // Scoped like the rewrite: the grep's path and declaration kinds; a grep
396
+ // with a file filter gets the grep answer, which honors it.
397
+ answer = flags.includes('-g') || flags.includes('-t')
398
+ ? { status: 'unavailable' }
399
+ : runShowAnswer({
400
+ cwd: root, symbols: block.symbols, within: searchPath ?? '',
401
+ kinds: declKindsBySymbol(extractPatterns(segment)),
402
+ });
386
403
  if (answer.status !== 'hits' && pattern) {
387
404
  answeredMode = 'grep';
388
405
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
@@ -376,6 +376,38 @@ function clauseWords(s) {
376
376
  // name the exact symbols the model wants to READ — extract them for `show`.
377
377
  const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
378
378
 
379
+ // What a `show` definition must be for the grep's declaration keyword to match
380
+ // its line: the kind label `show` prints (src/cli/symbols.rs
381
+ // format_node_compact) AND a file of a language that spells the declaration
382
+ // with that keyword. The label alone is coarser than the keyword — `fn` is also
383
+ // a Python `def` and a TS method, and a Swift `struct` is labelled `class`
384
+ // (review of D#125, round 2). `impl` names no definition of its own.
385
+ const DECL_KEYWORD_SHAPES = {
386
+ fn: { labels: ['fn'], exts: ['rs'] },
387
+ def: { labels: ['fn'], exts: ['py', 'rb'] },
388
+ function: { labels: ['fn'], exts: ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'php', 'sh', 'bash'] },
389
+ class: {
390
+ labels: ['class'],
391
+ exts: ['py', 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'java', 'kt', 'cs', 'php', 'rb', 'dart',
392
+ 'cpp', 'cc', 'hpp', 'h', 'scala'],
393
+ },
394
+ struct: { labels: ['struct'], exts: ['rs', 'go', 'c', 'h', 'cpp', 'cc', 'hpp', 'cs'] },
395
+ trait: { labels: ['trait', 'iface'], exts: ['rs', 'php', 'scala'] },
396
+ impl: { labels: [], exts: [] },
397
+ };
398
+
399
+ // Symbol → the (label, extension) shapes its declaration keywords match
400
+ // (`fn foo\|def foo` → foo: Rust fn, Python/Ruby def).
401
+ function declKindsBySymbol(patterns) {
402
+ const out = {};
403
+ for (const p of patterns) {
404
+ for (const m of p.matchAll(/\b(fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
405
+ (out[m[2]] = out[m[2]] || []).push(DECL_KEYWORD_SHAPES[m[1]]);
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
379
411
  function extractDeclSymbols(patterns) {
380
412
  const out = [];
381
413
  for (const p of patterns) {
@@ -656,6 +688,30 @@ function normalizeCommandPaths(cmd, cwd) {
656
688
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
657
689
  }
658
690
 
691
+ // D#125 #1 — the strip above cannot tell a path operand from a PATTERN, and
692
+ // strips both: `grep -rn "<root>/Foo" src/` searches the literal text
693
+ // `<root>/Foo`, but was answered for `Foo`. Re-picking the pattern with the
694
+ // root marked was tried first and missed shapes where the mark changes which
695
+ // word is picked (`"<root>/def foo"`, `"abc_<root>/def"`: review of D#125). The
696
+ // narrow form: every shell word that holds `<root>/` must BE the search path
697
+ // the answer scopes to; anything else runs as typed.
698
+ function rootOnlyInSearchPath(cmd, cwd, target) {
699
+ if (!cmd || typeof cmd !== 'string') return true;
700
+ if (!cwd || typeof cwd !== 'string' || cwd === '/') return true;
701
+ const prefix = cwd.endsWith('/') ? cwd : cwd + '/';
702
+ if (!cmd.includes(prefix)) return true;
703
+ // Round 2: counting words was not enough — a lone root-holding PATTERN whose
704
+ // stripped text equals the path (`grep -rn "<root>/src/x" src/x`) passed. The
705
+ // rewrite grammar names which word is the path operand: accept only a command
706
+ // it reads, with the root once, in that operand, and nowhere in the pattern.
707
+ if (cmd.split(prefix).length !== 2) return false;
708
+ const plan = rewritePlan(firstShellClause(cmd), { isDir: () => true });
709
+ if (!plan || plan.target === undefined || plan.pattern.includes(prefix)) return false;
710
+ if (!plan.target.startsWith(prefix)) return false;
711
+ const norm = (p) => p.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
712
+ return target !== undefined && norm(plan.target.slice(prefix.length)) === norm(target);
713
+ }
714
+
659
715
  // v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
660
716
  // hook shares it. Re-exported below for test/back-compat.
661
717
  const { resolveProjectRoot } = require('./project-root');
@@ -1245,6 +1301,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1245
1301
  let context = false;
1246
1302
  let endOfFlags = false;
1247
1303
  let include = false;
1304
+ let recursive = false;
1248
1305
  for (; i < words.length; i++) {
1249
1306
  const w = words[i];
1250
1307
  if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
@@ -1267,6 +1324,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1267
1324
  const name = w.text.slice(2, eq === -1 ? undefined : eq);
1268
1325
  if (!ALLOWED_LONG[verb].has(name)) return null;
1269
1326
  if (name === 'include') include = true;
1327
+ if (name === 'recursive') recursive = true;
1270
1328
  if (VALUE_LONG.has(name) && eq === -1) {
1271
1329
  const v = words[++i];
1272
1330
  if (!valueWordOk(v, false) || (name === 'include' && /^!|\{/.test(v.text))) return null;
@@ -1290,6 +1348,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1290
1348
  }
1291
1349
  if (!allowed.includes(ch)) return null;
1292
1350
  if (ch === 'i' || ch === 's') caseFlag = true;
1351
+ if (ch === 'r' || ch === 'R') recursive = true;
1293
1352
  }
1294
1353
  }
1295
1354
  if (operands.length === 0 || operands.length > 2) return null;
@@ -1307,12 +1366,38 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1307
1366
  // `grep --include='*.py' X src/a.rs` finds nothing; rg — and so cg — searches
1308
1367
  // a named file whatever its -g says (D#78). Equivalent only for a directory.
1309
1368
  if (include && target && !isDir(target.text)) return null;
1369
+ // Plain grep without -r reads no directory ("Is a directory") and, with no
1370
+ // path, reads stdin; the answer would search the tree (D#125 #3). rg and ag
1371
+ // recurse by default, and git grep searches the tracked tree.
1372
+ if (verb === 'grep' && !recursive && (!target || isDir(target.text))) return null;
1310
1373
  // ag is smart-case by default: an all-lowercase pattern matches any case,
1311
1374
  // and cg's search is case-sensitive, so the answer would find less.
1312
1375
  if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
1313
1376
  return { pattern: pattern.text, target: target ? target.text : undefined, context };
1314
1377
  }
1315
1378
 
1379
+ // D#125 #3 for the PostToolUse side, which has no rewrite grammar: a plain
1380
+ // `grep` with no recursion flag searches no directory ("Is a directory" under
1381
+ // GNU grep) and, with no path, reads its input.
1382
+ function grepSearchesNoDirectory(clause, target, isDir) {
1383
+ const words = shellWords(clause || '');
1384
+ if (!words || words.length === 0 || words[0].anyQuoted || words[0].text !== 'grep') return false;
1385
+ for (let k = 1; k < words.length; k++) {
1386
+ const w = words[k];
1387
+ if (w.op || w.startsQuoted) continue;
1388
+ if (w.text === '--') break;
1389
+ if (/^--(?:recursive|dereference-recursive|directories=recurse)$/.test(w.text)) return false;
1390
+ if (w.text === '-d' && words[k + 1] && words[k + 1].text === 'recurse') return false;
1391
+ if (/^-[^-]/.test(w.text)) {
1392
+ for (const ch of w.text.slice(1)) {
1393
+ if (ch === 'r' || ch === 'R') return false;
1394
+ if ('ABCefmdDg'.includes(ch)) break; // the rest is that flag's value
1395
+ }
1396
+ }
1397
+ }
1398
+ return target === undefined || isDir(target);
1399
+ }
1400
+
1316
1401
  // Does `rootTarget`, run from the root, name the path the grep clause's own
1317
1402
  // operand names from `cwd` — the directory the clause ran in? The raw clause is
1318
1403
  // parsed with the rewrite grammar; an absolute operand resolves the same from
@@ -1401,6 +1486,10 @@ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
1401
1486
  if (block.mode === 'show') {
1402
1487
  const f = cgFlagSet(extractCgFlags(cmd));
1403
1488
  if (f.has('-l') || f.has('-c')) return false;
1489
+ // `show` has no file filter: `--include`/`-g`/`-t` excluded definitions it
1490
+ // would print (review of D#125).
1491
+ const all = extractCgFlags(cmd);
1492
+ if (all.includes('-g') || all.includes('-t')) return false;
1404
1493
  // show answers at most three symbols; a fourth would silently vanish.
1405
1494
  if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
1406
1495
  }
@@ -1456,18 +1545,37 @@ const BRE_SWAPPED = '(){}+?|';
1456
1545
  const BRE_SAME_ESCAPE = 'wWsSbB<>.*[]^$\\/';
1457
1546
  const POSIX_CLASSES = new Set(['alpha', 'digit', 'alnum', 'upper', 'lower', 'space',
1458
1547
  'punct', 'xdigit', 'blank', 'cntrl', 'graph', 'print']);
1548
+ // An unescaped `^` is an anchor only where an alternative starts (the pattern's
1549
+ // start, after `\(` or `\|`), and `$` only where one ends (the pattern's end,
1550
+ // before `\)` or `\|`); elsewhere BRE reads them as literals and rust regex as
1551
+ // anchors — `getUser\|$user_id` searched the text `$user_id` (D#125 #6). No
1552
+ // translation: the grep runs.
1459
1553
  function breToRustRegex(pattern) {
1460
1554
  let out = '';
1555
+ let altStart = true;
1556
+ let anchor = false;
1461
1557
  for (let i = 0; i < pattern.length; i++) {
1462
1558
  const c = pattern[i];
1559
+ const wasAltStart = altStart;
1560
+ const wasAnchor = anchor;
1561
+ altStart = false;
1562
+ anchor = c === '^' && wasAltStart;
1463
1563
  if (c === '\\' && i + 1 < pattern.length) {
1464
1564
  const n = pattern[++i];
1465
1565
  if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
1466
1566
  if (BRE_SWAPPED.includes(n)) out += n;
1467
1567
  else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
1468
1568
  else return null;
1569
+ altStart = n === '(' || n === '|';
1469
1570
  continue;
1470
1571
  }
1572
+ if (c === '^' && !wasAltStart) return null;
1573
+ // A `*` where an alternative starts, or right after its `^`, repeats
1574
+ // nothing: BRE reads it as a literal, rust regex as an error or a repeat of
1575
+ // the anchor (review of D#125).
1576
+ if (c === '*' && (wasAltStart || (wasAnchor && out.endsWith('^')))) return null;
1577
+ if (c === '$' && i + 1 < pattern.length
1578
+ && !pattern.startsWith('\\)', i + 1) && !pattern.startsWith('\\|', i + 1)) return null;
1471
1579
  if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
1472
1580
  if (c !== '[') { out += c; continue; }
1473
1581
  // `]` right after `[` or `[^` is a member, not the end.
@@ -1650,6 +1758,7 @@ function runMain() {
1650
1758
  // forwarding `-F` without this guard would make it search the wrong text.
1651
1759
  const cgFlags = extractCgFlags(cmd);
1652
1760
  const rawGrepPattern = pickBlockPattern(cmd);
1761
+ if (!rootOnlyInSearchPath(rawCmd, root, extractSearchPath(cmd))) return;
1653
1762
  const grepPattern = cgFlagSet(cgFlags).has('-F')
1654
1763
  ? rawGrepPattern
1655
1764
  : translateBreToRg(cmd, rawGrepPattern);
@@ -1728,7 +1837,12 @@ function runMain() {
1728
1837
  // No fallback to a grep answer: a context grep (`-A5`) asked for the
1729
1838
  // body, and the grep rewrite would drop the context silently (pre-ship
1730
1839
  // review round 2). A show miss lets the raw grep run.
1731
- answer = runShowAnswer({ cwd: root, symbols: block.symbols });
1840
+ // Scoped to the grep's path: `show` alone answers the whole project
1841
+ // (D#125 #2 — `grep -A3 "fn f" lib/` was answered from src/).
1842
+ answer = runShowAnswer({
1843
+ cwd: root, symbols: block.symbols, within: searchPath ?? '',
1844
+ kinds: declKindsBySymbol(extractPatterns(cmd)),
1845
+ });
1732
1846
  } else if (pattern) {
1733
1847
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
1734
1848
  }
@@ -1800,7 +1914,7 @@ function runMain() {
1800
1914
  // stderr; the grep being replaced has no cap (round 3 M4). Only here, not
1801
1915
  // in the in-hook answer, which is a has-hits probe.
1802
1916
  const argvList = answeredMode === 'show'
1803
- ? answer.symbols.map((sym) => ['show', sym])
1917
+ ? answer.argvs
1804
1918
  : [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
1805
1919
  const command = buildRewriteCommand(argvList, {
1806
1920
  invocation: shellQuoteArg(resolveAnswerBinary({})),
@@ -1861,6 +1975,10 @@ module.exports = {
1861
1975
  isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
1862
1976
  extractSearchPath, // v0.47.0 — deny-with-answer
1863
1977
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
1978
+ rootOnlyInSearchPath, // D#125 #1
1979
+ isDirectory,
1980
+ grepSearchesNoDirectory, // D#125 #3
1981
+ declKindsBySymbol, // D#125 #2 review
1864
1982
  resolveProjectRoot, // v0.48 — subdir-cwd dark fix
1865
1983
  rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
1866
1984
  commandHasBypass, // v0.48 — bypass funnel visibility
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.160.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.161.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.160.0",
3
+ "version": "0.161.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.160.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.160.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.160.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.160.0",
42
- "@sdsrs/code-graph-win32-x64": "0.160.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.161.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.161.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.161.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.161.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.161.0"
43
43
  }
44
44
  }