@sdsrs/code-graph 0.160.0 → 0.162.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.
@@ -50,6 +50,8 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Pr
50
50
 
51
51
  const fs = require('fs');
52
52
  const path = require('path');
53
+ const { spawnSync } = require('child_process');
54
+ const { hidden } = require('./proc-opts');
53
55
  const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
54
56
  const { recordRecommendation } = require('./recommendation-log');
55
57
  const {
@@ -376,6 +378,38 @@ function clauseWords(s) {
376
378
  // name the exact symbols the model wants to READ — extract them for `show`.
377
379
  const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
378
380
 
381
+ // What a `show` definition must be for the grep's declaration keyword to match
382
+ // its line: the kind label `show` prints (src/cli/symbols.rs
383
+ // format_node_compact) AND a file of a language that spells the declaration
384
+ // with that keyword. The label alone is coarser than the keyword — `fn` is also
385
+ // a Python `def` and a TS method, and a Swift `struct` is labelled `class`
386
+ // (review of D#125, round 2). `impl` names no definition of its own.
387
+ const DECL_KEYWORD_SHAPES = {
388
+ fn: { labels: ['fn'], exts: ['rs'] },
389
+ def: { labels: ['fn'], exts: ['py', 'rb'] },
390
+ function: { labels: ['fn'], exts: ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'php', 'sh', 'bash'] },
391
+ class: {
392
+ labels: ['class'],
393
+ exts: ['py', 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'java', 'kt', 'cs', 'php', 'rb', 'dart',
394
+ 'cpp', 'cc', 'hpp', 'h', 'scala'],
395
+ },
396
+ struct: { labels: ['struct'], exts: ['rs', 'go', 'c', 'h', 'cpp', 'cc', 'hpp', 'cs'] },
397
+ trait: { labels: ['trait', 'iface'], exts: ['rs', 'php', 'scala'] },
398
+ impl: { labels: [], exts: [] },
399
+ };
400
+
401
+ // Symbol → the (label, extension) shapes its declaration keywords match
402
+ // (`fn foo\|def foo` → foo: Rust fn, Python/Ruby def).
403
+ function declKindsBySymbol(patterns) {
404
+ const out = {};
405
+ for (const p of patterns) {
406
+ for (const m of p.matchAll(/\b(fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
407
+ (out[m[2]] = out[m[2]] || []).push(DECL_KEYWORD_SHAPES[m[1]]);
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+
379
413
  function extractDeclSymbols(patterns) {
380
414
  const out = [];
381
415
  for (const p of patterns) {
@@ -656,6 +690,30 @@ function normalizeCommandPaths(cmd, cwd) {
656
690
  return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
657
691
  }
658
692
 
693
+ // D#125 #1 — the strip above cannot tell a path operand from a PATTERN, and
694
+ // strips both: `grep -rn "<root>/Foo" src/` searches the literal text
695
+ // `<root>/Foo`, but was answered for `Foo`. Re-picking the pattern with the
696
+ // root marked was tried first and missed shapes where the mark changes which
697
+ // word is picked (`"<root>/def foo"`, `"abc_<root>/def"`: review of D#125). The
698
+ // narrow form: every shell word that holds `<root>/` must BE the search path
699
+ // the answer scopes to; anything else runs as typed.
700
+ function rootOnlyInSearchPath(cmd, cwd, target) {
701
+ if (!cmd || typeof cmd !== 'string') return true;
702
+ if (!cwd || typeof cwd !== 'string' || cwd === '/') return true;
703
+ const prefix = cwd.endsWith('/') ? cwd : cwd + '/';
704
+ if (!cmd.includes(prefix)) return true;
705
+ // Round 2: counting words was not enough — a lone root-holding PATTERN whose
706
+ // stripped text equals the path (`grep -rn "<root>/src/x" src/x`) passed. The
707
+ // rewrite grammar names which word is the path operand: accept only a command
708
+ // it reads, with the root once, in that operand, and nowhere in the pattern.
709
+ if (cmd.split(prefix).length !== 2) return false;
710
+ const plan = rewritePlan(firstShellClause(cmd), { isDir: () => true });
711
+ if (!plan || plan.target === undefined || plan.pattern.includes(prefix)) return false;
712
+ if (!plan.target.startsWith(prefix)) return false;
713
+ const norm = (p) => p.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
714
+ return target !== undefined && norm(plan.target.slice(prefix.length)) === norm(target);
715
+ }
716
+
659
717
  // v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
660
718
  // hook shares it. Re-exported below for test/back-compat.
661
719
  const { resolveProjectRoot } = require('./project-root');
@@ -1199,8 +1257,10 @@ function shellWords(s) {
1199
1257
  // (recursion, line numbers, filenames, binary skipping, the regex dialect).
1200
1258
  // Per verb, because letters differ: ag's `-n` is --norecurse and `-H` is
1201
1259
  // --heading, rg's `-r` is --replace. Context letters (A/B/C, a count value)
1202
- // only reach the rewrite in `show` mode, which answers with the body.
1203
- const ALLOWED_SHORT = { grep: 'rRnHsIiwFlcEPABC', git: 'rnHIiwFlcEPABC', rg: 'nHiwFlcsABC', ag: 'iwlcsABC' };
1260
+ // only reach the rewrite in `show` mode, which answers with the body. Not
1261
+ // `-R`: it follows every symlink under the path, which cg's walk does not
1262
+ // (review of D#133, M-1).
1263
+ const ALLOWED_SHORT = { grep: 'rnHsIiwFlcEPABC', git: 'rnHIiwFlcEPABC', rg: 'nHiwFlcsABC', ag: 'iwlcsABC' };
1204
1264
  const VALUE_SHORT_BY_VERB = { grep: 'ABC', git: 'ABC', rg: 'gtABC', ag: 'ABC' };
1205
1265
  const COMMON_LONG = ['ignore-case', 'word-regexp', 'fixed-strings', 'files-with-matches', 'count', 'line-number'];
1206
1266
  const ALLOWED_LONG = {
@@ -1245,6 +1305,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1245
1305
  let context = false;
1246
1306
  let endOfFlags = false;
1247
1307
  let include = false;
1308
+ let recursive = false;
1248
1309
  for (; i < words.length; i++) {
1249
1310
  const w = words[i];
1250
1311
  if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
@@ -1258,8 +1319,8 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1258
1319
  const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
1259
1320
  if (!attachedFilter || w.bareOp) return null;
1260
1321
  // 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;
1322
+ // cg's -g has both (round 4 L2; review of D#78). Checked with the other
1323
+ // --include value rules below.
1263
1324
  }
1264
1325
  if (w.text === '--') { endOfFlags = true; continue; }
1265
1326
  if (w.text.startsWith('--')) {
@@ -1267,9 +1328,20 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1267
1328
  const name = w.text.slice(2, eq === -1 ? undefined : eq);
1268
1329
  if (!ALLOWED_LONG[verb].has(name)) return null;
1269
1330
  if (name === 'include') include = true;
1331
+ if (name === 'recursive') recursive = true;
1332
+ // GNU grep and ugrep print a `file:0` row for every file searched; cg,
1333
+ // rg and git grep print only files with matches (D#133 #12).
1334
+ if (name === 'count' && verb === 'grep') return null;
1270
1335
  if (VALUE_LONG.has(name) && eq === -1) {
1271
1336
  const v = words[++i];
1272
- if (!valueWordOk(v, false) || (name === 'include' && /^!|\{/.test(v.text))) return null;
1337
+ if (!valueWordOk(v, false)) return null;
1338
+ }
1339
+ // GNU grep matches --include against the file's base name, so a glob
1340
+ // with `/` or `**` finds nothing there; cg's -g matches the path
1341
+ // (D#133 #7). Checked however the value is quoted or attached.
1342
+ if (name === 'include') {
1343
+ const v = eq === -1 ? words[i].text : w.text.slice(eq + 1);
1344
+ if (/^!|[{/]|\*\*/.test(v)) return null;
1273
1345
  }
1274
1346
  if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
1275
1347
  continue;
@@ -1277,6 +1349,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1277
1349
  const letters = w.text.slice(1);
1278
1350
  for (let k = 0; k < letters.length; k++) {
1279
1351
  const ch = letters[k];
1352
+ if (ch === 'c' && verb === 'grep') return null; // D#133 #12, as --count above
1280
1353
  if (valueShort.includes(ch)) {
1281
1354
  const isCount = 'ABC'.includes(ch);
1282
1355
  if (isCount) context = true;
@@ -1290,6 +1363,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1290
1363
  }
1291
1364
  if (!allowed.includes(ch)) return null;
1292
1365
  if (ch === 'i' || ch === 's') caseFlag = true;
1366
+ if (ch === 'r' || ch === 'R') recursive = true;
1293
1367
  }
1294
1368
  }
1295
1369
  if (operands.length === 0 || operands.length > 2) return null;
@@ -1307,10 +1381,169 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1307
1381
  // `grep --include='*.py' X src/a.rs` finds nothing; rg — and so cg — searches
1308
1382
  // a named file whatever its -g says (D#78). Equivalent only for a directory.
1309
1383
  if (include && target && !isDir(target.text)) return null;
1384
+ // Plain grep without -r reads no directory ("Is a directory") and, with no
1385
+ // path, reads stdin; the answer would search the tree (D#125 #3). rg and ag
1386
+ // recurse by default, and git grep searches the tracked tree.
1387
+ if (verb === 'grep' && !recursive && (!target || isDir(target.text))) return null;
1310
1388
  // ag is smart-case by default: an all-lowercase pattern matches any case,
1311
1389
  // and cg's search is case-sensitive, so the answer would find less.
1312
1390
  if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
1313
- return { pattern: pattern.text, target: target ? target.text : undefined, context };
1391
+ return { verb, pattern: pattern.text, target: target ? target.text : undefined, context };
1392
+ }
1393
+
1394
+ /**
1395
+ * Does the answer search the same files the grep would (D#133 #8)?
1396
+ *
1397
+ * cg searches ripgrep's walk (not hidden, not ignored) plus every git-tracked
1398
+ * file. The verbs it replaces each search another set, measured on one fixture
1399
+ * with each tool:
1400
+ * - grep -r (GNU, and ugrep as Claude Code's shell runs it) also reads
1401
+ * untracked hidden and ignored files, and ugrep skips a tracked ignored
1402
+ * file when the .gitignore naming it sits inside the searched path;
1403
+ * - git grep reads tracked files only, not untracked ones;
1404
+ * - rg and ag skip tracked hidden and tracked ignored files.
1405
+ * So the answer is equivalent only when the path holds none of the files that
1406
+ * set the two apart. Outside a git work tree cg searches ripgrep's walk alone:
1407
+ * a hidden entry, or an ignore file ripgrep, ag or ugrep reads, differs from
1408
+ * grep -r. Any failure to tell (git missing, a timeout, a walk too large) is
1409
+ * a difference: the grep runs.
1410
+ *
1411
+ * Review of D#133 found what git's view leaves out, and each now declines for
1412
+ * every verb:
1413
+ * - H-1: ripgrep reads `.ignore` and `.rgignore` (ag `.agignore`) inside
1414
+ * the path and in every directory above it, up to `/`; git reads none of
1415
+ * them. cg passes rg no `--no-config`, so RIPGREP_CONFIG_PATH changes its
1416
+ * search too.
1417
+ * - M-1: grep -r and rg skip a symlink met while recursing, cg names a
1418
+ * tracked one on rg's command line (followed) and git grep searches the
1419
+ * link's text; git grep does not enter a submodule, cg's walk does.
1420
+ * With `show`, the answer is the index's, whose walk (src/indexer/merkle.rs)
1421
+ * also skips hidden entries even when tracked, the INDEX_EXCLUDED_DIRS
1422
+ * segments, files of no detected language (src/utils/config.rs), files over
1423
+ * max_file_size (src/domain.rs) and tracked ignored files; any of them in the
1424
+ * path declines (H-2). The three lists are pinned to the Rust source by test.
1425
+ * @param {{root: string, target?: string, verb: string, show?: boolean}} opts
1426
+ * `target` is root-relative (undefined: the whole root).
1427
+ */
1428
+ const HIDDEN_SEGMENT = /(?:^|\/)\.(?!\.?(?:\/|$))/;
1429
+ const RG_ONLY_IGNORE_FILES = ['.ignore', '.rgignore', '.agignore'];
1430
+ const WALK_LIMIT = 20000;
1431
+ const INDEX_EXCLUDED_DIRS = ['node_modules', 'vendor', 'target', 'bower_components'];
1432
+ const INDEX_EXTENSIONS = new Set(['rs', 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'go', 'py', 'pyi', 'java',
1433
+ 'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hh', 'hxx', 'html', 'htm', 'css', 'cs', 'kt', 'kts', 'rb', 'php',
1434
+ 'swift', 'dart', 'md', 'mdx', 'markdown', 'sh', 'bash', 'json']);
1435
+ const INDEX_DEFAULT_MAX_FILE_SIZE = 1048576;
1436
+ // As src/domain.rs reads it: a u64, else the default. The hook sees its own
1437
+ // environment, not the index process's.
1438
+ function indexMaxFileSize(env = process.env) {
1439
+ const raw = env.CODE_GRAPH_MAX_FILE_SIZE;
1440
+ return typeof raw === 'string' && /^\+?\d+$/.test(raw) ? Number(raw) : INDEX_DEFAULT_MAX_FILE_SIZE;
1441
+ }
1442
+ // Does the index read this root-relative file (by name; size is separate)?
1443
+ function indexReadsPath(rel) {
1444
+ const segs = rel.split('/');
1445
+ if (segs.some((s) => s.startsWith('.') || INDEX_EXCLUDED_DIRS.includes(s))) return false;
1446
+ const base = segs[segs.length - 1];
1447
+ const dot = base.lastIndexOf('.');
1448
+ return dot > 0 && INDEX_EXTENSIONS.has(base.slice(dot + 1));
1449
+ }
1450
+ // Is there a ripgrep-only ignore file in `dir` or any directory above it?
1451
+ function rgIgnoreAbove(dir) {
1452
+ for (let d = dir; ; d = path.dirname(d)) {
1453
+ if (RG_ONLY_IGNORE_FILES.some((f) => fs.existsSync(path.join(d, f)))) return true;
1454
+ if (path.dirname(d) === d) return false;
1455
+ }
1456
+ }
1457
+ // A listed entry that cannot be read counts as a symlink (the caller declines);
1458
+ // a missing path does not.
1459
+ function isSymlink(p, { missing = true } = {}) {
1460
+ try { return fs.lstatSync(p).isSymbolicLink(); } catch { return missing; }
1461
+ }
1462
+ function searchesSameFiles({ root, target, verb, show = false } = {}) {
1463
+ if (!root || !['grep', 'git', 'rg', 'ag'].includes(verb)) return false;
1464
+ const rel = target === undefined ? '' : String(target).replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
1465
+ if (rel.split('/').includes('..') || path.isAbsolute(rel)) return false;
1466
+ if (process.env.RIPGREP_CONFIG_PATH) return false;
1467
+ if (rgIgnoreAbove(path.join(root, rel))) return false;
1468
+ const maxSize = indexMaxFileSize();
1469
+ // For `show`: every file the grep reads must be one the index holds.
1470
+ const indexHolds = (files) => files.length <= WALK_LIMIT && files.every((f) => {
1471
+ if (!indexReadsPath(f)) return false;
1472
+ try { return fs.statSync(path.join(root, f)).size <= maxSize; } catch { return false; }
1473
+ });
1474
+ const git = (args) => {
1475
+ const budget = require('./hook-fail-open').remainingMs(1500);
1476
+ if (budget === null) return { status: null, stdout: '' };
1477
+ return spawnSync('git', args, hidden({
1478
+ cwd: root, encoding: 'utf8', timeout: budget, killSignal: 'SIGKILL',
1479
+ maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
1480
+ }));
1481
+ };
1482
+ const inside = git(['rev-parse', '--is-inside-work-tree']);
1483
+ if (inside.status === 0 && String(inside.stdout).trim() === 'true') {
1484
+ const ls = (...opts) => {
1485
+ const r = git(['ls-files', '-z', ...opts, '--', rel || '.']);
1486
+ return r.status === 0 ? String(r.stdout).split('\0').filter(Boolean) : null;
1487
+ };
1488
+ const staged = ls('--cached', '--stage'); // "<mode> <sha> <n>\t<path>"
1489
+ const others = ls('--others', '--exclude-standard'); // untracked, not ignored
1490
+ const ignoredOthers = ls('--others', '--ignored', '--exclude-standard', '--directory');
1491
+ const ignoredTracked = ls('--cached', '--ignored', '--exclude-standard');
1492
+ if (!staged || !others || !ignoredOthers || !ignoredTracked) return false;
1493
+ const tracked = [];
1494
+ for (const e of staged) {
1495
+ // 120000: a symlink; 160000: a submodule (M-1).
1496
+ if (/^1[26]0000 /.test(e)) return false;
1497
+ tracked.push(e.slice(e.indexOf('\t') + 1));
1498
+ }
1499
+ // An untracked nested repository is listed as `dir/`; a symlink as a file.
1500
+ if (others.length > WALK_LIMIT || others.some((f) => f.endsWith('/') || isSymlink(path.join(root, f)))) return false;
1501
+ const base = (f) => path.posix.basename(f.replace(/\/+$/, ''));
1502
+ if ([tracked, others, ignoredOthers].some((l) => l.some((f) => RG_ONLY_IGNORE_FILES.includes(base(f))))) return false;
1503
+ const anyHidden = (l) => l.some((f) => HIDDEN_SEGMENT.test(f));
1504
+ let reads;
1505
+ if (verb === 'grep') {
1506
+ if (anyHidden(others) || ignoredOthers.length || ignoredTracked.length) return false;
1507
+ reads = [...tracked, ...others];
1508
+ } else if (verb === 'git') {
1509
+ if (others.length) return false;
1510
+ reads = tracked;
1511
+ } else {
1512
+ if (anyHidden(tracked) || ignoredTracked.length) return false;
1513
+ reads = [...tracked, ...others.filter((f) => !HIDDEN_SEGMENT.test(f))];
1514
+ }
1515
+ return !show || (ignoredTracked.length === 0 && indexHolds(reads));
1516
+ }
1517
+ // 128 is git's "not a git repository"; anything else is a failure to tell.
1518
+ if (inside.status !== 128) return false;
1519
+ // Not a work tree: ripgrep's walk. A .gitignore from the root down to the
1520
+ // path (ugrep reads it; ripgrep's own ignore files were checked above), a
1521
+ // hidden or symlinked directory on the way, or any hidden entry or symlink
1522
+ // inside it sets the verbs apart.
1523
+ let dir = root;
1524
+ for (const seg of ['', ...(rel ? rel.split('/') : [])]) {
1525
+ if (seg) dir = path.join(dir, seg);
1526
+ if (seg.startsWith('.') || (seg && isSymlink(dir, { missing: false }))) return false;
1527
+ if (fs.existsSync(path.join(dir, '.gitignore'))) return false;
1528
+ }
1529
+ const start = path.join(root, rel);
1530
+ const files = [];
1531
+ let seen = 0;
1532
+ const stack = [start];
1533
+ while (stack.length) {
1534
+ const d = stack.pop();
1535
+ let entries;
1536
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
1537
+ for (const e of entries) {
1538
+ if (++seen > WALK_LIMIT) return false;
1539
+ if (e.name.startsWith('.') || e.isSymbolicLink()) return false;
1540
+ if (e.isDirectory()) stack.push(path.join(d, e.name));
1541
+ else if (show) files.push(path.relative(root, path.join(d, e.name)).split(path.sep).join('/'));
1542
+ }
1543
+ }
1544
+ // A file named as the path itself: its own entry was never listed.
1545
+ if (show && rel && files.length === 0 && !isDirectory(start)) files.push(rel);
1546
+ return !show || indexHolds(files);
1314
1547
  }
1315
1548
 
1316
1549
  // Does `rootTarget`, run from the root, name the path the grep clause's own
@@ -1336,9 +1569,13 @@ const CWD_NEUTRAL = new Set([
1336
1569
  'dirname', 'perl', 'gh', 'curl', 'make', 'bash', 'sh', 'timeout', 'tar', 'sqlite3',
1337
1570
  'code-graph-mcp', 'nproc', 'rustc', 'go', 'wait', 'column', 'md5sum', 'sha256sum',
1338
1571
  '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',
1572
+ // Not `exit` or `return` (D#133 #10): if one before the grep ran, the grep
1573
+ // did not, and its output is empty exactly when the inject fires. `set` is
1574
+ // neutral only without errexit — see segmentCwd.
1341
1575
  ]);
1576
+ // `set -e`, `set -euo pipefail`, `set -o errexit`: from here a failing command
1577
+ // ends the script, so a later grep may not have run.
1578
+ const SET_ERREXIT = /(?:^|\s)(?:-[a-zA-Z]*e[a-zA-Z]*|errexit)(?=\s|$)/;
1342
1579
  // `X=1 Y=$(mktemp -d)`: a command substitution runs in a subshell.
1343
1580
  const PURE_ASSIGNMENTS =
1344
1581
  /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\([^()]*\)|"[^"]*"|'[^']*'|[^\s;|&<>()'"]*)\s*)+$/;
@@ -1378,6 +1615,7 @@ function segmentCwd(segments, idx, shellCwd, { isDir = isDirectory, seps } = {})
1378
1615
  if (!words) return null;
1379
1616
  const head = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
1380
1617
  if (head && !(head.quotedFrom === -1 && CWD_NEUTRAL.has(head.text))) return null;
1618
+ if (head && head.text === 'set' && SET_ERREXIT.test(clause.replace(/^\s*set\b/, ''))) return null;
1381
1619
  }
1382
1620
  return cwd;
1383
1621
  }
@@ -1401,37 +1639,246 @@ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
1401
1639
  if (block.mode === 'show') {
1402
1640
  const f = cgFlagSet(extractCgFlags(cmd));
1403
1641
  if (f.has('-l') || f.has('-c')) return false;
1642
+ // `show` has no file filter: `--include`/`-g`/`-t` excluded definitions it
1643
+ // would print (review of D#125).
1644
+ const all = extractCgFlags(cmd);
1645
+ if (all.includes('-g') || all.includes('-t')) return false;
1404
1646
  // show answers at most three symbols; a fourth would silently vanish.
1405
1647
  if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
1648
+ if (!showAnswersPattern(plan.pattern, cmd)) return false;
1406
1649
  }
1407
1650
  return true;
1408
1651
  }
1409
1652
 
1653
+ // One alternative `show` answers: a declaration keyword, the name, and an end
1654
+ // of word (`\b`, or `\>` outside Perl) unless -w supplies it.
1655
+ const SHOW_ALT = /^(fn|def|class|function|struct|trait) +([A-Za-z_][A-Za-z0-9_]*)(\\b|\\>)?$/;
1656
+
1657
+ /**
1658
+ * Does `show` print exactly the definitions this grep's pattern matches?
1659
+ * `show` answers whole names, case-sensitively, whatever surrounds them (0.161.0
1660
+ * Not covered, D#133): `fn foo` also matches `fn foobar`, `-i` matches `Foo`,
1661
+ * `pub fn foo` misses a private `fn foo`, `def foo(` misses a Ruby `def foo`,
1662
+ * and an alternative without a keyword matches lines `show` never prints. So
1663
+ * every alternative must be `KEYWORD NAME` ending the word, and nothing else.
1664
+ */
1665
+ function showAnswersPattern(pattern, cmd) {
1666
+ if (typeof pattern !== 'string') return false;
1667
+ const flags = cgFlagSet(extractCgFlags(cmd));
1668
+ if (flags.has('-i')) return false;
1669
+ const fixed = flags.has('-F');
1670
+ const word = flags.has('-w');
1671
+ const dialect = patternDialect(cmd);
1672
+ if (dialect === 'conflict') return false;
1673
+ const alts = fixed ? [pattern] : pattern.split(dialect === 'basic' ? '\\|' : '|');
1674
+ return alts.every((alt) => {
1675
+ const m = SHOW_ALT.exec(alt);
1676
+ if (!m) return false;
1677
+ if (!m[3]) return word;
1678
+ // -F reads `\b` as two characters; PCRE reads `\>` as `>`.
1679
+ return !fixed && !(m[3] === '\\>' && dialect === 'perl');
1680
+ });
1681
+ }
1682
+
1410
1683
  // v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
1411
1684
  // (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
1412
1685
  // the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
1413
- // were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
1414
- // grep -E/-P are already extended.
1686
+ // were dialect/path-shape misses). Unescape for plain grep only; an -E, -P or
1687
+ // ag pattern is passed on only when every dialect reads it alike; rg's is the
1688
+ // answer's own. null: no equivalent pattern, the grep runs.
1415
1689
  function translateBreToRg(cmd, pattern) {
1416
1690
  if (typeof pattern !== 'string' || !pattern) return pattern;
1417
- const verb = (cmd.match(GREP_HEAD) || [])[1];
1418
- // git grep speaks BRE like plain grep; rg/ag are already extended-regex.
1419
- if (!verb || !/grep$/.test(verb)) return pattern;
1420
- // The grep's OWN clause, not the whole command. This was the one flag check
1421
- // in this module that v0.96 did not clause-scope, and round 4 of pre-ship
1422
- // review found what it costs: `grep -rln "a\|b" src/ | xargs -P4 wc -l` read
1423
- // the tail's `-P4` as "this grep speaks Perl regex", so the pattern was left
1424
- // escaped. On its own that is a wrong dialect decision; it also desynchronised
1425
- // the two hooks, because post-grep-inject passes a SEGMENT here while the deny
1426
- // path passes the whole command — the same pattern then filed under two
1427
- // spellings and the funnel scored a verbatim re-grep as neutral.
1428
- // Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
1429
- // is not the flag (review of D#62).
1691
+ const dialect = patternDialect(cmd);
1692
+ if (dialect === 'rust') return pattern;
1693
+ if (dialect === 'conflict') return null;
1694
+ // D#133 #5: an extended or Perl pattern was passed on unchecked, and the
1695
+ // dialects read `\d`, `[\(]`, `a{,2}`, `a+?b` and `(?i)` differently (GNU
1696
+ // grep 3.12, ugrep 7.8 and cg run on one fixture).
1697
+ const out = dialect === 'basic'
1698
+ ? breToRustRegex(pattern)
1699
+ : portableExtended(pattern, { perl: dialect === 'perl' });
1700
+ // ugrep, the `grep` Claude Code's shell runs, prints no line for an empty
1701
+ // match, where GNU grep and rg print every line (review of D#133, M-2).
1702
+ return out !== null && matchesEmpty(out) ? null : out;
1703
+ }
1704
+
1705
+ /**
1706
+ * Can this pattern (rust syntax, as translateBreToRg returns it) match the
1707
+ * empty string? An assertion (`^`, `$`, `\b`, `\B`, `\<`, `\>`) matches
1708
+ * empty; so does an atom under `*`, `?` or a `{0…}` repeat, a sequence of such
1709
+ * atoms, and an alternation with one such branch (`a|`). Unreadable: true, so
1710
+ * the caller declines.
1711
+ */
1712
+ function matchesEmpty(pattern) {
1713
+ let i = 0;
1714
+ let bad = false;
1715
+ const alt = () => {
1716
+ let any = seq();
1717
+ while (pattern[i] === '|') { i++; any = seq() || any; }
1718
+ return any;
1719
+ };
1720
+ const seq = () => {
1721
+ let all = true;
1722
+ while (i < pattern.length && pattern[i] !== '|' && pattern[i] !== ')') {
1723
+ let empty = atom();
1724
+ for (;;) {
1725
+ const c = pattern[i];
1726
+ if (c === '*' || c === '?') { empty = true; i++; continue; }
1727
+ if (c === '+') { i++; continue; }
1728
+ const m = c === '{' ? /^\{(\d+)(?:,\d*)?\}/.exec(pattern.slice(i)) : null;
1729
+ if (!m) break;
1730
+ if (Number(m[1]) === 0) empty = true;
1731
+ i += m[0].length;
1732
+ }
1733
+ all = all && empty;
1734
+ }
1735
+ return all;
1736
+ };
1737
+ const atom = () => {
1738
+ const c = pattern[i];
1739
+ if (c === '(') {
1740
+ i++;
1741
+ const inner = alt();
1742
+ if (pattern[i] !== ')') bad = true;
1743
+ i++;
1744
+ return inner;
1745
+ }
1746
+ if (c === '\\') {
1747
+ const n = pattern[i + 1];
1748
+ i += 2;
1749
+ return n === undefined || 'bB<>'.includes(n);
1750
+ }
1751
+ if (c === '[') {
1752
+ const end = bracketEnd(pattern, i);
1753
+ if (end === -1) { bad = true; i = pattern.length; return true; }
1754
+ i = end + 1;
1755
+ return false;
1756
+ }
1757
+ i++;
1758
+ return c === '^' || c === '$';
1759
+ };
1760
+ const empty = alt();
1761
+ return bad || i < pattern.length || empty;
1762
+ }
1763
+
1764
+ /**
1765
+ * The regex dialect a grep clause's pattern is written in: 'rust' (rg, the
1766
+ * answer's own), 'perl' (ag, `-P`), 'extended' (`-E`), 'basic' (plain grep
1767
+ * and git grep), or 'conflict' (`-E` and `-P` together, an error to GNU
1768
+ * grep). `-F` is the callers' to read, from extractCgFlags, which
1769
+ * knows a flag's value slot (`--include -F`).
1770
+ *
1771
+ * The grep's OWN clause, not the whole command. This was the one flag check
1772
+ * in this module that v0.96 did not clause-scope, and round 4 of pre-ship
1773
+ * review found what it costs: `grep -rln "a\|b" src/ | xargs -P4 wc -l` read
1774
+ * the tail's `-P4` as "this grep speaks Perl regex", so the pattern was left
1775
+ * escaped. On its own that is a wrong dialect decision; it also desynchronised
1776
+ * the two hooks, because post-grep-inject passes a SEGMENT here while the deny
1777
+ * path passes the whole command — the same pattern then filed under two
1778
+ * spellings and the funnel scored a verbatim re-grep as neutral.
1779
+ * Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
1780
+ * is not the flag (review of D#62). A word that is only quoted (`"-E"`) IS the
1781
+ * flag: the shell strips the quotes before grep reads its argv (D#133 M10).
1782
+ */
1783
+ function patternDialect(cmd) {
1784
+ const verb = (String(cmd).match(GREP_HEAD) || [])[1];
1785
+ if (!verb) return 'basic';
1786
+ if (verb === 'rg') return 'rust';
1787
+ if (verb === 'ag') return 'perl';
1430
1788
  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);
1789
+ const has = (letter, long) => words.some((w) => w.text === `--${long}`
1790
+ || new RegExp(`^-[a-zA-Z]*${letter}[a-zA-Z]*(?:=|\\d|$)`).test(w.text));
1791
+ const perl = has('P', 'perl-regexp');
1792
+ const extended = has('E', 'extended-regexp');
1793
+ // Both: GNU grep reports "conflicting matchers specified" and exits 2
1794
+ // (review of D#133, L-4); there is no dialect to answer in.
1795
+ if (perl && extended) return 'conflict';
1796
+ if (perl) return 'perl';
1797
+ if (extended) return 'extended';
1798
+ return 'basic';
1799
+ }
1800
+
1801
+ // An extended (ERE) or Perl pattern that GNU grep, ugrep and rust regex all
1802
+ // read alike, returned as is; anything else is null and the grep runs. Only
1803
+ // escapes of the word/space/boundary classes and of metacharacters pass, and
1804
+ // `\<` `\>` only in ERE (PCRE reads them as the characters `<` `>`). A
1805
+ // quantifier with nothing to repeat (so every `(?` group), after another
1806
+ // quantifier (`+?` is lazy in rust, a repeated group in ERE) or after an
1807
+ // assertion (`\b{2}`, `$+`: an error or a literal to GNU grep, every line to
1808
+ // rust), a brace that is not `{n}`, `{n,}` or `{n,m}`, and a bracket holding an
1809
+ // escape, a nested `[`, a set operator or a POSIX class are refused. Not
1810
+ // `\W`: ugrep's matches a line break, so `\Wfoo` also printed the line before
1811
+ // a match; not `\d` `\D` under Perl: ASCII to GNU grep, Unicode to rust, and
1812
+ // ugrep's `\D` matches a line break (review of D#133, M-2).
1813
+ const EXT_SAME_ESCAPE = 'wsSbB.[]^$*+?(){}|\\/';
1814
+ function portableExtended(pattern, { perl = false } = {}) {
1815
+ let atomStart = true; // a quantifier here would repeat nothing
1816
+ let quantified = false; // the previous token was a quantifier
1817
+ let assertion = false; // the previous token matches no character
1818
+ let altStart = true; // an alternative starts here
1819
+ for (let i = 0; i < pattern.length; i++) {
1820
+ const c = pattern[i];
1821
+ const wasAltStart = altStart;
1822
+ altStart = false;
1823
+ if (c === '\\') {
1824
+ const n = pattern[i + 1];
1825
+ const same = n !== undefined && (EXT_SAME_ESCAPE.includes(n)
1826
+ || (!perl && (n === '<' || n === '>')));
1827
+ if (!same) return null;
1828
+ i++;
1829
+ atomStart = false;
1830
+ quantified = false;
1831
+ assertion = 'bB<>'.includes(n);
1832
+ continue;
1833
+ }
1834
+ if (c === '*' || c === '+' || c === '?' || c === '{') {
1835
+ if (atomStart || quantified || assertion) return null;
1836
+ if (c === '{') {
1837
+ const m = /^\{\d+(?:,\d*)?\}/.exec(pattern.slice(i));
1838
+ if (!m) return null;
1839
+ i += m[0].length - 1;
1840
+ }
1841
+ quantified = true;
1842
+ continue;
1843
+ }
1844
+ quantified = false;
1845
+ assertion = false;
1846
+ // `^` and `$` only where an alternative starts or ends (so never `$+`):
1847
+ // elsewhere ugrep reads them apart from GNU grep and rust (`\[*^[ab]`
1848
+ // matched `[a]`). A stray `}` is an error to ugrep -P (review of D#133,
1849
+ // M-2).
1850
+ if (c === '^' && !wasAltStart) return null;
1851
+ if (c === '$' && i + 1 < pattern.length && !')|'.includes(pattern[i + 1])) return null;
1852
+ if (c === '}') return null;
1853
+ // After `(`, `|` or `^` a quantifier repeats nothing; that also refuses
1854
+ // every `(?` group (`(?i)`, `(?:`, a lookaround).
1855
+ if (c === '(' || c === '|') altStart = true;
1856
+ if (c === '(' || c === '|' || c === '^') { atomStart = true; continue; }
1857
+ atomStart = false;
1858
+ if (c !== '[') continue;
1859
+ const end = bracketEnd(pattern, i);
1860
+ if (end === -1) return null;
1861
+ i = end;
1862
+ }
1863
+ return pattern;
1864
+ }
1865
+
1866
+ // The index of the `]` closing the bracket expression opened at `i`, or -1
1867
+ // when the bracket reads differently in POSIX and rust regex: POSIX has no
1868
+ // escapes in a bracket, rust has escapes, `[` nesting and the `&&` `--` `~~`
1869
+ // set operators; a leading `]` is a member in POSIX. A POSIX class
1870
+ // (`[:alpha:]`) is ASCII to rust and follows the locale in GNU grep and ugrep
1871
+ // (`é`, `٣`; review of D#133, M-2), so it counts as a nested `[`.
1872
+ // Unterminated: -1 (grep reports an error).
1873
+ function bracketEnd(pattern, i) {
1874
+ let j = i + 1;
1875
+ if (pattern[j] === '^') j++;
1876
+ if (pattern[j] === ']') return -1;
1877
+ for (; j < pattern.length && pattern[j] !== ']'; j++) {
1878
+ if (pattern[j] === '\\' || pattern[j] === '[') return -1;
1879
+ if ('&-~'.includes(pattern[j]) && pattern[j + 1] === pattern[j]) return -1;
1880
+ }
1881
+ return j >= pattern.length ? -1 : j;
1435
1882
  }
1436
1883
 
1437
1884
  // The two dialects swap these characters' roles: in a basic regex `( ) { } + ?
@@ -1450,41 +1897,52 @@ function translateBreToRg(cmd, pattern) {
1450
1897
  // Escapes: only the ones both read alike pass — the swapped operators, the
1451
1898
  // word/space/boundary classes, and an escaped metacharacter. `\1`, `\d`, `\z`,
1452
1899
  // `\_`, `\=` 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).
1900
+ // do an empty group `\(\)`, any `[:class:]` and a leading `]` in a bracket
1901
+ // (review of D#65), `\W`, which matches a line break in ugrep, and a
1902
+ // quantifier after an assertion (`\b*`, `\>\{2\}`; review of D#133, M-2).
1455
1903
  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']);
1904
+ const BRE_SAME_ESCAPE = 'wsSbB<>.*[]^$\\/';
1905
+ // An unescaped `^` is an anchor only where an alternative starts (the pattern's
1906
+ // start, after `\(` or `\|`), and `$` only where one ends (the pattern's end,
1907
+ // before `\)` or `\|`); elsewhere BRE reads them as literals and rust regex as
1908
+ // anchors — `getUser\|$user_id` searched the text `$user_id` (D#125 #6). No
1909
+ // translation: the grep runs.
1459
1910
  function breToRustRegex(pattern) {
1460
1911
  let out = '';
1912
+ let altStart = true;
1913
+ let anchor = false;
1914
+ let assertion = false;
1461
1915
  for (let i = 0; i < pattern.length; i++) {
1462
1916
  const c = pattern[i];
1917
+ const wasAltStart = altStart;
1918
+ const wasAnchor = anchor;
1919
+ const wasAssertion = assertion;
1920
+ altStart = false;
1921
+ anchor = c === '^' && wasAltStart;
1922
+ assertion = anchor;
1463
1923
  if (c === '\\' && i + 1 < pattern.length) {
1464
1924
  const n = pattern[++i];
1465
1925
  if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
1926
+ if (wasAssertion && '+?{'.includes(n)) return null;
1466
1927
  if (BRE_SWAPPED.includes(n)) out += n;
1467
1928
  else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
1468
1929
  else return null;
1930
+ altStart = n === '(' || n === '|';
1931
+ assertion = 'bB<>'.includes(n);
1469
1932
  continue;
1470
1933
  }
1934
+ if (c === '*' && wasAssertion) return null;
1935
+ if (c === '^' && !wasAltStart) return null;
1936
+ // A `*` where an alternative starts, or right after its `^`, repeats
1937
+ // nothing: BRE reads it as a literal, rust regex as an error or a repeat of
1938
+ // the anchor (review of D#125).
1939
+ if (c === '*' && (wasAltStart || (wasAnchor && out.endsWith('^')))) return null;
1940
+ if (c === '$' && i + 1 < pattern.length
1941
+ && !pattern.startsWith('\\)', i + 1) && !pattern.startsWith('\\|', i + 1)) return null;
1471
1942
  if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
1472
1943
  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
1944
+ const j = bracketEnd(pattern, i);
1945
+ if (j === -1) return null;
1488
1946
  out += pattern.slice(i, j + 1);
1489
1947
  i = j;
1490
1948
  }
@@ -1650,6 +2108,7 @@ function runMain() {
1650
2108
  // forwarding `-F` without this guard would make it search the wrong text.
1651
2109
  const cgFlags = extractCgFlags(cmd);
1652
2110
  const rawGrepPattern = pickBlockPattern(cmd);
2111
+ if (!rootOnlyInSearchPath(rawCmd, root, extractSearchPath(cmd))) return;
1653
2112
  const grepPattern = cgFlagSet(cgFlags).has('-F')
1654
2113
  ? rawGrepPattern
1655
2114
  : translateBreToRg(cmd, rawGrepPattern);
@@ -1728,7 +2187,12 @@ function runMain() {
1728
2187
  // No fallback to a grep answer: a context grep (`-A5`) asked for the
1729
2188
  // body, and the grep rewrite would drop the context silently (pre-ship
1730
2189
  // review round 2). A show miss lets the raw grep run.
1731
- answer = runShowAnswer({ cwd: root, symbols: block.symbols });
2190
+ // Scoped to the grep's path: `show` alone answers the whole project
2191
+ // (D#125 #2 — `grep -A3 "fn f" lib/` was answered from src/).
2192
+ answer = runShowAnswer({
2193
+ cwd: root, symbols: block.symbols, within: searchPath ?? '',
2194
+ kinds: declKindsBySymbol(extractPatterns(cmd)),
2195
+ });
1732
2196
  } else if (pattern) {
1733
2197
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
1734
2198
  }
@@ -1760,6 +2224,16 @@ function runMain() {
1760
2224
  return;
1761
2225
  }
1762
2226
 
2227
+ // D#133 #8 — the answer searches tracked files plus ripgrep's walk; the
2228
+ // grep's own file set differs when the path holds untracked hidden, ignored
2229
+ // or (for git grep) untracked files. Checked only for an answer that would
2230
+ // replace the command: it runs git. The grep runs as typed.
2231
+ // A `show` answer reads the index instead, which skips more (review of
2232
+ // D#133, H-2).
2233
+ if (plan && !searchesSameFiles({
2234
+ root, target: extractSearchPath(cmd), verb: plan.verb, show: answeredMode === 'show',
2235
+ })) return;
2236
+
1763
2237
  const answered = answer.status === 'hits';
1764
2238
  recordRecommendation(root, {
1765
2239
  // Still `deny` in the funnel: the event it counts — a raw grep intercepted
@@ -1797,11 +2271,13 @@ function runMain() {
1797
2271
  // Re-run exactly what answered: the resolved symbols for `show`, the same
1798
2272
  // argv for `grep`.
1799
2273
  // `-m 0`: cg caps matches at 100 per file by default and says so only on
1800
- // stderr; the grep being replaced has no cap (round 3 M4). Only here, not
2274
+ // stderr; the grep being replaced has no cap (round 3 M4). `-M 0`: cg cuts
2275
+ // each line at 512 characters, so a match past that column was not in the
2276
+ // answer at all (D#133 #12); 0 is unlimited, as grep prints. Only here, not
1801
2277
  // in the in-hook answer, which is a has-hits probe.
1802
2278
  const argvList = answeredMode === 'show'
1803
- ? answer.symbols.map((sym) => ['show', sym])
1804
- : [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
2279
+ ? answer.argvs
2280
+ : [[...args.slice(0, 1), '-m', '0', '-M', '0', ...args.slice(1)]];
1805
2281
  const command = buildRewriteCommand(argvList, {
1806
2282
  invocation: shellQuoteArg(resolveAnswerBinary({})),
1807
2283
  root,
@@ -1861,6 +2337,15 @@ module.exports = {
1861
2337
  isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
1862
2338
  extractSearchPath, // v0.47.0 — deny-with-answer
1863
2339
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
2340
+ rootOnlyInSearchPath, // D#125 #1
2341
+ isDirectory,
2342
+ declKindsBySymbol, // D#125 #2 review
2343
+ searchesSameFiles, // D#133 #8
2344
+ indexReadsPath, // review of D#133 H-2: the index's own skip rules, mirrored
2345
+ INDEX_EXCLUDED_DIRS,
2346
+ INDEX_EXTENSIONS,
2347
+ INDEX_DEFAULT_MAX_FILE_SIZE,
2348
+ showAnswersPattern, // D#133 — show answers whole names only
1864
2349
  resolveProjectRoot, // v0.48 — subdir-cwd dark fix
1865
2350
  rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
1866
2351
  commandHasBypass, // v0.48 — bypass funnel visibility