@sdsrs/code-graph 0.159.0 → 0.160.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.160.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -47,6 +47,9 @@ const {
47
47
  cgFlagSet,
48
48
  extractSearchPath,
49
49
  bareSourceTarget,
50
+ segmentCwd,
51
+ segmentSeparators,
52
+ operandMatches,
50
53
  firstShellClause,
51
54
  normalizeCommandPaths,
52
55
  rebaseRelativePaths,
@@ -273,6 +276,18 @@ function runMain() {
273
276
  // closes, so a bare dir is answered only when the grep is the FIRST segment.
274
277
  if (bareSourceTarget(firstShellClause(segment))
275
278
  && (relPrefix || splitTopLevelSegments(cmd)[0] !== segment)) return;
279
+ // D#76 — the answer searches `extractSearchPath(segment)` from the root. The
280
+ // segment ran where the shell was by then: its own cwd, moved by any `cd`
281
+ // before it. Segments are read from the RAW command (normalizing strips the
282
+ // root from `cd <root>/x` too), which splits into the same segments.
283
+ const segs = splitTopLevelSegments(cmd);
284
+ const rawSegs = splitTopLevelSegments(rawCmd);
285
+ const idx = segs.indexOf(segment);
286
+ if (rawSegs.length !== segs.length) return;
287
+ const segCwd = segmentCwd(rawSegs, idx, shellCwd, { seps: segmentSeparators(rawCmd) });
288
+ if (segCwd === null) return;
289
+ if ((segCwd !== root || relPrefix)
290
+ && !operandMatches(firstShellClause(rawSegs[idx]), extractSearchPath(segment), root, segCwd)) return;
276
291
  // Run the answer exactly like the deny path.
277
292
  const rawPattern = pickBlockPattern(segment);
278
293
  // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
@@ -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,6 +309,69 @@ 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;
@@ -475,17 +548,21 @@ const CG_FLAG_ORDER = ['-i', '-w', '-F', '-l', '-c'];
475
548
  * file list was asked for (field report 2026-09-08).
476
549
  *
477
550
  * 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`.
551
+ * `-l` is not this grep's), words as the shell splits them (clauseWords — a
552
+ * quoted pattern holding ` -l ` is one word), and a word that STARTS quoted is
553
+ * an argument, not a flag — `grep "-l" src/` searches for the string `-l`.
480
554
  */
481
555
  function extractCgFlags(cmd) {
482
556
  if (!cmd || typeof cmd !== 'string') return [];
483
557
  const clause = firstShellClause(cmd);
484
558
  const isRg = RG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '');
485
- const toks = clause.replace(VERB_STRIP, '').trim().split(/\s+/);
559
+ const words = clauseWords(clause.replace(VERB_STRIP, ''));
560
+ if (!words) return [];
561
+ const toks = words.map((w) => (w.startsQuoted ? '' : w.text));
486
562
  const found = new Set();
487
563
  const filters = []; // [cgFlag, value] pairs, in the order the user wrote them
488
- const unquote = (s) => (s || '').replace(/^["']|["']$/g, '');
564
+ // A value is read from the word, whatever its quoting: `--include '*.rs'`.
565
+ const valueAt = (k) => (words[k] ? words[k].text : '');
489
566
  const addFilter = (flag, value) => {
490
567
  if (!value) return;
491
568
  // `-g '*.rs' -g '*.rs'` is one filter written twice; cg would honour both
@@ -494,12 +571,12 @@ function extractCgFlags(cmd) {
494
571
  };
495
572
  for (let i = 0; i < toks.length; i++) {
496
573
  const tok = toks[i];
497
- if (!tok || tok[0] === '"' || tok[0] === "'" || tok[0] !== '-') continue;
574
+ if (!tok || tok[0] !== '-') continue;
498
575
  if (tok.startsWith('--')) {
499
576
  const eq = tok.indexOf('=');
500
577
  const name = eq === -1 ? tok : tok.slice(0, eq);
501
578
  if (CG_VALUE_FLAGS[name]) {
502
- addFilter(CG_VALUE_FLAGS[name], unquote(eq === -1 ? toks[i + 1] : tok.slice(eq + 1)));
579
+ addFilter(CG_VALUE_FLAGS[name], eq === -1 ? valueAt(i + 1) : tok.slice(eq + 1));
503
580
  if (eq === -1) i++; // the value was a separate token — do not rescan it
504
581
  continue;
505
582
  }
@@ -515,8 +592,8 @@ function extractCgFlags(cmd) {
515
592
  const ch = letters[k];
516
593
  if (isRg && CG_VALUE_SHORT[ch]) {
517
594
  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
595
+ addFilter(CG_VALUE_SHORT[ch], attached || valueAt(i + 1));
596
+ // Consume the value word so the next pass cannot read it as a flag
520
597
  // cluster: `rg -g -i 'sym' src/` must be `-g` with value `-i`, not `-g`
521
598
  // plus a phantom `-i` (round 2 of pre-ship review — the test that named
522
599
  // this property used a quoted value, which the quote guard already
@@ -611,7 +688,10 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
611
688
  // Shell sits outside any known source dir (docs/, target/, …) — don't guess.
612
689
  if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
613
690
  let verbSeen = false;
614
- return cmd.split(/(\s+)/).map((tok) => {
691
+ // Split on whitespace outside quotes only: a quoted pattern is one token and
692
+ // its inner words are never paths (D#63 — `"fn main utils here"` from src/
693
+ // became `"fn main src/utils here"`).
694
+ return splitKeepingQuotes(cmd).map((tok) => {
615
695
  if (!tok || /^\s+$/.test(tok)) return tok;
616
696
  if (!verbSeen) {
617
697
  if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
@@ -635,6 +715,36 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
635
715
  }).join('');
636
716
  }
637
717
 
718
+ // Words and the whitespace between them, text unchanged, splitting on
719
+ // whitespace outside quotes only (the quotedSpans rules).
720
+ function splitKeepingQuotes(s) {
721
+ const out = [];
722
+ let cur = '';
723
+ let quote = null;
724
+ for (let i = 0; i < s.length; i++) {
725
+ const c = s[i];
726
+ if (quote) {
727
+ cur += c;
728
+ if (quote === '"' && c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; }
729
+ if (c === quote) quote = null;
730
+ continue;
731
+ }
732
+ if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
733
+ if (c === '"' || c === "'") { quote = c; cur += c; continue; }
734
+ if (/\s/.test(c)) {
735
+ if (cur) out.push(cur);
736
+ cur = c;
737
+ while (i + 1 < s.length && /\s/.test(s[i + 1])) cur += s[++i];
738
+ out.push(cur);
739
+ cur = '';
740
+ continue;
741
+ }
742
+ cur += c;
743
+ }
744
+ if (cur) out.push(cur);
745
+ return out;
746
+ }
747
+
638
748
  // v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
639
749
  // the escape in v0.49, but models that already know it — or learned it from a
640
750
  // session summary — must stay visible to the funnel.)
@@ -763,22 +873,117 @@ function pickBlockPattern(cmd) {
763
873
  // inside single/double quotes are literal command text, never split points.
764
874
  // Returns trimmed, non-empty segments. Shared by post-grep-inject so the
765
875
  // PostToolUse path reuses this splitter instead of copying it.
766
- function splitTopLevelSegments(cmd) {
876
+ //
877
+ // A heredoc body is skipped (D#66): it is data on the reading command's stdin,
878
+ // not commands. Lexed as shell, a Python `\'` in a `<<'PY'` body flipped the
879
+ // quote parity for the rest of the command, and a `grep` line in a script body
880
+ // was folded as if the shell had run it. The `<<WORD` stays in its segment;
881
+ // the lines after that segment's newline, through the terminator line, go.
882
+ //
883
+ // Also read as the shell reads them, because each misreading drops or invents
884
+ // commands: a comment (`#` at the start of a word) runs to the end of its line;
885
+ // `<<<` is a here-string and `<<` inside `((…))` a shift, neither a heredoc;
886
+ // the heredoc delimiter is a whole word with its quoting removed (`E"O"F`,
887
+ // `$'EOF'`, `\EOF` all end at a line `EOF`).
888
+ function readHeredocDelim(cmd, i) {
889
+ let j = i;
890
+ let delim = '';
891
+ while (j < cmd.length && !/[\s;&|<>()]/.test(cmd[j])) {
892
+ const c = cmd[j];
893
+ if (c === '$' && (cmd[j + 1] === "'" || cmd[j + 1] === '"')) { j++; continue; }
894
+ if (c === "'" || c === '"') {
895
+ const close = cmd.indexOf(c, j + 1);
896
+ if (close === -1) return null;
897
+ delim += cmd.slice(j + 1, close);
898
+ j = close + 1;
899
+ continue;
900
+ }
901
+ if (c === '\\' && j + 1 < cmd.length) { delim += cmd[j + 1]; j += 2; continue; }
902
+ delim += c;
903
+ j++;
904
+ }
905
+ return delim ? { delim, end: j } : null;
906
+ }
907
+
908
+ // Stronger of two separators joining the same pair of segments (an empty
909
+ // segment between them collapses): anything conditional beats `;`/newline.
910
+ const SEP_RANK = { '': 0, ';': 1, '\n': 1, '&&': 2, '||': 2, ctrl: 3 };
911
+ function joinSep(a, b) {
912
+ if (a === undefined) return b;
913
+ if (SEP_RANK[a] === 2 && SEP_RANK[b] === 2 && a !== b) return 'ctrl';
914
+ return SEP_RANK[b] > SEP_RANK[a] ? b : a;
915
+ }
916
+
917
+ /**
918
+ * Top-level segments with the separator before each: '' (the first), ';',
919
+ * '\n', '&&', '||', or 'ctrl' (after a `do`/`then`/… control word, where
920
+ * whether the segment runs is not a matter of the previous one).
921
+ * @returns {{text: string, sep: string}[]}
922
+ */
923
+ function splitTopLevelSegmentsWithSeps(cmd) {
767
924
  if (!cmd || typeof cmd !== 'string') return [];
768
925
  const segs = [];
769
926
  let cur = '';
927
+ let sep = '';
770
928
  let quote = null;
929
+ let arith = 0; // depth of `((` … `))`
930
+ let heredocs = []; // [{delim, stripTabs}] started on the current line
931
+ // The last char appended to `cur`, kept apart: reading `cur` itself flattens
932
+ // the string `+=` built, once per `#` — a 300 KB `echo a#a#…` took 7.9 s in
933
+ // a 5 s hook (pre-release review #11).
934
+ let last = '';
935
+ const add = (s) => { cur += s; last = s[s.length - 1]; };
936
+ const cut = (next) => { segs.push({ text: cur, sep }); cur = ''; last = ''; sep = next; };
771
937
  for (let i = 0; i < cmd.length; i++) {
772
938
  const c = cmd[i];
939
+ if (!quote) {
940
+ if (c === '#' && (last === '' || /[\s|(]/.test(last))) {
941
+ const nl = cmd.indexOf('\n', i);
942
+ i = (nl === -1 ? cmd.length : nl) - 1;
943
+ continue;
944
+ }
945
+ if (c === '(' && cmd[i + 1] === '(') { arith++; add('(('); i++; continue; }
946
+ if (c === ')' && cmd[i + 1] === ')' && arith > 0) { arith--; add('))'); i++; continue; }
947
+ if (c === '<' && cmd[i + 1] === '<' && cmd[i + 2] === '<') { add('<<<'); i += 2; continue; }
948
+ if (c === '<' && cmd[i + 1] === '<' && arith === 0) {
949
+ let j = i + 2;
950
+ const stripTabs = cmd[j] === '-';
951
+ if (stripTabs) j++;
952
+ while (cmd[j] === ' ' || cmd[j] === '\t') j++;
953
+ const d = readHeredocDelim(cmd, j);
954
+ if (d) {
955
+ heredocs.push({ delim: d.delim, stripTabs });
956
+ add(cmd.slice(i, d.end));
957
+ i = d.end - 1;
958
+ continue;
959
+ }
960
+ }
961
+ if (c === '\n' && heredocs.length > 0) {
962
+ cut('\n');
963
+ let j = i + 1;
964
+ for (const { delim, stripTabs } of heredocs) {
965
+ while (j < cmd.length) {
966
+ const nl = cmd.indexOf('\n', j);
967
+ const end = nl === -1 ? cmd.length : nl;
968
+ const line = cmd.slice(j, end);
969
+ j = end + 1;
970
+ if ((stripTabs ? line.replace(/^\t+/, '') : line) === delim) break;
971
+ }
972
+ }
973
+ heredocs = [];
974
+ i = j - 1;
975
+ continue;
976
+ }
977
+ }
773
978
  if (quote) {
774
- cur += c;
979
+ add(c);
775
980
  // Inside DOUBLE quotes a backslash escapes the next char, so `\"` does NOT
776
981
  // close the quote (POSIX). Single quotes do no escaping — `\` is literal
777
982
  // and `'` always closes — so this only applies to `"`. Without it,
778
983
  // `echo "x\" && grep \"Y\" src/"` (one literal echo arg) mis-closes at
779
984
  // `\"`, splits on `&&`, and yields a phantom foldable grep segment.
780
985
  if (quote === '"' && c === '\\' && i + 1 < cmd.length) {
781
- cur += cmd[i + 1];
986
+ add(cmd[i + 1]);
782
987
  i++;
783
988
  continue;
784
989
  }
@@ -790,46 +995,58 @@ function splitTopLevelSegments(cmd) {
790
995
  // removes both. Keeping them left `&& \⏎ grep …` a segment that starts
791
996
  // with `\`, which GREP_HEAD rejects (pre-ship review round 2 F1).
792
997
  if (c === '\\' && i + 1 < cmd.length) {
793
- if (cmd[i + 1] !== '\n') cur += c + cmd[i + 1];
998
+ if (cmd[i + 1] !== '\n') add(c + cmd[i + 1]);
794
999
  i++;
795
1000
  continue;
796
1001
  }
797
- if (c === '"' || c === "'") { quote = c; cur += c; continue; }
1002
+ if (c === '"' || c === "'") { quote = c; add(c); continue; }
798
1003
  // `&&` and `||` (a single `&`/`|` is NOT a split — `|` is an output-filter
799
1004
  // pipe, lone `&` is background and rare in tool calls).
800
1005
  if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) {
801
- segs.push(cur); cur = ''; i++; continue;
1006
+ cut(c + c); i++; continue;
802
1007
  }
803
- if (c === ';' || c === '\n') { segs.push(cur); cur = ''; continue; }
804
- cur += c;
1008
+ if (c === ';' || c === '\n') { cut(c); continue; }
1009
+ add(c);
805
1010
  }
806
- segs.push(cur);
1011
+ cut(undefined);
807
1012
  // Split out `for … in` / `do` / `done` control words as their own boundaries
808
1013
  // so a loop body grep is isolated (the head of `for s in …; do grep …` is the
809
1014
  // `for` keyword, which would otherwise mask the grep). Quote-safety already
810
1015
  // 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) {
1016
+ // control words only. A `for` header takes its word list with it: the list
1017
+ // is data, and left behind as a segment it read as a command that segmentCwd
1018
+ // cannot place (D#76).
1019
+ const pieces = [];
1020
+ const CTRL = /(?:^|\s)(for\s+\S+\s+in\b[\s\S]*$|do\b|done\b|then\b|fi\b)(?=\s|$)/g;
1021
+ for (const { text: raw, sep: rawSep } of segs) {
815
1022
  let last = 0;
816
1023
  let m;
1024
+ let pieceSep = rawSep;
817
1025
  CTRL.lastIndex = 0;
818
- let pushed = false;
819
1026
  while ((m = CTRL.exec(raw)) !== null) {
820
- const before = raw.slice(last, m.index);
821
- if (before.trim()) out.push(before);
1027
+ pieces.push({ text: raw.slice(last, m.index), sep: pieceSep });
822
1028
  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);
1029
+ pieceSep = 'ctrl';
830
1030
  }
1031
+ pieces.push({ text: raw.slice(last), sep: pieceSep });
831
1032
  }
832
- return out.map(s => s.trim()).filter(Boolean);
1033
+ const out = [];
1034
+ let carried;
1035
+ for (const { text, sep: s } of pieces) {
1036
+ carried = joinSep(carried, s);
1037
+ if (!text.trim()) continue;
1038
+ out.push({ text: text.trim(), sep: out.length === 0 ? '' : carried });
1039
+ carried = undefined;
1040
+ }
1041
+ return out;
1042
+ }
1043
+
1044
+ function splitTopLevelSegments(cmd) {
1045
+ return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.text);
1046
+ }
1047
+
1048
+ function segmentSeparators(cmd) {
1049
+ return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.sep);
833
1050
  }
834
1051
 
835
1052
  // One implementation of the cooldown quartet, in tmp-dir.js (ARC-02/ARC-06);
@@ -937,6 +1154,9 @@ function buildRewriteContext(mode, cmdShown) {
937
1154
  // `-`, and anything the tokenizer does not know — newline, `;`, `&`, `\`, `$`,
938
1155
  // a backtick, parentheses, braces, `~`. Returns a plan or null.
939
1156
  const WORD_CHAR = /[A-Za-z0-9_.,:@%+=/*?<>&-]/;
1157
+ function isDirectory(p) {
1158
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
1159
+ }
940
1160
  function shellWords(s) {
941
1161
  const words = [];
942
1162
  let cur = null;
@@ -1000,10 +1220,15 @@ function valueWordOk(w, isCount) {
1000
1220
  return !(w.bareSpecial);
1001
1221
  }
1002
1222
 
1003
- function rewritePlan(cmd) {
1223
+ // `isDir(target)` answers whether the path operand is a directory. runMain asks
1224
+ // the filesystem, resolved against the root its command is relative to; the
1225
+ // default is the spelling alone (a trailing `/`, or a last segment with no
1226
+ // extension), which keeps the classifiers that call this pure.
1227
+ const looksLikeDir = (t) => t.endsWith('/') || !/\.[A-Za-z0-9]{1,6}$/.test(t);
1228
+ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1004
1229
  if (!cmd || typeof cmd !== 'string' || cmd.length > 1000) return null;
1005
1230
  const words = shellWords(cmd);
1006
- if (!words || words.some((w) => w.op)) return null;
1231
+ if (!words || words.length === 0 || words.some((w) => w.op)) return null;
1007
1232
  let i = 0;
1008
1233
  let verb = !words[0].anyQuoted ? words[0].text : '';
1009
1234
  if (verb === 'git') {
@@ -1019,6 +1244,7 @@ function rewritePlan(cmd) {
1019
1244
  let caseFlag = false;
1020
1245
  let context = false;
1021
1246
  let endOfFlags = false;
1247
+ let include = false;
1022
1248
  for (; i < words.length; i++) {
1023
1249
  const w = words[i];
1024
1250
  if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
@@ -1031,17 +1257,19 @@ function rewritePlan(cmd) {
1031
1257
  // (`--include='*.rs'`, rg `-g'*.rs'`); an unquoted `<>&` is shell syntax.
1032
1258
  const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
1033
1259
  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;
1260
+ // GNU grep's --include has no `!` negation and no `{a,b}` alternatives;
1261
+ // cg's -g has both (round 4 L2; review of D#78).
1262
+ if (/^--include=(?:!|.*\{)/.test(w.text)) return null;
1036
1263
  }
1037
1264
  if (w.text === '--') { endOfFlags = true; continue; }
1038
1265
  if (w.text.startsWith('--')) {
1039
1266
  const eq = w.text.indexOf('=');
1040
1267
  const name = w.text.slice(2, eq === -1 ? undefined : eq);
1041
1268
  if (!ALLOWED_LONG[verb].has(name)) return null;
1269
+ if (name === 'include') include = true;
1042
1270
  if (VALUE_LONG.has(name) && eq === -1) {
1043
1271
  const v = words[++i];
1044
- if (!valueWordOk(v, false) || (name === 'include' && v.text.startsWith('!'))) return null;
1272
+ if (!valueWordOk(v, false) || (name === 'include' && /^!|\{/.test(v.text))) return null;
1045
1273
  }
1046
1274
  if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
1047
1275
  continue;
@@ -1075,12 +1303,85 @@ function rewritePlan(cmd) {
1075
1303
  // A `..` segment: extractSearchPath refuses to scope it, so the answer would
1076
1304
  // search the whole repo (round 4 M2).
1077
1305
  if (target && /(?:^|\/)\.\.(?:\/|$)/.test(target.text)) return null;
1306
+ // GNU grep applies --include to a file named on the command line too, so
1307
+ // `grep --include='*.py' X src/a.rs` finds nothing; rg — and so cg — searches
1308
+ // a named file whatever its -g says (D#78). Equivalent only for a directory.
1309
+ if (include && target && !isDir(target.text)) return null;
1078
1310
  // ag is smart-case by default: an all-lowercase pattern matches any case,
1079
1311
  // and cg's search is case-sensitive, so the answer would find less.
1080
1312
  if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
1081
1313
  return { pattern: pattern.text, target: target ? target.text : undefined, context };
1082
1314
  }
1083
1315
 
1316
+ // Does `rootTarget`, run from the root, name the path the grep clause's own
1317
+ // operand names from `cwd` — the directory the clause ran in? The raw clause is
1318
+ // parsed with the rewrite grammar; an absolute operand resolves the same from
1319
+ // anywhere. A clause the grammar cannot read proves nothing.
1320
+ function operandMatches(rawClause, rootTarget, root, cwd) {
1321
+ const raw = rewritePlan(rawClause, { isDir: (t) => isDirectory(path.resolve(cwd, t)) });
1322
+ if (!raw || raw.target === undefined || rootTarget === undefined) return false;
1323
+ return path.resolve(cwd, raw.target) === path.resolve(root, rootTarget);
1324
+ }
1325
+
1326
+ // Commands that cannot move the shell: external programs run in a child, and
1327
+ // these builtins leave the cwd alone. An allowlist, because the commands that
1328
+ // DO move it are open-ended — `builtin cd`, `if cd`, `{ cd …; }`, `eval`,
1329
+ // `source`, `popd`, any function (D#73 round 3 reproduced 19 forms).
1330
+ const CWD_NEUTRAL = new Set([
1331
+ 'echo', 'printf', 'grep', 'rg', 'ag', 'git', 'sed', 'awk', 'cat', 'head', 'tail', 'wc',
1332
+ 'ls', 'find', 'sort', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'test', '[', 'true', 'false',
1333
+ ':', 'node', 'python', 'python3', 'cargo', 'npm', 'npx', 'jq', 'xargs', 'tee', 'stat',
1334
+ 'file', 'du', 'df', 'date', 'sleep', 'which', 'env', 'export', 'set', 'unset', 'nl',
1335
+ 'rm', 'mkdir', 'cp', 'mv', 'touch', 'chmod', 'ln', 'readlink', 'realpath', 'basename',
1336
+ 'dirname', 'perl', 'gh', 'curl', 'make', 'bash', 'sh', 'timeout', 'tar', 'sqlite3',
1337
+ 'code-graph-mcp', 'nproc', 'rustc', 'go', 'wait', 'column', 'md5sum', 'sha256sum',
1338
+ 'od', 'xxd', 'seq', 'paste', 'comm', 'uname', 'id', 'printenv',
1339
+ // If an `exit` before the grep had run, the grep would not have.
1340
+ 'exit', 'return',
1341
+ ]);
1342
+ // `X=1 Y=$(mktemp -d)`: a command substitution runs in a subshell.
1343
+ const PURE_ASSIGNMENTS =
1344
+ /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\([^()]*\)|"[^"]*"|'[^']*'|[^\s;|&<>()'"]*)\s*)+$/;
1345
+ const CD_LITERAL = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*cd\s+('[^']*'|"[^"$`\\]*"|[^\s'"$`\\~*?[{|;&<>()]+)\s*$/;
1346
+
1347
+ /**
1348
+ * The directory `segments[idx]` runs in, or null when the segments before it
1349
+ * could have moved the shell somewhere we cannot name (D#76). Follows
1350
+ * cwd-neutral commands and `cd` to one literal path that exists; a relative
1351
+ * `cd` under a set CDPATH is not literal. A `cd` is followed only when it is
1352
+ * certain to have run if the grep did: it is unconditional (its `seps` entry,
1353
+ * from segmentSeparators, is '', `;` or a newline) or every separator from it
1354
+ * to the grep is `&&`, the one before it included: `true || cd x && grep` is
1355
+ * `(true || cd x) && grep`, which runs the grep without the cd. `false && cd x;
1356
+ * grep` and `cd x | cat` did not move the shell. Without `seps` every segment
1357
+ * counts as unconditional.
1358
+ */
1359
+ function segmentCwd(segments, idx, shellCwd, { isDir = isDirectory, seps } = {}) {
1360
+ let cwd = shellCwd;
1361
+ const ran = (k) => !seps || SEP_RANK[seps[k]] <= 1
1362
+ || seps.slice(k, idx + 1).every((x) => x === '&&');
1363
+ for (let k = 0; k < idx; k++) {
1364
+ const seg = segments[k];
1365
+ if (/^\s*#/.test(seg) || PURE_ASSIGNMENTS.test(seg)) continue;
1366
+ const clause = firstShellClause(seg);
1367
+ const cd = CD_LITERAL.exec(seg);
1368
+ if (cd) {
1369
+ if (!ran(k)) return null;
1370
+ const arg = /^['"]/.test(cd[1]) ? cd[1].slice(1, -1) : cd[1];
1371
+ if (!arg || arg === '-' || (!path.isAbsolute(arg) && process.env.CDPATH)) return null;
1372
+ const next = path.resolve(cwd, arg);
1373
+ if (!isDir(next)) return null; // `cd x; grep` runs the grep where it was
1374
+ try { cwd = fs.realpathSync(next); } catch { cwd = next; }
1375
+ continue;
1376
+ }
1377
+ const words = clauseWords(clause);
1378
+ if (!words) return null;
1379
+ const head = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
1380
+ if (head && !(head.quotedFrom === -1 && CWD_NEUTRAL.has(head.text))) return null;
1381
+ }
1382
+ return cwd;
1383
+ }
1384
+
1084
1385
  // The plan must describe the same search classifyBlock decided on. The pattern
1085
1386
  // the answer ran is picked from quoted spans (pickBlockPattern); the grammar's
1086
1387
  // pattern operand is the one the shell passes — `"is"'Foo'` or an unquoted
@@ -1124,11 +1425,70 @@ function translateBreToRg(cmd, pattern) {
1124
1425
  // the two hooks, because post-grep-inject passes a SEGMENT here while the deny
1125
1426
  // path passes the whole command — the same pattern then filed under two
1126
1427
  // 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;
1428
+ // Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
1429
+ // is not the flag (review of D#62).
1430
+ const words = clauseWords(firstShellClause(cmd).replace(VERB_STRIP, '')) || [];
1431
+ const extended = words.some((w) => w.quotedFrom === -1
1432
+ && (/^-[a-zA-Z]*[EP][a-zA-Z]*(?:=|\d|$)/.test(w.text) || /^--(?:extended-regexp|perl-regexp)$/.test(w.text)));
1433
+ if (extended) return pattern;
1434
+ return breToRustRegex(pattern);
1435
+ }
1436
+
1437
+ // The two dialects swap these characters' roles: in a basic regex `( ) { } + ?
1438
+ // |` are literals and `\(` … `\|` the operators, in rust regex the reverse. Only
1439
+ // unescaping the operators (the translation until D#65) left the literals as
1440
+ // operators, so `tombstoneActive()` searched `tombstoneActive` and the rewrite
1441
+ // returned a superset of grep's lines. Checked against the grep Claude Code's
1442
+ // shell runs (ugrep -G): `f()`, `a+b`, `a|b`, `{x}`, `a?` match only literally.
1443
+ //
1444
+ // A bracket expression is copied as written. Inside one, BRE has no escapes and
1445
+ // rust has escapes, `[` nesting and the `&&` `--` `~~` set operators, so a
1446
+ // bracket holding any of those means different things in the two; that pattern
1447
+ // is untranslatable and the result is null — the caller lets the grep run.
1448
+ // (`[:alpha:]` is a POSIX class in both and is kept.)
1449
+ //
1450
+ // Escapes: only the ones both read alike pass — the swapped operators, the
1451
+ // word/space/boundary classes, and an escaped metacharacter. `\1`, `\d`, `\z`,
1452
+ // `\_`, `\=` mean something else (or are an error) in one of the two, and so
1453
+ // do an empty group `\(\)`, an unknown `[:class:]` and a leading `]` in a
1454
+ // bracket (review of D#65).
1455
+ const BRE_SWAPPED = '(){}+?|';
1456
+ const BRE_SAME_ESCAPE = 'wWsSbB<>.*[]^$\\/';
1457
+ const POSIX_CLASSES = new Set(['alpha', 'digit', 'alnum', 'upper', 'lower', 'space',
1458
+ 'punct', 'xdigit', 'blank', 'cntrl', 'graph', 'print']);
1459
+ function breToRustRegex(pattern) {
1460
+ let out = '';
1461
+ for (let i = 0; i < pattern.length; i++) {
1462
+ const c = pattern[i];
1463
+ if (c === '\\' && i + 1 < pattern.length) {
1464
+ const n = pattern[++i];
1465
+ if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
1466
+ if (BRE_SWAPPED.includes(n)) out += n;
1467
+ else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
1468
+ else return null;
1469
+ continue;
1470
+ }
1471
+ if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
1472
+ if (c !== '[') { out += c; continue; }
1473
+ // `]` right after `[` or `[^` is a member, not the end.
1474
+ let j = i + 1;
1475
+ if (pattern[j] === '^') j++;
1476
+ if (pattern[j] === ']') return null;
1477
+ for (; j < pattern.length && pattern[j] !== ']'; j++) {
1478
+ if (pattern[j] === '[' && pattern[j + 1] === ':') {
1479
+ const close = pattern.indexOf(':]', j + 2);
1480
+ if (close === -1 || !POSIX_CLASSES.has(pattern.slice(j + 2, close))) return null;
1481
+ j = close + 1;
1482
+ continue;
1483
+ }
1484
+ if (pattern[j] === '\\' || pattern[j] === '[') return null;
1485
+ if ('&-~'.includes(pattern[j]) && pattern[j + 1] === pattern[j]) return null;
1486
+ }
1487
+ if (j >= pattern.length) return null; // unterminated: grep reports an error
1488
+ out += pattern.slice(i, j + 1);
1489
+ i = j;
1130
1490
  }
1131
- return pattern.replace(/\\([|(){}+?])/g, '$1');
1491
+ return out;
1132
1492
  }
1133
1493
 
1134
1494
  // v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
@@ -1334,8 +1694,17 @@ function runMain() {
1334
1694
  // The rewrite replaces the WHOLE command. One it cannot reproduce runs as
1335
1695
  // typed (see rewritePlan) — decided before the answer is spent on it. The
1336
1696
  // opt-in static deny keeps its own, older scope.
1337
- const plan = block && !isAnswerDisabled() ? rewritePlan(cmd) : null;
1697
+ const plan = block && !isAnswerDisabled()
1698
+ ? rewritePlan(cmd, { isDir: (t) => isDirectory(path.resolve(root, t)) })
1699
+ : null;
1338
1700
  if (block && !isAnswerDisabled() && !rewriteMatchesBlock(plan, block, cmd, rawGrepPattern)) return;
1701
+ // A pattern the dialect bridge cannot translate (null) has no equivalent search.
1702
+ if (block && !isAnswerDisabled() && block.mode === 'grep' && grepPattern === null) return;
1703
+ // D#76 — the rewrite searches its root-relative target from the root; the
1704
+ // command searched its own operand from the shell's cwd. From a subdirectory
1705
+ // the rebase moves only operands that exist there, so an operand it left
1706
+ // alone (`src/` from `xtask/`, `tests/` from `src/`) names another directory.
1707
+ if (plan && relPrefix && !operandMatches(rawCmd, plan.target, root, shellCwd)) return;
1339
1708
  if (block) {
1340
1709
  // v0.47.0 — run the AST-aware equivalent inside the hook and embed the
1341
1710
  // results in the deny reason ("answer in the deny"). Degrades to the
@@ -1481,6 +1850,9 @@ module.exports = {
1481
1850
  shellWords, // rewrite — the tokenizer rewritePlan's grammar reads
1482
1851
  rewritePlan, // rewrite — does the whole command parse as one the rewrite reproduces
1483
1852
  rewriteMatchesBlock, // rewrite — does that plan describe the search classifyBlock chose
1853
+ operandMatches, // D#76 — does a root-relative target name the path the command searched
1854
+ segmentCwd, // D#76 — the directory a compound command's segment runs in
1855
+ segmentSeparators, // D#76 — the separator before each top-level segment
1484
1856
  extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
1485
1857
  extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
1486
1858
  extractPatterns, // v0.32.1 — exposed for tests
@@ -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.160.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.160.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.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"
43
43
  }
44
44
  }