@sdsrs/code-graph 0.161.0 → 0.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/hook-emit.js +27 -1
- package/claude-plugin/scripts/hook-fail-open.js +5 -0
- package/claude-plugin/scripts/lifecycle.js +30 -2
- package/claude-plugin/scripts/post-grep-inject.js +28 -6
- package/claude-plugin/scripts/pre-edit-guide.js +21 -0
- package/claude-plugin/scripts/pre-grep-guide.js +435 -68
- package/claude-plugin/scripts/session-edits.js +134 -0
- package/claude-plugin/scripts/stop-impact.js +431 -0
- package/claude-plugin/scripts/subagent-start.js +95 -0
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -151,6 +151,14 @@ known boundaries, not bugs to report:
|
|
|
151
151
|
`impact` reports separately as `value reference(s) — callbacks / fn-pointers /
|
|
152
152
|
type positions`. Together with the row above, this is why `include_dead`
|
|
153
153
|
results are candidates to verify rather than a verdict.
|
|
154
|
+
- **Dispatch by name.** A function reached through a string key, a reflection
|
|
155
|
+
call or an event name (`handlers["save"]`, `getattr(obj, "save")`,
|
|
156
|
+
`send(:save)`, `bus.on("save", …)`) has no edge. When `callgraph`, `impact`
|
|
157
|
+
or `refs` finds no caller for a function, the answer lists up to 5 lines
|
|
158
|
+
where the name appears in such a shape or is passed as a value (`boundaries`
|
|
159
|
+
in `--json` and MCP), and a `code-graph-mcp grep` that shows every
|
|
160
|
+
occurrence. It is a disclosure, not an edge, and a lexical one: a line it
|
|
161
|
+
does not list is not proof that nothing dispatches to the function.
|
|
154
162
|
|
|
155
163
|
## Architecture
|
|
156
164
|
|
|
@@ -159,6 +167,7 @@ src/
|
|
|
159
167
|
├── domain.rs # Shared constants, relation types, env-var config
|
|
160
168
|
├── resolve.rs # Shared symbol resolution + ambiguity verdicts (CLI and MCP)
|
|
161
169
|
├── outcome.rs # Retrieval-adoption metrics from session transcripts
|
|
170
|
+
├── budget.rs # Output budgets (--budget / max_tokens): rank, shorten, drop, next step
|
|
162
171
|
├── cli/ # Every `code-graph-mcp <cmd>` subcommand (one file per command)
|
|
163
172
|
├── mcp/ # MCP protocol layer (JSON-RPC, tool registry, server)
|
|
164
173
|
│ └── server/ # McpServer with IndexingState + CacheState sub-structs
|
|
@@ -341,6 +350,8 @@ cargo uninstall code-graph-mcp # or delete the target/release binary
|
|
|
341
350
|
| `ast_search` | Search AST nodes by text and/or structural filters (type, return type, params) |
|
|
342
351
|
| `find_references` | Find all references to a symbol (callers, importers, inheritors, implementors, value/type references). Supports `compact` mode |
|
|
343
352
|
|
|
353
|
+
**Output budget.** `project_map`, `module_overview`, `get_call_graph` and `get_ast_node` take an optional `max_tokens` (100-100000, counted as bytes/3 of the JSON answer). The answer is ranked by caller count; lower-ranked items are first shortened (no key symbols, no signature, no source body), then left out, and never cut in the middle. A `budget` object reports what was shortened or left out and `budget.next` holds the CLI command that returns it (one per section that lost entries, joined by `; `). On `module_overview` and `project_map`, `budget.over_budget` is `true` when even the fixed part of the answer is larger than the budget. Without `max_tokens` the answer is unchanged. Every existing size cut in these four tools (`hot_functions_truncated`, `active_capped`, the call-graph rollup, `compressed_node`, `_truncated`) now carries a `next` command too.
|
|
354
|
+
|
|
344
355
|
**Hidden aliases.** These names are not in `tools/list` but still dispatch via `tools/call`, so existing clients keep working: `trace_http_chain` / `find_http_route` (→ `get_call_graph` with `route_path`), `read_snippet` (→ `get_ast_node`), `dependency_graph`, `find_similar_code`, `find_dead_code`, plus the management tools `start_watch`, `stop_watch`, `get_index_status` and `rebuild_index`. `impact_analysis` is **removed** — calling it returns `Unknown tool`; use `get_ast_node` with `include_impact=true`, or the CLI's `impact --json` for the full report.
|
|
345
356
|
|
|
346
357
|
## CLI Commands
|
|
@@ -384,6 +395,8 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
|
|
|
384
395
|
|
|
385
396
|
Common options: `--json` (JSON output), `--compact` (compact output), `--limit N`, `--depth N`, `--file <path>`.
|
|
386
397
|
|
|
398
|
+
`map`, `overview`, `callgraph` and `show` take `--budget <tokens>` (100-100000, counted as bytes/3 of the text answer; not with `--json` or `--compact`). Items with the fewest callers are shortened first (a module loses its key symbols, a symbol its signature, a definition its body, a call-graph node its type), then left out, deepest call-graph nodes first. Nothing is cut in the middle, and in `overview` of a directory one file takes at most 70% of the budget. A notice line says what was left out and is followed by `next: <command>`, which prints it. The existing `... and N more` lines of `map` are followed by a `next:` line too.
|
|
399
|
+
|
|
387
400
|
As of **v0.37.0** the CLI is [clap](https://docs.rs/clap)-based: **every subcommand has `--help`** for its full flag list (`code-graph-mcp <command> --help`), value flags accept both `--flag value` and `--flag=value`, and unknown flags or malformed arguments fail fast with a clear error and a non-zero exit code (`2`) instead of being silently ignored. For example, `trace` hides downstream middleware with `--no-middleware` (shown by default), and `snapshot` is a `create`/`inspect` subcommand pair.
|
|
388
401
|
|
|
389
402
|
## Plugin Skills
|
|
@@ -154,7 +154,33 @@ function emitPostToolContext(text) {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* additionalContext envelope for the two non-tool events the plugin answers
|
|
159
|
+
* (P1 #3). Both carry no decision:
|
|
160
|
+
* - SubagentStart: "Context only" — the text is added to the subagent's
|
|
161
|
+
* context before its first prompt.
|
|
162
|
+
* - Stop: `hookSpecificOutput.additionalContext` is the documented
|
|
163
|
+
* non-error form — the turn continues so Claude can act on it, labelled
|
|
164
|
+
* "Stop hook feedback", instead of `decision: "block"`'s hook error.
|
|
165
|
+
* Source: https://code.claude.com/docs/en/hooks (SubagentStart / Stop
|
|
166
|
+
* decision control), checked against Claude Code 2.1.283.
|
|
167
|
+
* @param {'SubagentStart'|'Stop'} eventName
|
|
168
|
+
* @param {string} text
|
|
169
|
+
* @returns {string} JSON line
|
|
170
|
+
*/
|
|
171
|
+
function emitEventContext(eventName, text) {
|
|
172
|
+
if (eventName !== 'SubagentStart' && eventName !== 'Stop') {
|
|
173
|
+
throw new Error(`emitEventContext: unsupported event ${eventName}`);
|
|
174
|
+
}
|
|
175
|
+
return JSON.stringify({
|
|
176
|
+
hookSpecificOutput: {
|
|
177
|
+
hookEventName: eventName,
|
|
178
|
+
additionalContext: capContext(text),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
157
183
|
module.exports = {
|
|
158
184
|
emitPreToolContext, emitPreToolAllowContext, emitPreToolRewrite, emitPostToolContext,
|
|
159
|
-
capContext, MAX_INJECTED_BYTES,
|
|
185
|
+
emitEventContext, capContext, MAX_INJECTED_BYTES,
|
|
160
186
|
};
|
|
@@ -41,6 +41,11 @@ const HOOK_TIMEOUT_SECONDS = {
|
|
|
41
41
|
'post-grep-inject.js': 5,
|
|
42
42
|
'user-prompt-context.js': 5,
|
|
43
43
|
'session-init.js': 5,
|
|
44
|
+
// P1 #3. SubagentStart delays the subagent's first turn: one health-check
|
|
45
|
+
// (~125 ms here) is all it spends. Stop delays the end of the turn: one
|
|
46
|
+
// `git rev-parse`, then per changed symbol one `git show` + one `refs`.
|
|
47
|
+
'subagent-start.js': 3,
|
|
48
|
+
'stop-impact.js': 5,
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
// Left for the hook to render its answer and exit after the last child returns.
|
|
@@ -923,6 +923,8 @@ const OUR_HOOK_SCRIPTS = [
|
|
|
923
923
|
'pre-grep-guide.js', // v0.32.0 — was in plugin-cache only, never fired
|
|
924
924
|
'pre-read-guide.js', // v0.32.0 — was in plugin-cache only, never fired
|
|
925
925
|
'post-grep-inject.js', // compound-grep — PostToolUse(Bash) permission-neutral answer inject
|
|
926
|
+
'subagent-start.js', // P1 #3 — SubagentStart index facts for Explore/Plan/general-purpose
|
|
927
|
+
'stop-impact.js', // P1 #3 — Stop: changed signatures with untouched callers
|
|
926
928
|
];
|
|
927
929
|
|
|
928
930
|
// Description markers — primary cleanup discriminator (immune to env/path
|
|
@@ -933,6 +935,8 @@ const SETTINGS_HOOK_DESC = {
|
|
|
933
935
|
postToolUseEdit: '[code-graph-mcp v0.32+] PostToolUse Write|Edit incremental-index update',
|
|
934
936
|
postToolUseInject:'[code-graph-mcp v0.32+] PostToolUse Bash compound-grep answer inject (permission-neutral additionalContext)',
|
|
935
937
|
userPromptSubmit: '[code-graph-mcp v0.32+] UserPromptSubmit context push',
|
|
938
|
+
subagentStart: '[code-graph-mcp v0.32+] SubagentStart index facts for Explore/Plan/general-purpose subagents',
|
|
939
|
+
stop: '[code-graph-mcp v0.32+] Stop signature-change check (callers in files not edited this turn)',
|
|
936
940
|
};
|
|
937
941
|
|
|
938
942
|
const OUR_DESCRIPTIONS = [
|
|
@@ -946,6 +950,8 @@ const OUR_DESCRIPTIONS = [
|
|
|
946
950
|
SETTINGS_HOOK_DESC.postToolUseEdit,
|
|
947
951
|
SETTINGS_HOOK_DESC.postToolUseInject,
|
|
948
952
|
SETTINGS_HOOK_DESC.userPromptSubmit,
|
|
953
|
+
SETTINGS_HOOK_DESC.subagentStart,
|
|
954
|
+
SETTINGS_HOOK_DESC.stop,
|
|
949
955
|
];
|
|
950
956
|
|
|
951
957
|
function isOurHookEntry(entry) {
|
|
@@ -1024,6 +1030,18 @@ function buildSettingsHookEntries() {
|
|
|
1024
1030
|
UserPromptSubmit: [
|
|
1025
1031
|
{ description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js')] },
|
|
1026
1032
|
],
|
|
1033
|
+
// P1 #3. SubagentStart matches on the agent TYPE (built-in names per the
|
|
1034
|
+
// hooks reference); Explore and Plan skip CLAUDE.md, general-purpose is
|
|
1035
|
+
// the default Agent type. Custom/plugin agents are left alone — their
|
|
1036
|
+
// own frontmatter decides what they know.
|
|
1037
|
+
SubagentStart: [
|
|
1038
|
+
{ description: SETTINGS_HOOK_DESC.subagentStart, matcher: 'Explore|Plan|general-purpose', hooks: [scriptCmd('subagent-start.js')] },
|
|
1039
|
+
],
|
|
1040
|
+
// Stop takes no matcher; '' keys the coverage survey as `Stop:*`, the same
|
|
1041
|
+
// way UserPromptSubmit's does.
|
|
1042
|
+
Stop: [
|
|
1043
|
+
{ description: SETTINGS_HOOK_DESC.stop, matcher: '', hooks: [scriptCmd('stop-impact.js')] },
|
|
1044
|
+
],
|
|
1027
1045
|
};
|
|
1028
1046
|
}
|
|
1029
1047
|
|
|
@@ -1212,7 +1230,17 @@ function surveyHookCoverage(settings) {
|
|
|
1212
1230
|
// payload is engaging (a source-tree search → a deny/hint IS emitted); the Edit
|
|
1213
1231
|
// payload's short old_string short-circuits before any binary spawn; the rest
|
|
1214
1232
|
// just exercise the require-chain + stdin parse.
|
|
1215
|
-
function hookFirePayload(matcher) {
|
|
1233
|
+
function hookFirePayload(matcher, event = '') {
|
|
1234
|
+
// Non-tool events are keyed by EVENT: Stop shares UserPromptSubmit's ''
|
|
1235
|
+
// matcher, and a prompt payload would drive the wrong path. Both new hooks
|
|
1236
|
+
// stay silent on these (no session edit log / no health report in the
|
|
1237
|
+
// throwaway fixture); the probe proves they load and exit 0.
|
|
1238
|
+
if (event === 'Stop') {
|
|
1239
|
+
return { hook_event_name: 'Stop', session_id: 'hook-fire-probe', stop_hook_active: false };
|
|
1240
|
+
}
|
|
1241
|
+
if (event === 'SubagentStart') {
|
|
1242
|
+
return { hook_event_name: 'SubagentStart', agent_id: 'hook-fire-probe', agent_type: 'Explore' };
|
|
1243
|
+
}
|
|
1216
1244
|
switch (matcher) {
|
|
1217
1245
|
case 'Bash':
|
|
1218
1246
|
// A QUOTED, identifier-like pattern → classifyBlock-positive → the
|
|
@@ -1251,7 +1279,7 @@ function defaultHookFireProbes() {
|
|
|
1251
1279
|
const cmd = e.hooks && e.hooks[0] && e.hooks[0].command;
|
|
1252
1280
|
const m = (cmd || '').match(/"([^"]+\.js)"/);
|
|
1253
1281
|
if (!m) continue;
|
|
1254
|
-
probes.push({ label: `${event}:${e.matcher || '*'}`, script: m[1], payload: hookFirePayload(e.matcher || '') });
|
|
1282
|
+
probes.push({ label: `${event}:${e.matcher || '*'}`, script: m[1], payload: hookFirePayload(e.matcher || '', event) });
|
|
1255
1283
|
}
|
|
1256
1284
|
}
|
|
1257
1285
|
return probes;
|
|
@@ -53,12 +53,14 @@ const {
|
|
|
53
53
|
firstShellClause,
|
|
54
54
|
normalizeCommandPaths,
|
|
55
55
|
rootOnlyInSearchPath,
|
|
56
|
-
grepSearchesNoDirectory,
|
|
57
56
|
isDirectory,
|
|
58
57
|
declKindsBySymbol,
|
|
59
58
|
extractPatterns,
|
|
60
59
|
rebaseRelativePaths,
|
|
61
60
|
resolveProjectRoot,
|
|
61
|
+
rewritePlan,
|
|
62
|
+
showAnswersPattern,
|
|
63
|
+
searchesSameFiles,
|
|
62
64
|
} = require('./pre-grep-guide');
|
|
63
65
|
|
|
64
66
|
// The command HEAD is grep/rg/ag (or git grep, or a KEY=VALUE/env prefix). Kept
|
|
@@ -295,11 +297,20 @@ function runMain() {
|
|
|
295
297
|
&& !operandMatches(firstShellClause(rawSegs[idx]), extractSearchPath(segment), root, segCwd)) return;
|
|
296
298
|
// D#125 #1 — the root strip rewrote a pattern that held the root.
|
|
297
299
|
if (!rootOnlyInSearchPath(rawSegs[idx], root, extractSearchPath(segment))) return;
|
|
298
|
-
// D#125 #3 — a plain grep without -r searched no directory.
|
|
299
|
-
if (grepSearchesNoDirectory(firstShellClause(segment), extractSearchPath(segment),
|
|
300
|
-
(t) => isDirectory(path.resolve(root, t)))) return;
|
|
301
300
|
// Run the answer exactly like the deny path.
|
|
302
301
|
const rawPattern = pickBlockPattern(segment);
|
|
302
|
+
// D#133 — the inject had no grammar of its own: a flag it did not know was
|
|
303
|
+
// dropped, so `"-rv"` (grep reads -r -v: the shell strips the quotes), ag's
|
|
304
|
+
// `-n` (no recursion), `rg --max-depth`, an abbreviated `--inv`, or a glob
|
|
305
|
+
// the shell expands one level deep (`src/*.js`) got an answer for another
|
|
306
|
+
// search. The grep clause must now read under the rewrite's grammar, the
|
|
307
|
+
// per-verb flag allowlist included, as the same pattern and path the answer
|
|
308
|
+
// uses. One grammar for both hooks, not a list of shapes to refuse. It also
|
|
309
|
+
// declines a plain grep without -r on a directory (D#125 #3), which a
|
|
310
|
+
// separate check here used to do.
|
|
311
|
+
const plan = rewritePlan(firstShellClause(segment), { isDir: (t) => isDirectory(path.resolve(root, t)) });
|
|
312
|
+
const norm = (p) => (p === undefined ? undefined : p.replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
313
|
+
if (!plan || plan.pattern !== rawPattern || norm(plan.target) !== norm(extractSearchPath(segment))) return;
|
|
303
314
|
// Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
|
|
304
315
|
// re-stated hits the model already had). If the command's OWN output already
|
|
305
316
|
// surfaced the grepped symbol, the inject is redundant → skip it, saving the
|
|
@@ -393,8 +404,10 @@ function runMain() {
|
|
|
393
404
|
if (block.mode === 'show') {
|
|
394
405
|
answeredMode = 'show';
|
|
395
406
|
// Scoped like the rewrite: the grep's path and declaration kinds; a grep
|
|
396
|
-
// with a file filter gets the grep answer, which honors it.
|
|
397
|
-
answer
|
|
407
|
+
// with a file filter gets the grep answer, which honors it. So does a
|
|
408
|
+
// pattern `show` does not answer exactly (`fn foo` also matches
|
|
409
|
+
// `fn foobar`; D#133).
|
|
410
|
+
answer = flags.includes('-g') || flags.includes('-t') || !showAnswersPattern(rawPattern, segment)
|
|
398
411
|
? { status: 'unavailable' }
|
|
399
412
|
: runShowAnswer({
|
|
400
413
|
cwd: root, symbols: block.symbols, within: searchPath ?? '',
|
|
@@ -443,6 +456,15 @@ function runMain() {
|
|
|
443
456
|
return;
|
|
444
457
|
}
|
|
445
458
|
|
|
459
|
+
// D#133 #8 — a grep or show answer must read the files the grep read
|
|
460
|
+
// (untracked hidden or ignored ones, git grep's tracked-only set). Checked
|
|
461
|
+
// only for an answer about to be injected: it runs git. The call graph does
|
|
462
|
+
// not depend on which files a grep reads. A `show` answer reads the index,
|
|
463
|
+
// which skips more (review of D#133, H-2).
|
|
464
|
+
if (answeredMode !== 'callgraph' && !searchesSameFiles({
|
|
465
|
+
root, target: searchPath, verb: plan.verb, show: answeredMode === 'show',
|
|
466
|
+
})) return;
|
|
467
|
+
|
|
446
468
|
recordRecommendation(root, {
|
|
447
469
|
hook: 'grep', action: 'inject', answered: true,
|
|
448
470
|
...(pattern ? { pattern } : {}),
|
|
@@ -49,6 +49,22 @@ try {
|
|
|
49
49
|
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
50
50
|
} catch { process.exit(0); }
|
|
51
51
|
|
|
52
|
+
// P1 #3 — the Stop hook (stop-impact.js) checks, at the end of the turn, which
|
|
53
|
+
// edited symbols changed signature and which of their callers were left alone.
|
|
54
|
+
// It needs to know what was edited in THIS session, so every Edit is logged
|
|
55
|
+
// here — first file-only (so "files touched this turn" is complete even for
|
|
56
|
+
// edits the signature extraction below gives up on), then again with the
|
|
57
|
+
// symbol once one is known. Append-only, best-effort: it cannot fail the Edit.
|
|
58
|
+
const sessionEdits = require('./session-edits');
|
|
59
|
+
const editedAbs = (input.tool_input && input.tool_input.file_path) || '';
|
|
60
|
+
const editedRel = editedAbs ? path.relative(cwd, path.resolve(cwd, editedAbs)) : '';
|
|
61
|
+
const logEdit = (symbol) => {
|
|
62
|
+
if (editedRel && !editedRel.startsWith('..') && !path.isAbsolute(editedRel)) {
|
|
63
|
+
sessionEdits.recordEdit(cwd, input.session_id, { file: editedRel.split(path.sep).join('/'), symbol });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
logEdit(null);
|
|
67
|
+
|
|
52
68
|
const oldStr = (input.tool_input && input.tool_input.old_string) || '';
|
|
53
69
|
if (!oldStr || oldStr.length < 10) process.exit(0);
|
|
54
70
|
|
|
@@ -159,6 +175,11 @@ function isCommonKeyword(s) {
|
|
|
159
175
|
return /^(if|for|while|switch|catch|else|return|new|get|set|try)$/i.test(s);
|
|
160
176
|
}
|
|
161
177
|
|
|
178
|
+
// Before the cooldown: the cooldown throttles the impact PUSH, not the record
|
|
179
|
+
// of what was edited — a second signature edit inside two minutes is exactly
|
|
180
|
+
// the one the Stop check must still see.
|
|
181
|
+
logEdit(symbol);
|
|
182
|
+
|
|
162
183
|
// --- Per-symbol cooldown: 2 minutes ---
|
|
163
184
|
// Project-scoped (see cwdHash in tmp-dir.js). A symbol name is the LEAST
|
|
164
185
|
// project-unique key there is — `main`, `run`, `new`, `parse` collide across
|