@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.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/hook-emit.js +27 -1
- package/claude-plugin/scripts/hook-fail-open.js +5 -0
- package/claude-plugin/scripts/lifecycle.js +30 -2
- package/claude-plugin/scripts/post-grep-inject.js +28 -6
- package/claude-plugin/scripts/pre-edit-guide.js +21 -0
- package/claude-plugin/scripts/pre-grep-guide.js +435 -68
- package/claude-plugin/scripts/session-edits.js +141 -0
- package/claude-plugin/scripts/stop-impact.js +496 -0
- package/claude-plugin/scripts/subagent-start.js +103 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1425
|
-
|
|
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
|
|
1503
|
-
//
|
|
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
|
|
1507
|
-
|
|
1508
|
-
if (
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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 `\(\)`,
|
|
1543
|
-
//
|
|
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 = '
|
|
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
|
-
|
|
1582
|
-
|
|
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).
|
|
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
|