@sdsrs/code-graph 0.142.0 → 0.144.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/bin/cli.js +18 -157
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/bin/code-graph-mcp +25 -0
- package/claude-plugin/bin/code-graph-mcp.cmd +11 -0
- package/claude-plugin/scripts/cg-answer.js +93 -5
- package/claude-plugin/scripts/cli-entry.js +205 -0
- package/claude-plugin/scripts/find-binary.js +47 -0
- package/claude-plugin/scripts/post-grep-inject.js +76 -9
- package/claude-plugin/scripts/pre-grep-guide.js +288 -52
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
package/bin/cli.js
CHANGED
|
@@ -1,159 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// idiom (doctor.js, lifecycle.js doctor, src/main.rs were the first three); it
|
|
21
|
-
// is the npm/npx surface, so it is the one most users reach.
|
|
22
|
-
function rejectUnknownFlags(name, known) {
|
|
23
|
-
const unknown = process.argv.slice(3).filter((a) => !known.has(a));
|
|
24
|
-
if (unknown.length) {
|
|
25
|
-
process.stderr.write(
|
|
26
|
-
`code-graph-mcp ${name}: unknown argument(s): ${unknown.join(" ")}\n` +
|
|
27
|
-
`Run \`code-graph-mcp ${name} --help\` for usage.\n`);
|
|
28
|
-
process.exit(2);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
if (sub === "adopt" || sub === "unadopt") {
|
|
32
|
-
// `--help`/`-h` must be side-effect-free: adopt() writes the memory file +
|
|
33
|
-
// MEMORY.md sentinel, unadopt() removes them. The Rust binary guards this for
|
|
34
|
-
// direct invocation, but npm/npx routes through this wrapper, which intercepts
|
|
35
|
-
// adopt/unadopt *before* the binary — so the guard must be repeated here, or
|
|
36
|
-
// `code-graph-mcp adopt --help` rewrites MEMORY.md (the common new-user path).
|
|
37
|
-
if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
|
|
38
|
-
process.stdout.write(sub === "adopt"
|
|
39
|
-
// Kept in sync with src/main.rs's adopt/unadopt help. This text described
|
|
40
|
-
// the pre-v0.74 scheme (a sentinel in the ~/.claude memory dir) for three
|
|
41
|
-
// releases after the target moved to the project's own CLAUDE.md, so npm
|
|
42
|
-
// users were told this command edits a file it has not touched since.
|
|
43
|
-
? "code-graph-mcp adopt — install the code-graph steering block into the project CLAUDE.md\n\n" +
|
|
44
|
-
"USAGE:\n code-graph-mcp adopt\n\n" +
|
|
45
|
-
"Writes a sentinel-wrapped managed block into <cwd>/CLAUDE.md plus a\n" +
|
|
46
|
-
"<cwd>/.claude/plugin_code_graph_mcp.md detail doc, so Claude Code loads the\n" +
|
|
47
|
-
"decision table each session. Run `code-graph-mcp unadopt` to remove it.\n"
|
|
48
|
-
: "code-graph-mcp unadopt — remove the code-graph steering block\n\n" +
|
|
49
|
-
"USAGE:\n code-graph-mcp unadopt\n\n" +
|
|
50
|
-
"Reverses `code-graph-mcp adopt`: strips the managed block from\n" +
|
|
51
|
-
"<cwd>/CLAUDE.md and deletes <cwd>/.claude/plugin_code_graph_mcp.md.\n" +
|
|
52
|
-
"User content outside the sentinel is kept.\n");
|
|
53
|
-
process.exit(0);
|
|
54
|
-
}
|
|
55
|
-
rejectUnknownFlags(sub, new Set(["--help", "-h"]));
|
|
56
|
-
const { adopt, unadopt, formatResult } = require("../claude-plugin/scripts/adopt");
|
|
57
|
-
const result = sub === "unadopt" ? unadopt() : adopt();
|
|
58
|
-
process.stdout.write(formatResult(sub, result) + "\n");
|
|
59
|
-
process.exit(result.ok === false ? 1 : 0);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Intercept `uninstall` — full local teardown (restore prior statusline, strip
|
|
63
|
-
// code-graph hooks from settings.json, delete ~/.cache/code-graph, and unadopt the
|
|
64
|
-
// CURRENT project). Node-only; no Rust counterpart. CC's `/plugin uninstall` fires
|
|
65
|
-
// no uninstall hook, so this is the user's one-shot CLI teardown. Guard `--help`
|
|
66
|
-
// before the destructive work (same discipline as adopt/unadopt above).
|
|
67
|
-
if (sub === "uninstall") {
|
|
68
|
-
if (process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
|
|
69
|
-
process.stdout.write(
|
|
70
|
-
"code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
|
|
71
|
-
"USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
|
|
72
|
-
"Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
|
|
73
|
-
"deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
|
|
74
|
-
"block. --unadopt-all also removes the managed block + detail file from every\n" +
|
|
75
|
-
"registered adopted project; --purge-global removes the globally-installed\n" +
|
|
76
|
-
"@sdsrs npm packages even without the plugin-install marker. Also run\n" +
|
|
77
|
-
"`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
|
|
78
|
-
process.exit(0);
|
|
79
|
-
}
|
|
80
|
-
rejectUnknownFlags("uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
|
|
81
|
-
const lifecycle = require("../claude-plugin/scripts/lifecycle");
|
|
82
|
-
const { unadopt } = require("../claude-plugin/scripts/adopt");
|
|
83
|
-
const r = lifecycle.uninstall({
|
|
84
|
-
purgeGlobal: process.argv.slice(3).includes("--purge-global"),
|
|
85
|
-
unadoptAll: process.argv.slice(3).includes("--unadopt-all"),
|
|
86
|
-
});
|
|
87
|
-
let ua = { ok: false };
|
|
88
|
-
try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
|
|
89
|
-
const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
|
|
90
|
-
let out =
|
|
91
|
-
`Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
|
|
92
|
-
` | this project unadopted=${projectUnadopted}\n`;
|
|
93
|
-
if (r.globalPkgsRemoved.length) {
|
|
94
|
-
out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
|
|
95
|
-
}
|
|
96
|
-
if (r.globalPkgsRemaining.length) {
|
|
97
|
-
out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
|
|
98
|
-
` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
|
|
99
|
-
(r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
|
|
100
|
-
}
|
|
101
|
-
if (r.unadopted.length) {
|
|
102
|
-
const cleaned = r.unadopted.filter((u) => u.cleaned).length;
|
|
103
|
-
out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
|
|
104
|
-
}
|
|
105
|
-
const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
|
|
106
|
-
if (otherAdopted.length) {
|
|
107
|
-
out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
|
|
108
|
-
" `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
|
|
109
|
-
otherAdopted.map((p) => ` ${p}\n`).join("");
|
|
110
|
-
}
|
|
111
|
-
out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
|
|
112
|
-
process.stdout.write(out);
|
|
113
|
-
process.exit(0);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const { findBinary, unsupportedPlatformHint } = require("../claude-plugin/scripts/find-binary");
|
|
117
|
-
|
|
118
|
-
const binary = findBinary();
|
|
119
|
-
|
|
120
|
-
if (!binary) {
|
|
121
|
-
const hint = unsupportedPlatformHint();
|
|
122
|
-
console.error(
|
|
123
|
-
"Error: code-graph-mcp binary not found.\n\n" +
|
|
124
|
-
(hint ? hint + "\n\n" : "") +
|
|
125
|
-
"To install:\n" +
|
|
126
|
-
" npm install -g @sdsrs/code-graph\n\n" +
|
|
127
|
-
"To build from source:\n" +
|
|
128
|
-
" cargo install code-graph-mcp --features embed-model\n"
|
|
129
|
-
);
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Spawn the binary, forwarding stdio for MCP JSON-RPC communication
|
|
134
|
-
// windowsHide as a literal rather than via claude-plugin/scripts/proc-opts:
|
|
135
|
-
// this file is the npm package's bin entry and ships in tarballs that may not
|
|
136
|
-
// carry claude-plugin/. Same meaning — CREATE_NO_WINDOW, so a console-less
|
|
137
|
-
// parent (the MCP client) does not flash a window; inherited stdio still works.
|
|
138
|
-
const child = spawn(binary, process.argv.slice(2), {
|
|
139
|
-
stdio: "inherit",
|
|
140
|
-
env: process.env,
|
|
141
|
-
windowsHide: true,
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
child.on("error", (err) => {
|
|
145
|
-
console.error(`Failed to start code-graph-mcp: ${err.message}`);
|
|
146
|
-
// A glibc binary installed on musl (older npm ignores the `libc` field) is present
|
|
147
|
-
// but fails to exec — surface the actionable platform hint instead of a bare error.
|
|
148
|
-
const hint = unsupportedPlatformHint();
|
|
149
|
-
if (hint) console.error("\n" + hint);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
child.on("exit", (code, signal) => {
|
|
154
|
-
if (signal) {
|
|
155
|
-
process.kill(process.pid, signal);
|
|
156
|
-
} else {
|
|
157
|
-
process.exit(code ?? 1);
|
|
158
|
-
}
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* The npm package's `code-graph-mcp` / `code-graph` bin entry.
|
|
5
|
+
*
|
|
6
|
+
* The dispatcher it used to hold now lives in claude-plugin/scripts/cli-entry.js,
|
|
7
|
+
* shared with the plugin's own launcher (claude-plugin/bin/code-graph-mcp) so
|
|
8
|
+
* the two PATH names cannot drift. `claude-plugin/` is in this package's `files`
|
|
9
|
+
* list and this entry already required from it, so nothing new is depended on
|
|
10
|
+
* here — a comment in the old body claimed some tarballs omit that directory,
|
|
11
|
+
* which was already false of the top-level `find-binary` require above it.
|
|
12
|
+
*
|
|
13
|
+
* `_FIND_BINARY_ROOT` is this package's root, one level up from bin/: it is what
|
|
14
|
+
* lets find-binary.js find a bundled `bin/<binary>` and detect a dev checkout.
|
|
15
|
+
*/
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
require('../claude-plugin/scripts/cli-entry').main({
|
|
19
|
+
findBinaryRoot: path.resolve(__dirname, '..'),
|
|
159
20
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* The plugin's `code-graph-mcp` on PATH.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code adds `<plugin-root>/bin` to the Bash tool's PATH for every
|
|
7
|
+
* enabled plugin. This plugin shipped no such directory, so the PATH entry
|
|
8
|
+
* pointed at nothing and every steering surface that printed the bare name —
|
|
9
|
+
* the CLAUDE.md block's six rows, the MCP `instructions` field, the `unadopt`
|
|
10
|
+
* escape hatch — printed a command the user's shell answered with "command not
|
|
11
|
+
* found" (issue #41). A `/plugin install` downloads the native binary to
|
|
12
|
+
* `~/.cache/code-graph/bin/` and resolves it internally; nothing ever put a
|
|
13
|
+
* name in front of it.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately a Node script and not a symlink to that cached binary: the
|
|
16
|
+
* binary is fetched at runtime, after this file is already on PATH, so a
|
|
17
|
+
* symlink would be dangling exactly when a new user first types the command.
|
|
18
|
+
* This can explain itself instead — and it reaches `adopt` / `unadopt` /
|
|
19
|
+
* `uninstall`, which are JS-dispatched and have no counterpart in the binary.
|
|
20
|
+
*
|
|
21
|
+
* No logic lives here. See scripts/cli-entry.js for why there is one dispatcher
|
|
22
|
+
* behind both PATH names, and for why this entry point passes no
|
|
23
|
+
* `findBinaryRoot`.
|
|
24
|
+
*/
|
|
25
|
+
require('../scripts/cli-entry').main();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM Windows PATHEXT sibling of the extensionless launcher beside it: cmd.exe
|
|
3
|
+
REM will not execute a file with no extension, so the same PATH entry that works
|
|
4
|
+
REM on POSIX resolves nothing here without this shim. Runs the very same file
|
|
5
|
+
REM through node rather than duplicating its three lines.
|
|
6
|
+
node "%~dp0code-graph-mcp" %*
|
|
7
|
+
REM Propagate the exit code explicitly. cmd.exe leaves %ERRORLEVEL% set after the
|
|
8
|
+
REM last command, but a caller that runs this through `cmd /c` gets the SCRIPT's
|
|
9
|
+
REM status, and relying on the implicit path is what makes a non-zero `doctor` or
|
|
10
|
+
REM a failed search look like success to a wrapper.
|
|
11
|
+
exit /b %ERRORLEVEL%
|
|
@@ -97,6 +97,91 @@ function sanitizeSearchPath(searchPath) {
|
|
|
97
97
|
return kept || undefined;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The same split, but keeping the half `sanitizeSearchPath` throws away.
|
|
102
|
+
*
|
|
103
|
+
* Widening `tests/*.mjs` to `tests` keeps rg from exiting 1, and for years that
|
|
104
|
+
* was the whole story — but the deny then PRINTS the widened command above an
|
|
105
|
+
* answer covering files the user excluded (field report 2026-09-08: a
|
|
106
|
+
* `tests/*.mjs` grep answered with 25+ hits from all of `tests/`). The glob has
|
|
107
|
+
* a faithful destination: `code-graph-mcp grep -g <glob>`, which matches
|
|
108
|
+
* basename-style across nested directories.
|
|
109
|
+
*
|
|
110
|
+
* `{scope, glob}`, either of which may be undefined: `tests/*.mjs` →
|
|
111
|
+
* `{scope:'tests', glob:'*.mjs'}`, `src/**` + `/*.rs` → `{scope:'src',
|
|
112
|
+
* glob:'**\/*.rs'}`, a leading glob (`*.py`) → `{scope:undefined,
|
|
113
|
+
* glob:'*.py'}` (repo-wide, filtered), no glob → `{scope:<path>}`.
|
|
114
|
+
*/
|
|
115
|
+
function splitSearchPathGlob(searchPath) {
|
|
116
|
+
if (!searchPath || typeof searchPath !== 'string') return {};
|
|
117
|
+
const segs = searchPath.split('/');
|
|
118
|
+
const i = segs.findIndex((s) => /[*?[\]{}]/.test(s));
|
|
119
|
+
if (i === -1) return { scope: searchPath };
|
|
120
|
+
const scope = segs.slice(0, i).join('/') || undefined;
|
|
121
|
+
const glob = segs.slice(i).join('/') || undefined;
|
|
122
|
+
return { scope, glob };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The argv for the grep answer — the ONE place the answer's shape is decided.
|
|
127
|
+
*
|
|
128
|
+
* Both the child process and the command string the deny prints are built from
|
|
129
|
+
* this array, because two renderings of one command are two things that drift:
|
|
130
|
+
* v0.142.0 shipped a fix for two printers of a single `unadopt` string that had
|
|
131
|
+
* done exactly that. Here the drift was live and user-visible in a different
|
|
132
|
+
* form — the printed command never carried the grep's flags at all, so a
|
|
133
|
+
* `grep -l` deny announced a file-list command and delivered hit lines.
|
|
134
|
+
*
|
|
135
|
+
* `flags` are already translated to cg spellings by the caller
|
|
136
|
+
* (pre-grep-guide.extractCgFlags). A `-g` among them came from `--include` and
|
|
137
|
+
* wins over one derived from the path: the user spelled that filter explicitly.
|
|
138
|
+
*/
|
|
139
|
+
function buildGrepArgs({ pattern, searchPath, flags = [] } = {}) {
|
|
140
|
+
const { scope, glob } = splitSearchPathGlob(searchPath);
|
|
141
|
+
const args = ['grep', ...flags, pattern];
|
|
142
|
+
if (scope) args.push(scope);
|
|
143
|
+
// `hasGlobFlag`, not `flags.includes('-g')`: `flags` is a flat argv fragment,
|
|
144
|
+
// so a VALUE can equal a flag name. `rg -t -g "sym" src/*.rs` yields
|
|
145
|
+
// `['-t','-g']` — `-g` there is the VALUE of `-t` — and the bare membership
|
|
146
|
+
// test dropped the path-derived glob, widening `src/*.rs` to `src`. That is
|
|
147
|
+
// the defect this function exists to fix, reintroduced one layer up (round 2
|
|
148
|
+
// of pre-ship review).
|
|
149
|
+
if (glob && !hasGlobFlag(flags)) args.push('-g', glob);
|
|
150
|
+
return args;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// True when `flags` carries a real `-g` (an explicit `--include`/`--glob`), as
|
|
154
|
+
// opposed to the string `-g` sitting in a value position.
|
|
155
|
+
function hasGlobFlag(flags) {
|
|
156
|
+
for (let i = 0; i < (flags || []).length; i++) {
|
|
157
|
+
if (flags[i] === '-g') return true;
|
|
158
|
+
if (flags[i] === '-t') i++; // skip its value
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Render an argv as the command a human can paste. Single-quote anything that
|
|
164
|
+
// would not survive a shell verbatim — the glob especially, which is the whole
|
|
165
|
+
// point of passing it (`-g *.mjs` unquoted is expanded by the user's shell).
|
|
166
|
+
// Characters that survive a shell verbatim. A glob does not, which is the whole
|
|
167
|
+
// reason the argument is worth quoting: `-g *.mjs` unquoted is expanded by the
|
|
168
|
+
// reader's own shell before cg ever sees it.
|
|
169
|
+
const SHELL_SAFE_ARG = /^[A-Za-z0-9_.,:@%+=/-]+$/;
|
|
170
|
+
// POSIX single-quote escape: close, emit an escaped quote, reopen.
|
|
171
|
+
const SQ_ESCAPED = "'\\''";
|
|
172
|
+
|
|
173
|
+
function formatCgCommand(args) {
|
|
174
|
+
// Deliberately no nested template literal, and no regex holding a quote.
|
|
175
|
+
// windows-hide.test.js masks literals with a character scanner that pairs
|
|
176
|
+
// backticks like any other quote, so a `${…`…`…}` inside a template closes the
|
|
177
|
+
// outer one early and swallows the rest of the file — which took all three of
|
|
178
|
+
// THIS file's spawn call sites out of that guard's view until it was written
|
|
179
|
+
// this way. Code a guard cannot parse is code the guard is not checking.
|
|
180
|
+
const shown = args.map((a) =>
|
|
181
|
+
(SHELL_SAFE_ARG.test(a) ? a : "'" + a.split("'").join(SQ_ESCAPED) + "'"));
|
|
182
|
+
return 'code-graph-mcp ' + shown.join(' ');
|
|
183
|
+
}
|
|
184
|
+
|
|
100
185
|
/**
|
|
101
186
|
* The four runners below each spawned the binary and mapped the result by hand
|
|
102
187
|
* — the same fifteen lines, four times (audit 2026-08-29 ARC-07). Hoisted into
|
|
@@ -206,6 +291,7 @@ function runGrepAnswer(opts = {}) {
|
|
|
206
291
|
cwd,
|
|
207
292
|
pattern,
|
|
208
293
|
searchPath,
|
|
294
|
+
flags = [],
|
|
209
295
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
210
296
|
maxBytes = DEFAULT_MAX_BYTES,
|
|
211
297
|
} = opts;
|
|
@@ -216,11 +302,10 @@ function runGrepAnswer(opts = {}) {
|
|
|
216
302
|
const binary = resolveAnswerBinary(opts);
|
|
217
303
|
if (!binary) return { status: 'no-binary' };
|
|
218
304
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
const args =
|
|
223
|
-
if (scope) args.push(scope);
|
|
305
|
+
// buildGrepArgs does the glob split itself, so a caller that passes a raw
|
|
306
|
+
// `dir/*.py` still cannot put a literal glob into argv (the guaranteed
|
|
307
|
+
// nonzero exit sanitizeSearchPath exists for) — it becomes scope + `-g`.
|
|
308
|
+
const args = buildGrepArgs({ pattern, searchPath, flags });
|
|
224
309
|
const res = runCg(binary, args, { cwd, timeoutMs });
|
|
225
310
|
// Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
|
|
226
311
|
// shape resolves to 'no-hits' through isEmptyAnswer below.
|
|
@@ -411,6 +496,9 @@ function runCallgraphAnswer(opts = {}) {
|
|
|
411
496
|
module.exports = {
|
|
412
497
|
runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
|
|
413
498
|
truncateAtLine, sanitizeSearchPath,
|
|
499
|
+
// The one argv builder + its renderer, so the deny copy and the child process
|
|
500
|
+
// cannot describe different commands.
|
|
501
|
+
splitSearchPathGlob, buildGrepArgs, formatCgCommand,
|
|
414
502
|
// Exported so the integer property can be asserted where it is ESTABLISHED.
|
|
415
503
|
// Asserting it end-to-end instead passes either way: `remainingMs` floors
|
|
416
504
|
// again downstream, so such a test cannot fail and proves nothing (NEW-02).
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The dispatcher behind every `code-graph-mcp <subcommand>` a user can type.
|
|
4
|
+
*
|
|
5
|
+
* Two files put that name on a PATH: the npm package's bin entry
|
|
6
|
+
* (`bin/cli.js`, from `npm i -g @sdsrs/code-graph`) and this plugin's launcher
|
|
7
|
+
* (`claude-plugin/bin/code-graph-mcp`, which Claude Code puts on PATH for every
|
|
8
|
+
* enabled plugin). Both are three lines that call `main()` here.
|
|
9
|
+
*
|
|
10
|
+
* Why one module and not two entry points: v0.142.0 shipped a fix for two
|
|
11
|
+
* printers of the same `unadopt` string that had drifted apart, and the release
|
|
12
|
+
* before it shipped a fix for the first of them. Two dispatchers for the same
|
|
13
|
+
* six subcommands would be that defect with a much larger surface — `adopt`
|
|
14
|
+
* writes the user's CLAUDE.md and `uninstall` deletes their cache, so a drift
|
|
15
|
+
* between them is a drift between two destructive paths.
|
|
16
|
+
*
|
|
17
|
+
* `adopt` / `unadopt` / `uninstall` are intercepted here rather than forwarded:
|
|
18
|
+
* they write `<cwd>/CLAUDE.md`, `<cwd>/.claude/` and `~/.cache/code-graph`, and
|
|
19
|
+
* have no counterpart in the Rust binary. Everything else is forwarded verbatim.
|
|
20
|
+
*/
|
|
21
|
+
const { spawn } = require('child_process');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { hidden } = require('./proc-opts');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reject unknown flags on an intercepted subcommand BEFORE doing its work.
|
|
27
|
+
*
|
|
28
|
+
* `--help` was already guarded, but every OTHER token was ignored, so
|
|
29
|
+
* `code-graph-mcp adopt --helpp` ran adopt and wrote the user's CLAUDE.md — a
|
|
30
|
+
* typo away from the very side effect the --help guard exists to prevent. This
|
|
31
|
+
* is the fourth entry point onto the same "ignore what you don't recognise"
|
|
32
|
+
* idiom (doctor.js, lifecycle.js doctor, src/main.rs were the first three); it
|
|
33
|
+
* is the surface both PATH names reach, so it is the one most users hit.
|
|
34
|
+
*/
|
|
35
|
+
function rejectUnknownFlags(argv, name, known) {
|
|
36
|
+
const unknown = argv.slice(3).filter((a) => !known.has(a));
|
|
37
|
+
if (unknown.length) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
`code-graph-mcp ${name}: unknown argument(s): ${unknown.join(" ")}\n` +
|
|
40
|
+
`Run \`code-graph-mcp ${name} --help\` for usage.\n`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} [opts]
|
|
47
|
+
* @param {string[]} [opts.argv] process.argv-shaped: [node, entry, sub, ...]
|
|
48
|
+
* @param {string} [opts.findBinaryRoot] package root for find-binary's dev-mode and
|
|
49
|
+
* bundled-`bin/` tiers. The npm entry passes its package root. The plugin
|
|
50
|
+
* launcher passes NOTHING on purpose: find-binary derives the right root from
|
|
51
|
+
* its own `__dirname` in every install shape, and pointing this at the plugin
|
|
52
|
+
* root would add the launcher's own directory to the bundled-binary tier.
|
|
53
|
+
*/
|
|
54
|
+
function main({ argv = process.argv, findBinaryRoot = null } = {}) {
|
|
55
|
+
if (findBinaryRoot) process.env._FIND_BINARY_ROOT = path.resolve(findBinaryRoot);
|
|
56
|
+
|
|
57
|
+
const sub = argv[2];
|
|
58
|
+
|
|
59
|
+
if (sub === "adopt" || sub === "unadopt") {
|
|
60
|
+
// `--help`/`-h` must be side-effect-free: adopt() writes the managed block
|
|
61
|
+
// into CLAUDE.md, unadopt() removes it. The Rust binary guards this for
|
|
62
|
+
// direct invocation, but both PATH names route through this wrapper, which
|
|
63
|
+
// intercepts adopt/unadopt *before* the binary — so the guard must be
|
|
64
|
+
// repeated here, or `code-graph-mcp adopt --help` adopts the project (the
|
|
65
|
+
// common new-user path).
|
|
66
|
+
if (argv.slice(3).some((a) => a === "--help" || a === "-h")) {
|
|
67
|
+
process.stdout.write(sub === "adopt"
|
|
68
|
+
// Kept in sync with src/main.rs's adopt/unadopt help. This text described
|
|
69
|
+
// the pre-v0.74 scheme (a sentinel in the ~/.claude memory dir) for three
|
|
70
|
+
// releases after the target moved to the project's own CLAUDE.md, so npm
|
|
71
|
+
// users were told this command edits a file it has not touched since.
|
|
72
|
+
? "code-graph-mcp adopt — install the code-graph steering block into the project CLAUDE.md\n\n" +
|
|
73
|
+
"USAGE:\n code-graph-mcp adopt\n\n" +
|
|
74
|
+
"Writes a sentinel-wrapped managed block into <cwd>/CLAUDE.md plus a\n" +
|
|
75
|
+
"<cwd>/.claude/plugin_code_graph_mcp.md detail doc, so Claude Code loads the\n" +
|
|
76
|
+
"decision table each session. Run `code-graph-mcp unadopt` to remove it.\n"
|
|
77
|
+
: "code-graph-mcp unadopt — remove the code-graph steering block\n\n" +
|
|
78
|
+
"USAGE:\n code-graph-mcp unadopt\n\n" +
|
|
79
|
+
"Reverses `code-graph-mcp adopt`: strips the managed block from\n" +
|
|
80
|
+
"<cwd>/CLAUDE.md and deletes <cwd>/.claude/plugin_code_graph_mcp.md.\n" +
|
|
81
|
+
"User content outside the sentinel is kept.\n");
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
rejectUnknownFlags(argv, sub, new Set(["--help", "-h"]));
|
|
85
|
+
const { adopt, unadopt, formatResult } = require("./adopt");
|
|
86
|
+
const result = sub === "unadopt" ? unadopt() : adopt();
|
|
87
|
+
process.stdout.write(formatResult(sub, result) + "\n");
|
|
88
|
+
process.exit(result.ok === false ? 1 : 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Full local teardown (restore prior statusline, strip code-graph hooks from
|
|
92
|
+
// settings.json, delete ~/.cache/code-graph, and unadopt the CURRENT project).
|
|
93
|
+
// Node-only; no Rust counterpart. Claude Code's `/plugin uninstall` fires no
|
|
94
|
+
// uninstall hook, so this is the user's one-shot CLI teardown. Guard `--help`
|
|
95
|
+
// before the destructive work (same discipline as adopt/unadopt above).
|
|
96
|
+
if (sub === "uninstall") {
|
|
97
|
+
if (argv.slice(3).some((a) => a === "--help" || a === "-h")) {
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
"code-graph-mcp uninstall — remove code-graph config + cache from this machine\n\n" +
|
|
100
|
+
"USAGE:\n code-graph-mcp uninstall [--unadopt-all] [--purge-global]\n\n" +
|
|
101
|
+
"Restores your prior statusline, strips code-graph hooks from settings.json,\n" +
|
|
102
|
+
"deletes ~/.cache/code-graph, and removes this project's CLAUDE.md adoption\n" +
|
|
103
|
+
"block. --unadopt-all also removes the managed block + detail file from every\n" +
|
|
104
|
+
"registered adopted project; --purge-global removes the globally-installed\n" +
|
|
105
|
+
"@sdsrs npm packages even without the plugin-install marker. Also run\n" +
|
|
106
|
+
"`/plugin uninstall code-graph-mcp` in Claude Code to sync its UI.\n");
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
rejectUnknownFlags(argv, "uninstall", new Set(["--help", "-h", "--unadopt-all", "--purge-global"]));
|
|
110
|
+
const lifecycle = require("./lifecycle");
|
|
111
|
+
const { unadopt } = require("./adopt");
|
|
112
|
+
const r = lifecycle.uninstall({
|
|
113
|
+
purgeGlobal: argv.slice(3).includes("--purge-global"),
|
|
114
|
+
unadoptAll: argv.slice(3).includes("--unadopt-all"),
|
|
115
|
+
});
|
|
116
|
+
let ua = { ok: false };
|
|
117
|
+
try { ua = unadopt(); } catch { /* best-effort — settings/cache already cleaned */ }
|
|
118
|
+
const projectUnadopted = !!(ua && (ua.blockPruned || ua.fileRemoved || ua.claudeMdRemoved));
|
|
119
|
+
let out =
|
|
120
|
+
`Uninstalled code-graph-mcp | settings cleaned=${r.settingsChanged}` +
|
|
121
|
+
` | this project unadopted=${projectUnadopted}\n`;
|
|
122
|
+
if (r.globalPkgsRemoved.length) {
|
|
123
|
+
out += ` Removed global npm package(s): ${r.globalPkgsRemoved.join(", ")}\n`;
|
|
124
|
+
}
|
|
125
|
+
if (r.globalPkgsRemaining.length) {
|
|
126
|
+
out += ` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(", ")}\n` +
|
|
127
|
+
` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(" ")}` +
|
|
128
|
+
(r.pluginInstalledGlobals ? "\n" : " (or re-run with --purge-global)\n");
|
|
129
|
+
}
|
|
130
|
+
if (r.unadopted.length) {
|
|
131
|
+
const cleaned = r.unadopted.filter((u) => u.cleaned).length;
|
|
132
|
+
out += ` Unadopted ${cleaned}/${r.unadopted.length} registered project(s) (--unadopt-all).\n`;
|
|
133
|
+
}
|
|
134
|
+
const otherAdopted = r.adoptedProjects.filter((p) => p !== process.cwd());
|
|
135
|
+
if (otherAdopted.length) {
|
|
136
|
+
out += " Other adopted project(s) — re-run with --unadopt-all, or in each:" +
|
|
137
|
+
" `code-graph-mcp unadopt` + `rm -rf .code-graph`\n" +
|
|
138
|
+
otherAdopted.map((p) => ` ${p}\n`).join("");
|
|
139
|
+
}
|
|
140
|
+
out += " Also run `/plugin uninstall code-graph-mcp` in Claude Code to sync its UI state.\n";
|
|
141
|
+
process.stdout.write(out);
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `doctor` is the third JS-dispatched subcommand, and the one that used to be
|
|
146
|
+
// reached the long way round: forwarded to the binary, which re-execs a
|
|
147
|
+
// `doctor.js` it looks for beside itself. That works for a source build and
|
|
148
|
+
// for the npm package; it does NOT work for the install this launcher exists
|
|
149
|
+
// for, where the binary is the downloaded one in `~/.cache/code-graph/bin/`
|
|
150
|
+
// and answers `doctor.js not found. Looked in: …`. The script is right here,
|
|
151
|
+
// so ask it directly.
|
|
152
|
+
//
|
|
153
|
+
// `runDoctorCli` rather than a private re-parse: it is the function
|
|
154
|
+
// `node doctor.js …` and `node lifecycle.js doctor …` already share, and it
|
|
155
|
+
// exists as one function because the first version of doctor's unknown-flag
|
|
156
|
+
// guard lived in only one of them and a typo'd flag kept running the repair
|
|
157
|
+
// pass on the other. A third entry point re-implementing the parse would
|
|
158
|
+
// restore exactly that.
|
|
159
|
+
if (sub === "doctor") {
|
|
160
|
+
const { runDoctorCli } = require("./doctor");
|
|
161
|
+
process.exit(runDoctorCli(argv.slice(3)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { findBinary, unsupportedPlatformHint } = require("./find-binary");
|
|
165
|
+
|
|
166
|
+
const binary = findBinary();
|
|
167
|
+
|
|
168
|
+
if (!binary) {
|
|
169
|
+
const hint = unsupportedPlatformHint();
|
|
170
|
+
console.error(
|
|
171
|
+
"Error: code-graph-mcp binary not found.\n\n" +
|
|
172
|
+
(hint ? hint + "\n\n" : "") +
|
|
173
|
+
"To install:\n" +
|
|
174
|
+
" npm install -g @sdsrs/code-graph\n\n" +
|
|
175
|
+
"To build from source:\n" +
|
|
176
|
+
" cargo install code-graph-mcp --features embed-model\n"
|
|
177
|
+
);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Forward stdio so MCP JSON-RPC (`serve`) and interactive output both work.
|
|
182
|
+
const child = spawn(binary, argv.slice(2), hidden({
|
|
183
|
+
stdio: "inherit",
|
|
184
|
+
env: process.env,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
child.on("error", (err) => {
|
|
188
|
+
console.error(`Failed to start code-graph-mcp: ${err.message}`);
|
|
189
|
+
// A glibc binary installed on musl (older npm ignores the `libc` field) is present
|
|
190
|
+
// but fails to exec — surface the actionable platform hint instead of a bare error.
|
|
191
|
+
const hint = unsupportedPlatformHint();
|
|
192
|
+
if (hint) console.error("\n" + hint);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
child.on("exit", (code, signal) => {
|
|
197
|
+
if (signal) {
|
|
198
|
+
process.kill(process.pid, signal);
|
|
199
|
+
} else {
|
|
200
|
+
process.exit(code ?? 1);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { main, rejectUnknownFlags };
|
|
@@ -190,11 +190,58 @@ function nvmNodeModulesDirs(base = path.join(os.homedir(), '.nvm', 'versions', '
|
|
|
190
190
|
.filter((d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The plugin's own `bin/`, resolved once through realpath (plugin caches can sit
|
|
195
|
+
* behind a symlinked home) and remembered.
|
|
196
|
+
*
|
|
197
|
+
* Claude Code puts this directory on the Bash tool's PATH, and the file in it is
|
|
198
|
+
* NAMED `code-graph-mcp` — that is the whole point of it (issue #41). See
|
|
199
|
+
* `isNativeBinary` for why that name has to be excluded here.
|
|
200
|
+
*/
|
|
201
|
+
let launcherDirMemo;
|
|
202
|
+
function launcherDir() {
|
|
203
|
+
if (launcherDirMemo === undefined) {
|
|
204
|
+
const dir = path.resolve(__dirname, '..', 'bin');
|
|
205
|
+
try { launcherDirMemo = fs.realpathSync(dir); } catch { launcherDirMemo = dir; }
|
|
206
|
+
}
|
|
207
|
+
return launcherDirMemo;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Is this candidate the native binary, as opposed to a wrapper that resolves it?
|
|
212
|
+
*
|
|
213
|
+
* The basename test is what rejects the npm shim: `npm i -g` installs
|
|
214
|
+
* `code-graph-mcp` as a symlink whose realpath is `.../bin/cli.js`, and running
|
|
215
|
+
* that instead of the binary would make the wrapper spawn itself.
|
|
216
|
+
*
|
|
217
|
+
* The plugin launcher is the same hazard under a name the basename test cannot
|
|
218
|
+
* catch, because PATH resolution requires it to BE `code-graph-mcp`. Two tiers
|
|
219
|
+
* of the discovery chain offer it: the PATH probe (`which code-graph-mcp`) and
|
|
220
|
+
* the bundled-`bin/` tier, which `mcp-launcher.js` points at the plugin root.
|
|
221
|
+
* It resolves the binary by calling `findBinary()` — so accepting it here is a
|
|
222
|
+
* launcher that execs the launcher, and a `binary-path` cache entry that
|
|
223
|
+
* remembers to keep doing it. Excluded by directory rather than by content
|
|
224
|
+
* (a magic-byte test would be the more general guard and would reject the
|
|
225
|
+
* planted fixtures this module's own suite is built on).
|
|
226
|
+
*/
|
|
193
227
|
function isNativeBinary(candidate) {
|
|
194
228
|
if (!candidate) return false;
|
|
195
229
|
try {
|
|
196
230
|
if (!fs.existsSync(candidate)) return false;
|
|
197
231
|
const realPath = fs.realpathSync(candidate);
|
|
232
|
+
const dir = path.dirname(realPath);
|
|
233
|
+
// Our OWN launcher directory, and — pre-ship review — any OTHER copy's.
|
|
234
|
+
// Matching only `launcherDir()` covers the copy this module was loaded from,
|
|
235
|
+
// so a machine with BOTH `npm i -g` and the plugin had the npm entry's PATH
|
|
236
|
+
// tier resolve the PLUGIN's launcher as the binary: accepted by the gate,
|
|
237
|
+
// written into the shared `~/.cache/code-graph/binary-path`, and thereafter
|
|
238
|
+
// one extra node process per call plus cache thrash between the two copies.
|
|
239
|
+
// The `.cmd` sibling is the marker because it is what makes a directory a
|
|
240
|
+
// launcher directory: `claude-plugin/bin/` always ships both spellings, and
|
|
241
|
+
// nothing that ships an actual native binary ships a `.cmd` beside it.
|
|
242
|
+
// Not a magic-byte test on purpose — see the note in tasks/specs.
|
|
243
|
+
if (dir === launcherDir()) return false;
|
|
244
|
+
if (fs.existsSync(path.join(dir, 'code-graph-mcp.cmd'))) return false;
|
|
198
245
|
return path.basename(realPath) === BINARY_NAME;
|
|
199
246
|
} catch {
|
|
200
247
|
return false;
|
|
@@ -23,22 +23,28 @@ if (require.main === module) require('./hook-fail-open').installHookFailOpen('Po
|
|
|
23
23
|
//
|
|
24
24
|
// Reuses the PreToolUse pure predicates wholesale (feedback_hook_class_bug_sweep —
|
|
25
25
|
// no inline copies of the grep gate): splitTopLevelSegments + classifyBlock pick
|
|
26
|
-
// the foldable segment; pickBlockPattern / translateBreToRg /
|
|
27
|
-
//
|
|
28
|
-
// deny path would have
|
|
26
|
+
// the foldable segment; pickBlockPattern / translateBreToRg / extractCgFlags +
|
|
27
|
+
// extractSearchPath + runGrepAnswer / runShowAnswer run the exact same answer the
|
|
28
|
+
// deny path would have — including its flags and glob scope, which this hook did
|
|
29
|
+
// NOT carry until v0.144 even as the deny path stopped taking these commands. Best-effort: any miss (no hits / unavailable / no binary)
|
|
29
30
|
// exits silently with NO injection — an enhancement, never a new failure mode.
|
|
30
31
|
|
|
31
32
|
const fs = require('fs');
|
|
32
33
|
const path = require('path');
|
|
33
34
|
const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
|
|
34
35
|
const { recordRecommendation } = require('./recommendation-log');
|
|
35
|
-
|
|
36
|
+
// `sanitizeSearchPath` is deliberately NOT imported any more: this hook passes
|
|
37
|
+
// the RAW path so buildGrepArgs can split a glob into scope + `-g` instead of
|
|
38
|
+
// widening it away. Leaving the require behind would read as "still used".
|
|
39
|
+
const { runGrepAnswer, runShowAnswer, runCallgraphAnswer } = require('./cg-answer');
|
|
36
40
|
const { emitPostToolContext } = require('./hook-emit');
|
|
37
41
|
const {
|
|
38
42
|
splitTopLevelSegments,
|
|
39
43
|
classifyBlock,
|
|
40
44
|
pickBlockPattern,
|
|
41
45
|
translateBreToRg,
|
|
46
|
+
extractCgFlags,
|
|
47
|
+
cgFlagSet,
|
|
42
48
|
extractSearchPath,
|
|
43
49
|
normalizeCommandPaths,
|
|
44
50
|
rebaseRelativePaths,
|
|
@@ -265,9 +271,70 @@ function runMain() {
|
|
|
265
271
|
// ~1KB context tax. Only a grep that found NOTHING (or an unreadable output —
|
|
266
272
|
// no regression on older CC) proceeds: then cg's structural answer (the real
|
|
267
273
|
// location / cross-file callers a failed grep never showed) is genuinely additive.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
274
|
+
// v0.144 — RECORD the suppression instead of returning silently. PreToolUse
|
|
275
|
+
// stopped denying compound commands in this release, which moves a measured
|
|
276
|
+
// 27% of former denies (23 of 85 in this repo's own log carried `tail:true`,
|
|
277
|
+
// every one of them `answered:true`) onto this path — and this `return` is
|
|
278
|
+
// where they end when the model's own grep hit. That was the right trade only
|
|
279
|
+
// if it stays visible: an unrecorded skip makes the whole class disappear from
|
|
280
|
+
// the funnel, so "the compound case is covered by the inject" becomes a claim
|
|
281
|
+
// nobody can check after ship. Pre-ship review found this before release.
|
|
282
|
+
// `hook:'grep'` and `action:'observe'`, NOT a new hook name — round 2 of
|
|
283
|
+
// pre-ship review caught round 1 inventing `hook:'grep-inject'/action:'skip'`
|
|
284
|
+
// and thereby breaking the metric this record was added to protect.
|
|
285
|
+
// `aggregate_recommendations_jsonl` (src/cli/usage.rs) clears `armed` on EVERY
|
|
286
|
+
// line it reads, but only scores lines whose hook is `grep`/`read`, so an
|
|
287
|
+
// unrecognized hook consumed the arm set by the preceding answered deny
|
|
288
|
+
// without scoring it — measured on a crafted log: one such line between an
|
|
289
|
+
// answered deny and its follow-up took `fallthrough_rate` from 1.0 to 0.0.
|
|
290
|
+
// `skip` was also absent from the `observe`/`use`/`live_impact` exclusions, so
|
|
291
|
+
// it inflated `s.total` and shifted `tool_calls_per_rec` across releases.
|
|
292
|
+
//
|
|
293
|
+
// `observe` is the honest bucket as well as the safe one: the grep ran and
|
|
294
|
+
// produced its own hits, so nothing was recommended. The aggregator already
|
|
295
|
+
// reads it as "acting on the answer — neither sustained nor fall-through" and
|
|
296
|
+
// excludes it from the recommendation total. `reason` distinguishes this skip
|
|
297
|
+
// from the cooldown observes when grepping the JSONL; no counter reads it.
|
|
298
|
+
//
|
|
299
|
+
// KNOWN CONSEQUENCE, measured rather than reasoned — and round 3 corrected
|
|
300
|
+
// round 2's version of this paragraph, which described the wrong branch. The
|
|
301
|
+
// funnel scores only the IMMEDIATELY-next event after an answered deny, and
|
|
302
|
+
// `usage.rs` tests SAME-PATTERN first. So on the shape this record actually
|
|
303
|
+
// measures — a re-grep of the denied pattern — the outcome is unchanged
|
|
304
|
+
// (`fallthrough_after_answer` 1, rate 1.0, because a verbatim re-grep is
|
|
305
|
+
// fall-through whether it is spelled as this observe or as the deny that used
|
|
306
|
+
// to follow it). Only when the compound grep searches a DIFFERENT pattern does
|
|
307
|
+
// the window move onto it: there the rate goes 1.0 → 0.0, since a different
|
|
308
|
+
// query is not evidence the answer failed. `total` is unaffected either way
|
|
309
|
+
// (`observe` is excluded). Measured on the built binary, both branches.
|
|
310
|
+
const flags = extractCgFlags(segment);
|
|
311
|
+
// `-F` means the user asked for a LITERAL pattern, so the BRE→rust-regex
|
|
312
|
+
// unescape must not also run: `grep -F 'x\|y'` searches for `x\|y`, and
|
|
313
|
+
// unescaping it to `x|y` searches for something else (verified against GNU
|
|
314
|
+
// grep: `grep -Fc 'x\|y'` is 1 and `grep -Fc 'x|y'` is 0 on the same file).
|
|
315
|
+
const pattern = cgFlagSet(flags).has('-F') ? rawPattern : translateBreToRg(segment, rawPattern);
|
|
316
|
+
// The redundancy gate matches against the model's OWN grep output, which
|
|
317
|
+
// carries the pattern as the user wrote it → `rawPattern`. The RECORD carries
|
|
318
|
+
// the translated `pattern`, because that is the vocabulary every sibling
|
|
319
|
+
// record uses (pre-grep-guide's deny and observe, this file's two injects) and
|
|
320
|
+
// the funnel compares those strings for equality. Round 2 recorded the raw
|
|
321
|
+
// form: a deny of `"fn foo\|fn bar"` files `fn foo|fn bar` while the follow-up
|
|
322
|
+
// filed `fn foo\|fn bar`, so a verbatim re-grep scored neutral instead of
|
|
323
|
+
// fall-through. Round 1's record never reached that comparison at all (wrong
|
|
324
|
+
// hook name), so promoting it in is what exposed the mismatch.
|
|
325
|
+
if (grepFoundPattern(extractGrepOutput(input), rawPattern)) {
|
|
326
|
+
recordRecommendation(root, {
|
|
327
|
+
hook: 'grep', action: 'observe', reason: 'grep-hit-redundant',
|
|
328
|
+
...(pattern ? { pattern } : {}),
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// RAW path, not `sanitizeSearchPath`: buildGrepArgs splits `tests/*.mjs` into
|
|
333
|
+
// scope + `-g`. The deny path got that in this release and this one did not,
|
|
334
|
+
// while the same release routed MORE traffic here — so `grep -rln "X"
|
|
335
|
+
// tests/*.mjs && echo done` ran without `-l` and against all of `tests/`,
|
|
336
|
+
// exactly the infidelity the deny path had just stopped committing.
|
|
337
|
+
const searchPath = extractSearchPath(segment);
|
|
271
338
|
let answer = { status: 'unavailable' };
|
|
272
339
|
let answeredMode = block.mode;
|
|
273
340
|
|
|
@@ -293,11 +360,11 @@ function runMain() {
|
|
|
293
360
|
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
294
361
|
if (answer.status !== 'hits' && pattern) {
|
|
295
362
|
answeredMode = 'grep';
|
|
296
|
-
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
363
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
297
364
|
}
|
|
298
365
|
} else if (pattern) {
|
|
299
366
|
answeredMode = 'grep';
|
|
300
|
-
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
367
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
301
368
|
}
|
|
302
369
|
}
|
|
303
370
|
|
|
@@ -48,7 +48,7 @@ const fs = require('fs');
|
|
|
48
48
|
const path = require('path');
|
|
49
49
|
const { cgTmpDir, cwdHash, makeCooldown } = require('./tmp-dir');
|
|
50
50
|
const { recordRecommendation } = require('./recommendation-log');
|
|
51
|
-
const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
|
|
51
|
+
const { runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand } = require('./cg-answer');
|
|
52
52
|
|
|
53
53
|
// --- Pure logic (testable) ---
|
|
54
54
|
|
|
@@ -248,6 +248,7 @@ function classifyBlock(cmd) {
|
|
|
248
248
|
// must not disqualify the answerable head grep).
|
|
249
249
|
const clause = firstShellClause(cmd);
|
|
250
250
|
if (UNANSWERABLE_FLAGS.test(clause)) return null; // intent the answer can't honor
|
|
251
|
+
if (isAgFilenameSearch(clause)) return null; // a filename search, not a content one
|
|
251
252
|
if (MARKER_ONLY.test(clause)) return null; // bare TODO/FIXME — no cg equivalent
|
|
252
253
|
const patterns = extractPatterns(clause);
|
|
253
254
|
if (patterns.length === 0) return null; // unquoted pattern — conservative, hint
|
|
@@ -266,6 +267,215 @@ function classifyBlock(cmd) {
|
|
|
266
267
|
return { mode: 'grep' };
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
// Flags of the user's grep that `code-graph-mcp grep` can honor, in its own
|
|
271
|
+
// spelling. Verified against the built binary rather than against help text:
|
|
272
|
+
// `-l` prints bare paths, `-c` prints `path:count` exactly as `grep -c` does.
|
|
273
|
+
//
|
|
274
|
+
// Everything absent from this map is absent on purpose. `-r`/`-R`, `-n` and
|
|
275
|
+
// `-H` ARE accepted by cg as grep-parity no-ops (it is always recursive and
|
|
276
|
+
// always prints path and line number), so forwarding them would be harmless
|
|
277
|
+
// rather than an error — they are simply nothing to say. `-h` is NOT the same
|
|
278
|
+
// flag in the two tools: cg reads it as `--help`, so forwarding it would print
|
|
279
|
+
// usage instead of searching. `-L`/`-v`/`--exclude*` never arrive here —
|
|
280
|
+
// UNANSWERABLE_FLAGS sends them to the hint tier — and `-A/-B/-C` are routed to
|
|
281
|
+
// show-mode or hint before this runs.
|
|
282
|
+
const CG_SHORT_FLAGS = { i: '-i', w: '-w', F: '-F', l: '-l', c: '-c' };
|
|
283
|
+
const CG_LONG_FLAGS = {
|
|
284
|
+
'--ignore-case': '-i',
|
|
285
|
+
'--word-regexp': '-w',
|
|
286
|
+
'--fixed-strings': '-F',
|
|
287
|
+
'--files-with-matches': '-l',
|
|
288
|
+
'--count': '-c',
|
|
289
|
+
};
|
|
290
|
+
// Flags that carry a VALUE and select which files are searched. All three spell
|
|
291
|
+
// the same filter cg spells `-g` / `-t`, and dropping one silently widens the
|
|
292
|
+
// answer to files the user excluded — the defect this whole flag map exists to
|
|
293
|
+
// stop. `rg`'s spellings matter because rg is a folded verb: `rg -g '*.rs' Sym
|
|
294
|
+
// src/` reached the answer as a bare `grep Sym src/`. cg takes `-g` repeatably,
|
|
295
|
+
// so every occurrence is forwarded rather than only the first.
|
|
296
|
+
const CG_VALUE_FLAGS = {
|
|
297
|
+
'--include': '-g', // grep
|
|
298
|
+
'--glob': '-g', // rg
|
|
299
|
+
'--type': '-t', // rg
|
|
300
|
+
};
|
|
301
|
+
// Short forms of the value-carrying flags. RIPGREP ONLY, and the verb is
|
|
302
|
+
// checked: `ag -t Sym src/` means "all text files" and takes no value, and ag's
|
|
303
|
+
// `-g` prints matching FILENAMES rather than filtering — mapping either would
|
|
304
|
+
// hand cg an argument the user never wrote. (Round 2 of pre-ship review; the
|
|
305
|
+
// symptom was mild — cg exits 2 on an unknown file type, the answer degrades to
|
|
306
|
+
// `unavailable` and the deny becomes an allow — but it is a fabricated
|
|
307
|
+
// argument.) grep has no short spelling for `--include`.
|
|
308
|
+
const CG_VALUE_SHORT = { g: '-g', t: '-t' };
|
|
309
|
+
const RG_VERB = /(?:^|\s)rg$/;
|
|
310
|
+
const AG_VERB = /(?:^|\s)ag$/;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `ag -g PATTERN` searches FILENAMES, not file contents.
|
|
314
|
+
*
|
|
315
|
+
* Round 2 gated the short `-g`/`-t` map on ripgrep so ag's spellings would stop
|
|
316
|
+
* being folded into cg arguments the user never wrote. Round 3 found what that
|
|
317
|
+
* left behind: with the fabricated argument gone, `ag -g some_symbol src/` is a
|
|
318
|
+
* plain answerable content grep as far as `classifyBlock` is concerned, so the
|
|
319
|
+
* hook now DENIES it and answers with matching LINES — a different question
|
|
320
|
+
* from the one asked. Before round 2 the fabricated `-g some_symbol` matched no
|
|
321
|
+
* file, the answer came back empty and the deny degraded to an allow; the bug
|
|
322
|
+
* was accidentally its own safety valve.
|
|
323
|
+
*
|
|
324
|
+
* cg has no filename-search mode, so this belongs in the hint tier with the
|
|
325
|
+
* other intents the answer cannot honor: the user's `ag` runs untouched.
|
|
326
|
+
*/
|
|
327
|
+
function isAgFilenameSearch(clause) {
|
|
328
|
+
if (typeof clause !== 'string') return false;
|
|
329
|
+
if (!AG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '')) return false;
|
|
330
|
+
// A TOKEN scan, not a regex over the whole clause (round 4 of pre-ship
|
|
331
|
+
// review). The first spelling of this guard matched `-g` only when it was
|
|
332
|
+
// whitespace-delimited, so `ag -g"some_symbol" src/` — the attached form
|
|
333
|
+
// `extractCgFlags` twelve lines below explicitly handles — still reached the
|
|
334
|
+
// deny and was answered with content lines. Scanning the raw clause instead
|
|
335
|
+
// had the mirror-image fault: a `-g` inside a quoted pattern
|
|
336
|
+
// (`ag "some_symbol -g x" src/`) demoted a real content search. Same
|
|
337
|
+
// flag-name-in-a-value-position class the sibling repairs fixed with
|
|
338
|
+
// cgFlagSet/hasGlobFlag, so this uses the same convention: a token that
|
|
339
|
+
// STARTS quoted is an argument, never a flag.
|
|
340
|
+
// Quoted spans are blanked BEFORE tokenizing, not skipped after: a quoted
|
|
341
|
+
// argument can contain whitespace, so `ag "some_symbol -g x" src/` splits into
|
|
342
|
+
// three tokens and the middle one looks exactly like a flag. Blanking leaves
|
|
343
|
+
// an attached value's flag behind (`-g"x"` → `-g`), which is what we want.
|
|
344
|
+
const scan = clause.replace(VERB_STRIP, '').replace(/"[^"]*"|'[^']*'/g, ' ');
|
|
345
|
+
for (const tok of scan.trim().split(/\s+/)) {
|
|
346
|
+
if (!tok || tok[0] !== '-') continue;
|
|
347
|
+
if (tok.startsWith('--')) {
|
|
348
|
+
if (/^--(?:filename-pattern|file-search-regex)(?:=|$)/.test(tok)) return true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Short cluster: ag's `-g` takes a value, so it ends the cluster — an
|
|
352
|
+
// attached value (`-g'x'`, `-g=x`) or the next token. Capital `-G` is a
|
|
353
|
+
// different ag flag (limit by filename) and must NOT match.
|
|
354
|
+
if (/^-[a-zA-Z]*g/.test(tok)) return true;
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The boolean flags in an `extractCgFlags` result, without the VALUES.
|
|
361
|
+
*
|
|
362
|
+
* The result is a flat argv fragment (`['-i','-g','*.rs']`), which is what
|
|
363
|
+
* `buildGrepArgs` needs — but it means a membership test sees value tokens too.
|
|
364
|
+
* Round 2 of pre-ship review found both live consequences: `rg -t -g "sym"
|
|
365
|
+
* src/*.rs` yields `['-t','-g']`, whose bare `includes('-g')` made buildGrepArgs
|
|
366
|
+
* drop the path-derived glob and widen the scope — the exact defect this change
|
|
367
|
+
* exists to fix — and `grep --include -F "sym" src/` yields `['-g','-F']`, where
|
|
368
|
+
* `-F` is a filename glob and firing the literal-pattern guard on it is wrong.
|
|
369
|
+
*/
|
|
370
|
+
function cgFlagSet(flags) {
|
|
371
|
+
const out = new Set();
|
|
372
|
+
for (let i = 0; i < (flags || []).length; i++) {
|
|
373
|
+
const f = flags[i];
|
|
374
|
+
if (f === '-g' || f === '-t') { i++; continue; } // skip its value
|
|
375
|
+
out.add(f);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
// Canonical emission order, so the argv (and therefore the printed command) is a
|
|
380
|
+
// function of WHICH flags were given, never of the order the user typed them.
|
|
381
|
+
const CG_FLAG_ORDER = ['-i', '-w', '-F', '-l', '-c'];
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The grep's flags, translated to cg's.
|
|
385
|
+
*
|
|
386
|
+
* Until v0.144 the answer ran `['grep', pattern, scope]` and the deny printed
|
|
387
|
+
* the same three tokens, so every flag was dropped in both places at once — a
|
|
388
|
+
* `grep -rln` deny announced "the AST-aware equivalent already ran for you"
|
|
389
|
+
* above a command that was not the equivalent and returned hit lines where a
|
|
390
|
+
* file list was asked for (field report 2026-09-08).
|
|
391
|
+
*
|
|
392
|
+
* Scanning rules that matter: only the grep's OWN clause (a tail command's
|
|
393
|
+
* `-l` is not this grep's), and a token that STARTS quoted is an argument, not
|
|
394
|
+
* a flag — `grep "-l" src/` searches for the string `-l`.
|
|
395
|
+
*/
|
|
396
|
+
function extractCgFlags(cmd) {
|
|
397
|
+
if (!cmd || typeof cmd !== 'string') return [];
|
|
398
|
+
const clause = firstShellClause(cmd);
|
|
399
|
+
const isRg = RG_VERB.test((clause.match(GREP_HEAD) || [])[1] || '');
|
|
400
|
+
const toks = clause.replace(VERB_STRIP, '').trim().split(/\s+/);
|
|
401
|
+
const found = new Set();
|
|
402
|
+
const filters = []; // [cgFlag, value] pairs, in the order the user wrote them
|
|
403
|
+
const unquote = (s) => (s || '').replace(/^["']|["']$/g, '');
|
|
404
|
+
const addFilter = (flag, value) => {
|
|
405
|
+
if (!value) return;
|
|
406
|
+
// `-g '*.rs' -g '*.rs'` is one filter written twice; cg would honour both
|
|
407
|
+
// identically, but the printed command should not look like a mistake.
|
|
408
|
+
if (!filters.some(([f, v]) => f === flag && v === value)) filters.push([flag, value]);
|
|
409
|
+
};
|
|
410
|
+
for (let i = 0; i < toks.length; i++) {
|
|
411
|
+
const tok = toks[i];
|
|
412
|
+
if (!tok || tok[0] === '"' || tok[0] === "'" || tok[0] !== '-') continue;
|
|
413
|
+
if (tok.startsWith('--')) {
|
|
414
|
+
const eq = tok.indexOf('=');
|
|
415
|
+
const name = eq === -1 ? tok : tok.slice(0, eq);
|
|
416
|
+
if (CG_VALUE_FLAGS[name]) {
|
|
417
|
+
addFilter(CG_VALUE_FLAGS[name], unquote(eq === -1 ? toks[i + 1] : tok.slice(eq + 1)));
|
|
418
|
+
if (eq === -1) i++; // the value was a separate token — do not rescan it
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (CG_LONG_FLAGS[name]) found.add(CG_LONG_FLAGS[name]);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// A short cluster: `-rln` is r + l + n, and only `l` has a cg spelling. A
|
|
425
|
+
// value-carrying short flag ends the cluster and takes what follows —
|
|
426
|
+
// attached (`-g*.rs`) or as the next token (`-g '*.rs'`).
|
|
427
|
+
const letters = tok.slice(1);
|
|
428
|
+
let consumed = false;
|
|
429
|
+
for (let k = 0; k < letters.length; k++) {
|
|
430
|
+
const ch = letters[k];
|
|
431
|
+
if (isRg && CG_VALUE_SHORT[ch]) {
|
|
432
|
+
const attached = letters.slice(k + 1);
|
|
433
|
+
addFilter(CG_VALUE_SHORT[ch], attached ? unquote(attached) : unquote(toks[i + 1]));
|
|
434
|
+
// Consume the value token so the next pass cannot read it as a flag
|
|
435
|
+
// cluster: `rg -g -i 'sym' src/` must be `-g` with value `-i`, not `-g`
|
|
436
|
+
// plus a phantom `-i` (round 2 of pre-ship review — the test that named
|
|
437
|
+
// this property used a quoted value, which the quote guard already
|
|
438
|
+
// skipped, so it passed with the increment removed).
|
|
439
|
+
if (!attached) { i++; }
|
|
440
|
+
consumed = true;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (CG_SHORT_FLAGS[ch]) found.add(CG_SHORT_FLAGS[ch]);
|
|
444
|
+
}
|
|
445
|
+
if (consumed) continue;
|
|
446
|
+
}
|
|
447
|
+
const out = CG_FLAG_ORDER.filter((f) => found.has(f));
|
|
448
|
+
for (const [flag, value] of filters) out.push(flag, value);
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The deny gate, as runMain applies it — strictly narrower than classifyBlock.
|
|
454
|
+
*
|
|
455
|
+
* classifyBlock answers "can the inline answer cover this grep". Denying asks
|
|
456
|
+
* something else: "may we cancel the WHOLE command". A top-level `;`/`&&` tail
|
|
457
|
+
* makes those different questions, because the deny cancels a `sed`/`wc`/`echo`
|
|
458
|
+
* the answer says nothing about. Until v0.144 the tail was detected and spent on
|
|
459
|
+
* a NOTE apologising for the loss; the deny fired anyway.
|
|
460
|
+
*
|
|
461
|
+
* It is the same incompleteness the ≥2-named-paths rule above already refuses to
|
|
462
|
+
* deny on ("only DENY when the inline grep answer can cover the SAME scope"),
|
|
463
|
+
* and the fallback was already built: post-grep-inject.js is a PostToolUse hook
|
|
464
|
+
* for compound greps whose segment walk has no head-is-grep exclusion, carries
|
|
465
|
+
* its own redundancy gate, and uses a distinct cooldown prefix. These commands
|
|
466
|
+
* reach it as soon as they are allowed to run.
|
|
467
|
+
*
|
|
468
|
+
* `|` and `||` are NOT tails: a pipe is one pipeline that the answer replaces
|
|
469
|
+
* whole, and the `||` branch would not have run given the answer carried hits.
|
|
470
|
+
*
|
|
471
|
+
* classifyBlock itself is deliberately left alone — post-grep-inject calls it
|
|
472
|
+
* per SEGMENT, where a top-level tail cannot exist by construction.
|
|
473
|
+
*/
|
|
474
|
+
function classifyDeny(cmd) {
|
|
475
|
+
if (extractUnansweredTail(cmd)) return null;
|
|
476
|
+
return classifyBlock(cmd);
|
|
477
|
+
}
|
|
478
|
+
|
|
269
479
|
function shouldBlock(cmd) {
|
|
270
480
|
return classifyBlock(cmd) !== null;
|
|
271
481
|
}
|
|
@@ -393,27 +603,12 @@ function extractUnansweredTail(cmd) {
|
|
|
393
603
|
return null;
|
|
394
604
|
}
|
|
395
605
|
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// so a human reading the truncated view saw a clean "answered" deny with no
|
|
403
|
-
// clue the compound's tail was dropped (2026-07-18: two such denies were
|
|
404
|
-
// misdiagnosed as product bugs). The model gets the full reason either way.
|
|
405
|
-
function tailFlagSuffix(tail) {
|
|
406
|
-
return tail ? ' (compound tail NOT run — see NOTE at end)' : '';
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function appendUnansweredTailNote(lines, tail) {
|
|
410
|
-
if (!tail) return;
|
|
411
|
-
const shown = tail.length > 300 ? tail.slice(0, 300) + '…' : tail;
|
|
412
|
-
lines.push(
|
|
413
|
-
'NOTE: the rest of this compound command (after the grep) did NOT run — re-issue it separately:',
|
|
414
|
-
`$ ${shown}`,
|
|
415
|
-
);
|
|
416
|
-
}
|
|
606
|
+
// v0.144 — the compound-tail apparatus that lived here is GONE, together with
|
|
607
|
+
// the deny it apologised for. It was a head-line marker plus a closing NOTE
|
|
608
|
+
// telling the model to re-issue the `; sed …` the deny had just cancelled, and
|
|
609
|
+
// the honest version of that message is not to cancel it: `classifyDeny` now
|
|
610
|
+
// refuses to deny a command with a top-level tail at all, so no deny reaching
|
|
611
|
+
// these builders can carry one. `extractUnansweredTail` survives as that gate.
|
|
417
612
|
|
|
418
613
|
// v0.47.0 — pull the first source-tree path token out of the denied command so
|
|
419
614
|
// the inline answer can scope its search the same way the raw grep would have.
|
|
@@ -547,22 +742,20 @@ function buildHint() {
|
|
|
547
742
|
].join('\n');
|
|
548
743
|
}
|
|
549
744
|
|
|
550
|
-
function buildBlockReason(
|
|
745
|
+
function buildBlockReason() {
|
|
551
746
|
// Shown to Claude via PreToolUse `decision: block` reason. Must give a
|
|
552
747
|
// concrete alternate command Claude can re-issue without further thinking.
|
|
553
748
|
// v0.49 — NO escape-hatch line anywhere in deny copy: the daagu 2026-06-12
|
|
554
749
|
// night proved even the "THIS command only" scoping reads as a teachable
|
|
555
750
|
// permanent prefix (adopted in 8s, reused 11×, incl. on the exact identifier
|
|
556
751
|
// searches this hook targets). The env opt-out stays documented in README.
|
|
557
|
-
|
|
558
|
-
|
|
752
|
+
return [
|
|
753
|
+
'[code-graph] Raw `grep -rn` on indexed source — denied by code-graph hook.',
|
|
559
754
|
'Use the AST-aware equivalent (returns containing fn/module per hit, repo-wide):',
|
|
560
755
|
' code-graph-mcp grep "<pattern>" [paths...] # AST context per hit; -F literal, -i, -w, -l, -C N, --max-count 0',
|
|
561
756
|
' code-graph-mcp ast-search "<pattern>" --type fn # filter by node type',
|
|
562
757
|
' code-graph-mcp callgraph SYMBOL # callers + callees',
|
|
563
|
-
];
|
|
564
|
-
appendUnansweredTailNote(lines, unansweredTail);
|
|
565
|
-
return lines.join('\n');
|
|
758
|
+
].join('\n');
|
|
566
759
|
}
|
|
567
760
|
|
|
568
761
|
// v0.47.0 — deny WITH the answer inline. Hint-only had ~0% transfer and a bare
|
|
@@ -572,10 +765,15 @@ function buildBlockReason(unansweredTail) {
|
|
|
572
765
|
// advertising the bypass taught it a permanent prefix within 5 seconds (daagu
|
|
573
766
|
// 2026-06-11: one deny → 14 bypassed greps). The static deny keeps a scoped
|
|
574
767
|
// escape because there we give no answer.
|
|
575
|
-
|
|
576
|
-
|
|
768
|
+
// `cmdShown` is rendered by the caller from the SAME argv array it handed the
|
|
769
|
+
// child process (cg-answer.buildGrepArgs → formatCgCommand). It used to be
|
|
770
|
+
// re-assembled here from `(pattern, searchPath)` alone, which is how a deny came
|
|
771
|
+
// to print `code-graph-mcp grep "X" tests` above an answer that had actually run
|
|
772
|
+
// with different arguments — the flags nobody forwarded, and a glob silently
|
|
773
|
+
// widened to its parent directory.
|
|
774
|
+
function buildBlockReasonWithAnswer(cmdShown, answer) {
|
|
577
775
|
const lines = [
|
|
578
|
-
|
|
776
|
+
'[code-graph] Raw `grep` on indexed source — denied; the AST-aware equivalent already ran for you:',
|
|
579
777
|
`$ ${cmdShown}`,
|
|
580
778
|
answer.text,
|
|
581
779
|
];
|
|
@@ -591,7 +789,6 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail)
|
|
|
591
789
|
// (deny-with-answer already satisfies in-place, 5/5 in the daagu replay). The
|
|
592
790
|
// engagement nudge is kept only where it pays — the pre-edit impact summary,
|
|
593
791
|
// a true before-you-edit reconciliation moment. Re-add here only behind an A/B.
|
|
594
|
-
appendUnansweredTailNote(lines, unansweredTail);
|
|
595
792
|
return lines.join('\n');
|
|
596
793
|
}
|
|
597
794
|
|
|
@@ -599,9 +796,9 @@ function buildBlockReasonWithAnswer(pattern, searchPath, answer, unansweredTail)
|
|
|
599
796
|
// context flags (-A/-B/-C = "show me the body"); the answer IS the bodies,
|
|
600
797
|
// fetched via `code-graph-mcp show`. answer.text already carries per-symbol
|
|
601
798
|
// `$ code-graph-mcp show <sym>` headers.
|
|
602
|
-
function buildShowDenyReason(answer
|
|
799
|
+
function buildShowDenyReason(answer) {
|
|
603
800
|
const lines = [
|
|
604
|
-
|
|
801
|
+
'[code-graph] Raw grep for symbol definitions — denied; here are the definitions from the AST index:',
|
|
605
802
|
answer.text,
|
|
606
803
|
];
|
|
607
804
|
if (answer.truncated) {
|
|
@@ -610,7 +807,6 @@ function buildShowDenyReason(answer, unansweredTail) {
|
|
|
610
807
|
lines.push('Use these directly instead of re-running the search.');
|
|
611
808
|
// NOTE (v0.63): forced-ack salience trialed + removed here too — see
|
|
612
809
|
// buildBlockReasonWithAnswer. The definitions are already delivered.
|
|
613
|
-
appendUnansweredTailNote(lines, unansweredTail);
|
|
614
810
|
return lines.join('\n');
|
|
615
811
|
}
|
|
616
812
|
|
|
@@ -624,7 +820,16 @@ function translateBreToRg(cmd, pattern) {
|
|
|
624
820
|
const verb = (cmd.match(GREP_HEAD) || [])[1];
|
|
625
821
|
// git grep speaks BRE like plain grep; rg/ag are already extended-regex.
|
|
626
822
|
if (!verb || !/grep$/.test(verb)) return pattern;
|
|
627
|
-
|
|
823
|
+
// The grep's OWN clause, not the whole command. This was the one flag check
|
|
824
|
+
// in this module that v0.96 did not clause-scope, and round 4 of pre-ship
|
|
825
|
+
// review found what it costs: `grep -rln "a\|b" src/ | xargs -P4 wc -l` read
|
|
826
|
+
// the tail's `-P4` as "this grep speaks Perl regex", so the pattern was left
|
|
827
|
+
// escaped. On its own that is a wrong dialect decision; it also desynchronised
|
|
828
|
+
// the two hooks, because post-grep-inject passes a SEGMENT here while the deny
|
|
829
|
+
// path passes the whole command — the same pattern then filed under two
|
|
830
|
+
// spellings and the funnel scored a verbatim re-grep as neutral.
|
|
831
|
+
const clause = firstShellClause(cmd);
|
|
832
|
+
if (/(?:^|\s)-[a-zA-Z]*[EP][a-zA-Z]*(?:\s|=|\d|$)|--(?:extended-regexp|perl-regexp)\b/.test(clause)) {
|
|
628
833
|
return pattern;
|
|
629
834
|
}
|
|
630
835
|
return pattern.replace(/\\([|(){}+?])/g, '$1');
|
|
@@ -725,7 +930,17 @@ function runMain() {
|
|
|
725
930
|
// of an answered deny (inline answer ignored → fall-through) from a deeper
|
|
726
931
|
// drill-down. undefined when there's no identifier-like pattern (unquoted / prose
|
|
727
932
|
// grep) → omitted from the event, so the funnel stays back-compatible.
|
|
728
|
-
|
|
933
|
+
// `-F` asks for a LITERAL pattern, and the BRE→rust-regex unescape must not
|
|
934
|
+
// also run on it: `grep -F 'x\|y'` searches for the five characters `x\|y`,
|
|
935
|
+
// while the unescaped `x|y` is a different string (GNU grep on a file holding
|
|
936
|
+
// the literal: `grep -Fc 'x\|y'` is 1, `grep -Fc 'x|y'` is 0). Before this
|
|
937
|
+
// release the flag was dropped entirely, so the answer was merely broader;
|
|
938
|
+
// forwarding `-F` without this guard would make it search the wrong text.
|
|
939
|
+
const cgFlags = extractCgFlags(cmd);
|
|
940
|
+
const rawGrepPattern = pickBlockPattern(cmd);
|
|
941
|
+
const grepPattern = cgFlagSet(cgFlags).has('-F')
|
|
942
|
+
? rawGrepPattern
|
|
943
|
+
: translateBreToRg(cmd, rawGrepPattern);
|
|
729
944
|
|
|
730
945
|
// v0.48 — deliberate escape: record it (funnel visibility) and stay silent.
|
|
731
946
|
// Before GREP_HEAD accepted bare KEY=VALUE prefixes these were invisible.
|
|
@@ -744,7 +959,26 @@ function runMain() {
|
|
|
744
959
|
|
|
745
960
|
markCooldown(rawCmd, root);
|
|
746
961
|
|
|
747
|
-
|
|
962
|
+
// classifyDeny, not classifyBlock: a command with a top-level `;`/`&&` tail is
|
|
963
|
+
// never denied, because cancelling it would cancel the tail too. Those run and
|
|
964
|
+
// are answered by post-grep-inject instead — which is also where they are
|
|
965
|
+
// RECORDED.
|
|
966
|
+
//
|
|
967
|
+
// Round 1 added an `observe compound:true` row here. Round 2 showed no counter
|
|
968
|
+
// reads the field, and round 3 showed the row double-counts: a head-grep
|
|
969
|
+
// compound whose grep hits writes this row AND post-grep-inject's redundancy
|
|
970
|
+
// observe, so one Bash call became two entries in a counter `usage.rs`
|
|
971
|
+
// documents as the model's raw search fan-out. The post-side rows carry more
|
|
972
|
+
// (they say what happened to the answer), so this side stays silent — exactly
|
|
973
|
+
// as it did before the compound change.
|
|
974
|
+
//
|
|
975
|
+
// One configuration where that leaves NO trace, named because round 4 caught
|
|
976
|
+
// the claim overstated: under `CODE_GRAPH_NO_INJECT=1` the post side does not
|
|
977
|
+
// run either, so a first-run compound is invisible to the funnel. A repeat
|
|
978
|
+
// within 60 s still lands in the cooldown `observe` branch above. A kill
|
|
979
|
+
// switch silencing the hook that carries the telemetry is coherent; silently
|
|
980
|
+
// claiming coverage it does not have is not.
|
|
981
|
+
const block = isBlockDisabled() ? null : classifyDeny(cmd);
|
|
748
982
|
if (block) {
|
|
749
983
|
// v0.47.0 — run the AST-aware equivalent inside the hook and embed the
|
|
750
984
|
// results in the deny reason ("answer in the deny"). Degrades to the
|
|
@@ -754,19 +988,24 @@ function runMain() {
|
|
|
754
988
|
// falling back to the grep answer, then the static deny.
|
|
755
989
|
let answer = { status: 'unavailable' };
|
|
756
990
|
const pattern = grepPattern; // computed once above; reused for the answer + deny fingerprint
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
|
|
991
|
+
// The path is passed RAW (globs and all). buildGrepArgs splits `tests/*.mjs`
|
|
992
|
+
// into scope `tests` + `-g '*.mjs'`, which is both what keeps a literal glob
|
|
993
|
+
// out of argv (the exit-1 shape sanitizeSearchPath was added for) and what
|
|
994
|
+
// stops the answer from quietly searching files the user excluded.
|
|
995
|
+
const searchPath = extractSearchPath(cmd);
|
|
996
|
+
const flags = cgFlags; // computed once above, beside the -F pattern guard
|
|
997
|
+
// ONE argv, used to run the child AND to render the command the deny prints.
|
|
998
|
+
const args = buildGrepArgs({ pattern, searchPath, flags });
|
|
760
999
|
let answeredMode = block.mode;
|
|
761
1000
|
if (!isAnswerDisabled()) {
|
|
762
1001
|
if (block.mode === 'show') {
|
|
763
1002
|
answer = runShowAnswer({ cwd: root, symbols: block.symbols });
|
|
764
1003
|
if (answer.status !== 'hits' && pattern) {
|
|
765
1004
|
answeredMode = 'grep';
|
|
766
|
-
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
1005
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
767
1006
|
}
|
|
768
1007
|
} else if (pattern) {
|
|
769
|
-
answer = runGrepAnswer({ cwd: root, pattern, searchPath });
|
|
1008
|
+
answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
|
|
770
1009
|
}
|
|
771
1010
|
}
|
|
772
1011
|
|
|
@@ -802,9 +1041,6 @@ function runMain() {
|
|
|
802
1041
|
// is the documented modern path. Exit 0 — this is a routing decision, not
|
|
803
1042
|
// a hook failure (exit 2 would mark the tool call as "hook errored").
|
|
804
1043
|
const answered = answer.status === 'hits';
|
|
805
|
-
// v0.50 — flag the unanswered `;`/`&&` tail. Extracted from rawCmd: its
|
|
806
|
-
// paths are valid in the model's shell cwd, where the re-issue will run.
|
|
807
|
-
const unansweredTail = extractUnansweredTail(rawCmd);
|
|
808
1044
|
recordRecommendation(root, {
|
|
809
1045
|
hook: 'grep', action: 'deny', answered,
|
|
810
1046
|
// pattern fingerprints the denied search so the funnel can score a verbatim
|
|
@@ -817,19 +1053,16 @@ function runMain() {
|
|
|
817
1053
|
// 'unavailable' (binary ran but failed/timed out). Without this the two
|
|
818
1054
|
// are indistinguishable in the funnel ("broken" looks like "no hits").
|
|
819
1055
|
...(answered ? {} : { reason: answer.status }),
|
|
820
|
-
// tail segments compound-command denies — lets the funnel compare
|
|
821
|
-
// re-issue behavior for denies that carried a tail note.
|
|
822
|
-
...(unansweredTail ? { tail: true } : {}),
|
|
823
1056
|
});
|
|
824
1057
|
process.stdout.write(JSON.stringify({
|
|
825
1058
|
hookSpecificOutput: {
|
|
826
1059
|
hookEventName: 'PreToolUse',
|
|
827
1060
|
permissionDecision: 'deny',
|
|
828
1061
|
permissionDecisionReason: !answered
|
|
829
|
-
? buildBlockReason(
|
|
1062
|
+
? buildBlockReason()
|
|
830
1063
|
: answeredMode === 'show'
|
|
831
|
-
? buildShowDenyReason(answer
|
|
832
|
-
: buildBlockReasonWithAnswer(
|
|
1064
|
+
? buildShowDenyReason(answer)
|
|
1065
|
+
: buildBlockReasonWithAnswer(formatCgCommand(args), answer),
|
|
833
1066
|
},
|
|
834
1067
|
}) + '\n');
|
|
835
1068
|
return;
|
|
@@ -853,6 +1086,9 @@ module.exports = {
|
|
|
853
1086
|
shouldHint,
|
|
854
1087
|
shouldBlock,
|
|
855
1088
|
classifyBlock, // v0.49 — intent-aware block tiers
|
|
1089
|
+
classifyDeny, // v0.144 — the deny gate: block-tier AND nothing discarded
|
|
1090
|
+
extractCgFlags, // v0.144 — the grep's flags in cg's spelling
|
|
1091
|
+
cgFlagSet, // v0.144 — the boolean flags of that result, without values
|
|
856
1092
|
splitTopLevelSegments, // compound-grep — quote-aware top-level segment splitter (PostToolUse reuse)
|
|
857
1093
|
firstShellClause, // v0.96 — grep's own clause (up to first top-level separator)
|
|
858
1094
|
extractDeclSymbols, // v0.49 — show-mode symbol extraction
|
|
@@ -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.144.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.144.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.144.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.144.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.144.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.144.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.144.0"
|
|
43
43
|
}
|
|
44
44
|
}
|