@sdsrs/code-graph 0.159.0 → 0.161.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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/cg-answer.js +51 -2
- package/claude-plugin/scripts/post-grep-inject.js +33 -1
- package/claude-plugin/scripts/pre-grep-guide.js +537 -47
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -347,10 +347,45 @@ function runGrepAnswer(opts = {}) {
|
|
|
347
347
|
* `no-binary` distinguishes a missing/unlocatable binary from a runtime
|
|
348
348
|
* `unavailable`, so the deny funnel can see a dark flagship answer-in-deny.
|
|
349
349
|
*/
|
|
350
|
+
/**
|
|
351
|
+
* The files of the definitions a `show` printed: each definition opens with an
|
|
352
|
+
* unindented header `<kind> <name> <path>:<start>-<end> <signature>`, its body
|
|
353
|
+
* indented below. null when an unindented line is not such a header, so a
|
|
354
|
+
* caller that must account for every definition can refuse.
|
|
355
|
+
*/
|
|
356
|
+
function showDefinitionFiles(out) {
|
|
357
|
+
const defs = [];
|
|
358
|
+
for (const line of out.split('\n')) {
|
|
359
|
+
if (line === '' || /^\s/.test(line)) continue;
|
|
360
|
+
const m = /^(\S+) \S+ {2}(\S+):\d+-\d+(?: |$)/.exec(line);
|
|
361
|
+
if (!m) return null;
|
|
362
|
+
defs.push({ kind: m[1], file: m[2] });
|
|
363
|
+
}
|
|
364
|
+
return defs.length ? defs : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Whether a root-relative file lies under a grep path ('' = the whole root). */
|
|
368
|
+
function pathWithin(file, within) {
|
|
369
|
+
const w = within.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
370
|
+
return w === '' || w === '.' || file === w || file.startsWith(w + '/');
|
|
371
|
+
}
|
|
372
|
+
|
|
350
373
|
function runShowAnswer(opts = {}) {
|
|
351
374
|
const {
|
|
352
375
|
cwd,
|
|
353
376
|
symbols,
|
|
377
|
+
// A grep path the answer must stay inside (D#125 #2): `show` prints every
|
|
378
|
+
// same-named definition in the project. With `within`, a symbol is answered
|
|
379
|
+
// only when EVERY definition `show` prints lies inside the path and is one
|
|
380
|
+
// the grep's declaration keyword matches (`kinds`: symbol → the label and
|
|
381
|
+
// file-extension shapes it matches); a symbol with no definition inside is
|
|
382
|
+
// skipped (the grep finds none either), and any other mix refuses the whole
|
|
383
|
+
// answer. Narrowing with `show --file` was tried and removed: the label is
|
|
384
|
+
// coarser than the keyword, so a narrowed answer dropped real matches, and
|
|
385
|
+
// the inject printed the unnarrowed text (review of D#125, round 2).
|
|
386
|
+
// undefined: unscoped, as before.
|
|
387
|
+
within,
|
|
388
|
+
kinds,
|
|
354
389
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
355
390
|
maxBytes = DEFAULT_MAX_BYTES,
|
|
356
391
|
} = opts;
|
|
@@ -365,6 +400,8 @@ function runShowAnswer(opts = {}) {
|
|
|
365
400
|
// Which symbols actually resolved — the rewrite re-runs exactly these, so a
|
|
366
401
|
// symbol that printed nothing here cannot turn into an exit-1 in the Bash call.
|
|
367
402
|
const resolved = [];
|
|
403
|
+
// The argv that re-runs each resolved symbol, scoped when `within` is set.
|
|
404
|
+
const argvs = [];
|
|
368
405
|
for (const sym of symbols.slice(0, 3)) {
|
|
369
406
|
if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
|
|
370
407
|
const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
|
|
@@ -383,12 +420,24 @@ function runShowAnswer(opts = {}) {
|
|
|
383
420
|
if (classifyRun(res, { exitOneIsNoHits: false }) !== 'ok') continue;
|
|
384
421
|
const out = (res.stdout || '').trim();
|
|
385
422
|
if (isEmptyAnswer(out)) continue;
|
|
386
|
-
|
|
423
|
+
const argv = ['show', sym];
|
|
424
|
+
if (within !== undefined) {
|
|
425
|
+
const defs = showDefinitionFiles(out);
|
|
426
|
+
if (!defs) return { status: 'unavailable', reason: 'scope' };
|
|
427
|
+
if (!defs.some((d) => pathWithin(d.file, within))) continue;
|
|
428
|
+
const shapes = (kinds && kinds[sym]) || [];
|
|
429
|
+
const ext = (f) => (f.match(/\.([A-Za-z0-9]+)$/) || [])[1];
|
|
430
|
+
const matches = (d) => pathWithin(d.file, within)
|
|
431
|
+
&& shapes.some((k) => k.labels.includes(d.kind) && k.exts.includes(ext(d.file)));
|
|
432
|
+
if (!defs.every(matches)) return { status: 'unavailable', reason: 'scope' };
|
|
433
|
+
}
|
|
434
|
+
parts.push(`$ code-graph-mcp ${argv.join(' ')}\n${out}`);
|
|
387
435
|
resolved.push(sym);
|
|
436
|
+
argvs.push(argv);
|
|
388
437
|
}
|
|
389
438
|
if (parts.length === 0) return { status: 'no-hits' };
|
|
390
439
|
const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
|
|
391
|
-
return { status: 'hits', text, truncated, symbols: resolved };
|
|
440
|
+
return { status: 'hits', text, truncated, symbols: resolved, argvs };
|
|
392
441
|
} catch {
|
|
393
442
|
return { status: 'unavailable' };
|
|
394
443
|
}
|
|
@@ -47,8 +47,16 @@ const {
|
|
|
47
47
|
cgFlagSet,
|
|
48
48
|
extractSearchPath,
|
|
49
49
|
bareSourceTarget,
|
|
50
|
+
segmentCwd,
|
|
51
|
+
segmentSeparators,
|
|
52
|
+
operandMatches,
|
|
50
53
|
firstShellClause,
|
|
51
54
|
normalizeCommandPaths,
|
|
55
|
+
rootOnlyInSearchPath,
|
|
56
|
+
grepSearchesNoDirectory,
|
|
57
|
+
isDirectory,
|
|
58
|
+
declKindsBySymbol,
|
|
59
|
+
extractPatterns,
|
|
52
60
|
rebaseRelativePaths,
|
|
53
61
|
resolveProjectRoot,
|
|
54
62
|
} = require('./pre-grep-guide');
|
|
@@ -273,6 +281,23 @@ function runMain() {
|
|
|
273
281
|
// closes, so a bare dir is answered only when the grep is the FIRST segment.
|
|
274
282
|
if (bareSourceTarget(firstShellClause(segment))
|
|
275
283
|
&& (relPrefix || splitTopLevelSegments(cmd)[0] !== segment)) return;
|
|
284
|
+
// D#76 — the answer searches `extractSearchPath(segment)` from the root. The
|
|
285
|
+
// segment ran where the shell was by then: its own cwd, moved by any `cd`
|
|
286
|
+
// before it. Segments are read from the RAW command (normalizing strips the
|
|
287
|
+
// root from `cd <root>/x` too), which splits into the same segments.
|
|
288
|
+
const segs = splitTopLevelSegments(cmd);
|
|
289
|
+
const rawSegs = splitTopLevelSegments(rawCmd);
|
|
290
|
+
const idx = segs.indexOf(segment);
|
|
291
|
+
if (rawSegs.length !== segs.length) return;
|
|
292
|
+
const segCwd = segmentCwd(rawSegs, idx, shellCwd, { seps: segmentSeparators(rawCmd) });
|
|
293
|
+
if (segCwd === null) return;
|
|
294
|
+
if ((segCwd !== root || relPrefix)
|
|
295
|
+
&& !operandMatches(firstShellClause(rawSegs[idx]), extractSearchPath(segment), root, segCwd)) return;
|
|
296
|
+
// D#125 #1 — the root strip rewrote a pattern that held the root.
|
|
297
|
+
if (!rootOnlyInSearchPath(rawSegs[idx], root, extractSearchPath(segment))) return;
|
|
298
|
+
// D#125 #3 — a plain grep without -r searched no directory.
|
|
299
|
+
if (grepSearchesNoDirectory(firstShellClause(segment), extractSearchPath(segment),
|
|
300
|
+
(t) => isDirectory(path.resolve(root, t)))) return;
|
|
276
301
|
// Run the answer exactly like the deny path.
|
|
277
302
|
const rawPattern = pickBlockPattern(segment);
|
|
278
303
|
// Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
|
|
@@ -367,7 +392,14 @@ function runMain() {
|
|
|
367
392
|
if (answer.status !== 'hits') {
|
|
368
393
|
if (block.mode === 'show') {
|
|
369
394
|
answeredMode = 'show';
|
|
370
|
-
|
|
395
|
+
// Scoped like the rewrite: the grep's path and declaration kinds; a grep
|
|
396
|
+
// with a file filter gets the grep answer, which honors it.
|
|
397
|
+
answer = flags.includes('-g') || flags.includes('-t')
|
|
398
|
+
? { status: 'unavailable' }
|
|
399
|
+
: runShowAnswer({
|
|
400
|
+
cwd: root, symbols: block.symbols, within: searchPath ?? '',
|
|
401
|
+
kinds: declKindsBySymbol(extractPatterns(segment)),
|
|
402
|
+
});
|
|
371
403
|
if (answer.status !== 'hits' && pattern) {
|
|
372
404
|
answeredMode = 'grep';
|
|
373
405
|
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
@@ -255,8 +255,18 @@ function extractPatterns(cmd) {
|
|
|
255
255
|
const stripped = firstShellClause(cmd).replace(VERB_STRIP, '');
|
|
256
256
|
// Collect every quoted argument — first one is the pattern in standard grep
|
|
257
257
|
// usage; subsequent ones (e.g. `-e "second"`) are also patterns or filter
|
|
258
|
-
// expressions and worth screening too.
|
|
259
|
-
|
|
258
|
+
// expressions and worth screening too. Whole words, as the shell passes them
|
|
259
|
+
// (D#63): `"Foo"'BarBaz'` is `FooBarBaz`, not two patterns; a flag's attached
|
|
260
|
+
// quoted value (`-e"Foo"`, `--include='*.rs'`) is the value. A clause the
|
|
261
|
+
// shell would reject, or whose quoted words it expands (`"$MAX"`, D#64), has
|
|
262
|
+
// no pattern we can read.
|
|
263
|
+
const words = clauseWords(stripped);
|
|
264
|
+
if (!words) return [];
|
|
265
|
+
const quoted = words.filter((w) => w.quotedFrom !== -1);
|
|
266
|
+
if (quoted.some((w) => w.expands)) return [];
|
|
267
|
+
return quoted
|
|
268
|
+
.map((w) => (!w.startsQuoted && w.text[0] === '-' ? w.text.slice(w.quotedFrom) : w.text))
|
|
269
|
+
.filter(Boolean);
|
|
260
270
|
}
|
|
261
271
|
|
|
262
272
|
/**
|
|
@@ -299,10 +309,105 @@ function quotedSpans(s) {
|
|
|
299
309
|
return spans;
|
|
300
310
|
}
|
|
301
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The words of a shell clause, split and unquoted the way quotedSpans reads
|
|
314
|
+
* quotes: whitespace separates words only outside quotes, and adjacent quoted
|
|
315
|
+
* and bare parts join into one word (`-r"l"` is `-rl`, `"Foo"'Bar'` is
|
|
316
|
+
* `FooBar`). Per word:
|
|
317
|
+
* - `startsQuoted`: the first character was a quote — an argument, never a
|
|
318
|
+
* flag, so `"-l"` searches for the string `-l`;
|
|
319
|
+
* - `quotedFrom`: where in `text` the first quoted part begins (-1 if none),
|
|
320
|
+
* so `-e"Foo"` yields the value `Foo`;
|
|
321
|
+
* - `expands`: the shell substitutes something into it — `$NAME`, `${…}`,
|
|
322
|
+
* `$(…)`, a backtick, or `$'…'`/`$"…"` quoting — outside single quotes,
|
|
323
|
+
* so `text` is not what the command received.
|
|
324
|
+
* Returns null for an unterminated quote: the shell would not run it at all.
|
|
325
|
+
*
|
|
326
|
+
* A whitespace split read `-l` out of `grep -rn "FooBar -l x" src/` and the
|
|
327
|
+
* rewrite answered with a file list (D#62).
|
|
328
|
+
* @returns {{text: string, startsQuoted: boolean, quotedFrom: number, expands: boolean}[]|null}
|
|
329
|
+
*/
|
|
330
|
+
function clauseWords(s) {
|
|
331
|
+
const words = [];
|
|
332
|
+
if (typeof s !== 'string') return words;
|
|
333
|
+
let cur = null;
|
|
334
|
+
// Inside double quotes `$"` is a literal `$` before the closing quote
|
|
335
|
+
// (`"FooBar$"`); bare, `$'…'` and `$"…"` are quoting forms that translate.
|
|
336
|
+
const expandsAt = (k, inDouble) => s[k] === '`'
|
|
337
|
+
|| (s[k] === '$' && k + 1 < s.length
|
|
338
|
+
&& (inDouble ? /[A-Za-z0-9_{(@*#?$!-]/ : /[A-Za-z0-9_{(@*#?$!'"-]/).test(s[k + 1]));
|
|
339
|
+
for (let i = 0; i < s.length; i++) {
|
|
340
|
+
const c = s[i];
|
|
341
|
+
if (c === ' ' || c === '\t' || c === '\n') {
|
|
342
|
+
if (cur) words.push(cur);
|
|
343
|
+
cur = null;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!cur) cur = { text: '', startsQuoted: c === '"' || c === "'", quotedFrom: -1, expands: false };
|
|
347
|
+
if (c === '\\') {
|
|
348
|
+
if (i + 1 < s.length && s[i + 1] !== '\n') cur.text += s[i + 1];
|
|
349
|
+
i++;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (c !== '"' && c !== "'") {
|
|
353
|
+
if (expandsAt(i, false)) cur.expands = true;
|
|
354
|
+
cur.text += c;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (cur.quotedFrom === -1) cur.quotedFrom = cur.text.length;
|
|
358
|
+
let j = i + 1;
|
|
359
|
+
for (; j < s.length && s[j] !== c; j++) {
|
|
360
|
+
if (c === '"' && s[j] === '\\' && j + 1 < s.length && '"\\$`\n'.includes(s[j + 1])) {
|
|
361
|
+
j++;
|
|
362
|
+
if (s[j] === '\n') continue;
|
|
363
|
+
} else if (c === '"' && expandsAt(j, true)) {
|
|
364
|
+
cur.expands = true;
|
|
365
|
+
}
|
|
366
|
+
cur.text += s[j];
|
|
367
|
+
}
|
|
368
|
+
if (j >= s.length) return null;
|
|
369
|
+
i = j;
|
|
370
|
+
}
|
|
371
|
+
if (cur) words.push(cur);
|
|
372
|
+
return words;
|
|
373
|
+
}
|
|
374
|
+
|
|
302
375
|
// Declaration anchors inside a pattern (`def cascade_failure|class TaskState`)
|
|
303
376
|
// name the exact symbols the model wants to READ — extract them for `show`.
|
|
304
377
|
const DECL_SYMBOL = /(?:fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
305
378
|
|
|
379
|
+
// What a `show` definition must be for the grep's declaration keyword to match
|
|
380
|
+
// its line: the kind label `show` prints (src/cli/symbols.rs
|
|
381
|
+
// format_node_compact) AND a file of a language that spells the declaration
|
|
382
|
+
// with that keyword. The label alone is coarser than the keyword — `fn` is also
|
|
383
|
+
// a Python `def` and a TS method, and a Swift `struct` is labelled `class`
|
|
384
|
+
// (review of D#125, round 2). `impl` names no definition of its own.
|
|
385
|
+
const DECL_KEYWORD_SHAPES = {
|
|
386
|
+
fn: { labels: ['fn'], exts: ['rs'] },
|
|
387
|
+
def: { labels: ['fn'], exts: ['py', 'rb'] },
|
|
388
|
+
function: { labels: ['fn'], exts: ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'php', 'sh', 'bash'] },
|
|
389
|
+
class: {
|
|
390
|
+
labels: ['class'],
|
|
391
|
+
exts: ['py', 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'java', 'kt', 'cs', 'php', 'rb', 'dart',
|
|
392
|
+
'cpp', 'cc', 'hpp', 'h', 'scala'],
|
|
393
|
+
},
|
|
394
|
+
struct: { labels: ['struct'], exts: ['rs', 'go', 'c', 'h', 'cpp', 'cc', 'hpp', 'cs'] },
|
|
395
|
+
trait: { labels: ['trait', 'iface'], exts: ['rs', 'php', 'scala'] },
|
|
396
|
+
impl: { labels: [], exts: [] },
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Symbol → the (label, extension) shapes its declaration keywords match
|
|
400
|
+
// (`fn foo\|def foo` → foo: Rust fn, Python/Ruby def).
|
|
401
|
+
function declKindsBySymbol(patterns) {
|
|
402
|
+
const out = {};
|
|
403
|
+
for (const p of patterns) {
|
|
404
|
+
for (const m of p.matchAll(/\b(fn|def|class|function|struct|impl|trait)\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
405
|
+
(out[m[2]] = out[m[2]] || []).push(DECL_KEYWORD_SHAPES[m[1]]);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
306
411
|
function extractDeclSymbols(patterns) {
|
|
307
412
|
const out = [];
|
|
308
413
|
for (const p of patterns) {
|
|
@@ -475,17 +580,21 @@ const CG_FLAG_ORDER = ['-i', '-w', '-F', '-l', '-c'];
|
|
|
475
580
|
* file list was asked for (field report 2026-09-08).
|
|
476
581
|
*
|
|
477
582
|
* Scanning rules that matter: only the grep's OWN clause (a tail command's
|
|
478
|
-
* `-l` is not this grep's),
|
|
479
|
-
*
|
|
583
|
+
* `-l` is not this grep's), words as the shell splits them (clauseWords — a
|
|
584
|
+
* quoted pattern holding ` -l ` is one word), and a word that STARTS quoted is
|
|
585
|
+
* an argument, not a flag — `grep "-l" src/` searches for the string `-l`.
|
|
480
586
|
*/
|
|
481
587
|
function extractCgFlags(cmd) {
|
|
482
588
|
if (!cmd || typeof cmd !== 'string') return [];
|
|
483
589
|
const clause = firstShellClause(cmd);
|
|
484
590
|
const isRg = RG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '');
|
|
485
|
-
const
|
|
591
|
+
const words = clauseWords(clause.replace(VERB_STRIP, ''));
|
|
592
|
+
if (!words) return [];
|
|
593
|
+
const toks = words.map((w) => (w.startsQuoted ? '' : w.text));
|
|
486
594
|
const found = new Set();
|
|
487
595
|
const filters = []; // [cgFlag, value] pairs, in the order the user wrote them
|
|
488
|
-
|
|
596
|
+
// A value is read from the word, whatever its quoting: `--include '*.rs'`.
|
|
597
|
+
const valueAt = (k) => (words[k] ? words[k].text : '');
|
|
489
598
|
const addFilter = (flag, value) => {
|
|
490
599
|
if (!value) return;
|
|
491
600
|
// `-g '*.rs' -g '*.rs'` is one filter written twice; cg would honour both
|
|
@@ -494,12 +603,12 @@ function extractCgFlags(cmd) {
|
|
|
494
603
|
};
|
|
495
604
|
for (let i = 0; i < toks.length; i++) {
|
|
496
605
|
const tok = toks[i];
|
|
497
|
-
if (!tok || tok[0]
|
|
606
|
+
if (!tok || tok[0] !== '-') continue;
|
|
498
607
|
if (tok.startsWith('--')) {
|
|
499
608
|
const eq = tok.indexOf('=');
|
|
500
609
|
const name = eq === -1 ? tok : tok.slice(0, eq);
|
|
501
610
|
if (CG_VALUE_FLAGS[name]) {
|
|
502
|
-
addFilter(CG_VALUE_FLAGS[name],
|
|
611
|
+
addFilter(CG_VALUE_FLAGS[name], eq === -1 ? valueAt(i + 1) : tok.slice(eq + 1));
|
|
503
612
|
if (eq === -1) i++; // the value was a separate token — do not rescan it
|
|
504
613
|
continue;
|
|
505
614
|
}
|
|
@@ -515,8 +624,8 @@ function extractCgFlags(cmd) {
|
|
|
515
624
|
const ch = letters[k];
|
|
516
625
|
if (isRg && CG_VALUE_SHORT[ch]) {
|
|
517
626
|
const attached = letters.slice(k + 1);
|
|
518
|
-
addFilter(CG_VALUE_SHORT[ch], attached
|
|
519
|
-
// Consume the value
|
|
627
|
+
addFilter(CG_VALUE_SHORT[ch], attached || valueAt(i + 1));
|
|
628
|
+
// Consume the value word so the next pass cannot read it as a flag
|
|
520
629
|
// cluster: `rg -g -i 'sym' src/` must be `-g` with value `-i`, not `-g`
|
|
521
630
|
// plus a phantom `-i` (round 2 of pre-ship review — the test that named
|
|
522
631
|
// this property used a quoted value, which the quote guard already
|
|
@@ -579,6 +688,30 @@ function normalizeCommandPaths(cmd, cwd) {
|
|
|
579
688
|
return cmd.split(cwd.endsWith('/') ? cwd : cwd + '/').join('');
|
|
580
689
|
}
|
|
581
690
|
|
|
691
|
+
// D#125 #1 — the strip above cannot tell a path operand from a PATTERN, and
|
|
692
|
+
// strips both: `grep -rn "<root>/Foo" src/` searches the literal text
|
|
693
|
+
// `<root>/Foo`, but was answered for `Foo`. Re-picking the pattern with the
|
|
694
|
+
// root marked was tried first and missed shapes where the mark changes which
|
|
695
|
+
// word is picked (`"<root>/def foo"`, `"abc_<root>/def"`: review of D#125). The
|
|
696
|
+
// narrow form: every shell word that holds `<root>/` must BE the search path
|
|
697
|
+
// the answer scopes to; anything else runs as typed.
|
|
698
|
+
function rootOnlyInSearchPath(cmd, cwd, target) {
|
|
699
|
+
if (!cmd || typeof cmd !== 'string') return true;
|
|
700
|
+
if (!cwd || typeof cwd !== 'string' || cwd === '/') return true;
|
|
701
|
+
const prefix = cwd.endsWith('/') ? cwd : cwd + '/';
|
|
702
|
+
if (!cmd.includes(prefix)) return true;
|
|
703
|
+
// Round 2: counting words was not enough — a lone root-holding PATTERN whose
|
|
704
|
+
// stripped text equals the path (`grep -rn "<root>/src/x" src/x`) passed. The
|
|
705
|
+
// rewrite grammar names which word is the path operand: accept only a command
|
|
706
|
+
// it reads, with the root once, in that operand, and nowhere in the pattern.
|
|
707
|
+
if (cmd.split(prefix).length !== 2) return false;
|
|
708
|
+
const plan = rewritePlan(firstShellClause(cmd), { isDir: () => true });
|
|
709
|
+
if (!plan || plan.target === undefined || plan.pattern.includes(prefix)) return false;
|
|
710
|
+
if (!plan.target.startsWith(prefix)) return false;
|
|
711
|
+
const norm = (p) => p.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
712
|
+
return target !== undefined && norm(plan.target.slice(prefix.length)) === norm(target);
|
|
713
|
+
}
|
|
714
|
+
|
|
582
715
|
// v0.48 — subdir-cwd fix; v0.49 — extracted to project-root.js so the read
|
|
583
716
|
// hook shares it. Re-exported below for test/back-compat.
|
|
584
717
|
const { resolveProjectRoot } = require('./project-root');
|
|
@@ -611,7 +744,10 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
|
|
|
611
744
|
// Shell sits outside any known source dir (docs/, target/, …) — don't guess.
|
|
612
745
|
if (!SRC_PATH_TOKEN.test(prefix + '/')) return cmd;
|
|
613
746
|
let verbSeen = false;
|
|
614
|
-
|
|
747
|
+
// Split on whitespace outside quotes only: a quoted pattern is one token and
|
|
748
|
+
// its inner words are never paths (D#63 — `"fn main utils here"` from src/
|
|
749
|
+
// became `"fn main src/utils here"`).
|
|
750
|
+
return splitKeepingQuotes(cmd).map((tok) => {
|
|
615
751
|
if (!tok || /^\s+$/.test(tok)) return tok;
|
|
616
752
|
if (!verbSeen) {
|
|
617
753
|
if (/^(?:env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/.test(tok)) return tok;
|
|
@@ -635,6 +771,36 @@ function rebaseRelativePaths(cmd, relPrefix, rootDir, exists = fs.existsSync) {
|
|
|
635
771
|
}).join('');
|
|
636
772
|
}
|
|
637
773
|
|
|
774
|
+
// Words and the whitespace between them, text unchanged, splitting on
|
|
775
|
+
// whitespace outside quotes only (the quotedSpans rules).
|
|
776
|
+
function splitKeepingQuotes(s) {
|
|
777
|
+
const out = [];
|
|
778
|
+
let cur = '';
|
|
779
|
+
let quote = null;
|
|
780
|
+
for (let i = 0; i < s.length; i++) {
|
|
781
|
+
const c = s[i];
|
|
782
|
+
if (quote) {
|
|
783
|
+
cur += c;
|
|
784
|
+
if (quote === '"' && c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; }
|
|
785
|
+
if (c === quote) quote = null;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
|
|
789
|
+
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
|
|
790
|
+
if (/\s/.test(c)) {
|
|
791
|
+
if (cur) out.push(cur);
|
|
792
|
+
cur = c;
|
|
793
|
+
while (i + 1 < s.length && /\s/.test(s[i + 1])) cur += s[++i];
|
|
794
|
+
out.push(cur);
|
|
795
|
+
cur = '';
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
cur += c;
|
|
799
|
+
}
|
|
800
|
+
if (cur) out.push(cur);
|
|
801
|
+
return out;
|
|
802
|
+
}
|
|
803
|
+
|
|
638
804
|
// v0.48 — bypass detection on the RAW command. (The deny copy stopped teaching
|
|
639
805
|
// the escape in v0.49, but models that already know it — or learned it from a
|
|
640
806
|
// session summary — must stay visible to the funnel.)
|
|
@@ -763,22 +929,117 @@ function pickBlockPattern(cmd) {
|
|
|
763
929
|
// inside single/double quotes are literal command text, never split points.
|
|
764
930
|
// Returns trimmed, non-empty segments. Shared by post-grep-inject so the
|
|
765
931
|
// PostToolUse path reuses this splitter instead of copying it.
|
|
766
|
-
|
|
932
|
+
//
|
|
933
|
+
// A heredoc body is skipped (D#66): it is data on the reading command's stdin,
|
|
934
|
+
// not commands. Lexed as shell, a Python `\'` in a `<<'PY'` body flipped the
|
|
935
|
+
// quote parity for the rest of the command, and a `grep` line in a script body
|
|
936
|
+
// was folded as if the shell had run it. The `<<WORD` stays in its segment;
|
|
937
|
+
// the lines after that segment's newline, through the terminator line, go.
|
|
938
|
+
//
|
|
939
|
+
// Also read as the shell reads them, because each misreading drops or invents
|
|
940
|
+
// commands: a comment (`#` at the start of a word) runs to the end of its line;
|
|
941
|
+
// `<<<` is a here-string and `<<` inside `((…))` a shift, neither a heredoc;
|
|
942
|
+
// the heredoc delimiter is a whole word with its quoting removed (`E"O"F`,
|
|
943
|
+
// `$'EOF'`, `\EOF` all end at a line `EOF`).
|
|
944
|
+
function readHeredocDelim(cmd, i) {
|
|
945
|
+
let j = i;
|
|
946
|
+
let delim = '';
|
|
947
|
+
while (j < cmd.length && !/[\s;&|<>()]/.test(cmd[j])) {
|
|
948
|
+
const c = cmd[j];
|
|
949
|
+
if (c === '$' && (cmd[j + 1] === "'" || cmd[j + 1] === '"')) { j++; continue; }
|
|
950
|
+
if (c === "'" || c === '"') {
|
|
951
|
+
const close = cmd.indexOf(c, j + 1);
|
|
952
|
+
if (close === -1) return null;
|
|
953
|
+
delim += cmd.slice(j + 1, close);
|
|
954
|
+
j = close + 1;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (c === '\\' && j + 1 < cmd.length) { delim += cmd[j + 1]; j += 2; continue; }
|
|
958
|
+
delim += c;
|
|
959
|
+
j++;
|
|
960
|
+
}
|
|
961
|
+
return delim ? { delim, end: j } : null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Stronger of two separators joining the same pair of segments (an empty
|
|
965
|
+
// segment between them collapses): anything conditional beats `;`/newline.
|
|
966
|
+
const SEP_RANK = { '': 0, ';': 1, '\n': 1, '&&': 2, '||': 2, ctrl: 3 };
|
|
967
|
+
function joinSep(a, b) {
|
|
968
|
+
if (a === undefined) return b;
|
|
969
|
+
if (SEP_RANK[a] === 2 && SEP_RANK[b] === 2 && a !== b) return 'ctrl';
|
|
970
|
+
return SEP_RANK[b] > SEP_RANK[a] ? b : a;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Top-level segments with the separator before each: '' (the first), ';',
|
|
975
|
+
* '\n', '&&', '||', or 'ctrl' (after a `do`/`then`/… control word, where
|
|
976
|
+
* whether the segment runs is not a matter of the previous one).
|
|
977
|
+
* @returns {{text: string, sep: string}[]}
|
|
978
|
+
*/
|
|
979
|
+
function splitTopLevelSegmentsWithSeps(cmd) {
|
|
767
980
|
if (!cmd || typeof cmd !== 'string') return [];
|
|
768
981
|
const segs = [];
|
|
769
982
|
let cur = '';
|
|
983
|
+
let sep = '';
|
|
770
984
|
let quote = null;
|
|
985
|
+
let arith = 0; // depth of `((` … `))`
|
|
986
|
+
let heredocs = []; // [{delim, stripTabs}] started on the current line
|
|
987
|
+
// The last char appended to `cur`, kept apart: reading `cur` itself flattens
|
|
988
|
+
// the string `+=` built, once per `#` — a 300 KB `echo a#a#…` took 7.9 s in
|
|
989
|
+
// a 5 s hook (pre-release review #11).
|
|
990
|
+
let last = '';
|
|
991
|
+
const add = (s) => { cur += s; last = s[s.length - 1]; };
|
|
992
|
+
const cut = (next) => { segs.push({ text: cur, sep }); cur = ''; last = ''; sep = next; };
|
|
771
993
|
for (let i = 0; i < cmd.length; i++) {
|
|
772
994
|
const c = cmd[i];
|
|
995
|
+
if (!quote) {
|
|
996
|
+
if (c === '#' && (last === '' || /[\s|(]/.test(last))) {
|
|
997
|
+
const nl = cmd.indexOf('\n', i);
|
|
998
|
+
i = (nl === -1 ? cmd.length : nl) - 1;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (c === '(' && cmd[i + 1] === '(') { arith++; add('(('); i++; continue; }
|
|
1002
|
+
if (c === ')' && cmd[i + 1] === ')' && arith > 0) { arith--; add('))'); i++; continue; }
|
|
1003
|
+
if (c === '<' && cmd[i + 1] === '<' && cmd[i + 2] === '<') { add('<<<'); i += 2; continue; }
|
|
1004
|
+
if (c === '<' && cmd[i + 1] === '<' && arith === 0) {
|
|
1005
|
+
let j = i + 2;
|
|
1006
|
+
const stripTabs = cmd[j] === '-';
|
|
1007
|
+
if (stripTabs) j++;
|
|
1008
|
+
while (cmd[j] === ' ' || cmd[j] === '\t') j++;
|
|
1009
|
+
const d = readHeredocDelim(cmd, j);
|
|
1010
|
+
if (d) {
|
|
1011
|
+
heredocs.push({ delim: d.delim, stripTabs });
|
|
1012
|
+
add(cmd.slice(i, d.end));
|
|
1013
|
+
i = d.end - 1;
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (c === '\n' && heredocs.length > 0) {
|
|
1018
|
+
cut('\n');
|
|
1019
|
+
let j = i + 1;
|
|
1020
|
+
for (const { delim, stripTabs } of heredocs) {
|
|
1021
|
+
while (j < cmd.length) {
|
|
1022
|
+
const nl = cmd.indexOf('\n', j);
|
|
1023
|
+
const end = nl === -1 ? cmd.length : nl;
|
|
1024
|
+
const line = cmd.slice(j, end);
|
|
1025
|
+
j = end + 1;
|
|
1026
|
+
if ((stripTabs ? line.replace(/^\t+/, '') : line) === delim) break;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
heredocs = [];
|
|
1030
|
+
i = j - 1;
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
773
1034
|
if (quote) {
|
|
774
|
-
|
|
1035
|
+
add(c);
|
|
775
1036
|
// Inside DOUBLE quotes a backslash escapes the next char, so `\"` does NOT
|
|
776
1037
|
// close the quote (POSIX). Single quotes do no escaping — `\` is literal
|
|
777
1038
|
// and `'` always closes — so this only applies to `"`. Without it,
|
|
778
1039
|
// `echo "x\" && grep \"Y\" src/"` (one literal echo arg) mis-closes at
|
|
779
1040
|
// `\"`, splits on `&&`, and yields a phantom foldable grep segment.
|
|
780
1041
|
if (quote === '"' && c === '\\' && i + 1 < cmd.length) {
|
|
781
|
-
|
|
1042
|
+
add(cmd[i + 1]);
|
|
782
1043
|
i++;
|
|
783
1044
|
continue;
|
|
784
1045
|
}
|
|
@@ -790,46 +1051,58 @@ function splitTopLevelSegments(cmd) {
|
|
|
790
1051
|
// removes both. Keeping them left `&& \⏎ grep …` a segment that starts
|
|
791
1052
|
// with `\`, which GREP_HEAD rejects (pre-ship review round 2 F1).
|
|
792
1053
|
if (c === '\\' && i + 1 < cmd.length) {
|
|
793
|
-
if (cmd[i + 1] !== '\n')
|
|
1054
|
+
if (cmd[i + 1] !== '\n') add(c + cmd[i + 1]);
|
|
794
1055
|
i++;
|
|
795
1056
|
continue;
|
|
796
1057
|
}
|
|
797
|
-
if (c === '"' || c === "'") { quote = c;
|
|
1058
|
+
if (c === '"' || c === "'") { quote = c; add(c); continue; }
|
|
798
1059
|
// `&&` and `||` (a single `&`/`|` is NOT a split — `|` is an output-filter
|
|
799
1060
|
// pipe, lone `&` is background and rare in tool calls).
|
|
800
1061
|
if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) {
|
|
801
|
-
|
|
1062
|
+
cut(c + c); i++; continue;
|
|
802
1063
|
}
|
|
803
|
-
if (c === ';' || c === '\n') {
|
|
804
|
-
|
|
1064
|
+
if (c === ';' || c === '\n') { cut(c); continue; }
|
|
1065
|
+
add(c);
|
|
805
1066
|
}
|
|
806
|
-
|
|
1067
|
+
cut(undefined);
|
|
807
1068
|
// Split out `for … in` / `do` / `done` control words as their own boundaries
|
|
808
1069
|
// so a loop body grep is isolated (the head of `for s in …; do grep …` is the
|
|
809
1070
|
// `for` keyword, which would otherwise mask the grep). Quote-safety already
|
|
810
1071
|
// handled above — these run per already-split segment on whitespace-delimited
|
|
811
|
-
// control words only.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1072
|
+
// control words only. A `for` header takes its word list with it: the list
|
|
1073
|
+
// is data, and left behind as a segment it read as a command that segmentCwd
|
|
1074
|
+
// cannot place (D#76).
|
|
1075
|
+
const pieces = [];
|
|
1076
|
+
const CTRL = /(?:^|\s)(for\s+\S+\s+in\b[\s\S]*$|do\b|done\b|then\b|fi\b)(?=\s|$)/g;
|
|
1077
|
+
for (const { text: raw, sep: rawSep } of segs) {
|
|
815
1078
|
let last = 0;
|
|
816
1079
|
let m;
|
|
1080
|
+
let pieceSep = rawSep;
|
|
817
1081
|
CTRL.lastIndex = 0;
|
|
818
|
-
let pushed = false;
|
|
819
1082
|
while ((m = CTRL.exec(raw)) !== null) {
|
|
820
|
-
|
|
821
|
-
if (before.trim()) out.push(before);
|
|
1083
|
+
pieces.push({ text: raw.slice(last, m.index), sep: pieceSep });
|
|
822
1084
|
last = CTRL.lastIndex;
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
if (pushed) {
|
|
826
|
-
const tail = raw.slice(last);
|
|
827
|
-
if (tail.trim()) out.push(tail);
|
|
828
|
-
} else {
|
|
829
|
-
out.push(raw);
|
|
1085
|
+
pieceSep = 'ctrl';
|
|
830
1086
|
}
|
|
1087
|
+
pieces.push({ text: raw.slice(last), sep: pieceSep });
|
|
1088
|
+
}
|
|
1089
|
+
const out = [];
|
|
1090
|
+
let carried;
|
|
1091
|
+
for (const { text, sep: s } of pieces) {
|
|
1092
|
+
carried = joinSep(carried, s);
|
|
1093
|
+
if (!text.trim()) continue;
|
|
1094
|
+
out.push({ text: text.trim(), sep: out.length === 0 ? '' : carried });
|
|
1095
|
+
carried = undefined;
|
|
831
1096
|
}
|
|
832
|
-
return out
|
|
1097
|
+
return out;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function splitTopLevelSegments(cmd) {
|
|
1101
|
+
return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.text);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function segmentSeparators(cmd) {
|
|
1105
|
+
return splitTopLevelSegmentsWithSeps(cmd).map((s) => s.sep);
|
|
833
1106
|
}
|
|
834
1107
|
|
|
835
1108
|
// One implementation of the cooldown quartet, in tmp-dir.js (ARC-02/ARC-06);
|
|
@@ -937,6 +1210,9 @@ function buildRewriteContext(mode, cmdShown) {
|
|
|
937
1210
|
// `-`, and anything the tokenizer does not know — newline, `;`, `&`, `\`, `$`,
|
|
938
1211
|
// a backtick, parentheses, braces, `~`. Returns a plan or null.
|
|
939
1212
|
const WORD_CHAR = /[A-Za-z0-9_.,:@%+=/*?<>&-]/;
|
|
1213
|
+
function isDirectory(p) {
|
|
1214
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
1215
|
+
}
|
|
940
1216
|
function shellWords(s) {
|
|
941
1217
|
const words = [];
|
|
942
1218
|
let cur = null;
|
|
@@ -1000,10 +1276,15 @@ function valueWordOk(w, isCount) {
|
|
|
1000
1276
|
return !(w.bareSpecial);
|
|
1001
1277
|
}
|
|
1002
1278
|
|
|
1003
|
-
|
|
1279
|
+
// `isDir(target)` answers whether the path operand is a directory. runMain asks
|
|
1280
|
+
// the filesystem, resolved against the root its command is relative to; the
|
|
1281
|
+
// default is the spelling alone (a trailing `/`, or a last segment with no
|
|
1282
|
+
// extension), which keeps the classifiers that call this pure.
|
|
1283
|
+
const looksLikeDir = (t) => t.endsWith('/') || !/\.[A-Za-z0-9]{1,6}$/.test(t);
|
|
1284
|
+
function rewritePlan(cmd, { isDir = looksLikeDir } = {}) {
|
|
1004
1285
|
if (!cmd || typeof cmd !== 'string' || cmd.length > 1000) return null;
|
|
1005
1286
|
const words = shellWords(cmd);
|
|
1006
|
-
if (!words || words.some((w) => w.op)) return null;
|
|
1287
|
+
if (!words || words.length === 0 || words.some((w) => w.op)) return null;
|
|
1007
1288
|
let i = 0;
|
|
1008
1289
|
let verb = !words[0].anyQuoted ? words[0].text : '';
|
|
1009
1290
|
if (verb === 'git') {
|
|
@@ -1019,6 +1300,8 @@ function rewritePlan(cmd) {
|
|
|
1019
1300
|
let caseFlag = false;
|
|
1020
1301
|
let context = false;
|
|
1021
1302
|
let endOfFlags = false;
|
|
1303
|
+
let include = false;
|
|
1304
|
+
let recursive = false;
|
|
1022
1305
|
for (; i < words.length; i++) {
|
|
1023
1306
|
const w = words[i];
|
|
1024
1307
|
if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
|
|
@@ -1031,17 +1314,20 @@ function rewritePlan(cmd) {
|
|
|
1031
1314
|
// (`--include='*.rs'`, rg `-g'*.rs'`); an unquoted `<>&` is shell syntax.
|
|
1032
1315
|
const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
|
|
1033
1316
|
if (!attachedFilter || w.bareOp) return null;
|
|
1034
|
-
// GNU grep's --include has no `!` negation
|
|
1035
|
-
|
|
1317
|
+
// 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;
|
|
1036
1320
|
}
|
|
1037
1321
|
if (w.text === '--') { endOfFlags = true; continue; }
|
|
1038
1322
|
if (w.text.startsWith('--')) {
|
|
1039
1323
|
const eq = w.text.indexOf('=');
|
|
1040
1324
|
const name = w.text.slice(2, eq === -1 ? undefined : eq);
|
|
1041
1325
|
if (!ALLOWED_LONG[verb].has(name)) return null;
|
|
1326
|
+
if (name === 'include') include = true;
|
|
1327
|
+
if (name === 'recursive') recursive = true;
|
|
1042
1328
|
if (VALUE_LONG.has(name) && eq === -1) {
|
|
1043
1329
|
const v = words[++i];
|
|
1044
|
-
if (!valueWordOk(v, false) || (name === 'include' && v.text
|
|
1330
|
+
if (!valueWordOk(v, false) || (name === 'include' && /^!|\{/.test(v.text))) return null;
|
|
1045
1331
|
}
|
|
1046
1332
|
if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
|
|
1047
1333
|
continue;
|
|
@@ -1062,6 +1348,7 @@ function rewritePlan(cmd) {
|
|
|
1062
1348
|
}
|
|
1063
1349
|
if (!allowed.includes(ch)) return null;
|
|
1064
1350
|
if (ch === 'i' || ch === 's') caseFlag = true;
|
|
1351
|
+
if (ch === 'r' || ch === 'R') recursive = true;
|
|
1065
1352
|
}
|
|
1066
1353
|
}
|
|
1067
1354
|
if (operands.length === 0 || operands.length > 2) return null;
|
|
@@ -1075,12 +1362,111 @@ function rewritePlan(cmd) {
|
|
|
1075
1362
|
// A `..` segment: extractSearchPath refuses to scope it, so the answer would
|
|
1076
1363
|
// search the whole repo (round 4 M2).
|
|
1077
1364
|
if (target && /(?:^|\/)\.\.(?:\/|$)/.test(target.text)) return null;
|
|
1365
|
+
// GNU grep applies --include to a file named on the command line too, so
|
|
1366
|
+
// `grep --include='*.py' X src/a.rs` finds nothing; rg — and so cg — searches
|
|
1367
|
+
// a named file whatever its -g says (D#78). Equivalent only for a directory.
|
|
1368
|
+
if (include && target && !isDir(target.text)) return null;
|
|
1369
|
+
// Plain grep without -r reads no directory ("Is a directory") and, with no
|
|
1370
|
+
// path, reads stdin; the answer would search the tree (D#125 #3). rg and ag
|
|
1371
|
+
// recurse by default, and git grep searches the tracked tree.
|
|
1372
|
+
if (verb === 'grep' && !recursive && (!target || isDir(target.text))) return null;
|
|
1078
1373
|
// ag is smart-case by default: an all-lowercase pattern matches any case,
|
|
1079
1374
|
// and cg's search is case-sensitive, so the answer would find less.
|
|
1080
1375
|
if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
|
|
1081
1376
|
return { pattern: pattern.text, target: target ? target.text : undefined, context };
|
|
1082
1377
|
}
|
|
1083
1378
|
|
|
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
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return target === undefined || isDir(target);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Does `rootTarget`, run from the root, name the path the grep clause's own
|
|
1402
|
+
// operand names from `cwd` — the directory the clause ran in? The raw clause is
|
|
1403
|
+
// parsed with the rewrite grammar; an absolute operand resolves the same from
|
|
1404
|
+
// anywhere. A clause the grammar cannot read proves nothing.
|
|
1405
|
+
function operandMatches(rawClause, rootTarget, root, cwd) {
|
|
1406
|
+
const raw = rewritePlan(rawClause, { isDir: (t) => isDirectory(path.resolve(cwd, t)) });
|
|
1407
|
+
if (!raw || raw.target === undefined || rootTarget === undefined) return false;
|
|
1408
|
+
return path.resolve(cwd, raw.target) === path.resolve(root, rootTarget);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Commands that cannot move the shell: external programs run in a child, and
|
|
1412
|
+
// these builtins leave the cwd alone. An allowlist, because the commands that
|
|
1413
|
+
// DO move it are open-ended — `builtin cd`, `if cd`, `{ cd …; }`, `eval`,
|
|
1414
|
+
// `source`, `popd`, any function (D#73 round 3 reproduced 19 forms).
|
|
1415
|
+
const CWD_NEUTRAL = new Set([
|
|
1416
|
+
'echo', 'printf', 'grep', 'rg', 'ag', 'git', 'sed', 'awk', 'cat', 'head', 'tail', 'wc',
|
|
1417
|
+
'ls', 'find', 'sort', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'test', '[', 'true', 'false',
|
|
1418
|
+
':', 'node', 'python', 'python3', 'cargo', 'npm', 'npx', 'jq', 'xargs', 'tee', 'stat',
|
|
1419
|
+
'file', 'du', 'df', 'date', 'sleep', 'which', 'env', 'export', 'set', 'unset', 'nl',
|
|
1420
|
+
'rm', 'mkdir', 'cp', 'mv', 'touch', 'chmod', 'ln', 'readlink', 'realpath', 'basename',
|
|
1421
|
+
'dirname', 'perl', 'gh', 'curl', 'make', 'bash', 'sh', 'timeout', 'tar', 'sqlite3',
|
|
1422
|
+
'code-graph-mcp', 'nproc', 'rustc', 'go', 'wait', 'column', 'md5sum', 'sha256sum',
|
|
1423
|
+
'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',
|
|
1426
|
+
]);
|
|
1427
|
+
// `X=1 Y=$(mktemp -d)`: a command substitution runs in a subshell.
|
|
1428
|
+
const PURE_ASSIGNMENTS =
|
|
1429
|
+
/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:\$\([^()]*\)|"[^"]*"|'[^']*'|[^\s;|&<>()'"]*)\s*)+$/;
|
|
1430
|
+
const CD_LITERAL = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*cd\s+('[^']*'|"[^"$`\\]*"|[^\s'"$`\\~*?[{|;&<>()]+)\s*$/;
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* The directory `segments[idx]` runs in, or null when the segments before it
|
|
1434
|
+
* could have moved the shell somewhere we cannot name (D#76). Follows
|
|
1435
|
+
* cwd-neutral commands and `cd` to one literal path that exists; a relative
|
|
1436
|
+
* `cd` under a set CDPATH is not literal. A `cd` is followed only when it is
|
|
1437
|
+
* certain to have run if the grep did: it is unconditional (its `seps` entry,
|
|
1438
|
+
* from segmentSeparators, is '', `;` or a newline) or every separator from it
|
|
1439
|
+
* to the grep is `&&`, the one before it included: `true || cd x && grep` is
|
|
1440
|
+
* `(true || cd x) && grep`, which runs the grep without the cd. `false && cd x;
|
|
1441
|
+
* grep` and `cd x | cat` did not move the shell. Without `seps` every segment
|
|
1442
|
+
* counts as unconditional.
|
|
1443
|
+
*/
|
|
1444
|
+
function segmentCwd(segments, idx, shellCwd, { isDir = isDirectory, seps } = {}) {
|
|
1445
|
+
let cwd = shellCwd;
|
|
1446
|
+
const ran = (k) => !seps || SEP_RANK[seps[k]] <= 1
|
|
1447
|
+
|| seps.slice(k, idx + 1).every((x) => x === '&&');
|
|
1448
|
+
for (let k = 0; k < idx; k++) {
|
|
1449
|
+
const seg = segments[k];
|
|
1450
|
+
if (/^\s*#/.test(seg) || PURE_ASSIGNMENTS.test(seg)) continue;
|
|
1451
|
+
const clause = firstShellClause(seg);
|
|
1452
|
+
const cd = CD_LITERAL.exec(seg);
|
|
1453
|
+
if (cd) {
|
|
1454
|
+
if (!ran(k)) return null;
|
|
1455
|
+
const arg = /^['"]/.test(cd[1]) ? cd[1].slice(1, -1) : cd[1];
|
|
1456
|
+
if (!arg || arg === '-' || (!path.isAbsolute(arg) && process.env.CDPATH)) return null;
|
|
1457
|
+
const next = path.resolve(cwd, arg);
|
|
1458
|
+
if (!isDir(next)) return null; // `cd x; grep` runs the grep where it was
|
|
1459
|
+
try { cwd = fs.realpathSync(next); } catch { cwd = next; }
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
const words = clauseWords(clause);
|
|
1463
|
+
if (!words) return null;
|
|
1464
|
+
const head = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text));
|
|
1465
|
+
if (head && !(head.quotedFrom === -1 && CWD_NEUTRAL.has(head.text))) return null;
|
|
1466
|
+
}
|
|
1467
|
+
return cwd;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1084
1470
|
// The plan must describe the same search classifyBlock decided on. The pattern
|
|
1085
1471
|
// the answer ran is picked from quoted spans (pickBlockPattern); the grammar's
|
|
1086
1472
|
// pattern operand is the one the shell passes — `"is"'Foo'` or an unquoted
|
|
@@ -1100,6 +1486,10 @@ function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
|
|
|
1100
1486
|
if (block.mode === 'show') {
|
|
1101
1487
|
const f = cgFlagSet(extractCgFlags(cmd));
|
|
1102
1488
|
if (f.has('-l') || f.has('-c')) return false;
|
|
1489
|
+
// `show` has no file filter: `--include`/`-g`/`-t` excluded definitions it
|
|
1490
|
+
// would print (review of D#125).
|
|
1491
|
+
const all = extractCgFlags(cmd);
|
|
1492
|
+
if (all.includes('-g') || all.includes('-t')) return false;
|
|
1103
1493
|
// show answers at most three symbols; a fourth would silently vanish.
|
|
1104
1494
|
if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
|
|
1105
1495
|
}
|
|
@@ -1124,11 +1514,89 @@ function translateBreToRg(cmd, pattern) {
|
|
|
1124
1514
|
// the two hooks, because post-grep-inject passes a SEGMENT here while the deny
|
|
1125
1515
|
// path passes the whole command — the same pattern then filed under two
|
|
1126
1516
|
// spellings and the funnel scored a verbatim re-grep as neutral.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1517
|
+
// Read from the clause's words, like extractCgFlags: a pattern holding ` -E `
|
|
1518
|
+
// is not the flag (review of D#62).
|
|
1519
|
+
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);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// The two dialects swap these characters' roles: in a basic regex `( ) { } + ?
|
|
1527
|
+
// |` are literals and `\(` … `\|` the operators, in rust regex the reverse. Only
|
|
1528
|
+
// unescaping the operators (the translation until D#65) left the literals as
|
|
1529
|
+
// operators, so `tombstoneActive()` searched `tombstoneActive` and the rewrite
|
|
1530
|
+
// returned a superset of grep's lines. Checked against the grep Claude Code's
|
|
1531
|
+
// shell runs (ugrep -G): `f()`, `a+b`, `a|b`, `{x}`, `a?` match only literally.
|
|
1532
|
+
//
|
|
1533
|
+
// A bracket expression is copied as written. Inside one, BRE has no escapes and
|
|
1534
|
+
// rust has escapes, `[` nesting and the `&&` `--` `~~` set operators, so a
|
|
1535
|
+
// bracket holding any of those means different things in the two; that pattern
|
|
1536
|
+
// is untranslatable and the result is null — the caller lets the grep run.
|
|
1537
|
+
// (`[:alpha:]` is a POSIX class in both and is kept.)
|
|
1538
|
+
//
|
|
1539
|
+
// Escapes: only the ones both read alike pass — the swapped operators, the
|
|
1540
|
+
// word/space/boundary classes, and an escaped metacharacter. `\1`, `\d`, `\z`,
|
|
1541
|
+
// `\_`, `\=` 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).
|
|
1544
|
+
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']);
|
|
1548
|
+
// An unescaped `^` is an anchor only where an alternative starts (the pattern's
|
|
1549
|
+
// start, after `\(` or `\|`), and `$` only where one ends (the pattern's end,
|
|
1550
|
+
// before `\)` or `\|`); elsewhere BRE reads them as literals and rust regex as
|
|
1551
|
+
// anchors — `getUser\|$user_id` searched the text `$user_id` (D#125 #6). No
|
|
1552
|
+
// translation: the grep runs.
|
|
1553
|
+
function breToRustRegex(pattern) {
|
|
1554
|
+
let out = '';
|
|
1555
|
+
let altStart = true;
|
|
1556
|
+
let anchor = false;
|
|
1557
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1558
|
+
const c = pattern[i];
|
|
1559
|
+
const wasAltStart = altStart;
|
|
1560
|
+
const wasAnchor = anchor;
|
|
1561
|
+
altStart = false;
|
|
1562
|
+
anchor = c === '^' && wasAltStart;
|
|
1563
|
+
if (c === '\\' && i + 1 < pattern.length) {
|
|
1564
|
+
const n = pattern[++i];
|
|
1565
|
+
if (n === '(' && pattern.startsWith('\\)', i + 1)) return null;
|
|
1566
|
+
if (BRE_SWAPPED.includes(n)) out += n;
|
|
1567
|
+
else if (BRE_SAME_ESCAPE.includes(n)) out += c + n;
|
|
1568
|
+
else return null;
|
|
1569
|
+
altStart = n === '(' || n === '|';
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
if (c === '^' && !wasAltStart) return null;
|
|
1573
|
+
// A `*` where an alternative starts, or right after its `^`, repeats
|
|
1574
|
+
// nothing: BRE reads it as a literal, rust regex as an error or a repeat of
|
|
1575
|
+
// the anchor (review of D#125).
|
|
1576
|
+
if (c === '*' && (wasAltStart || (wasAnchor && out.endsWith('^')))) return null;
|
|
1577
|
+
if (c === '$' && i + 1 < pattern.length
|
|
1578
|
+
&& !pattern.startsWith('\\)', i + 1) && !pattern.startsWith('\\|', i + 1)) return null;
|
|
1579
|
+
if (BRE_SWAPPED.includes(c)) { out += '\\' + c; continue; }
|
|
1580
|
+
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
|
|
1596
|
+
out += pattern.slice(i, j + 1);
|
|
1597
|
+
i = j;
|
|
1130
1598
|
}
|
|
1131
|
-
return
|
|
1599
|
+
return out;
|
|
1132
1600
|
}
|
|
1133
1601
|
|
|
1134
1602
|
// v0.47.0 — cg grep found nothing. Regex-dialect differences (BRE `\|` vs
|
|
@@ -1290,6 +1758,7 @@ function runMain() {
|
|
|
1290
1758
|
// forwarding `-F` without this guard would make it search the wrong text.
|
|
1291
1759
|
const cgFlags = extractCgFlags(cmd);
|
|
1292
1760
|
const rawGrepPattern = pickBlockPattern(cmd);
|
|
1761
|
+
if (!rootOnlyInSearchPath(rawCmd, root, extractSearchPath(cmd))) return;
|
|
1293
1762
|
const grepPattern = cgFlagSet(cgFlags).has('-F')
|
|
1294
1763
|
? rawGrepPattern
|
|
1295
1764
|
: translateBreToRg(cmd, rawGrepPattern);
|
|
@@ -1334,8 +1803,17 @@ function runMain() {
|
|
|
1334
1803
|
// The rewrite replaces the WHOLE command. One it cannot reproduce runs as
|
|
1335
1804
|
// typed (see rewritePlan) — decided before the answer is spent on it. The
|
|
1336
1805
|
// opt-in static deny keeps its own, older scope.
|
|
1337
|
-
const plan = block && !isAnswerDisabled()
|
|
1806
|
+
const plan = block && !isAnswerDisabled()
|
|
1807
|
+
? rewritePlan(cmd, { isDir: (t) => isDirectory(path.resolve(root, t)) })
|
|
1808
|
+
: null;
|
|
1338
1809
|
if (block && !isAnswerDisabled() && !rewriteMatchesBlock(plan, block, cmd, rawGrepPattern)) return;
|
|
1810
|
+
// A pattern the dialect bridge cannot translate (null) has no equivalent search.
|
|
1811
|
+
if (block && !isAnswerDisabled() && block.mode === 'grep' && grepPattern === null) return;
|
|
1812
|
+
// D#76 — the rewrite searches its root-relative target from the root; the
|
|
1813
|
+
// command searched its own operand from the shell's cwd. From a subdirectory
|
|
1814
|
+
// the rebase moves only operands that exist there, so an operand it left
|
|
1815
|
+
// alone (`src/` from `xtask/`, `tests/` from `src/`) names another directory.
|
|
1816
|
+
if (plan && relPrefix && !operandMatches(rawCmd, plan.target, root, shellCwd)) return;
|
|
1339
1817
|
if (block) {
|
|
1340
1818
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
1341
1819
|
// results in the deny reason ("answer in the deny"). Degrades to the
|
|
@@ -1359,7 +1837,12 @@ function runMain() {
|
|
|
1359
1837
|
// No fallback to a grep answer: a context grep (`-A5`) asked for the
|
|
1360
1838
|
// body, and the grep rewrite would drop the context silently (pre-ship
|
|
1361
1839
|
// review round 2). A show miss lets the raw grep run.
|
|
1362
|
-
|
|
1840
|
+
// Scoped to the grep's path: `show` alone answers the whole project
|
|
1841
|
+
// (D#125 #2 — `grep -A3 "fn f" lib/` was answered from src/).
|
|
1842
|
+
answer = runShowAnswer({
|
|
1843
|
+
cwd: root, symbols: block.symbols, within: searchPath ?? '',
|
|
1844
|
+
kinds: declKindsBySymbol(extractPatterns(cmd)),
|
|
1845
|
+
});
|
|
1363
1846
|
} else if (pattern) {
|
|
1364
1847
|
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
1365
1848
|
}
|
|
@@ -1431,7 +1914,7 @@ function runMain() {
|
|
|
1431
1914
|
// stderr; the grep being replaced has no cap (round 3 M4). Only here, not
|
|
1432
1915
|
// in the in-hook answer, which is a has-hits probe.
|
|
1433
1916
|
const argvList = answeredMode === 'show'
|
|
1434
|
-
? answer.
|
|
1917
|
+
? answer.argvs
|
|
1435
1918
|
: [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
|
|
1436
1919
|
const command = buildRewriteCommand(argvList, {
|
|
1437
1920
|
invocation: shellQuoteArg(resolveAnswerBinary({})),
|
|
@@ -1481,6 +1964,9 @@ module.exports = {
|
|
|
1481
1964
|
shellWords, // rewrite — the tokenizer rewritePlan's grammar reads
|
|
1482
1965
|
rewritePlan, // rewrite — does the whole command parse as one the rewrite reproduces
|
|
1483
1966
|
rewriteMatchesBlock, // rewrite — does that plan describe the search classifyBlock chose
|
|
1967
|
+
operandMatches, // D#76 — does a root-relative target name the path the command searched
|
|
1968
|
+
segmentCwd, // D#76 — the directory a compound command's segment runs in
|
|
1969
|
+
segmentSeparators, // D#76 — the separator before each top-level segment
|
|
1484
1970
|
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
1485
1971
|
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
1486
1972
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
@@ -1489,6 +1975,10 @@ module.exports = {
|
|
|
1489
1975
|
isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
|
|
1490
1976
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
1491
1977
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
1978
|
+
rootOnlyInSearchPath, // D#125 #1
|
|
1979
|
+
isDirectory,
|
|
1980
|
+
grepSearchesNoDirectory, // D#125 #3
|
|
1981
|
+
declKindsBySymbol, // D#125 #2 review
|
|
1492
1982
|
resolveProjectRoot, // v0.48 — subdir-cwd dark fix
|
|
1493
1983
|
rebaseRelativePaths, // v0.48 — subdir-cwd dark fix
|
|
1494
1984
|
commandHasBypass, // v0.48 — bypass funnel visibility
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
node-version: '20'
|
|
36
36
|
- name: Build snapshot
|
|
37
37
|
run: |
|
|
38
|
-
npx -y -p @sdsrs/code-graph@0.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.161.0 code-graph-mcp snapshot create --out snapshot.db
|
|
39
39
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
40
40
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
41
41
|
- name: Upload to release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.161.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.161.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.161.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.161.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.161.0"
|
|
43
43
|
}
|
|
44
44
|
}
|