@sdsrs/code-graph 0.160.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.160.0",
7
+ "version": "0.162.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -347,10 +347,45 @@ function runGrepAnswer(opts = {}) {
347
347
  * `no-binary` distinguishes a missing/unlocatable binary from a runtime
348
348
  * `unavailable`, so the deny funnel can see a dark flagship answer-in-deny.
349
349
  */
350
+ /**
351
+ * The files of the definitions a `show` printed: each definition opens with an
352
+ * unindented header `<kind> <name> <path>:<start>-<end> <signature>`, its body
353
+ * indented below. null when an unindented line is not such a header, so a
354
+ * caller that must account for every definition can refuse.
355
+ */
356
+ function showDefinitionFiles(out) {
357
+ const defs = [];
358
+ for (const line of out.split('\n')) {
359
+ if (line === '' || /^\s/.test(line)) continue;
360
+ const m = /^(\S+) \S+ {2}(\S+):\d+-\d+(?: |$)/.exec(line);
361
+ if (!m) return null;
362
+ defs.push({ kind: m[1], file: m[2] });
363
+ }
364
+ return defs.length ? defs : null;
365
+ }
366
+
367
+ /** Whether a root-relative file lies under a grep path ('' = the whole root). */
368
+ function pathWithin(file, within) {
369
+ const w = within.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
370
+ return w === '' || w === '.' || file === w || file.startsWith(w + '/');
371
+ }
372
+
350
373
  function runShowAnswer(opts = {}) {
351
374
  const {
352
375
  cwd,
353
376
  symbols,
377
+ // A grep path the answer must stay inside (D#125 #2): `show` prints every
378
+ // same-named definition in the project. With `within`, a symbol is answered
379
+ // only when EVERY definition `show` prints lies inside the path and is one
380
+ // the grep's declaration keyword matches (`kinds`: symbol → the label and
381
+ // file-extension shapes it matches); a symbol with no definition inside is
382
+ // skipped (the grep finds none either), and any other mix refuses the whole
383
+ // answer. Narrowing with `show --file` was tried and removed: the label is
384
+ // coarser than the keyword, so a narrowed answer dropped real matches, and
385
+ // the inject printed the unnarrowed text (review of D#125, round 2).
386
+ // undefined: unscoped, as before.
387
+ within,
388
+ kinds,
354
389
  timeoutMs = DEFAULT_TIMEOUT_MS,
355
390
  maxBytes = DEFAULT_MAX_BYTES,
356
391
  } = opts;
@@ -365,6 +400,8 @@ function runShowAnswer(opts = {}) {
365
400
  // Which symbols actually resolved — the rewrite re-runs exactly these, so a
366
401
  // symbol that printed nothing here cannot turn into an exit-1 in the Bash call.
367
402
  const resolved = [];
403
+ // The argv that re-runs each resolved symbol, scoped when `within` is set.
404
+ const argvs = [];
368
405
  for (const sym of symbols.slice(0, 3)) {
369
406
  if (typeof sym !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sym)) continue;
370
407
  const res = runCg(binary, ['show', sym], { cwd, timeoutMs });
@@ -383,12 +420,24 @@ function runShowAnswer(opts = {}) {
383
420
  if (classifyRun(res, { exitOneIsNoHits: false }) !== 'ok') continue;
384
421
  const out = (res.stdout || '').trim();
385
422
  if (isEmptyAnswer(out)) continue;
386
- parts.push(`$ code-graph-mcp show ${sym}\n${out}`);
423
+ const argv = ['show', sym];
424
+ if (within !== undefined) {
425
+ const defs = showDefinitionFiles(out);
426
+ if (!defs) return { status: 'unavailable', reason: 'scope' };
427
+ if (!defs.some((d) => pathWithin(d.file, within))) continue;
428
+ const shapes = (kinds && kinds[sym]) || [];
429
+ const ext = (f) => (f.match(/\.([A-Za-z0-9]+)$/) || [])[1];
430
+ const matches = (d) => pathWithin(d.file, within)
431
+ && shapes.some((k) => k.labels.includes(d.kind) && k.exts.includes(ext(d.file)));
432
+ if (!defs.every(matches)) return { status: 'unavailable', reason: 'scope' };
433
+ }
434
+ parts.push(`$ code-graph-mcp ${argv.join(' ')}\n${out}`);
387
435
  resolved.push(sym);
436
+ argvs.push(argv);
388
437
  }
389
438
  if (parts.length === 0) return { status: 'no-hits' };
390
439
  const { text, truncated } = truncateAtLine(parts.join('\n\n'), maxBytes);
391
- return { status: 'hits', text, truncated, symbols: resolved };
440
+ return { status: 'hits', text, truncated, symbols: resolved, argvs };
392
441
  } catch {
393
442
  return { status: 'unavailable' };
394
443
  }
@@ -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;
@@ -52,8 +52,15 @@ const {
52
52
  operandMatches,
53
53
  firstShellClause,
54
54
  normalizeCommandPaths,
55
+ rootOnlyInSearchPath,
56
+ isDirectory,
57
+ declKindsBySymbol,
58
+ extractPatterns,
55
59
  rebaseRelativePaths,
56
60
  resolveProjectRoot,
61
+ rewritePlan,
62
+ showAnswersPattern,
63
+ searchesSameFiles,
57
64
  } = require('./pre-grep-guide');
58
65
 
59
66
  // The command HEAD is grep/rg/ag (or git grep, or a KEY=VALUE/env prefix). Kept
@@ -288,8 +295,22 @@ function runMain() {
288
295
  if (segCwd === null) return;
289
296
  if ((segCwd !== root || relPrefix)
290
297
  && !operandMatches(firstShellClause(rawSegs[idx]), extractSearchPath(segment), root, segCwd)) return;
298
+ // D#125 #1 — the root strip rewrote a pattern that held the root.
299
+ if (!rootOnlyInSearchPath(rawSegs[idx], root, extractSearchPath(segment))) return;
291
300
  // Run the answer exactly like the deny path.
292
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;
293
314
  // Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
294
315
  // re-stated hits the model already had). If the command's OWN output already
295
316
  // surfaced the grepped symbol, the inject is redundant → skip it, saving the
@@ -382,7 +403,16 @@ function runMain() {
382
403
  if (answer.status !== 'hits') {
383
404
  if (block.mode === 'show') {
384
405
  answeredMode = 'show';
385
- answer = runShowAnswer({ cwd: root, symbols: block.symbols });
406
+ // Scoped like the rewrite: the grep's path and declaration kinds; a grep
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)
411
+ ? { status: 'unavailable' }
412
+ : runShowAnswer({
413
+ cwd: root, symbols: block.symbols, within: searchPath ?? '',
414
+ kinds: declKindsBySymbol(extractPatterns(segment)),
415
+ });
386
416
  if (answer.status !== 'hits' && pattern) {
387
417
  answeredMode = 'grep';
388
418
  answer = runGrepAnswer({ cwd: root, pattern, searchPath, flags });
@@ -426,6 +456,15 @@ function runMain() {
426
456
  return;
427
457
  }
428
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
+
429
468
  recordRecommendation(root, {
430
469
  hook: 'grep', action: 'inject', answered: true,
431
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