@sdsrs/code-graph 0.162.0 → 0.164.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 +5 -6
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/agents/code-explorer.md +15 -12
- package/claude-plugin/hooks/hooks.json +91 -1
- package/claude-plugin/scripts/adopt.js +49 -37
- package/claude-plugin/scripts/cg-answer.js +7 -0
- package/claude-plugin/scripts/covering-tests.js +1 -2
- package/claude-plugin/scripts/doctor.js +40 -1
- package/claude-plugin/scripts/incremental-index.js +4 -1
- package/claude-plugin/scripts/lifecycle.js +49 -13
- package/claude-plugin/scripts/post-grep-inject.js +58 -3
- package/claude-plugin/scripts/pre-edit-guide.js +122 -83
- package/claude-plugin/scripts/pre-grep-guide.js +243 -11
- package/claude-plugin/scripts/pre-read-guide.js +42 -28
- package/claude-plugin/scripts/project-root.js +23 -1
- package/claude-plugin/scripts/session-edits.js +67 -5
- package/claude-plugin/scripts/session-init.js +101 -348
- package/claude-plugin/scripts/stop-impact.js +244 -18
- package/claude-plugin/scripts/subagent-start.js +8 -0
- package/claude-plugin/scripts/user-prompt-context.js +48 -16
- package/claude-plugin/skills/explore/SKILL.md +10 -7
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/claude-plugin/templates/plugin_code_graph_mcp.md +58 -169
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
13
|
-
// 1. The old_string contains a function/method definition
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
} catch {
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
//
|
|
226
|
-
|
|
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
|