@sdsrs/code-graph 0.163.0 → 0.165.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.
@@ -36,7 +36,7 @@ const { recordRecommendation } = require('./recommendation-log');
36
36
  // `sanitizeSearchPath` is deliberately NOT imported any more: this hook passes
37
37
  // the RAW path so buildGrepArgs can split a glob into scope + `-g` instead of
38
38
  // widening it away. Leaving the require behind would read as "still used".
39
- const { runGrepAnswer, runShowAnswer, runCallgraphAnswer } = require('./cg-answer');
39
+ const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, shellQuoteArg, formatCgCommand } = require('./cg-answer');
40
40
  const { emitPostToolContext } = require('./hook-emit');
41
41
  const {
42
42
  splitTopLevelSegments,
@@ -58,10 +58,14 @@ const {
58
58
  extractPatterns,
59
59
  rebaseRelativePaths,
60
60
  resolveProjectRoot,
61
+ readSourceRoots,
62
+ useSourceRoots,
63
+ grepListsBinaryMatches,
61
64
  rewritePlan,
62
65
  showAnswersPattern,
63
66
  searchesSameFiles,
64
67
  } = require('./pre-grep-guide');
68
+ const { indexBuildInProgress } = require('./project-root');
65
69
 
66
70
  // The command HEAD is grep/rg/ag (or git grep, or a KEY=VALUE/env prefix). Kept
67
71
  // loosely aligned with pre-grep-guide's GREP_VERB; this only gates "is this
@@ -205,6 +209,38 @@ const INJECT_HEADER = '[code-graph] AST-aware view of your grep (ran alongside):
205
209
  const CALLGRAPH_HEADER =
206
210
  '[code-graph] Cross-file call graph for the symbol you grepped (grep can\'t show this):';
207
211
 
212
+ // B11 (2026-09-29 usage evaluation) — a call graph about a symbol defined
213
+ // outside the path the grep searched. The grep's own scope says where the model
214
+ // is looking; a same-named symbol's 4 KB call tree from elsewhere is not about
215
+ // that place (a grep in tests/cli_e2e.rs got `indexed_project`'s tree from
216
+ // src/indexer/resync.rs). Its location was the useful part, so that is what it
217
+ // gets: one pointer line.
218
+
219
+ /** The file on a `callgraph` answer's root line (`name (path)`), or null. */
220
+ function definitionFile(text) {
221
+ const m = /^\S+ \(([^()\n]+)\)/.exec(String(text || ''));
222
+ return m ? m[1] : null;
223
+ }
224
+
225
+ /**
226
+ * Whether a grep over root-relative `searchPath` reads `file`. No path, `.`,
227
+ * or an unknown file count as covered (the full answer, as before); a glob
228
+ * counts as its directory.
229
+ */
230
+ function searchCovers(searchPath, file) {
231
+ if (!file) return true;
232
+ let p = String(searchPath || '').replace(/^\.\//, '').replace(/\/+$/, '');
233
+ const glob = p.search(/[*?[]/);
234
+ if (glob !== -1) p = p.slice(0, glob).replace(/\/?[^/]*$/, '');
235
+ if (p === '' || p === '.') return true;
236
+ return file === p || file.startsWith(p + '/');
237
+ }
238
+
239
+ function buildPointerText(symbol, file, searchPath) {
240
+ return `[code-graph] ${symbol} is defined in ${shellQuoteArg(file)}, outside the path your grep ` +
241
+ `searched (${shellQuoteArg(searchPath)}). Its callers and callees: ${formatCgCommand(['callgraph', symbol])}`;
242
+ }
243
+
208
244
  function buildInjectText(answer, mode) {
209
245
  if (mode === 'callgraph') {
210
246
  const lines = [CALLGRAPH_HEADER, answer.text];
@@ -251,6 +287,8 @@ function runMain() {
251
287
  const shellCwd = process.cwd();
252
288
  const root = resolveProjectRoot(shellCwd);
253
289
  if (root === null) return; // no index anywhere up to $HOME
290
+ // The same source dirs the PreToolUse half recognized (F1).
291
+ useSourceRoots(readSourceRoots(root));
254
292
 
255
293
  let input;
256
294
  try {
@@ -391,11 +429,16 @@ function runMain() {
391
429
  // stopping at the first symbol with real edges. runCallgraphAnswer returns `hits`
392
430
  // ONLY when the symbol has edges → a leaf/absent symbol self-filters to the
393
431
  // show/grep echo below.
394
- for (const symbol of extractCallgraphSymbols(rawPattern)) {
432
+ // Not while the startup index is being written: its call edges are a subset
433
+ // shown as the whole graph (D5). The grep echo below reads the files.
434
+ const callgraphSymbols = indexBuildInProgress(root) ? [] : extractCallgraphSymbols(rawPattern);
435
+ let answeredSymbol = null;
436
+ for (const symbol of callgraphSymbols) {
395
437
  const cg = runCallgraphAnswer({ cwd: root, symbol });
396
438
  if (cg.status === 'hits') {
397
439
  answer = cg;
398
440
  answeredMode = 'callgraph';
441
+ answeredSymbol = symbol;
399
442
  break;
400
443
  }
401
444
  }
@@ -463,14 +506,23 @@ function runMain() {
463
506
  // which skips more (review of D#133, H-2).
464
507
  if (answeredMode !== 'callgraph' && !searchesSameFiles({
465
508
  root, target: searchPath, verb: plan.verb, show: answeredMode === 'show',
509
+ binaryCacheOk: !grepListsBinaryMatches(segment),
466
510
  })) return;
467
511
 
512
+ // B11: `scope:'outside'` marks a pointer in place of the tree; `mode` stays
513
+ // `callgraph`, the one the funnel aggregator buckets.
514
+ const rootFile = answeredMode === 'callgraph' ? definitionFile(answer.text) : null;
515
+ const outside = answeredMode === 'callgraph' && !searchCovers(searchPath, rootFile);
468
516
  recordRecommendation(root, {
469
517
  hook: 'grep', action: 'inject', answered: true,
470
518
  ...(pattern ? { pattern } : {}),
471
519
  mode: answeredMode,
520
+ ...(outside ? { scope: 'outside' } : {}),
472
521
  });
473
- process.stdout.write(emitPostToolContext(buildInjectText(answer, answeredMode)) + '\n');
522
+ const text = outside
523
+ ? buildPointerText(answeredSymbol, rootFile, searchPath)
524
+ : buildInjectText(answer, answeredMode);
525
+ process.stdout.write(emitPostToolContext(text) + '\n');
474
526
  }
475
527
 
476
528
  if (require.main === module) {
@@ -483,6 +535,9 @@ module.exports = {
483
535
  extractGrepOutput,
484
536
  grepFoundPattern,
485
537
  buildInjectText,
538
+ definitionFile,
539
+ searchCovers,
540
+ buildPointerText,
486
541
  isSilenced,
487
542
  isInjectDisabled,
488
543
  commandHash,
@@ -9,17 +9,20 @@
9
9
  if (require.main === module) require('./hook-fail-open').installHookFailOpen('PreToolUse:Edit');
10
10
 
11
11
  // PreToolUse(Edit) hook: auto-inject impact analysis when editing function definitions.
12
- // Only fires when:
13
- // 1. The old_string contains a function/method definition (signature being modified)
14
- // 2. The symbol has 2+ production callers (high impact)
15
- // 3. Same symbol not queried in last 2 minutes
16
- // Silently exits otherwise — zero noise for normal edits.
12
+ // Write reaches it too (Q1): only logged for the Stop check, never answered. For Edit, it only fires when:
13
+ // 1. The old_string contains a function/method definition AND the edit changes
14
+ // that definition's header (a body-only edit cannot break a caller)
15
+ // 2. The edited file is inside the project
16
+ // 3. The symbol has 1+ production callers
17
+ // 4. Same symbol not queried in last 2 minutes
18
+ // Silently exits otherwise — zero noise for normal edits. CODE_GRAPH_QUIET_HOOKS=1
19
+ // silences it like the other injecting hooks.
17
20
  const { execFileSync } = require('child_process');
18
21
  const fs = require('fs');
19
22
  const path = require('path');
20
23
  const { findBinary } = require('./find-binary');
21
24
  const { cgTmpDir, cwdHash } = require('./tmp-dir');
22
- const { resolveProjectRoot } = require('./project-root');
25
+ const { resolveProjectRoot, indexBuildInProgress } = require('./project-root');
23
26
  const { recordRecommendation } = require('./recommendation-log');
24
27
  const { formatCoveringTests } = require('./covering-tests');
25
28
  const { emitPreToolContext } = require('./hook-emit');
@@ -56,16 +59,37 @@ try {
56
59
  // edits the signature extraction below gives up on), then again with the
57
60
  // symbol once one is known. Append-only, best-effort: it cannot fail the Edit.
58
61
  const sessionEdits = require('./session-edits');
59
- const editedAbs = (input.tool_input && input.tool_input.file_path) || '';
62
+ const toolInput = input.tool_input || {};
63
+ const editedAbs = toolInput.file_path || '';
60
64
  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 });
65
+ const insideProject = editedRel && !editedRel.startsWith('..') && !path.isAbsolute(editedRel);
66
+ const editedKey = insideProject ? editedRel.split(path.sep).join('/') : '';
67
+
68
+ // Q1 — a Write (matcher `Edit|Write`) is logged for the Stop check and never
69
+ // answered: it carries the whole file, and the pick below would name
70
+ // whichever definition its first matching pattern finds, not the one that
71
+ // changed. A Write over
72
+ // an existing file records a baseline for each definition whose header its
73
+ // content changes.
74
+ if (input.tool_name === 'Write') {
75
+ if (editedKey) {
76
+ const newText = typeof toolInput.content === 'string' ? toolInput.content : null;
77
+ sessionEdits.recordFileEdit(cwd, input.session_id, editedKey, { newText });
64
78
  }
79
+ process.exit(0);
80
+ }
81
+
82
+ const logEdit = (symbol) => {
83
+ if (editedKey) sessionEdits.recordEdit(cwd, input.session_id, { file: editedKey, symbol });
65
84
  };
66
85
  logEdit(null);
67
86
 
68
- const oldStr = (input.tool_input && input.tool_input.old_string) || '';
87
+ // An edit outside the project cannot be about this project's symbols; asking
88
+ // the index anyway injected a same-named project function's callers.
89
+ if (!insideProject) process.exit(0);
90
+
91
+ const oldStr = toolInput.old_string || '';
92
+ const newStr = toolInput.new_string || '';
69
93
  if (!oldStr || oldStr.length < 10) process.exit(0);
70
94
 
71
95
  // --- Extract function/method signature from the edited text ---
@@ -107,63 +131,27 @@ const fnPatterns = [
107
131
  // future author adds to the array without reading the note above.
108
132
  const scanned = oldStr.length > 8192 ? oldStr.slice(0, 8192) : oldStr;
109
133
 
134
+ // Which definition the hunk names: the first pattern, in array order, that
135
+ // matches — 0.163.0's rule. Three replacements (the earliest match, then an
136
+ // allow-list of what may precede a method name, then keyword-anchored arms
137
+ // first) each fixed shapes and broke others, and three review rounds found a
138
+ // regression in each (pre-tag review 2026-09-29), so the released rule stays
139
+ // until a per-language design with a corpus for each language replaces it.
140
+ //
141
+ // Where the definition starts is its name's line: `// TODO: make this
142
+ // async\nfn f(` matches from the comment line, and the same-file pick below
143
+ // counts lines from here. That moves no name, only the line.
110
144
  let symbol = null;
145
+ let symbolAt = -1;
111
146
  for (const pat of fnPatterns) {
112
- const m = scanned.match(pat);
147
+ const m = scanned.match(new RegExp(pat.source, `${pat.flags}d`));
113
148
  if (m) {
114
- // Find the first captured group
115
149
  symbol = m[1] || m[2];
150
+ const nameAt = (m.indices[1] || m.indices[2])[0];
151
+ symbolAt = Math.max(m.index, scanned.lastIndexOf('\n', nameAt - 1) + 1);
116
152
  break;
117
153
  }
118
154
  }
119
-
120
- // Fallback: if old_string is inside a function body (not a definition),
121
- // extract a unique identifier from the code and grep for it to find the containing function
122
- if (!symbol || symbol.length < 3) {
123
- const filePath = (input.tool_input && input.tool_input.file_path) || '';
124
- if (filePath && oldStr.length >= 10) {
125
- try {
126
- // Extract identifiers from old_string, try the most specific one first
127
- const identifiers = (oldStr.match(/\b([a-z]\w*(?:_\w+)+|[a-z]\w*(?:[A-Z]\w*)+|[A-Z]\w+\.\w+|[A-Z]\w+::\w+)\b/g) || [])
128
- .filter(id => id.length >= 6);
129
- const skipWords = new Set(['return', 'function', 'default', 'require', 'module', 'exports', 'import', 'console']);
130
- // Sort by length descending (longer = more specific = fewer matches)
131
- const candidates = [...new Set(identifiers)]
132
- .filter(id => !skipWords.has(id.toLowerCase()))
133
- .sort((a, b) => b.length - a.length);
134
- // Two candidates, not five. They are sorted most-specific-first, so the
135
- // 3rd–5th were the least likely to resolve AND the ones that pushed this
136
- // loop past the hook's whole 4 s budget (5 × 2000 ms here + 2500 ms of
137
- // impact below); the impact query they starve is the hook's actual output
138
- // (audit 2026-09-05 JS-03).
139
- for (const candidate of candidates.slice(0, 2)) {
140
- const budget = remainingMs(2000);
141
- if (budget === null) break;
142
- try {
143
- const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
144
- cwd, timeout: budget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
145
- env: internalEnv,
146
- }));
147
- const grepResult = JSON.parse(raw);
148
- // Pick this candidate if it has few matches (precise location)
149
- const withContainer = (grepResult || []).filter(m => m.container && m.container.name);
150
- if (withContainer.length > 0 && withContainer.length <= 5) {
151
- // If multiple containers, vote for the most common one
152
- const votes = {};
153
- for (const m of withContainer) {
154
- const cn = m.container.name;
155
- votes[cn] = (votes[cn] || 0) + 1;
156
- }
157
- const best = Object.entries(votes).sort((a, b) => b[1] - a[1])[0][0];
158
- symbol = best.includes('.') ? best.split('.').pop() : best.includes('::') ? best.split('::').pop() : best;
159
- break;
160
- }
161
- } catch { /* try next candidate */ }
162
- }
163
- } catch { /* grep failed or no match — fall through */ }
164
- }
165
- }
166
-
167
155
  if (!symbol || symbol.length < 3) process.exit(0);
168
156
 
169
157
  // Skip common patterns that aren't real function names
@@ -180,6 +168,21 @@ function isCommonKeyword(s) {
180
168
  // the one the Stop check must still see.
181
169
  logEdit(symbol);
182
170
 
171
+ // The definition's header, from where it starts to where its body opens. An
172
+ // edit that leaves it verbatim changes only the body, which no caller can see.
173
+ // (A body-only hunk with no definition in it exited above: the grep fallback
174
+ // that used to guess its enclosing function named a wrong one for 108 of 608
175
+ // TypeScript definitions, 2026-09-28 hook audit.)
176
+ function headerAt(text, at) {
177
+ const rest = text.slice(at, at + 400);
178
+ const ends = [rest.indexOf('{'), rest.indexOf('=>'), rest.search(/:\s*(?:\n|$)/)]
179
+ .filter((i) => i >= 0);
180
+ return ends.length ? rest.slice(0, Math.min(...ends)) : rest.split('\n', 1)[0];
181
+ }
182
+ if (newStr.includes(headerAt(scanned, symbolAt))) process.exit(0);
183
+
184
+ if (process.env.CODE_GRAPH_QUIET_HOOKS === '1') process.exit(0);
185
+
183
186
  // --- Per-symbol cooldown: 2 minutes ---
184
187
  // Project-scoped (see cwdHash in tmp-dir.js). A symbol name is the LEAST
185
188
  // project-unique key there is — `main`, `run`, `new`, `parse` collide across
@@ -190,6 +193,10 @@ try {
190
193
  if (Date.now() - fs.statSync(cooldownFile).mtimeMs < 120000) process.exit(0);
191
194
  } catch { /* first time for this symbol */ }
192
195
 
196
+ // A half-built index gives a wrong caller count and risk, not a short one (D5).
197
+ // The edit itself is already logged above for the Stop hook.
198
+ if (indexBuildInProgress(cwd)) process.exit(0);
199
+
193
200
  // --- Run impact analysis (JSON mode for programmatic parsing) ---
194
201
  // Disambiguate via --file: file_path from tool_input is absolute, but the
195
202
  // indexer stores files as repo-relative paths — converting here is what makes
@@ -198,33 +205,65 @@ try {
198
205
  // path, which previously caused silent exits for the most common edit cases.
199
206
  const editedFile = (input.tool_input && input.tool_input.file_path) || '';
200
207
  const relFile = editedFile ? path.relative(cwd, editedFile) : '';
201
- let jsonResult;
202
208
  // Whatever is left of the registered 4 s, capped at this call's own 2500 ms.
203
209
  // `null` = the candidate loop above already spent the budget; running anyway is
204
210
  // what got the hook killed by Claude Code mid-Edit (audit 2026-09-05 JS-03).
205
- const impactBudget = remainingMs(2500);
206
- if (impactBudget === null) process.exit(0);
207
- try {
208
- const args = ['impact', symbol, '--json'];
209
- if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
210
- // v0.49 — use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
211
- // diverging from the findBinary() result the rest of the hook trusts).
212
- const raw = execFileSync(binary, args, hidden({
213
- cwd,
214
- timeout: impactBudget,
215
- encoding: 'utf8',
216
- stdio: ['pipe', 'pipe', 'pipe'],
217
- env: internalEnv,
218
- }));
219
- jsonResult = JSON.parse(raw);
220
- } catch {
221
- // Symbol not found, timeout, or parse error — exit silently
222
- process.exit(0);
211
+ // A refusal exits 1 with its JSON on stdout, which is read too.
212
+ function runImpact(args) {
213
+ const budget = remainingMs(2500);
214
+ if (budget === null) return null;
215
+ let raw;
216
+ try {
217
+ // v0.49 — use the resolved binary (bare 'code-graph-mcp' was PATH-dependent,
218
+ // diverging from the findBinary() result the rest of the hook trusts).
219
+ raw = execFileSync(binary, args, hidden({
220
+ cwd,
221
+ timeout: budget,
222
+ encoding: 'utf8',
223
+ stdio: ['pipe', 'pipe', 'pipe'],
224
+ env: internalEnv,
225
+ }));
226
+ } catch (e) {
227
+ raw = e && typeof e.stdout === 'string' ? e.stdout : '';
228
+ }
229
+ try { return JSON.parse(raw); } catch { return null; }
230
+ }
231
+
232
+ // Q4 — the definition this edit changes, among same-file same-name ones: the
233
+ // last candidate starting at or before the edited header's line. null when
234
+ // the old_string is not in the file (the Edit will fail) or no candidate fits.
235
+ // The CLI lists at most five, in source order, and says how many exist in
236
+ // `total`: past the last listed one an unlisted definition may start, so an
237
+ // edit there is not guessed at (pre-tag review 2026-09-29).
238
+ function editedNodeId(suggestions, fileKey, total) {
239
+ let text;
240
+ try { text = fs.readFileSync(path.join(cwd, fileKey), 'utf8'); } catch { return null; }
241
+ const at = text.indexOf(oldStr);
242
+ if (at === -1) return null;
243
+ const line = text.slice(0, at + symbolAt).split('\n').length;
244
+ let best = null;
245
+ for (const c of suggestions) {
246
+ if (!c || c.file_path !== fileKey || !Number.isInteger(c.node_id) || !Number.isInteger(c.start_line)) continue;
247
+ if (c.start_line <= line && (best === null || c.start_line > best.start_line)) best = c;
248
+ }
249
+ const lastListed = Math.max(...suggestions.map((c) => (c && Number.isInteger(c.start_line) ? c.start_line : -Infinity)));
250
+ if (Number.isInteger(total) && total > suggestions.length && line > lastListed) return null;
251
+ return best ? best.node_id : null;
252
+ }
253
+
254
+ const impactArgs = ['impact', symbol, '--json'];
255
+ if (relFile && !relFile.startsWith('..')) impactArgs.push('--file', relFile);
256
+ let jsonResult = runImpact(impactArgs);
257
+ // A file that defines the name more than once (two classes' `get`) makes
258
+ // `--file` refuse and list each definition's node_id (174f9b6). Ask again for
259
+ // the one being edited.
260
+ if (jsonResult && jsonResult.error && Array.isArray(jsonResult.suggestions)) {
261
+ const nodeId = editedNodeId(jsonResult.suggestions, editedKey, jsonResult.total);
262
+ jsonResult = nodeId === null ? null : runImpact(['impact', '--node-id', String(nodeId), '--json']);
223
263
  }
224
264
 
225
- // CLI returns {"error": "..."} on ambiguous / not-found instead of throwing.
226
- // Treat as silent skip — direct_callers will be undefined.
227
- if (jsonResult && jsonResult.error) process.exit(0);
265
+ // Symbol not found, another refusal, timeout, or unparsable output — silent.
266
+ if (!jsonResult || jsonResult.error) process.exit(0);
228
267
 
229
268
  // --- Inject when the symbol has any caller (1+) ---
230
269
  // Earlier gate was 2+ direct callers; reality is that editing a function with