@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 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
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.161.0",
7
+ "version": "0.162.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -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 = flags.includes('-g') || flags.includes('-t')
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