@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.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/cg-answer.js +51 -2
- 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 +40 -1
- package/claude-plugin/scripts/pre-edit-guide.js +21 -0
- package/claude-plugin/scripts/pre-grep-guide.js +536 -51
- package/claude-plugin/scripts/session-edits.js +134 -0
- package/claude-plugin/scripts/stop-impact.js +431 -0
- package/claude-plugin/scripts/subagent-start.js +95 -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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1340
|
-
|
|
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
|
|
1414
|
-
//
|
|
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
|
|
1418
|
-
|
|
1419
|
-
if (
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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 `\(\)`,
|
|
1454
|
-
//
|
|
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 = '
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
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).
|
|
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.
|
|
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
|