@sdsrs/code-graph 0.153.0 → 0.155.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 +1 -1
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/cg-answer.js +18 -6
- package/claude-plugin/scripts/doctor.js +3 -3
- package/claude-plugin/scripts/hook-emit.js +39 -1
- package/claude-plugin/scripts/pre-grep-guide.js +301 -70
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -427,7 +427,7 @@ skill does is also runnable by hand.
|
|
|
427
427
|
- **Rust has no `inherits` edges** — the language has no class inheritance, so `impl Trait for Type` is recorded as `implements` and an `inherits`-filtered query returns empty for Rust.
|
|
428
428
|
- **Kotlin/Swift interface conformance** is recorded as `inherits` (both use a single `: Type` grammar for base classes and protocols/interfaces), so `implements`-filtered queries return empty for these two languages.
|
|
429
429
|
- **Cross-file dead-code detection** may false-positive a type whose only cross-file reference sits beyond the 4096-byte stored-content cap per node (documented accepted limitation, v0.97.1).
|
|
430
|
-
- **
|
|
430
|
+
- **A file tree-sitter cannot fully parse is indexed over a damaged parse.** Symbols inside the damaged region can be missing or mis-scoped — e.g. a Swift class whose body holds `#if`/`#error` directives, or macro-heavy C++ headers. The index reports the damage rather than hiding it. A run prints `Syntax errors in <file> — symbols may be incomplete` per file plus an `N file(s) parsed with syntax errors` total, and the verdict is stored with the index, so it survives the run: `health-check` names the files (`Parse: N file(s) indexed over a damaged parse`, plus `files_with_parse_errors` / `parse_error_files` under `--json`), `doctor` shows a `Parse` row, and the MCP `get_index_status` carries the same two fields. An incremental run over unrelated files does not clear it — only re-parsing the offending file, or deleting it, does. Rust borrows of a binding named `raw` (`&raw`, `&raw[..]`, `&raw.field`), which the old `tree-sitter-rust` 0.23 grammar misread as the `&raw const` operator, parse cleanly since the tree-sitter 0.25 upgrade: this repo indexed fresh went from 9 damaged Rust files to 0.
|
|
431
431
|
|
|
432
432
|
## Team-shared graph snapshot
|
|
433
433
|
|
|
@@ -170,16 +170,22 @@ const SHELL_SAFE_ARG = /^[A-Za-z0-9_.,:@%+=/-]+$/;
|
|
|
170
170
|
// POSIX single-quote escape: close, emit an escaped quote, reopen.
|
|
171
171
|
const SQ_ESCAPED = "'\\''";
|
|
172
172
|
|
|
173
|
-
function
|
|
173
|
+
function shellQuoteArg(a) {
|
|
174
174
|
// Deliberately no nested template literal, and no regex holding a quote.
|
|
175
175
|
// windows-hide.test.js masks literals with a character scanner that pairs
|
|
176
176
|
// backticks like any other quote, so a `${…`…`…}` inside a template closes the
|
|
177
177
|
// outer one early and swallows the rest of the file — which took all three of
|
|
178
178
|
// THIS file's spawn call sites out of that guard's view until it was written
|
|
179
179
|
// this way. Code a guard cannot parse is code the guard is not checking.
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
const s = String(a);
|
|
181
|
+
return SHELL_SAFE_ARG.test(s) ? s : "'" + s.split("'").join(SQ_ESCAPED) + "'";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// `invocation` is what names the binary. The default is the name a reader can
|
|
185
|
+
// paste; pre-grep-guide's rewrite passes an already-quoted absolute path when
|
|
186
|
+
// that name would not resolve in the Bash tool's shell.
|
|
187
|
+
function formatCgCommand(args, invocation = 'code-graph-mcp') {
|
|
188
|
+
return invocation + ' ' + args.map(shellQuoteArg).join(' ');
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
/**
|
|
@@ -356,6 +362,9 @@ function runShowAnswer(opts = {}) {
|
|
|
356
362
|
if (!binary) return { status: 'no-binary' };
|
|
357
363
|
|
|
358
364
|
const parts = [];
|
|
365
|
+
// Which symbols actually resolved — the rewrite re-runs exactly these, so a
|
|
366
|
+
// symbol that printed nothing here cannot turn into an exit-1 in the Bash call.
|
|
367
|
+
const resolved = [];
|
|
359
368
|
for (const sym of symbols.slice(0, 3)) {
|
|
360
369
|
if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
|
|
361
370
|
const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
|
|
@@ -375,10 +384,11 @@ function runShowAnswer(opts = {}) {
|
|
|
375
384
|
const out = (res.stdout || '').trim();
|
|
376
385
|
if (isEmptyAnswer(out)) continue;
|
|
377
386
|
parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
|
|
387
|
+
resolved.push(sym);
|
|
378
388
|
}
|
|
379
389
|
if (parts.length === 0) return { status: 'no-hits' };
|
|
380
390
|
const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
|
|
381
|
-
return { status: 'hits', text, truncated };
|
|
391
|
+
return { status: 'hits', text, truncated, symbols: resolved };
|
|
382
392
|
} catch {
|
|
383
393
|
return { status: 'unavailable' };
|
|
384
394
|
}
|
|
@@ -498,7 +508,9 @@ module.exports = {
|
|
|
498
508
|
truncateAtLine, sanitizeSearchPath,
|
|
499
509
|
// The one argv builder + its renderer, so the deny copy and the child process
|
|
500
510
|
// cannot describe different commands.
|
|
501
|
-
splitSearchPathGlob, buildGrepArgs, formatCgCommand,
|
|
511
|
+
splitSearchPathGlob, buildGrepArgs, formatCgCommand, shellQuoteArg,
|
|
512
|
+
// pre-grep-guide names this binary in the command it rewrites a grep into.
|
|
513
|
+
resolveAnswerBinary,
|
|
502
514
|
// Exported so the integer property can be asserted where it is ESTABLISHED.
|
|
503
515
|
// Asserting it end-to-end instead passes either way: `remainingMs` floors
|
|
504
516
|
// again downstream, so such a test cannot fail and proves nothing (NEW-02).
|
|
@@ -1393,9 +1393,9 @@ const DOCTOR_USAGE = [
|
|
|
1393
1393
|
'By default doctor repairs detected issues (re-registers hooks in',
|
|
1394
1394
|
'~/.claude/settings.json, fixes stale binary/model paths). Pass',
|
|
1395
1395
|
'--check-only to report issues without repairing: it never writes',
|
|
1396
|
-
'~/.claude/settings.json.
|
|
1397
|
-
'
|
|
1398
|
-
'
|
|
1396
|
+
'~/.claude/settings.json. It is not otherwise write-free — the Node',
|
|
1397
|
+
'layer memoizes the resolved binary under ~/.cache/code-graph/, and',
|
|
1398
|
+
'the npm checks let npm write its own logs.',
|
|
1399
1399
|
].join('\n');
|
|
1400
1400
|
|
|
1401
1401
|
function parseDoctorArgs(args) {
|
|
@@ -100,6 +100,44 @@ function emitPreToolAllowContext(text) {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* PreToolUse rewrite envelope: allow + updatedInput + additionalContext.
|
|
105
|
+
*
|
|
106
|
+
* pre-grep-guide ONLY. A raw source grep the index can answer is rewritten into
|
|
107
|
+
* the `code-graph-mcp grep`/`show` command that answers it, instead of being
|
|
108
|
+
* denied with the answer in the reason. A deny is rendered as a failed tool call
|
|
109
|
+
* (the red `Error` block every intercepted grep used to print), and the model
|
|
110
|
+
* reads it as one; a rewrite runs as an ordinary successful call.
|
|
111
|
+
*
|
|
112
|
+
* Why `allow` rather than no decision. Claude Code 2.1.282 honours
|
|
113
|
+
* `updatedInput` WITHOUT a decision too (pre-ship review reproduced it; an
|
|
114
|
+
* earlier version of this comment said otherwise). But then the rewritten call
|
|
115
|
+
* goes through the permission flow as a command the user never approved: a user
|
|
116
|
+
* who allowlisted `grep` and not this binary would be prompted on every
|
|
117
|
+
* intercepted search — worse than the red deny this replaces. `"ask"` prompts
|
|
118
|
+
* by definition. So this carries `allow`, argued the same way as
|
|
119
|
+
* pre-read-guide's: the command that runs is one this hook assembled from a
|
|
120
|
+
* fixed argv (read-only `grep`/`show`, every argument shell-quoted, the binary
|
|
121
|
+
* by absolute path), never the model's text. The docs state deny and ask rules
|
|
122
|
+
* are still evaluated against the rewritten input.
|
|
123
|
+
*
|
|
124
|
+
* `updatedInput` REPLACES the tool input, so the caller passes the whole object.
|
|
125
|
+
* `reason` is shown to the user, not the model; `context` reaches the model.
|
|
126
|
+
* @param {{updatedInput: object, reason: string, context: string}} opts
|
|
127
|
+
* @returns {string} JSON line
|
|
128
|
+
*/
|
|
129
|
+
function emitPreToolRewrite({ updatedInput, reason, context }) {
|
|
130
|
+
return JSON.stringify({
|
|
131
|
+
hookSpecificOutput: {
|
|
132
|
+
hookEventName: 'PreToolUse',
|
|
133
|
+
permissionDecision: 'allow',
|
|
134
|
+
permissionDecisionReason: reason,
|
|
135
|
+
updatedInput,
|
|
136
|
+
additionalContext: capContext(context),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
/**
|
|
104
142
|
* PostToolUse additionalContext envelope (string, no trailing newline).
|
|
105
143
|
* Permission-neutral: NO permissionDecision, so the underlying Bash tool call's
|
|
@@ -117,6 +155,6 @@ function emitPostToolContext(text) {
|
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
module.exports = {
|
|
120
|
-
emitPreToolContext, emitPreToolAllowContext, emitPostToolContext,
|
|
158
|
+
emitPreToolContext, emitPreToolAllowContext, emitPreToolRewrite, emitPostToolContext,
|
|
121
159
|
capContext, MAX_INJECTED_BYTES,
|
|
122
160
|
};
|
|
@@ -14,6 +14,10 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Pr
|
|
|
14
14
|
// ~13× more than the indexed CLI on bash-heavy days (15-day baseline: 429 raw
|
|
15
15
|
// grep vs 191 functional CLI). v0.25.0 hint-only had ~0% transfer rate; v0.32.0
|
|
16
16
|
// upgrades the narrowest "I'm searching for a symbol" subset to block-with-reason.
|
|
17
|
+
// Since the rewrite change, an ANSWERED block no longer denies: the grep is
|
|
18
|
+
// rewritten (PreToolUse `updatedInput`) into the cg command that answers it, so
|
|
19
|
+
// the call succeeds instead of rendering as a red failed tool call. Only the
|
|
20
|
+
// opt-in static deny (CODE_GRAPH_NO_ANSWER_IN_DENY=1) still denies.
|
|
17
21
|
//
|
|
18
22
|
// HINT fires when ALL conditions met (shouldHint):
|
|
19
23
|
// 1. Command HEAD is grep/rg/ag (NOT piped — pipe-greps are output filters)
|
|
@@ -48,7 +52,11 @@ const fs = require('fs');
|
|
|
48
52
|
const path = require('path');
|
|
49
53
|
const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
|
|
50
54
|
const { recordRecommendation } = require('./recommendation-log');
|
|
51
|
-
const {
|
|
55
|
+
const {
|
|
56
|
+
runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand, shellQuoteArg,
|
|
57
|
+
resolveAnswerBinary,
|
|
58
|
+
} = require('./cg-answer');
|
|
59
|
+
const { emitPreToolRewrite } = require('./hook-emit');
|
|
52
60
|
|
|
53
61
|
// --- Pure logic (testable) ---
|
|
54
62
|
|
|
@@ -758,56 +766,245 @@ function buildBlockReason() {
|
|
|
758
766
|
].join('\n');
|
|
759
767
|
}
|
|
760
768
|
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
769
|
+
// Answered interceptions are REWRITES, not denies. Until this release the hook
|
|
770
|
+
// ran the cg equivalent itself and denied the grep with the output in the
|
|
771
|
+
// reason. It worked — the model used the answer — but Claude Code renders every
|
|
772
|
+
// deny as a failed tool call, so each intercepted search printed a red `Error`
|
|
773
|
+
// block over a perfectly good result. Now the grep is replaced (PreToolUse
|
|
774
|
+
// `updatedInput`) by the command whose output it would have embedded, and the
|
|
775
|
+
// call runs as an ordinary success. The hook still runs the answer first: it is
|
|
776
|
+
// what tells an answerable grep (rewrite) from a dialect miss or a broken binary
|
|
777
|
+
// (let the raw grep run), and a rewrite must never turn into an empty result.
|
|
778
|
+
//
|
|
779
|
+
// v0.48/v0.63 copy rules still hold for the context line: no escape-hatch
|
|
780
|
+
// advertisement (one deny once taught a 14-grep bypass prefix) and no forced
|
|
781
|
+
// restatement of which hit the model will use.
|
|
782
|
+
|
|
783
|
+
// The command the grep is rewritten into. One argv per cg call (several for a
|
|
784
|
+
// multi-symbol `show`), each run with CODE_GRAPH_INTERNAL=1 so the CLI's `use`
|
|
785
|
+
// record does not count a delivered answer as a model-initiated conversion —
|
|
786
|
+
// per command, because a `;`-joined list would scope a bare prefix to the first
|
|
787
|
+
// one only, and `export` would leak into the persistent shell. Paths in the argv
|
|
788
|
+
// are root-relative, so a shell sitting in a subdirectory runs them from the
|
|
789
|
+
// root in a subshell, leaving its own cwd alone.
|
|
790
|
+
//
|
|
791
|
+
// `invocation` is the binary that ANSWERED, by quoted absolute path — runMain
|
|
792
|
+
// passes it. Pre-ship review of the first cut: naming it `code-graph-mcp` let
|
|
793
|
+
// the Bash shell resolve a different, older copy (exit 2 on `-g`, a red error
|
|
794
|
+
// again) or a non-executable match (exit 127), bypassing the version gate
|
|
795
|
+
// `findBinary` exists for. The bare default is for the printed copy only.
|
|
796
|
+
function buildRewriteCommand(argvList, { invocation = 'code-graph-mcp', root, shellCwd } = {}) {
|
|
797
|
+
const body = argvList
|
|
798
|
+
.map((args) => 'CODE_GRAPH_INTERNAL=1 ' + formatCgCommand(args, invocation))
|
|
799
|
+
.join('; echo; ');
|
|
800
|
+
if (!root || !shellCwd || path.resolve(shellCwd) === path.resolve(root)) return body;
|
|
801
|
+
return '(cd ' + shellQuoteArg(root) + ' || exit 1; ' + body + ')';
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// What the model is told alongside the rewritten call's output. `cmdShown` is
|
|
805
|
+
// rendered from the SAME argv the rewrite runs (no env prefix, bare name — the
|
|
806
|
+
// form a reader re-runs).
|
|
807
|
+
function buildRewriteContext(mode, cmdShown) {
|
|
808
|
+
const lines = mode === 'show'
|
|
809
|
+
? ['[code-graph] Raw grep for symbol definitions on indexed source was rewritten to `code-graph-mcp show` — this call\'s output is the definitions from the AST index:',
|
|
810
|
+
`$ ${cmdShown}`,
|
|
811
|
+
'Use these directly instead of re-running the search.']
|
|
812
|
+
: ['[code-graph] Raw `grep` on indexed source was rewritten to its AST-aware equivalent — this call\'s output comes from:',
|
|
813
|
+
`$ ${cmdShown}`,
|
|
814
|
+
'Each hit shows its containing fn/module — use these results directly instead of re-running the search.'];
|
|
792
815
|
return lines.join('\n');
|
|
793
816
|
}
|
|
794
817
|
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
818
|
+
// A rewrite REPLACES the whole command and reports success, so it is only
|
|
819
|
+
// honest when the cg call does everything the command would have done. Three
|
|
820
|
+
// rounds of pre-ship review each found a way past the previous gate: a
|
|
821
|
+
// side-effecting pipe stage, a command after `|| …` on the next line, flags a
|
|
822
|
+
// denylist did not name, then a redirect in a flag's value slot, filters whose
|
|
823
|
+
// hit count did not survive cg's output format, and globs cg reads differently
|
|
824
|
+
// from the shell. So the accepted shape is deliberately NARROW — a command
|
|
825
|
+
// outside it is not intercepted and runs as typed, which also shows no red
|
|
826
|
+
// block; narrowing costs interception, never the user's command:
|
|
827
|
+
//
|
|
828
|
+
// (grep | rg | ag | git grep) ARG… [2>&1 | 2>/dev/null]
|
|
829
|
+
//
|
|
830
|
+
// (`2>/dev/null` parses, but countNamedPaths counts it as a path, so it
|
|
831
|
+
// reaches a rewrite only when the command names no other path.)
|
|
832
|
+
//
|
|
833
|
+
// Every ARG is a flag from the verb's allowlist (values, where a flag takes
|
|
834
|
+
// one, are checked like any other word) or a plain/quoted operand: exactly one
|
|
835
|
+
// pattern and at most one path. Not accepted: any pipe, any other redirect, a
|
|
836
|
+
// NAME=value or `env` prefix (RIPGREP_CONFIG_PATH, GREP_OPTIONS, LC_ALL change
|
|
837
|
+
// the search), a glob in a path or an unquoted pattern, a pattern starting with
|
|
838
|
+
// `-`, and anything the tokenizer does not know — newline, `;`, `&`, `\`, `$`,
|
|
839
|
+
// a backtick, parentheses, braces, `~`. Returns a plan or null.
|
|
840
|
+
const WORD_CHAR = /[A-Za-z0-9_.,:@%+=/*?<>&-]/;
|
|
841
|
+
function shellWords(s) {
|
|
842
|
+
const words = [];
|
|
843
|
+
let cur = null;
|
|
844
|
+
const push = () => { if (cur) words.push(cur); cur = null; };
|
|
845
|
+
const start = (quoted) => { if (!cur) cur = { text: '', startsQuoted: quoted, anyQuoted: false, bareSpecial: false, bareOp: false }; };
|
|
846
|
+
for (let i = 0; i < s.length;) {
|
|
847
|
+
const c = s[i];
|
|
848
|
+
if (c === ' ' || c === '\t') { push(); i++; continue; }
|
|
849
|
+
if (c === '|') { push(); words.push({ op: '|' }); i++; continue; }
|
|
850
|
+
if (c === "'" || c === '"') {
|
|
851
|
+
const j = s.indexOf(c, i + 1);
|
|
852
|
+
if (j === -1) return null;
|
|
853
|
+
const body = s.slice(i + 1, j);
|
|
854
|
+
// Inside double quotes the shell still expands `$`/backticks, and a
|
|
855
|
+
// backslash escapes `"` `\` `$` backtick — so the span may not be what
|
|
856
|
+
// it looks like. A backslash before anything else is literal (`"a\|b"`,
|
|
857
|
+
// the BRE alternation models write constantly).
|
|
858
|
+
if (c === '"' && (/[$`!]|\\["\\$`]/.test(body) || body.endsWith('\\'))) return null;
|
|
859
|
+
start(true);
|
|
860
|
+
cur.anyQuoted = true;
|
|
861
|
+
cur.text += body;
|
|
862
|
+
i = j + 1;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (!WORD_CHAR.test(c)) return null;
|
|
866
|
+
start(false);
|
|
867
|
+
if ('<>&*?'.includes(c)) cur.bareSpecial = true;
|
|
868
|
+
// Per character, independent of quoting elsewhere in the word: `-g'!x'>out`
|
|
869
|
+
// is one word whose quoted part must not excuse the redirect after it
|
|
870
|
+
// (pre-ship review round 4 M1).
|
|
871
|
+
if ('<>&'.includes(c)) cur.bareOp = true;
|
|
872
|
+
cur.text += c;
|
|
873
|
+
i++;
|
|
806
874
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
875
|
+
push();
|
|
876
|
+
return words;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Flags cg honors (`-i -w -F -l -c`, extractCgFlags) or that are no-ops for it
|
|
880
|
+
// (recursion, line numbers, filenames, binary skipping, the regex dialect).
|
|
881
|
+
// Per verb, because letters differ: ag's `-n` is --norecurse and `-H` is
|
|
882
|
+
// --heading, rg's `-r` is --replace. Context letters (A/B/C, a count value)
|
|
883
|
+
// only reach the rewrite in `show` mode, which answers with the body.
|
|
884
|
+
const ALLOWED_SHORT = { grep: 'rRnHsIiwFlcEPABC', git: 'rnHIiwFlcEPABC', rg: 'nHiwFlcsABC', ag: 'iwlcsABC' };
|
|
885
|
+
const VALUE_SHORT_BY_VERB = { grep: 'ABC', git: 'ABC', rg: 'gtABC', ag: 'ABC' };
|
|
886
|
+
const COMMON_LONG = ['ignore-case', 'word-regexp', 'fixed-strings', 'files-with-matches', 'count', 'line-number'];
|
|
887
|
+
const ALLOWED_LONG = {
|
|
888
|
+
grep: new Set([...COMMON_LONG, 'recursive', 'with-filename', 'extended-regexp', 'perl-regexp', 'no-messages', 'include']),
|
|
889
|
+
git: new Set([...COMMON_LONG, 'extended-regexp', 'perl-regexp']),
|
|
890
|
+
rg: new Set([...COMMON_LONG, 'with-filename', 'no-heading', 'glob', 'type']),
|
|
891
|
+
ag: new Set(['ignore-case', 'word-regexp', 'files-with-matches', 'count', 'case-sensitive']),
|
|
892
|
+
};
|
|
893
|
+
const VALUE_LONG = new Set(['include', 'glob', 'type']);
|
|
894
|
+
|
|
895
|
+
// A flag's value word gets the same scrutiny as any other word: round 3 put
|
|
896
|
+
// `&`/`>` in the `-A 3&` slot and dropped a command. Counts are digits; a glob
|
|
897
|
+
// value is fine only quoted (unquoted, the shell would expand it first).
|
|
898
|
+
function valueWordOk(w, isCount) {
|
|
899
|
+
if (!w || w.op) return false;
|
|
900
|
+
if (isCount) return !w.anyQuoted && /^\d+$/.test(w.text);
|
|
901
|
+
return !(w.bareSpecial);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function rewritePlan(cmd) {
|
|
905
|
+
if (!cmd || typeof cmd !== 'string' || cmd.length > 1000) return null;
|
|
906
|
+
const words = shellWords(cmd);
|
|
907
|
+
if (!words || words.some((w) => w.op)) return null;
|
|
908
|
+
let i = 0;
|
|
909
|
+
let verb = !words[0].anyQuoted ? words[0].text : '';
|
|
910
|
+
if (verb === 'git') {
|
|
911
|
+
if (!words[1] || words[1].anyQuoted || words[1].text !== 'grep') return null;
|
|
912
|
+
i = 1;
|
|
913
|
+
} else if (!['grep', 'rg', 'ag'].includes(verb)) {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
i++;
|
|
917
|
+
const allowed = ALLOWED_SHORT[verb];
|
|
918
|
+
const valueShort = VALUE_SHORT_BY_VERB[verb];
|
|
919
|
+
const operands = [];
|
|
920
|
+
let caseFlag = false;
|
|
921
|
+
let context = false;
|
|
922
|
+
let endOfFlags = false;
|
|
923
|
+
for (; i < words.length; i++) {
|
|
924
|
+
const w = words[i];
|
|
925
|
+
if (!w.anyQuoted && (w.text === '2>&1' || w.text === '2>/dev/null')) continue;
|
|
926
|
+
if (w.startsQuoted || endOfFlags || w.text[0] !== '-' || w.text === '-') {
|
|
927
|
+
operands.push(w);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (w.anyQuoted || w.bareSpecial) {
|
|
931
|
+
// Only an ATTACHED file-filter value may be quoted or carry a glob
|
|
932
|
+
// (`--include='*.rs'`, rg `-g'*.rs'`); an unquoted `<>&` is shell syntax.
|
|
933
|
+
const attachedFilter = /^--(?:include|glob|type)=/.test(w.text) || (verb === 'rg' && /^-[gt]./.test(w.text));
|
|
934
|
+
if (!attachedFilter || w.bareOp) return null;
|
|
935
|
+
// GNU grep's --include has no `!` negation; cg's -g does (round 4 L2).
|
|
936
|
+
if (/^--include=!/.test(w.text)) return null;
|
|
937
|
+
}
|
|
938
|
+
if (w.text === '--') { endOfFlags = true; continue; }
|
|
939
|
+
if (w.text.startsWith('--')) {
|
|
940
|
+
const eq = w.text.indexOf('=');
|
|
941
|
+
const name = w.text.slice(2, eq === -1 ? undefined : eq);
|
|
942
|
+
if (!ALLOWED_LONG[verb].has(name)) return null;
|
|
943
|
+
if (VALUE_LONG.has(name) && eq === -1) {
|
|
944
|
+
const v = words[++i];
|
|
945
|
+
if (!valueWordOk(v, false) || (name === 'include' && v.text.startsWith('!'))) return null;
|
|
946
|
+
}
|
|
947
|
+
if (name === 'ignore-case' || name === 'case-sensitive') caseFlag = true;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const letters = w.text.slice(1);
|
|
951
|
+
for (let k = 0; k < letters.length; k++) {
|
|
952
|
+
const ch = letters[k];
|
|
953
|
+
if (valueShort.includes(ch)) {
|
|
954
|
+
const isCount = 'ABC'.includes(ch);
|
|
955
|
+
if (isCount) context = true;
|
|
956
|
+
const attached = letters.slice(k + 1);
|
|
957
|
+
if (attached) {
|
|
958
|
+
if (isCount && !/^\d+$/.test(attached)) return null;
|
|
959
|
+
} else if (!valueWordOk(words[++i], isCount)) {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
if (!allowed.includes(ch)) return null;
|
|
965
|
+
if (ch === 'i' || ch === 's') caseFlag = true;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (operands.length === 0 || operands.length > 2) return null;
|
|
969
|
+
const [pattern, target] = operands;
|
|
970
|
+
// The pattern: unquoted glob characters would be expanded by the shell, and
|
|
971
|
+
// a leading `-` would reach cg as flags.
|
|
972
|
+
if ((!pattern.anyQuoted && pattern.bareSpecial) || pattern.text.startsWith('-')) return null;
|
|
973
|
+
// The path: bash expands a glob at one level with no dotfiles; cg's `-g`
|
|
974
|
+
// matches recursively and includes them (round 3 M5). Not reproducible.
|
|
975
|
+
if (target && (/[*?[\]{}]/.test(target.text) || target.bareSpecial)) return null;
|
|
976
|
+
// A `..` segment: extractSearchPath refuses to scope it, so the answer would
|
|
977
|
+
// search the whole repo (round 4 M2).
|
|
978
|
+
if (target && /(?:^|\/)\.\.(?:\/|$)/.test(target.text)) return null;
|
|
979
|
+
// ag is smart-case by default: an all-lowercase pattern matches any case,
|
|
980
|
+
// and cg's search is case-sensitive, so the answer would find less.
|
|
981
|
+
if (verb === 'ag' && !caseFlag && !/[A-Z]/.test(pattern.text)) return null;
|
|
982
|
+
return { pattern: pattern.text, target: target ? target.text : undefined, context };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// The plan must describe the same search classifyBlock decided on. The pattern
|
|
986
|
+
// the answer ran is picked from quoted spans (pickBlockPattern); the grammar's
|
|
987
|
+
// pattern operand is the one the shell passes — `"is"'Foo'` or an unquoted
|
|
988
|
+
// pattern with a quoted path made them differ (round 3 L3). A context count the
|
|
989
|
+
// raw-text CONTEXT_FLAG check missed (`-A"3"`) sent a context grep to grep mode,
|
|
990
|
+
// which drops the context (L2). `show` answers with bodies, so a grep asking
|
|
991
|
+
// for a file list or counts (`-l`/`-c`) is not its equivalent (M6).
|
|
992
|
+
function rewriteMatchesBlock(plan, block, cmd, rawPattern) {
|
|
993
|
+
if (!plan || !block) return false;
|
|
994
|
+
if (plan.pattern !== rawPattern) return false;
|
|
995
|
+
// Same for the path: extractSearchPath takes the first src-prefixed token,
|
|
996
|
+
// which can be a quoted PATTERN (`grep -rn "src/foo_mod" tmp/` searched
|
|
997
|
+
// src/foo_mod — round 4 H1, inherited from the deny's answer).
|
|
998
|
+
const norm = (p) => (p === undefined ? undefined : p.replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
999
|
+
if (norm(plan.target) !== norm(extractSearchPath(cmd))) return false;
|
|
1000
|
+
if (block.mode === 'grep' && plan.context) return false;
|
|
1001
|
+
if (block.mode === 'show') {
|
|
1002
|
+
const f = cgFlagSet(extractCgFlags(cmd));
|
|
1003
|
+
if (f.has('-l') || f.has('-c')) return false;
|
|
1004
|
+
// show answers at most three symbols; a fourth would silently vanish.
|
|
1005
|
+
if (extractDeclSymbols(extractPatterns(cmd)).length > 3) return false;
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
811
1008
|
}
|
|
812
1009
|
|
|
813
1010
|
// v0.49 — plain `grep` speaks BRE: alternation/grouping arrive escaped
|
|
@@ -979,6 +1176,11 @@ function runMain() {
|
|
|
979
1176
|
// switch silencing the hook that carries the telemetry is coherent; silently
|
|
980
1177
|
// claiming coverage it does not have is not.
|
|
981
1178
|
const block = isBlockDisabled() ? null : classifyDeny(cmd);
|
|
1179
|
+
// The rewrite replaces the WHOLE command. One it cannot reproduce runs as
|
|
1180
|
+
// typed (see rewritePlan) — decided before the answer is spent on it. The
|
|
1181
|
+
// opt-in static deny keeps its own, older scope.
|
|
1182
|
+
const plan = block && !isAnswerDisabled() ? rewritePlan(cmd) : null;
|
|
1183
|
+
if (block && !isAnswerDisabled() && !rewriteMatchesBlock(plan, block, cmd, rawGrepPattern)) return;
|
|
982
1184
|
if (block) {
|
|
983
1185
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
984
1186
|
// results in the deny reason ("answer in the deny"). Degrades to the
|
|
@@ -996,14 +1198,13 @@ function runMain() {
|
|
|
996
1198
|
const flags = cgFlags; // computed once above, beside the -F pattern guard
|
|
997
1199
|
// ONE argv, used to run the child AND to render the command the deny prints.
|
|
998
1200
|
const args = buildGrepArgs({ pattern, searchPath, flags });
|
|
999
|
-
|
|
1201
|
+
const answeredMode = block.mode;
|
|
1000
1202
|
if (!isAnswerDisabled()) {
|
|
1001
1203
|
if (block.mode === 'show') {
|
|
1204
|
+
// No fallback to a grep answer: a context grep (`-A5`) asked for the
|
|
1205
|
+
// body, and the grep rewrite would drop the context silently (pre-ship
|
|
1206
|
+
// review round 2). A show miss lets the raw grep run.
|
|
1002
1207
|
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
1003
|
-
if (answer.status !== 'hits' && pattern) {
|
|
1004
|
-
answeredMode = 'grep';
|
|
1005
|
-
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
1006
|
-
}
|
|
1007
1208
|
} else if (pattern) {
|
|
1008
1209
|
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
1009
1210
|
}
|
|
@@ -1035,35 +1236,62 @@ function runMain() {
|
|
|
1035
1236
|
return;
|
|
1036
1237
|
}
|
|
1037
1238
|
|
|
1038
|
-
// PreToolUse block via current CC schema (`hookSpecificOutput.permissionDecision`).
|
|
1039
|
-
// Verified empirically 2026-05-24: legacy `{decision:"block",reason}` was
|
|
1040
|
-
// ignored by Claude Code — the grep ran anyway. The hookSpecificOutput form
|
|
1041
|
-
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
1042
|
-
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
1043
1239
|
const answered = answer.status === 'hits';
|
|
1044
1240
|
recordRecommendation(root, {
|
|
1241
|
+
// Still `deny` in the funnel: the event it counts — a raw grep intercepted
|
|
1242
|
+
// and answered in place — is unchanged, and the Rust aggregator keys on
|
|
1243
|
+
// it. `delivery` says HOW the answer arrived.
|
|
1045
1244
|
hook: 'grep', action: 'deny', answered,
|
|
1046
1245
|
// pattern fingerprints the denied search so the funnel can score a verbatim
|
|
1047
1246
|
// re-grep of it (the inline answer was ignored) as fall-through, not a win.
|
|
1048
1247
|
...(pattern ? { pattern } : {}),
|
|
1049
1248
|
// mode segments which answer type converts (show=bodies, grep=hits).
|
|
1050
|
-
...(answered ? { mode: answeredMode } : {}),
|
|
1249
|
+
...(answered ? { mode: answeredMode, delivery: 'rewrite' } : {}),
|
|
1051
1250
|
// reason segments WHY an unanswered deny fell back to the static copy:
|
|
1052
1251
|
// 'no-binary' (flagship answer-in-deny dark — binary missing) vs
|
|
1053
1252
|
// 'unavailable' (binary ran but failed/timed out). Without this the two
|
|
1054
1253
|
// are indistinguishable in the funnel ("broken" looks like "no hits").
|
|
1055
1254
|
...(answered ? {} : { reason: answer.status }),
|
|
1056
1255
|
});
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1256
|
+
|
|
1257
|
+
if (!answered) {
|
|
1258
|
+
// CODE_GRAPH_NO_ANSWER_IN_DENY=1 — the user opted into the v0.46 static
|
|
1259
|
+
// deny, so nothing ran and there is nothing to rewrite into. Current CC
|
|
1260
|
+
// schema (`hookSpecificOutput.permissionDecision`): the legacy
|
|
1261
|
+
// `{decision:"block"}` was ignored (verified 2026-05-24). Exit 0 — a
|
|
1262
|
+
// routing decision, not a hook failure.
|
|
1263
|
+
process.stdout.write(JSON.stringify({
|
|
1264
|
+
hookSpecificOutput: {
|
|
1265
|
+
hookEventName: 'PreToolUse',
|
|
1266
|
+
permissionDecision: 'deny',
|
|
1267
|
+
permissionDecisionReason: buildBlockReason(),
|
|
1268
|
+
},
|
|
1269
|
+
}) + '\n');
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Re-run exactly what answered: the resolved symbols for `show`, the same
|
|
1274
|
+
// argv for `grep`.
|
|
1275
|
+
// `-m 0`: cg caps matches at 100 per file by default and says so only on
|
|
1276
|
+
// stderr; the grep being replaced has no cap (round 3 M4). Only here, not
|
|
1277
|
+
// in the in-hook answer, which is a has-hits probe.
|
|
1278
|
+
const argvList = answeredMode === 'show'
|
|
1279
|
+
? answer.symbols.map((sym) => ['show', sym])
|
|
1280
|
+
: [[...args.slice(0, 1), '-m', '0', ...args.slice(1)]];
|
|
1281
|
+
const command = buildRewriteCommand(argvList, {
|
|
1282
|
+
invocation: shellQuoteArg(resolveAnswerBinary({})),
|
|
1283
|
+
root,
|
|
1284
|
+
shellCwd,
|
|
1285
|
+
});
|
|
1286
|
+
const cmdShown = argvList.map((a) => formatCgCommand(a)).join('; ');
|
|
1287
|
+
// Not carried over: a model-set `dangerouslyDisableSandbox`. The call this
|
|
1288
|
+
// hook auto-allows is its own read-only cg command, and it needs no escape
|
|
1289
|
+
// from a sandbox the model's grep would have run inside.
|
|
1290
|
+
const { dangerouslyDisableSandbox, ...toolInput } = input.tool_input || {};
|
|
1291
|
+
process.stdout.write(emitPreToolRewrite({
|
|
1292
|
+
updatedInput: { ...toolInput, command },
|
|
1293
|
+
reason: '[code-graph] raw grep → AST-aware equivalent',
|
|
1294
|
+
context: buildRewriteContext(answeredMode, cmdShown),
|
|
1067
1295
|
}) + '\n');
|
|
1068
1296
|
return;
|
|
1069
1297
|
}
|
|
@@ -1093,7 +1321,11 @@ module.exports = {
|
|
|
1093
1321
|
firstShellClause, // v0.96 — grep's own clause (up to first top-level separator)
|
|
1094
1322
|
extractDeclSymbols, // v0.49 — show-mode symbol extraction
|
|
1095
1323
|
translateBreToRg, // v0.49 — BRE→rust-regex dialect bridge
|
|
1096
|
-
|
|
1324
|
+
buildRewriteCommand, // rewrite — the command the grep becomes
|
|
1325
|
+
buildRewriteContext, // rewrite — what the model is told about it
|
|
1326
|
+
shellWords, // rewrite — the tokenizer rewritePlan's grammar reads
|
|
1327
|
+
rewritePlan, // rewrite — does the whole command parse as one the rewrite reproduces
|
|
1328
|
+
rewriteMatchesBlock, // rewrite — does that plan describe the search classifyBlock chose
|
|
1097
1329
|
extractSedReadTargets, // v0.49 — sed-range reads feed the read-fanout state
|
|
1098
1330
|
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
1099
1331
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
@@ -1107,7 +1339,6 @@ module.exports = {
|
|
|
1107
1339
|
pickBlockPattern,
|
|
1108
1340
|
buildHint,
|
|
1109
1341
|
buildBlockReason,
|
|
1110
|
-
buildBlockReasonWithAnswer,
|
|
1111
1342
|
buildNoHitsFyi,
|
|
1112
1343
|
buildUnavailableFyi, // v0.92 — allow-on-unavailable breadcrumb
|
|
1113
1344
|
commandHash,
|
|
@@ -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.155.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.155.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.155.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.155.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.155.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.155.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.155.0"
|
|
43
43
|
}
|
|
44
44
|
}
|