@sdsrs/code-graph 0.161.0 → 0.163.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 {
@@ -1255,8 +1257,10 @@ function shellWords(s) {
1255
1257
  // (recursion, line numbers, filenames, binary skipping, the regex dialect).
1256
1258
  // Per verb, because letters differ: ag's `-n` is --norecurse and `-H` is
1257
1259
  // --heading, rg's `-r` is --replace. Context letters (A/B/C, a count value)
1258
- // only reach the rewrite in `show` mode, which answers with the body.
1259
- 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' };
1260
1264
  const VALUE_SHORT_BY_VERB = { grep: 'ABC', git: 'ABC', rg: 'gtABC', ag: 'ABC' };
1261
1265
  const COMMON_LONG = ['ignore-case', 'word-regexp', 'fixed-strings', 'files-with-matches', 'count', 'line-number'];
1262
1266
  const ALLOWED_LONG = {
@@ -1315,8 +1319,8 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1315
1319
  const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
1316
1320
  if (!attachedFilter || w.bareOp) return null;
1317
1321
  // 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;
1322
+ // cg's -g has both (round 4 L2; review of D#78). Checked with the other
1323
+ // --include value rules below.
1320
1324
  }
1321
1325
  if (w.text === '--') { endOfFlags = true; continue; }
1322
1326
  if (w.text.startsWith('--')) {
@@ -1325,9 +1329,19 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1325
1329
  if (!ALLOWED_LONG[verb].has(name)) return null;
1326
1330
  if (name === 'include') include = true;
1327
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;
1328
1335
  if (VALUE_LONG.has(name) && eq === -1) {
1329
1336
  const v = words[++i];
1330
- 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;
1331
1345
  }
1332
1346
  if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
1333
1347
  continue;
@@ -1335,6 +1349,7 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1335
1349
  const letters = w.text.slice(1);
1336
1350
  for (let k = 0; k < letters.length; k++) {
1337
1351
  const ch = letters[k];
1352
+ if (ch === 'c' && verb === 'grep') return null; // D#133 #12, as --count above
1338
1353
  if (valueShort.includes(ch)) {
1339
1354
  const isCount = 'ABC'.includes(ch);
1340
1355
  if (isCount) context = true;
@@ -1373,29 +1388,162 @@ function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
1373
1388
  // ag is smart-case by default: an all-lowercase pattern matches any case,
1374
1389
  // and cg's search is case-sensitive, so the answer would find less.
1375
1390
  if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
1376
- return { pattern: pattern.text, target: target ? target.text : undefined, context };
1391
+ return { verb, pattern: pattern.text, target: target ? target.text : undefined, context };
1377
1392
  }
1378
1393
 
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
- }
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))];
1396
1514
  }
1515
+ return !show || (ignoredTracked.length === 0 && indexHolds(reads));
1397
1516
  }
1398
- return target === undefined || isDir(target);
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);
1399
1547
  }
1400
1548
 
1401
1549
  // Does `rootTarget`, run from the root, name the path the grep clause's own
@@ -1421,9 +1569,13 @@ const CWD_NEUTRAL = new Set([
1421
1569
  'dirname', 'perl', 'gh', 'curl', 'make', 'bash', 'sh', 'timeout', 'tar', 'sqlite3',
1422
1570
  'code-graph-mcp', 'nproc', 'rustc', 'go', 'wait', 'column', 'md5sum', 'sha256sum',
1423
1571
  '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',
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.
1426
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|$)/;
1427
1579
  // `X=1 Y=$(mktemp -d)`: a command substitution runs in a subshell.
1428
1580
  const PURE_ASSIGNMENTS =
1429
1581
  /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\([^()]*\)|"[^"]*"|'[^']*'|[^\s;|&<>()'"]*)\s*)+$/;
@@ -1463,6 +1615,7 @@ function segmentCwd(segments, idx, shellCwd, { isDir = isDirectory, seps } = {})
1463
1615
  if (!words) return null;
1464
1616
  const head = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
1465
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;
1466
1619
  }
1467
1620
  return cwd;
1468
1621
  }
@@ -1492,35 +1645,240 @@ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
1492
1645
  if (all.includes('-g') || all.includes('-t')) return false;
1493
1646
  // show answers at most three symbols; a fourth would silently vanish.
1494
1647
  if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
1648
+ if (!showAnswersPattern(plan.pattern, cmd)) return false;
1495
1649
  }
1496
1650
  return true;
1497
1651
  }
1498
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
+
1499
1683
  // v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
1500
1684
  // (`a\|b`, `\(x\)`) and 0-hit against cg grep's rust-regex dialect, wasting
1501
1685
  // the answer on the ALLOW fallthrough (2026-06-12: both answered:false denies
1502
- // were dialect/path-shape misses). Unescape for plain grep only — rg/ag and
1503
- // 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.
1504
1689
  function translateBreToRg(cmd, pattern) {
1505
1690
  if (typeof pattern !== 'string' || !pattern) return pattern;
1506
- const verb = (cmd.match(GREP_HEAD) || [])[1];
1507
- // git grep speaks BRE like plain grep; rg/ag are already extended-regex.
1508
- if (!verb || !/grep$/.test(verb)) return pattern;
1509
- // The grep's OWN clause, not the whole command. This was the one flag check
1510
- // in this module that v0.96 did not clause-scope, and round 4 of pre-ship
1511
- // review found what it costs: `grep -rln "a\|b" src/ | xargs -P4 wc -l` read
1512
- // the tail's `-P4` as "this grep speaks Perl regex", so the pattern was left
1513
- // escaped. On its own that is a wrong dialect decision; it also desynchronised
1514
- // the two hooks, because post-grep-inject passes a SEGMENT here while the deny
1515
- // path passes the whole command — the same pattern then filed under two
1516
- // spellings and the funnel scored a verbatim re-grep as neutral.
1517
- // Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
1518
- // 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';
1519
1788
  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);
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;
1524
1882
  }
1525
1883
 
1526
1884
  // The two dialects swap these characters' roles: in a basic regex `( ) { } + ?
@@ -1539,12 +1897,11 @@ function translateBreToRg(cmd, pattern) {
1539
1897
  // Escapes: only the ones both read alike pass — the swapped operators, the
1540
1898
  // word/space/boundary classes, and an escaped metacharacter. `\1`, `\d`, `\z`,
1541
1899
  // `\_`, `\=` 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).
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).
1544
1903
  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']);
1904
+ const BRE_SAME_ESCAPE = 'wsSbB<>.*[]^$\\/';
1548
1905
  // An unescaped `^` is an anchor only where an alternative starts (the pattern's
1549
1906
  // start, after `\(` or `\|`), and `$` only where one ends (the pattern's end,
1550
1907
  // before `\)` or `\|`); elsewhere BRE reads them as literals and rust regex as
@@ -1554,21 +1911,27 @@ function breToRustRegex(pattern) {
1554
1911
  let out = '';
1555
1912
  let altStart = true;
1556
1913
  let anchor = false;
1914
+ let assertion = false;
1557
1915
  for (let i = 0; i < pattern.length; i++) {
1558
1916
  const c = pattern[i];
1559
1917
  const wasAltStart = altStart;
1560
1918
  const wasAnchor = anchor;
1919
+ const wasAssertion = assertion;
1561
1920
  altStart = false;
1562
1921
  anchor = c === '^' && wasAltStart;
1922
+ assertion = anchor;
1563
1923
  if (c === '\\' && i + 1 < pattern.length) {
1564
1924
  const n = pattern[++i];
1565
1925
  if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
1926
+ if (wasAssertion && '+?{'.includes(n)) return null;
1566
1927
  if (BRE_SWAPPED.includes(n)) out += n;
1567
1928
  else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
1568
1929
  else return null;
1569
1930
  altStart = n === '(' || n === '|';
1931
+ assertion = 'bB<>'.includes(n);
1570
1932
  continue;
1571
1933
  }
1934
+ if (c === '*' && wasAssertion) return null;
1572
1935
  if (c === '^' && !wasAltStart) return null;
1573
1936
  // A `*` where an alternative starts, or right after its `^`, repeats
1574
1937
  // nothing: BRE reads it as a literal, rust regex as an error or a repeat of
@@ -1578,21 +1941,8 @@ function breToRustRegex(pattern) {
1578
1941
  && !pattern.startsWith('\\)', i + 1) && !pattern.startsWith('\\|', i + 1)) return null;
1579
1942
  if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
1580
1943
  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
1944
+ const j = bracketEnd(pattern, i);
1945
+ if (j === -1) return null;
1596
1946
  out += pattern.slice(i, j + 1);
1597
1947
  i = j;
1598
1948
  }
@@ -1874,6 +2224,16 @@ function runMain() {
1874
2224
  return;
1875
2225
  }
1876
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
+
1877
2237
  const answered = answer.status === 'hits';
1878
2238
  recordRecommendation(root, {
1879
2239
  // Still `deny` in the funnel: the event it counts — a raw grep intercepted
@@ -1911,11 +2271,13 @@ function runMain() {
1911
2271
  // Re-run exactly what answered: the resolved symbols for `show`, the same
1912
2272
  // argv for `grep`.
1913
2273
  // `-m 0`: cg caps matches at 100 per file by default and says so only on
1914
- // 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
1915
2277
  // in the in-hook answer, which is a has-hits probe.
1916
2278
  const argvList = answeredMode === 'show'
1917
2279
  ? answer.argvs
1918
- : [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
2280
+ : [[...args.slice(0, 1), '-m', '0', '-M', '0', ...args.slice(1)]];
1919
2281
  const command = buildRewriteCommand(argvList, {
1920
2282
  invocation: shellQuoteArg(resolveAnswerBinary({})),
1921
2283
  root,
@@ -1977,8 +2339,13 @@ module.exports = {
1977
2339
  normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
1978
2340
  rootOnlyInSearchPath, // D#125 #1
1979
2341
  isDirectory,
1980
- grepSearchesNoDirectory, // D#125 #3
1981
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
1982
2349
  resolveProjectRoot, // v0.48 — subdir-cwd dark fix
1983
2350
  rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
1984
2351
  commandHasBypass, // v0.48 — bypass funnel visibility