@sdsrs/code-graph 0.159.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.159.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
  }
@@ -47,8 +47,16 @@ const {
47
47
  cgFlagSet,
48
48
  extractSearchPath,
49
49
  bareSourceTarget,
50
+ segmentCwd,
51
+ segmentSeparators,
52
+ operandMatches,
50
53
  firstShellClause,
51
54
  normalizeCommandPaths,
55
+ rootOnlyInSearchPath,
56
+ grepSearchesNoDirectory,
57
+ isDirectory,
58
+ declKindsBySymbol,
59
+ extractPatterns,
52
60
  rebaseRelativePaths,
53
61
  resolveProjectRoot,
54
62
  } = require('./pre-grep-guide');
@@ -273,6 +281,23 @@ function runMain() {
273
281
  // closes, so a bare dir is answered only when the grep is the FIRST segment.
274
282
  if (bareSourceTarget(firstShellClause(segment))
275
283
  && (relPrefix || splitTopLevelSegments(cmd)[0] !== segment)) return;
284
+ // D#76 — the answer searches `extractSearchPath(segment)` from the root. The
285
+ // segment ran where the shell was by then: its own cwd, moved by any `cd`
286
+ // before it. Segments are read from the RAW command (normalizing strips the
287
+ // root from `cd <root>/x` too), which splits into the same segments.
288
+ const segs = splitTopLevelSegments(cmd);
289
+ const rawSegs = splitTopLevelSegments(rawCmd);
290
+ const idx = segs.indexOf(segment);
291
+ if (rawSegs.length !== segs.length) return;
292
+ const segCwd = segmentCwd(rawSegs, idx, shellCwd, { seps: segmentSeparators(rawCmd) });
293
+ if (segCwd === null) return;
294
+ if ((segCwd !== root || relPrefix)
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;
276
301
  // Run the answer exactly like the deny path.
277
302
  const rawPattern = pickBlockPattern(segment);
278
303
  // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
@@ -367,7 +392,14 @@ function runMain() {
367
392
  if (answer.status !== 'hits') {
368
393
  if (block.mode === 'show') {
369
394
  answeredMode = 'show';
370
- 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
+ });
371
403
  if (answer.status !== 'hits' && pattern) {
372
404
  answeredMode = 'grep';
373
405
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
@@ -255,8 +255,18 @@ function extractPatterns(cmd) {
255
255
  const stripped = firstShellClause(cmd).replace(VERB_STRIP, '');
256
256
  // Collect every quoted argument — first one is the pattern in standard grep
257
257
  // usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
258
- // expressions and worth screening too.
259
- return quotedSpans(stripped).map(s => s.body).filter(Boolean);
258
+ // expressions and worth screening too. Whole words, as the shell passes them
259
+ // (D#63): `"Foo"'BarBaz'` is `FooBarBaz`, not two patterns; a flag's attached
260
+ // quoted value (`-e"Foo"`, `--include='*.rs'`) is the value. A clause the
261
+ // shell would reject, or whose quoted words it expands (`"$MAX"`, D#64), has
262
+ // no pattern we can read.
263
+ const words = clauseWords(stripped);
264
+ if (!words) return [];
265
+ const quoted = words.filter((w) => w.quotedFrom !== -1);
266
+ if (quoted.some((w) => w.expands)) return [];
267
+ return quoted
268
+ .map((w) => (!w.startsQuoted && w.text[0] === '-' ? w.text.slice(w.quotedFrom) : w.text))
269
+ .filter(Boolean);
260
270
  }
261
271
 
262
272
  /**
@@ -299,10 +309,105 @@ function quotedSpans(s) {
299
309
  return spans;
300
310
  }
301
311
 
312
+ /**
313
+ * The words of a shell clause, split and unquoted the way quotedSpans reads
314
+ * quotes: whitespace separates words only outside quotes, and adjacent quoted
315
+ * and bare parts join into one word (`-r"l"` is `-rl`, `"Foo"'Bar'` is
316
+ * `FooBar`). Per word:
317
+ * - `startsQuoted`: the first character was a quote — an argument, never a
318
+ * flag, so `"-l"` searches for the string `-l`;
319
+ * - `quotedFrom`: where in `text` the first quoted part begins (-1 if none),
320
+ * so `-e"Foo"` yields the value `Foo`;
321
+ * - `expands`: the shell substitutes something into it — `$NAME`, `${…}`,
322
+ * `$(…)`, a backtick, or `$'…'`/`$"…"` quoting — outside single quotes,
323
+ * so `text` is not what the command received.
324
+ * Returns null for an unterminated quote: the shell would not run it at all.
325
+ *
326
+ * A whitespace split read `-l` out of `grep -rn "FooBar -l x" src/` and the
327
+ * rewrite answered with a file list (D#62).
328
+ * @returns {{text: string, startsQuoted: boolean, quotedFrom: number, expands: boolean}[]|null}
329
+ */
330
+ function clauseWords(s) {
331
+ const words = [];
332
+ if (typeof s !== 'string') return words;
333
+ let cur = null;
334
+ // Inside double quotes `$"` is a literal `$` before the closing quote
335
+ // (`"FooBar$"`); bare, `$'…'` and `$"…"` are quoting forms that translate.
336
+ const expandsAt = (k, inDouble) => s[k] === '`'
337
+ || (s[k] === '$' && k + 1 < s.length
338
+ && (inDouble ? /[A-Za-z0-9_{(@*#?$!-]/ : /[A-Za-z0-9_{(@*#?$!'"-]/).test(s[k + 1]));
339
+ for (let i = 0; i < s.length; i++) {
340
+ const c = s[i];
341
+ if (c === ' ' || c === '\t' || c === '\n') {
342
+ if (cur) words.push(cur);
343
+ cur = null;
344
+ continue;
345
+ }
346
+ if (!cur) cur = { text: '', startsQuoted: c === '"' || c === "'", quotedFrom: -1, expands: false };
347
+ if (c === '\\') {
348
+ if (i + 1 < s.length && s[i + 1] !== '\n') cur.text += s[i + 1];
349
+ i++;
350
+ continue;
351
+ }
352
+ if (c !== '"' && c !== "'") {
353
+ if (expandsAt(i, false)) cur.expands = true;
354
+ cur.text += c;
355
+ continue;
356
+ }
357
+ if (cur.quotedFrom === -1) cur.quotedFrom = cur.text.length;
358
+ let j = i + 1;
359
+ for (; j < s.length && s[j] !== c; j++) {
360
+ if (c === '"' && s[j] === '\\' && j + 1 < s.length && '"\\$`\n'.includes(s[j + 1])) {
361
+ j++;
362
+ if (s[j] === '\n') continue;
363
+ } else if (c === '"' && expandsAt(j, true)) {
364
+ cur.expands = true;
365
+ }
366
+ cur.text += s[j];
367
+ }
368
+ if (j >= s.length) return null;
369
+ i = j;
370
+ }
371
+ if (cur) words.push(cur);
372
+ return words;
373
+ }
374
+
302
375
  // Declaration anchors inside a pattern (`def cascade_failure|class TaskState`)
303
376
  // name the exact symbols the model wants to READ — extract them for `show`.
304
377
  const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
305
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
+
306
411
  function extractDeclSymbols(patterns) {
307
412
  const out = [];
308
413
  for (const p of patterns) {
@@ -475,17 +580,21 @@ const CG_FLAG_ORDER = ['-i', '-w', '-F', '-l', '-c'];
475
580
  * file list was asked for (field report 2026-09-08).
476
581
  *
477
582
  * Scanning rules that matter: only the grep's OWN clause (a tail command's
478
- * `-l` is not this grep's), and a token that STARTS quoted is an argument, not
479
- * a flag — `grep "-l" src/` searches for the string `-l`.
583
+ * `-l` is not this grep's), words as the shell splits them (clauseWords — a
584
+ * quoted pattern holding ` -l ` is one word), and a word that STARTS quoted is
585
+ * an argument, not a flag — `grep "-l" src/` searches for the string `-l`.
480
586
  */
481
587
  function extractCgFlags(cmd) {
482
588
  if (!cmd || typeof cmd !== 'string') return [];
483
589
  const clause = firstShellClause(cmd);
484
590
  const isRg = RG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '');
485
- const toks = clause.replace(VERB_STRIP, '').trim().split(/\s+/);
591
+ const words = clauseWords(clause.replace(VERB_STRIP, ''));
592
+ if (!words) return [];
593
+ const toks = words.map((w) => (w.startsQuoted ? '' : w.text));
486
594
  const found = new Set();
487
595
  const filters = []; // [cgFlag, value] pairs, in the order the user wrote them
488
- const unquote = (s) => (s || '').replace(/^["']|["']$/g, '');
596
+ // A value is read from the word, whatever its quoting: `--include '*.rs'`.
597
+ const valueAt = (k) => (words[k] ? words[k].text : '');
489
598
  const addFilter = (flag, value) => {
490
599
  if (!value) return;
491
600
  // `-g '*.rs' -g '*.rs'` is one filter written twice; cg would honour both
@@ -494,12 +603,12 @@ function extractCgFlags(cmd) {
494
603
  };
495
604
  for (let i = 0; i < toks.length; i++) {
496
605
  const tok = toks[i];
497
- if (!tok || tok[0] === '"' || tok[0] === "'" || tok[0] !== '-') continue;
606
+ if (!tok || tok[0] !== '-') continue;
498
607
  if (tok.startsWith('--')) {
499
608
  const eq = tok.indexOf('=');
500
609
  const name = eq === -1 ? tok : tok.slice(0, eq);
501
610
  if (CG_VALUE_FLAGS[name]) {
502
- addFilter(CG_VALUE_FLAGS[name], unquote(eq === -1 ? toks[i + 1] : tok.slice(eq + 1)));
611
+ addFilter(CG_VALUE_FLAGS[name], eq === -1 ? valueAt(i + 1) : tok.slice(eq + 1));
503
612
  if (eq === -1) i++; // the value was a separate token — do not rescan it
504
613
  continue;
505
614
  }
@@ -515,8 +624,8 @@ function extractCgFlags(cmd) {
515
624
  const ch = letters[k];
516
625
  if (isRg && CG_VALUE_SHORT[ch]) {
517
626
  const attached = letters.slice(k + 1);
518
- addFilter(CG_VALUE_SHORT[ch], attached ? unquote(attached) : unquote(toks[i + 1]));
519
- // Consume the value token so the next pass cannot read it as a flag
627
+ addFilter(CG_VALUE_SHORT[ch], attached || valueAt(i + 1));
628
+ // Consume the value word so the next pass cannot read it as a flag
520
629
  // cluster: `rg -g -i 'sym' src/` must be `-g` with value `-i`, not `-g`
521
630
  // plus a phantom `-i` (round 2 of pre-ship review — the test that named
522
631
  // this property used a quoted value, which the quote guard already
@@ -579,6 +688,30 @@ function normalizeCommandPaths(cmd, cwd) {
579
688
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
580
689
  }
581
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
+
582
715
  // v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
583
716
  // hook shares it. Re-exported below for test/back-compat.
584
717
  const { resolveProjectRoot } = require('./project-root');
@@ -611,7 +744,10 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
611
744
  // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
612
745
  if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
613
746
  let verbSeen = false;
614
- return cmd.split(/(\s+)/).map((tok) => {
747
+ // Split on whitespace outside quotes only: a quoted pattern is one token and
748
+ // its inner words are never paths (D#63 — `"fn main utils here"` from src/
749
+ // became `"fn main src/utils here"`).
750
+ return splitKeepingQuotes(cmd).map((tok) => {
615
751
  if (!tok || /^\s+$/.test(tok)) return tok;
616
752
  if (!verbSeen) {
617
753
  if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
@@ -635,6 +771,36 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
635
771
  }).join('');
636
772
  }
637
773
 
774
+ // Words and the whitespace between them, text unchanged, splitting on
775
+ // whitespace outside quotes only (the quotedSpans rules).
776
+ function splitKeepingQuotes(s) {
777
+ const out = [];
778
+ let cur = '';
779
+ let quote = null;
780
+ for (let i = 0; i < s.length; i++) {
781
+ const c = s[i];
782
+ if (quote) {
783
+ cur += c;
784
+ if (quote === '"' && c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; }
785
+ if (c === quote) quote = null;
786
+ continue;
787
+ }
788
+ if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
789
+ if (c === '"' || c === "'") { quote = c; cur += c; continue; }
790
+ if (/\s/.test(c)) {
791
+ if (cur) out.push(cur);
792
+ cur = c;
793
+ while (i + 1 < s.length && /\s/.test(s[i + 1])) cur += s[++i];
794
+ out.push(cur);
795
+ cur = '';
796
+ continue;
797
+ }
798
+ cur += c;
799
+ }
800
+ if (cur) out.push(cur);
801
+ return out;
802
+ }
803
+
638
804
  // v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
639
805
  // the escape in v0.49, but models that already know it — or learned it from a
640
806
  // session summary — must stay visible to the funnel.)
@@ -763,22 +929,117 @@ function pickBlockPattern(cmd) {
763
929
  // inside single/double quotes are literal command text, never split points.
764
930
  // Returns trimmed, non-empty segments. Shared by post-grep-inject so the
765
931
  // PostToolUse path reuses this splitter instead of copying it.
766
- function splitTopLevelSegments(cmd) {
932
+ //
933
+ // A heredoc body is skipped (D#66): it is data on the reading command's stdin,
934
+ // not commands. Lexed as shell, a Python `\'` in a `<<'PY'` body flipped the
935
+ // quote parity for the rest of the command, and a `grep` line in a script body
936
+ // was folded as if the shell had run it. The `<<WORD` stays in its segment;
937
+ // the lines after that segment's newline, through the terminator line, go.
938
+ //
939
+ // Also read as the shell reads them, because each misreading drops or invents
940
+ // commands: a comment (`#` at the start of a word) runs to the end of its line;
941
+ // `<<<` is a here-string and `<<` inside `((…))` a shift, neither a heredoc;
942
+ // the heredoc delimiter is a whole word with its quoting removed (`E"O"F`,
943
+ // `$'EOF'`, `\EOF` all end at a line `EOF`).
944
+ function readHeredocDelim(cmd, i) {
945
+ let j = i;
946
+ let delim = '';
947
+ while (j < cmd.length && !/[\s;&|<>()]/.test(cmd[j])) {
948
+ const c = cmd[j];
949
+ if (c === '$' && (cmd[j + 1] === "'" || cmd[j + 1] === '"')) { j++; continue; }
950
+ if (c === "'" || c === '"') {
951
+ const close = cmd.indexOf(c, j + 1);
952
+ if (close === -1) return null;
953
+ delim += cmd.slice(j + 1, close);
954
+ j = close + 1;
955
+ continue;
956
+ }
957
+ if (c === '\\' && j + 1 < cmd.length) { delim += cmd[j + 1]; j += 2; continue; }
958
+ delim += c;
959
+ j++;
960
+ }
961
+ return delim ? { delim, end: j } : null;
962
+ }
963
+
964
+ // Stronger of two separators joining the same pair of segments (an empty
965
+ // segment between them collapses): anything conditional beats `;`/newline.
966
+ const SEP_RANK = { '': 0, ';': 1, '\n': 1, '&&': 2, '||': 2, ctrl: 3 };
967
+ function joinSep(a, b) {
968
+ if (a === undefined) return b;
969
+ if (SEP_RANK[a] === 2 && SEP_RANK[b] === 2 && a !== b) return 'ctrl';
970
+ return SEP_RANK[b] > SEP_RANK[a] ? b : a;
971
+ }
972
+
973
+ /**
974
+ * Top-level segments with the separator before each: '' (the first), ';',
975
+ * '\n', '&&', '||', or 'ctrl' (after a `do`/`then`/… control word, where
976
+ * whether the segment runs is not a matter of the previous one).
977
+ * @returns {{text: string, sep: string}[]}
978
+ */
979
+ function splitTopLevelSegmentsWithSeps(cmd) {
767
980
  if (!cmd || typeof cmd !== 'string') return [];
768
981
  const segs = [];
769
982
  let cur = '';
983
+ let sep = '';
770
984
  let quote = null;
985
+ let arith = 0; // depth of `((` … `))`
986
+ let heredocs = []; // [{delim, stripTabs}] started on the current line
987
+ // The last char appended to `cur`, kept apart: reading `cur` itself flattens
988
+ // the string `+=` built, once per `#` — a 300 KB `echo a#a#…` took 7.9 s in
989
+ // a 5 s hook (pre-release review #11).
990
+ let last = '';
991
+ const add = (s) => { cur += s; last = s[s.length - 1]; };
992
+ const cut = (next) => { segs.push({ text: cur, sep }); cur = ''; last = ''; sep = next; };
771
993
  for (let i = 0; i < cmd.length; i++) {
772
994
  const c = cmd[i];
995
+ if (!quote) {
996
+ if (c === '#' && (last === '' || /[\s|(]/.test(last))) {
997
+ const nl = cmd.indexOf('\n', i);
998
+ i = (nl === -1 ? cmd.length : nl) - 1;
999
+ continue;
1000
+ }
1001
+ if (c === '(' && cmd[i + 1] === '(') { arith++; add('(('); i++; continue; }
1002
+ if (c === ')' && cmd[i + 1] === ')' && arith > 0) { arith--; add('))'); i++; continue; }
1003
+ if (c === '<' && cmd[i + 1] === '<' && cmd[i + 2] === '<') { add('<<<'); i += 2; continue; }
1004
+ if (c === '<' && cmd[i + 1] === '<' && arith === 0) {
1005
+ let j = i + 2;
1006
+ const stripTabs = cmd[j] === '-';
1007
+ if (stripTabs) j++;
1008
+ while (cmd[j] === ' ' || cmd[j] === '\t') j++;
1009
+ const d = readHeredocDelim(cmd, j);
1010
+ if (d) {
1011
+ heredocs.push({ delim: d.delim, stripTabs });
1012
+ add(cmd.slice(i, d.end));
1013
+ i = d.end - 1;
1014
+ continue;
1015
+ }
1016
+ }
1017
+ if (c === '\n' && heredocs.length > 0) {
1018
+ cut('\n');
1019
+ let j = i + 1;
1020
+ for (const { delim, stripTabs } of heredocs) {
1021
+ while (j < cmd.length) {
1022
+ const nl = cmd.indexOf('\n', j);
1023
+ const end = nl === -1 ? cmd.length : nl;
1024
+ const line = cmd.slice(j, end);
1025
+ j = end + 1;
1026
+ if ((stripTabs ? line.replace(/^\t+/, '') : line) === delim) break;
1027
+ }
1028
+ }
1029
+ heredocs = [];
1030
+ i = j - 1;
1031
+ continue;
1032
+ }
1033
+ }
773
1034
  if (quote) {
774
- cur += c;
1035
+ add(c);
775
1036
  // Inside DOUBLE quotes a backslash escapes the next char, so `\"` does NOT
776
1037
  // close the quote (POSIX). Single quotes do no escaping — `\` is literal
777
1038
  // and `'` always closes — so this only applies to `"`. Without it,
778
1039
  // `echo "x\" && grep \"Y\" src/"` (one literal echo arg) mis-closes at
779
1040
  // `\"`, splits on `&&`, and yields a phantom foldable grep segment.
780
1041
  if (quote === '"' && c === '\\' && i + 1 < cmd.length) {
781
- cur += cmd[i + 1];
1042
+ add(cmd[i + 1]);
782
1043
  i++;
783
1044
  continue;
784
1045
  }
@@ -790,46 +1051,58 @@ function splitTopLevelSegments(cmd) {
790
1051
  // removes both. Keeping them left `&& \⏎ grep …` a segment that starts
791
1052
  // with `\`, which GREP_HEAD rejects (pre-ship review round 2 F1).
792
1053
  if (c === '\\' && i + 1 < cmd.length) {
793
- if (cmd[i + 1] !== '\n') cur += c + cmd[i + 1];
1054
+ if (cmd[i + 1] !== '\n') add(c + cmd[i + 1]);
794
1055
  i++;
795
1056
  continue;
796
1057
  }
797
- if (c === '"' || c === "'") { quote = c; cur += c; continue; }
1058
+ if (c === '"' || c === "'") { quote = c; add(c); continue; }
798
1059
  // `&&` and `||` (a single `&`/`|` is NOT a split — `|` is an output-filter
799
1060
  // pipe, lone `&` is background and rare in tool calls).
800
1061
  if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) {
801
- segs.push(cur); cur = ''; i++; continue;
1062
+ cut(c + c); i++; continue;
802
1063
  }
803
- if (c === ';' || c === '\n') { segs.push(cur); cur = ''; continue; }
804
- cur += c;
1064
+ if (c === ';' || c === '\n') { cut(c); continue; }
1065
+ add(c);
805
1066
  }
806
- segs.push(cur);
1067
+ cut(undefined);
807
1068
  // Split out `for … in` / `do` / `done` control words as their own boundaries
808
1069
  // so a loop body grep is isolated (the head of `for s in …; do grep …` is the
809
1070
  // `for` keyword, which would otherwise mask the grep). Quote-safety already
810
1071
  // handled above — these run per already-split segment on whitespace-delimited
811
- // control words only.
812
- const out = [];
813
- const CTRL = /(?:^|\s)(for\s+\S+\s+in\b|do\b|done\b|then\b|fi\b)(?=\s|$)/g;
814
- for (const raw of segs) {
1072
+ // control words only. A `for` header takes its word list with it: the list
1073
+ // is data, and left behind as a segment it read as a command that segmentCwd
1074
+ // cannot place (D#76).
1075
+ const pieces = [];
1076
+ const CTRL = /(?:^|\s)(for\s+\S+\s+in\b[\s\S]*$|do\b|done\b|then\b|fi\b)(?=\s|$)/g;
1077
+ for (const { text: raw, sep: rawSep } of segs) {
815
1078
  let last = 0;
816
1079
  let m;
1080
+ let pieceSep = rawSep;
817
1081
  CTRL.lastIndex = 0;
818
- let pushed = false;
819
1082
  while ((m = CTRL.exec(raw)) !== null) {
820
- const before = raw.slice(last, m.index);
821
- if (before.trim()) out.push(before);
1083
+ pieces.push({ text: raw.slice(last, m.index), sep: pieceSep });
822
1084
  last = CTRL.lastIndex;
823
- pushed = true;
824
- }
825
- if (pushed) {
826
- const tail = raw.slice(last);
827
- if (tail.trim()) out.push(tail);
828
- } else {
829
- out.push(raw);
1085
+ pieceSep = 'ctrl';
830
1086
  }
1087
+ pieces.push({ text: raw.slice(last), sep: pieceSep });
1088
+ }
1089
+ const out = [];
1090
+ let carried;
1091
+ for (const { text, sep: s } of pieces) {
1092
+ carried = joinSep(carried, s);
1093
+ if (!text.trim()) continue;
1094
+ out.push({ text: text.trim(), sep: out.length === 0 ? '' : carried });
1095
+ carried = undefined;
831
1096
  }
832
- return out.map(s => s.trim()).filter(Boolean);
1097
+ return out;
1098
+ }
1099
+
1100
+ function splitTopLevelSegments(cmd) {
1101
+ return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.text);
1102
+ }
1103
+
1104
+ function segmentSeparators(cmd) {
1105
+ return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.sep);
833
1106
  }
834
1107
 
835
1108
  // One implementation of the cooldown quartet, in tmp-dir.js (ARC-02/ARC-06);
@@ -937,6 +1210,9 @@ function buildRewriteContext(mode, cmdShown) {
937
1210
  // `-`, and anything the tokenizer does not know — newline, `;`, `&`, `\`, `$`,
938
1211
  // a backtick, parentheses, braces, `~`. Returns a plan or null.
939
1212
  const WORD_CHAR = /[A-Za-z0-9_.,:@%+=/*?<>&-]/;
1213
+ function isDirectory(p) {
1214
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
1215
+ }
940
1216
  function shellWords(s) {
941
1217
  const words = [];
942
1218
  let cur = null;
@@ -1000,10 +1276,15 @@ function valueWordOk(w, isCount) {
1000
1276
  return !(w.bareSpecial);
1001
1277
  }
1002
1278
 
1003
- function rewritePlan(cmd) {
1279
+ // `isDir(target)` answers whether the path operand is a directory. runMain asks
1280
+ // the filesystem, resolved against the root its command is relative to; the
1281
+ // default is the spelling alone (a trailing `/`, or a last segment with no
1282
+ // extension), which keeps the classifiers that call this pure.
1283
+ const looksLikeDir = (t) => t.endsWith('/') || !/\.[A-Za-z0-9]{1,6}$/.test(t);
1284
+ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1004
1285
  if (!cmd || typeof cmd !== 'string' || cmd.length > 1000) return null;
1005
1286
  const words = shellWords(cmd);
1006
- if (!words || words.some((w) => w.op)) return null;
1287
+ if (!words || words.length === 0 || words.some((w) => w.op)) return null;
1007
1288
  let i = 0;
1008
1289
  let verb = !words[0].anyQuoted ? words[0].text : '';
1009
1290
  if (verb === 'git') {
@@ -1019,6 +1300,8 @@ function rewritePlan(cmd) {
1019
1300
  let caseFlag = false;
1020
1301
  let context = false;
1021
1302
  let endOfFlags = false;
1303
+ let include = false;
1304
+ let recursive = false;
1022
1305
  for (; i < words.length; i++) {
1023
1306
  const w = words[i];
1024
1307
  if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
@@ -1031,17 +1314,20 @@ function rewritePlan(cmd) {
1031
1314
  // (`--include='*.rs'`, rg `-g'*.rs'`); an unquoted `<>&` is shell syntax.
1032
1315
  const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
1033
1316
  if (!attachedFilter || w.bareOp) return null;
1034
- // GNU grep's --include has no `!` negation; cg's -g does (round 4 L2).
1035
- if (/^--include=!/.test(w.text)) return null;
1317
+ // GNU grep's --include has no `!` negation and no `{a,b}` alternatives;
1318
+ // cg's -g has both (round 4 L2; review of D#78).
1319
+ if (/^--include=(?:!|.*\{)/.test(w.text)) return null;
1036
1320
  }
1037
1321
  if (w.text === '--') { endOfFlags = true; continue; }
1038
1322
  if (w.text.startsWith('--')) {
1039
1323
  const eq = w.text.indexOf('=');
1040
1324
  const name = w.text.slice(2, eq === -1 ? undefined : eq);
1041
1325
  if (!ALLOWED_LONG[verb].has(name)) return null;
1326
+ if (name === 'include') include = true;
1327
+ if (name === 'recursive') recursive = true;
1042
1328
  if (VALUE_LONG.has(name) && eq === -1) {
1043
1329
  const v = words[++i];
1044
- if (!valueWordOk(v, false) || (name === 'include' && v.text.startsWith('!'))) return null;
1330
+ if (!valueWordOk(v, false) || (name === 'include' && /^!|\{/.test(v.text))) return null;
1045
1331
  }
1046
1332
  if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
1047
1333
  continue;
@@ -1062,6 +1348,7 @@ function rewritePlan(cmd) {
1062
1348
  }
1063
1349
  if (!allowed.includes(ch)) return null;
1064
1350
  if (ch === 'i' || ch === 's') caseFlag = true;
1351
+ if (ch === 'r' || ch === 'R') recursive = true;
1065
1352
  }
1066
1353
  }
1067
1354
  if (operands.length === 0 || operands.length > 2) return null;
@@ -1075,12 +1362,111 @@ function rewritePlan(cmd) {
1075
1362
  // A `..` segment: extractSearchPath refuses to scope it, so the answer would
1076
1363
  // search the whole repo (round 4 M2).
1077
1364
  if (target && /(?:^|\/)\.\.(?:\/|$)/.test(target.text)) return null;
1365
+ // GNU grep applies --include to a file named on the command line too, so
1366
+ // `grep --include='*.py' X src/a.rs` finds nothing; rg — and so cg — searches
1367
+ // a named file whatever its -g says (D#78). Equivalent only for a directory.
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;
1078
1373
  // ag is smart-case by default: an all-lowercase pattern matches any case,
1079
1374
  // and cg's search is case-sensitive, so the answer would find less.
1080
1375
  if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
1081
1376
  return { pattern: pattern.text, target: target ? target.text : undefined, context };
1082
1377
  }
1083
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
+
1401
+ // Does `rootTarget`, run from the root, name the path the grep clause's own
1402
+ // operand names from `cwd` — the directory the clause ran in? The raw clause is
1403
+ // parsed with the rewrite grammar; an absolute operand resolves the same from
1404
+ // anywhere. A clause the grammar cannot read proves nothing.
1405
+ function operandMatches(rawClause, rootTarget, root, cwd) {
1406
+ const raw = rewritePlan(rawClause, { isDir: (t) => isDirectory(path.resolve(cwd, t)) });
1407
+ if (!raw || raw.target === undefined || rootTarget === undefined) return false;
1408
+ return path.resolve(cwd, raw.target) === path.resolve(root, rootTarget);
1409
+ }
1410
+
1411
+ // Commands that cannot move the shell: external programs run in a child, and
1412
+ // these builtins leave the cwd alone. An allowlist, because the commands that
1413
+ // DO move it are open-ended — `builtin cd`, `if cd`, `{ cd …; }`, `eval`,
1414
+ // `source`, `popd`, any function (D#73 round 3 reproduced 19 forms).
1415
+ const CWD_NEUTRAL = new Set([
1416
+ 'echo', 'printf', 'grep', 'rg', 'ag', 'git', 'sed', 'awk', 'cat', 'head', 'tail', 'wc',
1417
+ 'ls', 'find', 'sort', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'test', '[', 'true', 'false',
1418
+ ':', 'node', 'python', 'python3', 'cargo', 'npm', 'npx', 'jq', 'xargs', 'tee', 'stat',
1419
+ 'file', 'du', 'df', 'date', 'sleep', 'which', 'env', 'export', 'set', 'unset', 'nl',
1420
+ 'rm', 'mkdir', 'cp', 'mv', 'touch', 'chmod', 'ln', 'readlink', 'realpath', 'basename',
1421
+ 'dirname', 'perl', 'gh', 'curl', 'make', 'bash', 'sh', 'timeout', 'tar', 'sqlite3',
1422
+ 'code-graph-mcp', 'nproc', 'rustc', 'go', 'wait', 'column', 'md5sum', 'sha256sum',
1423
+ 'od', 'xxd', 'seq', 'paste', 'comm', 'uname', 'id', 'printenv',
1424
+ // If an `exit` before the grep had run, the grep would not have.
1425
+ 'exit', 'return',
1426
+ ]);
1427
+ // `X=1 Y=$(mktemp -d)`: a command substitution runs in a subshell.
1428
+ const PURE_ASSIGNMENTS =
1429
+ /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\([^()]*\)|"[^"]*"|'[^']*'|[^\s;|&<>()'"]*)\s*)+$/;
1430
+ const CD_LITERAL = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*cd\s+('[^']*'|"[^"$`\\]*"|[^\s'"$`\\~*?[{|;&<>()]+)\s*$/;
1431
+
1432
+ /**
1433
+ * The directory `segments[idx]` runs in, or null when the segments before it
1434
+ * could have moved the shell somewhere we cannot name (D#76). Follows
1435
+ * cwd-neutral commands and `cd` to one literal path that exists; a relative
1436
+ * `cd` under a set CDPATH is not literal. A `cd` is followed only when it is
1437
+ * certain to have run if the grep did: it is unconditional (its `seps` entry,
1438
+ * from segmentSeparators, is '', `;` or a newline) or every separator from it
1439
+ * to the grep is `&&`, the one before it included: `true || cd x && grep` is
1440
+ * `(true || cd x) && grep`, which runs the grep without the cd. `false && cd x;
1441
+ * grep` and `cd x | cat` did not move the shell. Without `seps` every segment
1442
+ * counts as unconditional.
1443
+ */
1444
+ function segmentCwd(segments, idx, shellCwd, { isDir = isDirectory, seps } = {}) {
1445
+ let cwd = shellCwd;
1446
+ const ran = (k) => !seps || SEP_RANK[seps[k]] <= 1
1447
+ || seps.slice(k, idx + 1).every((x) => x === '&&');
1448
+ for (let k = 0; k < idx; k++) {
1449
+ const seg = segments[k];
1450
+ if (/^\s*#/.test(seg) || PURE_ASSIGNMENTS.test(seg)) continue;
1451
+ const clause = firstShellClause(seg);
1452
+ const cd = CD_LITERAL.exec(seg);
1453
+ if (cd) {
1454
+ if (!ran(k)) return null;
1455
+ const arg = /^['"]/.test(cd[1]) ? cd[1].slice(1, -1) : cd[1];
1456
+ if (!arg || arg === '-' || (!path.isAbsolute(arg) && process.env.CDPATH)) return null;
1457
+ const next = path.resolve(cwd, arg);
1458
+ if (!isDir(next)) return null; // `cd x; grep` runs the grep where it was
1459
+ try { cwd = fs.realpathSync(next); } catch { cwd = next; }
1460
+ continue;
1461
+ }
1462
+ const words = clauseWords(clause);
1463
+ if (!words) return null;
1464
+ const head = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
1465
+ if (head && !(head.quotedFrom === -1 && CWD_NEUTRAL.has(head.text))) return null;
1466
+ }
1467
+ return cwd;
1468
+ }
1469
+
1084
1470
  // The plan must describe the same search classifyBlock decided on. The pattern
1085
1471
  // the answer ran is picked from quoted spans (pickBlockPattern); the grammar's
1086
1472
  // pattern operand is the one the shell passes — `"is"'Foo'` or an unquoted
@@ -1100,6 +1486,10 @@ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
1100
1486
  if (block.mode === 'show') {
1101
1487
  const f = cgFlagSet(extractCgFlags(cmd));
1102
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;
1103
1493
  // show answers at most three symbols; a fourth would silently vanish.
1104
1494
  if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
1105
1495
  }
@@ -1124,11 +1514,89 @@ function translateBreToRg(cmd, pattern) {
1124
1514
  // the two hooks, because post-grep-inject passes a SEGMENT here while the deny
1125
1515
  // path passes the whole command — the same pattern then filed under two
1126
1516
  // spellings and the funnel scored a verbatim re-grep as neutral.
1127
- const clause = firstShellClause(cmd);
1128
- if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(clause)) {
1129
- return pattern;
1517
+ // Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
1518
+ // is not the flag (review of D#62).
1519
+ const words = clauseWords(firstShellClause(cmd).replace(VERB_STRIP, '')) || [];
1520
+ const extended = words.some((w) => w.quotedFrom === -1
1521
+ && (/^-[a-zA-Z]*[EP][a-zA-Z]*(?:=|\d|$)/.test(w.text) || /^--(?:extended-regexp|perl-regexp)$/.test(w.text)));
1522
+ if (extended) return pattern;
1523
+ return breToRustRegex(pattern);
1524
+ }
1525
+
1526
+ // The two dialects swap these characters' roles: in a basic regex `( ) { } + ?
1527
+ // |` are literals and `\(` … `\|` the operators, in rust regex the reverse. Only
1528
+ // unescaping the operators (the translation until D#65) left the literals as
1529
+ // operators, so `tombstoneActive()` searched `tombstoneActive` and the rewrite
1530
+ // returned a superset of grep's lines. Checked against the grep Claude Code's
1531
+ // shell runs (ugrep -G): `f()`, `a+b`, `a|b`, `{x}`, `a?` match only literally.
1532
+ //
1533
+ // A bracket expression is copied as written. Inside one, BRE has no escapes and
1534
+ // rust has escapes, `[` nesting and the `&&` `--` `~~` set operators, so a
1535
+ // bracket holding any of those means different things in the two; that pattern
1536
+ // is untranslatable and the result is null — the caller lets the grep run.
1537
+ // (`[:alpha:]` is a POSIX class in both and is kept.)
1538
+ //
1539
+ // Escapes: only the ones both read alike pass — the swapped operators, the
1540
+ // word/space/boundary classes, and an escaped metacharacter. `\1`, `\d`, `\z`,
1541
+ // `\_`, `\=` mean something else (or are an error) in one of the two, and so
1542
+ // do an empty group `\(\)`, an unknown `[:class:]` and a leading `]` in a
1543
+ // bracket (review of D#65).
1544
+ const BRE_SWAPPED = '(){}+?|';
1545
+ const BRE_SAME_ESCAPE = 'wWsSbB<>.*[]^$\\/';
1546
+ const POSIX_CLASSES = new Set(['alpha', 'digit', 'alnum', 'upper', 'lower', 'space',
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.
1553
+ function breToRustRegex(pattern) {
1554
+ let out = '';
1555
+ let altStart = true;
1556
+ let anchor = false;
1557
+ for (let i = 0; i < pattern.length; i++) {
1558
+ const c = pattern[i];
1559
+ const wasAltStart = altStart;
1560
+ const wasAnchor = anchor;
1561
+ altStart = false;
1562
+ anchor = c === '^' && wasAltStart;
1563
+ if (c === '\\' && i + 1 < pattern.length) {
1564
+ const n = pattern[++i];
1565
+ if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
1566
+ if (BRE_SWAPPED.includes(n)) out += n;
1567
+ else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
1568
+ else return null;
1569
+ altStart = n === '(' || n === '|';
1570
+ continue;
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;
1579
+ if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
1580
+ if (c !== '[') { out += c; continue; }
1581
+ // `]` right after `[` or `[^` is a member, not the end.
1582
+ let j = i + 1;
1583
+ if (pattern[j] === '^') j++;
1584
+ if (pattern[j] === ']') return null;
1585
+ for (; j < pattern.length && pattern[j] !== ']'; j++) {
1586
+ if (pattern[j] === '[' && pattern[j + 1] === ':') {
1587
+ const close = pattern.indexOf(':]', j + 2);
1588
+ if (close === -1 || !POSIX_CLASSES.has(pattern.slice(j + 2, close))) return null;
1589
+ j = close + 1;
1590
+ continue;
1591
+ }
1592
+ if (pattern[j] === '\\' || pattern[j] === '[') return null;
1593
+ if ('&-~'.includes(pattern[j]) && pattern[j + 1] === pattern[j]) return null;
1594
+ }
1595
+ if (j >= pattern.length) return null; // unterminated: grep reports an error
1596
+ out += pattern.slice(i, j + 1);
1597
+ i = j;
1130
1598
  }
1131
- return pattern.replace(/\\([|(){}+?])/g, '$1');
1599
+ return out;
1132
1600
  }
1133
1601
 
1134
1602
  // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
@@ -1290,6 +1758,7 @@ function runMain() {
1290
1758
  // forwarding `-F` without this guard would make it search the wrong text.
1291
1759
  const cgFlags = extractCgFlags(cmd);
1292
1760
  const rawGrepPattern = pickBlockPattern(cmd);
1761
+ if (!rootOnlyInSearchPath(rawCmd, root, extractSearchPath(cmd))) return;
1293
1762
  const grepPattern = cgFlagSet(cgFlags).has('-F')
1294
1763
  ? rawGrepPattern
1295
1764
  : translateBreToRg(cmd, rawGrepPattern);
@@ -1334,8 +1803,17 @@ function runMain() {
1334
1803
  // The rewrite replaces the WHOLE command. One it cannot reproduce runs as
1335
1804
  // typed (see rewritePlan) — decided before the answer is spent on it. The
1336
1805
  // opt-in static deny keeps its own, older scope.
1337
- const plan = block && !isAnswerDisabled() ? rewritePlan(cmd) : null;
1806
+ const plan = block && !isAnswerDisabled()
1807
+ ? rewritePlan(cmd, { isDir: (t) => isDirectory(path.resolve(root, t)) })
1808
+ : null;
1338
1809
  if (block && !isAnswerDisabled() && !rewriteMatchesBlock(plan, block, cmd, rawGrepPattern)) return;
1810
+ // A pattern the dialect bridge cannot translate (null) has no equivalent search.
1811
+ if (block && !isAnswerDisabled() && block.mode === 'grep' && grepPattern === null) return;
1812
+ // D#76 — the rewrite searches its root-relative target from the root; the
1813
+ // command searched its own operand from the shell's cwd. From a subdirectory
1814
+ // the rebase moves only operands that exist there, so an operand it left
1815
+ // alone (`src/` from `xtask/`, `tests/` from `src/`) names another directory.
1816
+ if (plan && relPrefix && !operandMatches(rawCmd, plan.target, root, shellCwd)) return;
1339
1817
  if (block) {
1340
1818
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
1341
1819
  // results in the deny reason ("answer in the deny"). Degrades to the
@@ -1359,7 +1837,12 @@ function runMain() {
1359
1837
  // No fallback to a grep answer: a context grep (`-A5`) asked for the
1360
1838
  // body, and the grep rewrite would drop the context silently (pre-ship
1361
1839
  // review round 2). A show miss lets the raw grep run.
1362
- 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
+ });
1363
1846
  } else if (pattern) {
1364
1847
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
1365
1848
  }
@@ -1431,7 +1914,7 @@ function runMain() {
1431
1914
  // stderr; the grep being replaced has no cap (round 3 M4). Only here, not
1432
1915
  // in the in-hook answer, which is a has-hits probe.
1433
1916
  const argvList = answeredMode === 'show'
1434
- ? answer.symbols.map((sym) => ['show', sym])
1917
+ ? answer.argvs
1435
1918
  : [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
1436
1919
  const command = buildRewriteCommand(argvList, {
1437
1920
  invocation: shellQuoteArg(resolveAnswerBinary({})),
@@ -1481,6 +1964,9 @@ module.exports = {
1481
1964
  shellWords, // rewrite — the tokenizer rewritePlan's grammar reads
1482
1965
  rewritePlan, // rewrite — does the whole command parse as one the rewrite reproduces
1483
1966
  rewriteMatchesBlock, // rewrite — does that plan describe the search classifyBlock chose
1967
+ operandMatches, // D#76 — does a root-relative target name the path the command searched
1968
+ segmentCwd, // D#76 — the directory a compound command's segment runs in
1969
+ segmentSeparators, // D#76 — the separator before each top-level segment
1484
1970
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
1485
1971
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
1486
1972
  extractPatterns, // v0.32.1 — exposed for tests
@@ -1489,6 +1975,10 @@ module.exports = {
1489
1975
  isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
1490
1976
  extractSearchPath, // v0.47.0 — deny-with-answer
1491
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
1492
1982
  resolveProjectRoot, // v0.48 — subdir-cwd dark fix
1493
1983
  rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
1494
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.159.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.159.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.159.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.159.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.159.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.159.0",
42
- "@sdsrs/code-graph-win32-x64": "0.159.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
  }