@colbymchenry/codegraph-darwin-arm64 0.9.3 → 0.9.5
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/lib/dist/bin/codegraph.d.ts +3 -0
- package/lib/dist/bin/codegraph.d.ts.map +1 -1
- package/lib/dist/bin/codegraph.js +250 -0
- package/lib/dist/bin/codegraph.js.map +1 -1
- package/lib/dist/context/index.d.ts +13 -0
- package/lib/dist/context/index.d.ts.map +1 -1
- package/lib/dist/context/index.js +120 -1
- package/lib/dist/context/index.js.map +1 -1
- package/lib/dist/db/index.d.ts +18 -0
- package/lib/dist/db/index.d.ts.map +1 -1
- package/lib/dist/db/index.js +31 -0
- package/lib/dist/db/index.js.map +1 -1
- package/lib/dist/db/queries.d.ts +16 -0
- package/lib/dist/db/queries.d.ts.map +1 -1
- package/lib/dist/db/queries.js +80 -27
- package/lib/dist/db/queries.js.map +1 -1
- package/lib/dist/extraction/grammars.d.ts +6 -0
- package/lib/dist/extraction/grammars.d.ts.map +1 -1
- package/lib/dist/extraction/grammars.js +31 -1
- package/lib/dist/extraction/grammars.js.map +1 -1
- package/lib/dist/extraction/index.d.ts +15 -2
- package/lib/dist/extraction/index.d.ts.map +1 -1
- package/lib/dist/extraction/index.js +170 -78
- package/lib/dist/extraction/index.js.map +1 -1
- package/lib/dist/extraction/languages/index.d.ts.map +1 -1
- package/lib/dist/extraction/languages/index.js +2 -0
- package/lib/dist/extraction/languages/index.js.map +1 -1
- package/lib/dist/extraction/languages/objc.d.ts +3 -0
- package/lib/dist/extraction/languages/objc.d.ts.map +1 -0
- package/lib/dist/extraction/languages/objc.js +133 -0
- package/lib/dist/extraction/languages/objc.js.map +1 -0
- package/lib/dist/extraction/tree-sitter-types.d.ts +4 -0
- package/lib/dist/extraction/tree-sitter-types.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.js +155 -9
- package/lib/dist/extraction/tree-sitter.js.map +1 -1
- package/lib/dist/extraction/wasm-runtime-flags.d.ts +12 -0
- package/lib/dist/extraction/wasm-runtime-flags.d.ts.map +1 -1
- package/lib/dist/extraction/wasm-runtime-flags.js +14 -2
- package/lib/dist/extraction/wasm-runtime-flags.js.map +1 -1
- package/lib/dist/graph/traversal.d.ts.map +1 -1
- package/lib/dist/graph/traversal.js +71 -36
- package/lib/dist/graph/traversal.js.map +1 -1
- package/lib/dist/index.d.ts +21 -2
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +42 -0
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/installer/instructions-template.d.ts +2 -2
- package/lib/dist/installer/instructions-template.d.ts.map +1 -1
- package/lib/dist/installer/instructions-template.js +3 -2
- package/lib/dist/installer/instructions-template.js.map +1 -1
- package/lib/dist/mcp/daemon-paths.d.ts +46 -0
- package/lib/dist/mcp/daemon-paths.d.ts.map +1 -0
- package/lib/dist/mcp/daemon-paths.js +125 -0
- package/lib/dist/mcp/daemon-paths.js.map +1 -0
- package/lib/dist/mcp/daemon.d.ts +161 -0
- package/lib/dist/mcp/daemon.d.ts.map +1 -0
- package/lib/dist/mcp/daemon.js +403 -0
- package/lib/dist/mcp/daemon.js.map +1 -0
- package/lib/dist/mcp/engine.d.ts +100 -0
- package/lib/dist/mcp/engine.d.ts.map +1 -0
- package/lib/dist/mcp/engine.js +291 -0
- package/lib/dist/mcp/engine.js.map +1 -0
- package/lib/dist/mcp/index.d.ts +67 -52
- package/lib/dist/mcp/index.d.ts.map +1 -1
- package/lib/dist/mcp/index.js +347 -330
- package/lib/dist/mcp/index.js.map +1 -1
- package/lib/dist/mcp/proxy.d.ts +46 -0
- package/lib/dist/mcp/proxy.d.ts.map +1 -0
- package/lib/dist/mcp/proxy.js +276 -0
- package/lib/dist/mcp/proxy.js.map +1 -0
- package/lib/dist/mcp/server-instructions.d.ts +1 -1
- package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
- package/lib/dist/mcp/server-instructions.js +3 -1
- package/lib/dist/mcp/server-instructions.js.map +1 -1
- package/lib/dist/mcp/session.d.ts +67 -0
- package/lib/dist/mcp/session.d.ts.map +1 -0
- package/lib/dist/mcp/session.js +276 -0
- package/lib/dist/mcp/session.js.map +1 -0
- package/lib/dist/mcp/tools.d.ts +130 -2
- package/lib/dist/mcp/tools.d.ts.map +1 -1
- package/lib/dist/mcp/tools.js +902 -37
- package/lib/dist/mcp/tools.js.map +1 -1
- package/lib/dist/mcp/transport.d.ts +111 -29
- package/lib/dist/mcp/transport.d.ts.map +1 -1
- package/lib/dist/mcp/transport.js +181 -71
- package/lib/dist/mcp/transport.js.map +1 -1
- package/lib/dist/mcp/version.d.ts +19 -0
- package/lib/dist/mcp/version.d.ts.map +1 -0
- package/lib/dist/mcp/version.js +71 -0
- package/lib/dist/mcp/version.js.map +1 -0
- package/lib/dist/resolution/callback-synthesizer.d.ts +10 -0
- package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -0
- package/lib/dist/resolution/callback-synthesizer.js +847 -0
- package/lib/dist/resolution/callback-synthesizer.js.map +1 -0
- package/lib/dist/resolution/frameworks/csharp.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/csharp.js +36 -8
- package/lib/dist/resolution/frameworks/csharp.js.map +1 -1
- package/lib/dist/resolution/frameworks/drupal.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/drupal.js +44 -12
- package/lib/dist/resolution/frameworks/drupal.js.map +1 -1
- package/lib/dist/resolution/frameworks/expo-modules.d.ts +3 -0
- package/lib/dist/resolution/frameworks/expo-modules.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/expo-modules.js +143 -0
- package/lib/dist/resolution/frameworks/expo-modules.js.map +1 -0
- package/lib/dist/resolution/frameworks/express.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/express.js +102 -19
- package/lib/dist/resolution/frameworks/express.js.map +1 -1
- package/lib/dist/resolution/frameworks/fabric.d.ts +3 -0
- package/lib/dist/resolution/frameworks/fabric.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/fabric.js +354 -0
- package/lib/dist/resolution/frameworks/fabric.js.map +1 -0
- package/lib/dist/resolution/frameworks/go.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/go.js +6 -3
- package/lib/dist/resolution/frameworks/go.js.map +1 -1
- package/lib/dist/resolution/frameworks/index.d.ts +5 -0
- package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/index.js +25 -1
- package/lib/dist/resolution/frameworks/index.js.map +1 -1
- package/lib/dist/resolution/frameworks/java.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/java.js +70 -12
- package/lib/dist/resolution/frameworks/java.js.map +1 -1
- package/lib/dist/resolution/frameworks/laravel.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/laravel.js +17 -8
- package/lib/dist/resolution/frameworks/laravel.js.map +1 -1
- package/lib/dist/resolution/frameworks/play.d.ts +19 -0
- package/lib/dist/resolution/frameworks/play.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/play.js +111 -0
- package/lib/dist/resolution/frameworks/play.js.map +1 -0
- package/lib/dist/resolution/frameworks/python.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/python.js +134 -16
- package/lib/dist/resolution/frameworks/python.js.map +1 -1
- package/lib/dist/resolution/frameworks/react-native.d.ts +3 -0
- package/lib/dist/resolution/frameworks/react-native.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/react-native.js +360 -0
- package/lib/dist/resolution/frameworks/react-native.js.map +1 -0
- package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/react.js +96 -3
- package/lib/dist/resolution/frameworks/react.js.map +1 -1
- package/lib/dist/resolution/frameworks/ruby.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/ruby.js +106 -2
- package/lib/dist/resolution/frameworks/ruby.js.map +1 -1
- package/lib/dist/resolution/frameworks/rust.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/rust.js +102 -5
- package/lib/dist/resolution/frameworks/rust.js.map +1 -1
- package/lib/dist/resolution/frameworks/swift-objc.d.ts +37 -0
- package/lib/dist/resolution/frameworks/swift-objc.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/swift-objc.js +252 -0
- package/lib/dist/resolution/frameworks/swift-objc.js.map +1 -0
- package/lib/dist/resolution/frameworks/swift.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/swift.js +30 -6
- package/lib/dist/resolution/frameworks/swift.js.map +1 -1
- package/lib/dist/resolution/import-resolver.d.ts.map +1 -1
- package/lib/dist/resolution/import-resolver.js +1 -0
- package/lib/dist/resolution/import-resolver.js.map +1 -1
- package/lib/dist/resolution/index.d.ts.map +1 -1
- package/lib/dist/resolution/index.js +61 -9
- package/lib/dist/resolution/index.js.map +1 -1
- package/lib/dist/resolution/lru-cache.d.ts +24 -0
- package/lib/dist/resolution/lru-cache.d.ts.map +1 -0
- package/lib/dist/resolution/lru-cache.js +62 -0
- package/lib/dist/resolution/lru-cache.js.map +1 -0
- package/lib/dist/resolution/swift-objc-bridge.d.ts +134 -0
- package/lib/dist/resolution/swift-objc-bridge.d.ts.map +1 -0
- package/lib/dist/resolution/swift-objc-bridge.js +256 -0
- package/lib/dist/resolution/swift-objc-bridge.js.map +1 -0
- package/lib/dist/resolution/types.d.ts +8 -0
- package/lib/dist/resolution/types.d.ts.map +1 -1
- package/lib/dist/sync/index.d.ts +3 -1
- package/lib/dist/sync/index.d.ts.map +1 -1
- package/lib/dist/sync/index.js +7 -1
- package/lib/dist/sync/index.js.map +1 -1
- package/lib/dist/sync/watcher.d.ts +109 -7
- package/lib/dist/sync/watcher.d.ts.map +1 -1
- package/lib/dist/sync/watcher.js +215 -33
- package/lib/dist/sync/watcher.js.map +1 -1
- package/lib/dist/sync/worktree.d.ts +54 -0
- package/lib/dist/sync/worktree.d.ts.map +1 -0
- package/lib/dist/sync/worktree.js +136 -0
- package/lib/dist/sync/worktree.js.map +1 -0
- package/lib/dist/types.d.ts +1 -1
- package/lib/dist/types.d.ts.map +1 -1
- package/lib/dist/types.js +1 -0
- package/lib/dist/types.js.map +1 -1
- package/lib/dist/utils.js +1 -1
- package/lib/node_modules/.package-lock.json +29 -1
- package/lib/node_modules/chokidar/LICENSE +21 -0
- package/lib/node_modules/chokidar/README.md +305 -0
- package/lib/node_modules/chokidar/esm/handler.d.ts +90 -0
- package/lib/node_modules/chokidar/esm/handler.js +629 -0
- package/lib/node_modules/chokidar/esm/index.d.ts +215 -0
- package/lib/node_modules/chokidar/esm/index.js +798 -0
- package/lib/node_modules/chokidar/esm/package.json +1 -0
- package/lib/node_modules/chokidar/handler.d.ts +90 -0
- package/lib/node_modules/chokidar/handler.js +635 -0
- package/lib/node_modules/chokidar/index.d.ts +215 -0
- package/lib/node_modules/chokidar/index.js +804 -0
- package/lib/node_modules/chokidar/package.json +69 -0
- package/lib/node_modules/readdirp/LICENSE +21 -0
- package/lib/node_modules/readdirp/README.md +120 -0
- package/lib/node_modules/readdirp/esm/index.d.ts +108 -0
- package/lib/node_modules/readdirp/esm/index.js +257 -0
- package/lib/node_modules/readdirp/esm/package.json +1 -0
- package/lib/node_modules/readdirp/index.d.ts +108 -0
- package/lib/node_modules/readdirp/index.js +263 -0
- package/lib/node_modules/readdirp/package.json +70 -0
- package/lib/package.json +2 -1
- package/package.json +1 -1
package/lib/dist/mcp/tools.js
CHANGED
|
@@ -41,7 +41,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
41
41
|
exports.ToolHandler = exports.tools = void 0;
|
|
42
42
|
exports.getExploreBudget = getExploreBudget;
|
|
43
43
|
exports.getExploreOutputBudget = getExploreOutputBudget;
|
|
44
|
+
exports.formatStaleBanner = formatStaleBanner;
|
|
45
|
+
exports.formatStaleFooter = formatStaleFooter;
|
|
44
46
|
const index_1 = __importStar(require("../index"));
|
|
47
|
+
const worktree_1 = require("../sync/worktree");
|
|
45
48
|
const crypto_1 = require("crypto");
|
|
46
49
|
const fs_1 = require("fs");
|
|
47
50
|
const utils_1 = require("../utils");
|
|
@@ -49,6 +52,20 @@ const os_1 = require("os");
|
|
|
49
52
|
const path_1 = require("path");
|
|
50
53
|
/** Maximum output length to prevent context bloat (characters) */
|
|
51
54
|
const MAX_OUTPUT_LENGTH = 15000;
|
|
55
|
+
/**
|
|
56
|
+
* Maximum length for free-form string inputs (query, task, symbol).
|
|
57
|
+
* Bounds memory and CPU when a buggy or hostile MCP client sends a
|
|
58
|
+
* huge payload — without this an attacker could ship a 100MB string
|
|
59
|
+
* and force a full FTS5 scan / OOM the server. 10 000 characters is
|
|
60
|
+
* far beyond any realistic legitimate query.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_INPUT_LENGTH = 10_000;
|
|
63
|
+
/**
|
|
64
|
+
* Maximum length for path-like string inputs (projectPath, path
|
|
65
|
+
* filter, glob pattern). Paths beyond a few thousand chars are
|
|
66
|
+
* never legitimate and signal abuse or a bug upstream.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_PATH_LENGTH = 4_096;
|
|
52
69
|
/**
|
|
53
70
|
* Rust path roots that have no file-system equivalent — `crate` is the
|
|
54
71
|
* current crate, `super` is the parent module, `self` is the current
|
|
@@ -104,12 +121,17 @@ function getExploreOutputBudget(fileCount) {
|
|
|
104
121
|
}
|
|
105
122
|
if (fileCount < 5000) {
|
|
106
123
|
return {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
// Sized so ONE explore can cover a flow that centers on a god-file (e.g.
|
|
125
|
+
// excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
|
|
126
|
+
// a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
|
|
127
|
+
// smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
|
|
128
|
+
// cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
|
|
129
|
+
maxOutputChars: 28000,
|
|
130
|
+
defaultMaxFiles: 10,
|
|
131
|
+
maxCharsPerFile: 6500,
|
|
132
|
+
gapThreshold: 12,
|
|
133
|
+
maxSymbolsInFileHeader: 10,
|
|
134
|
+
maxEdgesPerRelationshipKind: 10,
|
|
113
135
|
includeRelationships: true,
|
|
114
136
|
includeAdditionalFiles: true,
|
|
115
137
|
includeCompletenessSignal: true,
|
|
@@ -191,6 +213,18 @@ function markSessionConsulted(sessionId) {
|
|
|
191
213
|
try {
|
|
192
214
|
const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
|
|
193
215
|
const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
|
|
216
|
+
// Refuse to follow a pre-planted symlink at the marker path (CWE-59).
|
|
217
|
+
// O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
|
|
218
|
+
// `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
|
|
219
|
+
// drops it and openSync would follow the link. This lstat check closes that
|
|
220
|
+
// gap cross-platform; ENOENT (path is free) falls through to create it.
|
|
221
|
+
try {
|
|
222
|
+
if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// No existing entry (or stat failed) — nothing to refuse; proceed.
|
|
227
|
+
}
|
|
194
228
|
// O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
|
|
195
229
|
// O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
|
|
196
230
|
// mode 0o600 prevents readback by other local users (the marker payload is
|
|
@@ -210,6 +244,42 @@ function markSessionConsulted(sessionId) {
|
|
|
210
244
|
// to write rather than overwrite an attacker-chosen target.
|
|
211
245
|
}
|
|
212
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Per-file staleness banner emitted at the top of a tool response when the
|
|
249
|
+
* file watcher has pending events for files referenced by the response.
|
|
250
|
+
* The agent uses this to fall back to Read for those specific files
|
|
251
|
+
* without waiting for the debounced sync (issue #403).
|
|
252
|
+
*/
|
|
253
|
+
function formatStaleBanner(stale) {
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const lines = stale.map((p) => {
|
|
256
|
+
const ageMs = Math.max(0, now - p.lastSeenMs);
|
|
257
|
+
const label = p.indexing ? 'indexing in progress' : 'pending sync';
|
|
258
|
+
return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
|
|
259
|
+
});
|
|
260
|
+
return ('⚠️ Some files referenced below were edited since the last index sync — ' +
|
|
261
|
+
'their codegraph entries may be stale:\n' +
|
|
262
|
+
lines.join('\n') +
|
|
263
|
+
'\nFor accurate content of those specific files, Read them directly. ' +
|
|
264
|
+
'The rest of this response is fresh.');
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Compact footer listing pending files that are NOT referenced in this
|
|
268
|
+
* response. Gives the agent a complete project-wide freshness picture
|
|
269
|
+
* without bloating the main banner.
|
|
270
|
+
*/
|
|
271
|
+
function formatStaleFooter(stale) {
|
|
272
|
+
const MAX = 5;
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const shown = stale.slice(0, MAX);
|
|
275
|
+
const lines = shown.map((p) => {
|
|
276
|
+
const ageMs = Math.max(0, now - p.lastSeenMs);
|
|
277
|
+
return ` - ${p.path} (edited ${ageMs}ms ago)`;
|
|
278
|
+
});
|
|
279
|
+
const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
|
|
280
|
+
return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
|
|
281
|
+
`sync but were not referenced above:\n${lines.join('\n')}${more})`);
|
|
282
|
+
}
|
|
213
283
|
/**
|
|
214
284
|
* Common projectPath property for cross-project queries
|
|
215
285
|
*/
|
|
@@ -338,7 +408,7 @@ exports.tools = [
|
|
|
338
408
|
},
|
|
339
409
|
{
|
|
340
410
|
name: 'codegraph_node',
|
|
341
|
-
description: 'Get
|
|
411
|
+
description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
|
|
342
412
|
inputSchema: {
|
|
343
413
|
type: 'object',
|
|
344
414
|
properties: {
|
|
@@ -358,7 +428,7 @@ exports.tools = [
|
|
|
358
428
|
},
|
|
359
429
|
{
|
|
360
430
|
name: 'codegraph_explore',
|
|
361
|
-
description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts".',
|
|
431
|
+
description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
|
|
362
432
|
inputSchema: {
|
|
363
433
|
type: 'object',
|
|
364
434
|
properties: {
|
|
@@ -419,6 +489,25 @@ exports.tools = [
|
|
|
419
489
|
},
|
|
420
490
|
},
|
|
421
491
|
},
|
|
492
|
+
{
|
|
493
|
+
name: 'codegraph_trace',
|
|
494
|
+
description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line and its body inlined, plus the outgoing calls of the destination itself) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
|
|
495
|
+
inputSchema: {
|
|
496
|
+
type: 'object',
|
|
497
|
+
properties: {
|
|
498
|
+
from: {
|
|
499
|
+
type: 'string',
|
|
500
|
+
description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
|
|
501
|
+
},
|
|
502
|
+
to: {
|
|
503
|
+
type: 'string',
|
|
504
|
+
description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
|
|
505
|
+
},
|
|
506
|
+
projectPath: projectPathProperty,
|
|
507
|
+
},
|
|
508
|
+
required: ['from', 'to'],
|
|
509
|
+
},
|
|
510
|
+
},
|
|
422
511
|
];
|
|
423
512
|
/**
|
|
424
513
|
* Tool handler that executes tools against a CodeGraph instance
|
|
@@ -433,6 +522,12 @@ class ToolHandler {
|
|
|
433
522
|
// The directory the server last searched for a default project. Surfaced in
|
|
434
523
|
// the "not initialized" error so users can see why detection missed.
|
|
435
524
|
defaultProjectHint = null;
|
|
525
|
+
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
|
|
526
|
+
// mismatch is a fixed property of (where the request came from → which
|
|
527
|
+
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
|
|
528
|
+
// once and every later tool call reuses the result — never shelling out to
|
|
529
|
+
// git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
|
|
530
|
+
worktreeMismatchCache = new Map();
|
|
436
531
|
constructor(cg) {
|
|
437
532
|
this.cg = cg;
|
|
438
533
|
}
|
|
@@ -455,18 +550,44 @@ class ToolHandler {
|
|
|
455
550
|
hasDefaultCodeGraph() {
|
|
456
551
|
return this.cg !== null;
|
|
457
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
|
|
555
|
+
* env var (comma-separated short names, e.g. "trace,search,node,context").
|
|
556
|
+
* Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
|
|
557
|
+
* trim the tool surface without rebuilding the client config; the ablated
|
|
558
|
+
* tool is then truly absent from ListTools rather than merely denied on call.
|
|
559
|
+
* Matching is on the short form, so "trace" and "codegraph_trace" both work.
|
|
560
|
+
*/
|
|
561
|
+
toolAllowlist() {
|
|
562
|
+
const raw = process.env.CODEGRAPH_MCP_TOOLS;
|
|
563
|
+
if (!raw || !raw.trim())
|
|
564
|
+
return null;
|
|
565
|
+
const short = (s) => s.trim().replace(/^codegraph_/, '');
|
|
566
|
+
const set = new Set(raw.split(',').map(short).filter(Boolean));
|
|
567
|
+
return set.size ? set : null;
|
|
568
|
+
}
|
|
569
|
+
/** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
|
|
570
|
+
isToolAllowed(name) {
|
|
571
|
+
const allow = this.toolAllowlist();
|
|
572
|
+
return !allow || allow.has(name.replace(/^codegraph_/, ''));
|
|
573
|
+
}
|
|
458
574
|
/**
|
|
459
575
|
* Get tool definitions with dynamic descriptions based on project size.
|
|
460
576
|
* The codegraph_explore tool description includes a budget recommendation
|
|
461
|
-
* scaled to the number of indexed files.
|
|
577
|
+
* scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
|
|
578
|
+
* allowlist so a trimmed surface is reflected in ListTools.
|
|
462
579
|
*/
|
|
463
580
|
getTools() {
|
|
581
|
+
const allow = this.toolAllowlist();
|
|
582
|
+
const visible = allow
|
|
583
|
+
? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
|
|
584
|
+
: exports.tools;
|
|
464
585
|
if (!this.cg)
|
|
465
|
-
return
|
|
586
|
+
return visible;
|
|
466
587
|
try {
|
|
467
588
|
const stats = this.cg.getStats();
|
|
468
589
|
const budget = getExploreBudget(stats.fileCount);
|
|
469
|
-
return
|
|
590
|
+
return visible.map(tool => {
|
|
470
591
|
if (tool.name === 'codegraph_explore') {
|
|
471
592
|
return {
|
|
472
593
|
...tool,
|
|
@@ -477,7 +598,7 @@ class ToolHandler {
|
|
|
477
598
|
});
|
|
478
599
|
}
|
|
479
600
|
catch {
|
|
480
|
-
return
|
|
601
|
+
return visible;
|
|
481
602
|
}
|
|
482
603
|
}
|
|
483
604
|
/**
|
|
@@ -507,6 +628,17 @@ class ToolHandler {
|
|
|
507
628
|
if (this.projectCache.has(projectPath)) {
|
|
508
629
|
return this.projectCache.get(projectPath);
|
|
509
630
|
}
|
|
631
|
+
// Reject sensitive system directories before opening. Only validate a
|
|
632
|
+
// path that actually exists — a nested or not-yet-created sub-path of a
|
|
633
|
+
// real project must still be allowed to resolve UP to its .codegraph/
|
|
634
|
+
// root below (issue #238), so we don't run the existence-checking
|
|
635
|
+
// validator on paths that are meant to walk up.
|
|
636
|
+
if ((0, fs_1.existsSync)(projectPath)) {
|
|
637
|
+
const pathError = (0, utils_1.validateProjectPath)(projectPath);
|
|
638
|
+
if (pathError) {
|
|
639
|
+
throw new Error(pathError);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
510
642
|
// Walk up parent directories to find nearest .codegraph/
|
|
511
643
|
const resolvedRoot = (0, index_1.findNearestCodeGraphRoot)(projectPath);
|
|
512
644
|
if (!resolvedRoot) {
|
|
@@ -545,43 +677,241 @@ class ToolHandler {
|
|
|
545
677
|
cg.close();
|
|
546
678
|
}
|
|
547
679
|
this.projectCache.clear();
|
|
680
|
+
this.worktreeMismatchCache.clear();
|
|
548
681
|
}
|
|
549
682
|
/**
|
|
550
|
-
* Validate that a value is a non-empty string
|
|
683
|
+
* Validate that a value is a non-empty string within length bounds.
|
|
684
|
+
*
|
|
685
|
+
* The `maxLength` cap protects against MCP clients that ship huge
|
|
686
|
+
* payloads (10MB+ query strings either by accident or maliciously).
|
|
687
|
+
* Without this, a single oversized input can pin the FTS5 index or
|
|
688
|
+
* exhaust memory before any real work runs.
|
|
551
689
|
*/
|
|
552
|
-
validateString(value, name) {
|
|
690
|
+
validateString(value, name, maxLength = MAX_INPUT_LENGTH) {
|
|
553
691
|
if (typeof value !== 'string' || value.length === 0) {
|
|
554
692
|
return this.errorResult(`${name} must be a non-empty string`);
|
|
555
693
|
}
|
|
694
|
+
if (value.length > maxLength) {
|
|
695
|
+
return this.errorResult(`${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
|
|
696
|
+
}
|
|
556
697
|
return value;
|
|
557
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* Validate an optional path-like string input. Returns the value if
|
|
701
|
+
* valid (or undefined), or a ToolResult with the error.
|
|
702
|
+
*/
|
|
703
|
+
validateOptionalPath(value, name) {
|
|
704
|
+
if (value === undefined || value === null)
|
|
705
|
+
return undefined;
|
|
706
|
+
if (typeof value !== 'string') {
|
|
707
|
+
return this.errorResult(`${name} must be a string`);
|
|
708
|
+
}
|
|
709
|
+
if (value.length > MAX_PATH_LENGTH) {
|
|
710
|
+
return this.errorResult(`${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`);
|
|
711
|
+
}
|
|
712
|
+
return value;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Cached git worktree/index mismatch for a tool call's effective project.
|
|
716
|
+
*
|
|
717
|
+
* The "effective project" is what the request targets: an explicit
|
|
718
|
+
* `projectPath` arg, else the directory the server resolved its default
|
|
719
|
+
* project from (`defaultProjectHint`), else cwd. Memoized per start path —
|
|
720
|
+
* see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
|
|
721
|
+
* (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
|
|
722
|
+
* broken by this check.
|
|
723
|
+
*/
|
|
724
|
+
worktreeMismatchFor(projectPath) {
|
|
725
|
+
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
|
|
726
|
+
const cached = this.worktreeMismatchCache.get(startPath);
|
|
727
|
+
if (cached !== undefined)
|
|
728
|
+
return cached;
|
|
729
|
+
let mismatch = null;
|
|
730
|
+
try {
|
|
731
|
+
mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, this.getCodeGraph(projectPath).getProjectRoot());
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// No resolvable project (or any other resolution error) → nothing to warn.
|
|
735
|
+
mismatch = null;
|
|
736
|
+
}
|
|
737
|
+
this.worktreeMismatchCache.set(startPath, mismatch);
|
|
738
|
+
return mismatch;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Prefix a successful read-tool result with a compact worktree-mismatch
|
|
742
|
+
* notice when the resolved index belongs to a different git working tree than
|
|
743
|
+
* the caller's (issue #155). Without this, an agent in a nested worktree
|
|
744
|
+
* silently trusts main-branch results. No-op on error results and when there
|
|
745
|
+
* is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
|
|
746
|
+
* warning — so it stays out of this path.
|
|
747
|
+
*/
|
|
748
|
+
withWorktreeNotice(result, projectPath) {
|
|
749
|
+
if (result.isError)
|
|
750
|
+
return result;
|
|
751
|
+
const mismatch = this.worktreeMismatchFor(projectPath);
|
|
752
|
+
if (!mismatch)
|
|
753
|
+
return result;
|
|
754
|
+
const notice = (0, worktree_1.worktreeMismatchNotice)(mismatch);
|
|
755
|
+
const [first, ...rest] = result.content;
|
|
756
|
+
if (first && first.type === 'text') {
|
|
757
|
+
return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
|
|
758
|
+
}
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Annotate a successful read-tool result with per-file staleness — the
|
|
763
|
+
* non-blocking answer to issue #403. The file watcher tracks every event
|
|
764
|
+
* it sees per path; here we intersect "files referenced in this response"
|
|
765
|
+
* against that pending set and prepend a compact banner so the agent can
|
|
766
|
+
* fall back to Read for those *specific* files without waiting for the
|
|
767
|
+
* debounced sync to fire. Other pending files in the project (not
|
|
768
|
+
* referenced by this response) get a small footer so the agent has a
|
|
769
|
+
* complete picture without bloating the banner.
|
|
770
|
+
*
|
|
771
|
+
* Cost when nothing is pending — the common case — is one boolean check.
|
|
772
|
+
* No I/O, no parsing of markdown beyond a per-pending-file substring scan.
|
|
773
|
+
*/
|
|
774
|
+
withStalenessNotice(result, projectPath) {
|
|
775
|
+
if (result.isError)
|
|
776
|
+
return result;
|
|
777
|
+
let cg;
|
|
778
|
+
try {
|
|
779
|
+
cg = this.getCodeGraph(projectPath);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return result; // no default project — leave as is
|
|
783
|
+
}
|
|
784
|
+
// Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
|
|
785
|
+
// watcher (watchers are only attached to the default session project).
|
|
786
|
+
// When the cross-project path happens to be the same project as the
|
|
787
|
+
// default cg, the cached instance is the wrong one — its pendingFiles is
|
|
788
|
+
// permanently empty. Detect the equal-path case and prefer the default
|
|
789
|
+
// cg so the staleness signal still fires when an agent passes the
|
|
790
|
+
// explicit projectPath form of its own project.
|
|
791
|
+
if (this.cg && cg !== this.cg) {
|
|
792
|
+
try {
|
|
793
|
+
const sameProject = (0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot());
|
|
794
|
+
if (sameProject)
|
|
795
|
+
cg = this.cg;
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
/* getProjectRoot may throw on a closed instance — leave cg as is */
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// Defensive: some test fakes inject a partial CodeGraph stub without the
|
|
802
|
+
// newer pending-files API. Treat missing/throwing as "no pending files."
|
|
803
|
+
let pending = [];
|
|
804
|
+
try {
|
|
805
|
+
pending = cg.getPendingFiles?.() ?? [];
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
if (pending.length === 0)
|
|
811
|
+
return result;
|
|
812
|
+
const [first, ...rest] = result.content;
|
|
813
|
+
if (!first || first.type !== 'text')
|
|
814
|
+
return result;
|
|
815
|
+
const text = first.text;
|
|
816
|
+
const inResponse = [];
|
|
817
|
+
const elsewhere = [];
|
|
818
|
+
for (const p of pending) {
|
|
819
|
+
// Substring match against the project-relative POSIX path — that's
|
|
820
|
+
// exactly the format both the watcher and every codegraph response
|
|
821
|
+
// emit, so a plain includes() is sufficient and avoids regex pitfalls.
|
|
822
|
+
if (text.includes(p.path))
|
|
823
|
+
inResponse.push(p);
|
|
824
|
+
else
|
|
825
|
+
elsewhere.push(p);
|
|
826
|
+
}
|
|
827
|
+
let banner = '';
|
|
828
|
+
if (inResponse.length > 0) {
|
|
829
|
+
banner = formatStaleBanner(inResponse);
|
|
830
|
+
}
|
|
831
|
+
let footer = '';
|
|
832
|
+
if (elsewhere.length > 0) {
|
|
833
|
+
footer = formatStaleFooter(elsewhere);
|
|
834
|
+
}
|
|
835
|
+
if (!banner && !footer)
|
|
836
|
+
return result;
|
|
837
|
+
const composed = [banner, text, footer].filter(Boolean).join('\n\n');
|
|
838
|
+
return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
|
|
839
|
+
}
|
|
558
840
|
/**
|
|
559
841
|
* Execute a tool by name
|
|
560
842
|
*/
|
|
561
843
|
async execute(toolName, args) {
|
|
562
844
|
try {
|
|
845
|
+
// Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
|
|
846
|
+
// surface rejects ablated tools defensively even if a client cached them.
|
|
847
|
+
if (!this.isToolAllowed(toolName)) {
|
|
848
|
+
return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
|
|
849
|
+
}
|
|
850
|
+
// Cross-cutting input validation. All tools accept an optional
|
|
851
|
+
// `projectPath` and most accept either `query`, `task`, or
|
|
852
|
+
// `symbol` — bound their lengths centrally so individual handlers
|
|
853
|
+
// can stay focused on tool-specific logic.
|
|
854
|
+
const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
|
|
855
|
+
if (typeof pathCheck === 'object' && pathCheck !== undefined) {
|
|
856
|
+
return pathCheck;
|
|
857
|
+
}
|
|
858
|
+
// The `path` and `pattern` properties used by codegraph_files are
|
|
859
|
+
// also path-shaped — apply the same cap.
|
|
860
|
+
if (args.path !== undefined) {
|
|
861
|
+
const check = this.validateOptionalPath(args.path, 'path');
|
|
862
|
+
if (typeof check === 'object' && check !== undefined)
|
|
863
|
+
return check;
|
|
864
|
+
}
|
|
865
|
+
if (args.pattern !== undefined) {
|
|
866
|
+
const check = this.validateOptionalPath(args.pattern, 'pattern');
|
|
867
|
+
if (typeof check === 'object' && check !== undefined)
|
|
868
|
+
return check;
|
|
869
|
+
}
|
|
870
|
+
// Read tools resolve through a single result variable so cross-cutting
|
|
871
|
+
// notices — worktree-index mismatch (issue #155) and per-file
|
|
872
|
+
// staleness (issue #403) — can be applied in one place. status embeds
|
|
873
|
+
// its own verbose worktree warning but still flows through the
|
|
874
|
+
// staleness wrapper so its pending-files section stays consistent
|
|
875
|
+
// with what the read tools surface.
|
|
876
|
+
let result;
|
|
563
877
|
switch (toolName) {
|
|
564
878
|
case 'codegraph_search':
|
|
565
|
-
|
|
879
|
+
result = await this.handleSearch(args);
|
|
880
|
+
break;
|
|
566
881
|
case 'codegraph_context':
|
|
567
|
-
|
|
882
|
+
result = await this.handleContext(args);
|
|
883
|
+
break;
|
|
568
884
|
case 'codegraph_callers':
|
|
569
|
-
|
|
885
|
+
result = await this.handleCallers(args);
|
|
886
|
+
break;
|
|
570
887
|
case 'codegraph_callees':
|
|
571
|
-
|
|
888
|
+
result = await this.handleCallees(args);
|
|
889
|
+
break;
|
|
572
890
|
case 'codegraph_impact':
|
|
573
|
-
|
|
891
|
+
result = await this.handleImpact(args);
|
|
892
|
+
break;
|
|
574
893
|
case 'codegraph_explore':
|
|
575
|
-
|
|
894
|
+
result = await this.handleExplore(args);
|
|
895
|
+
break;
|
|
576
896
|
case 'codegraph_node':
|
|
577
|
-
|
|
897
|
+
result = await this.handleNode(args);
|
|
898
|
+
break;
|
|
578
899
|
case 'codegraph_status':
|
|
900
|
+
// status embeds the pending-files list as a first-class section
|
|
901
|
+
// (see handleStatus), so we skip the auto-banner wrapper here to
|
|
902
|
+
// avoid duplicating the same info at the top of the response.
|
|
579
903
|
return await this.handleStatus(args);
|
|
580
904
|
case 'codegraph_files':
|
|
581
|
-
|
|
905
|
+
result = await this.handleFiles(args);
|
|
906
|
+
break;
|
|
907
|
+
case 'codegraph_trace':
|
|
908
|
+
result = await this.handleTrace(args);
|
|
909
|
+
break;
|
|
582
910
|
default:
|
|
583
911
|
return this.errorResult(`Unknown tool: ${toolName}`);
|
|
584
912
|
}
|
|
913
|
+
const withWorktree = this.withWorktreeNotice(result, args.projectPath);
|
|
914
|
+
return this.withStalenessNotice(withWorktree, args.projectPath);
|
|
585
915
|
}
|
|
586
916
|
catch (err) {
|
|
587
917
|
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -635,10 +965,10 @@ class ToolHandler {
|
|
|
635
965
|
: '';
|
|
636
966
|
// buildContext returns string when format is 'markdown'
|
|
637
967
|
if (typeof context === 'string') {
|
|
638
|
-
return this.textResult(context + reminder);
|
|
968
|
+
return this.textResult(this.truncateOutput(context + reminder));
|
|
639
969
|
}
|
|
640
970
|
// If it returns TaskContext, format it
|
|
641
|
-
return this.textResult(this.formatTaskContext(context) + reminder);
|
|
971
|
+
return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
|
|
642
972
|
}
|
|
643
973
|
/**
|
|
644
974
|
* Heuristic to detect if a query looks like a feature request
|
|
@@ -764,6 +1094,395 @@ class ToolHandler {
|
|
|
764
1094
|
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
|
|
765
1095
|
return this.textResult(this.truncateOutput(formatted));
|
|
766
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Handle codegraph_trace — shortest CALL PATH between two symbols.
|
|
1099
|
+
*
|
|
1100
|
+
* Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
|
|
1101
|
+
* each hop annotated with file:line and the call-site line. This is the
|
|
1102
|
+
* capability grep/Read structurally cannot provide. When no static path
|
|
1103
|
+
* exists, the chain has almost certainly broken at dynamic dispatch
|
|
1104
|
+
* (callbacks, descriptors, metaclasses) — we say so and surface the start
|
|
1105
|
+
* symbol's outgoing calls so the agent bridges the one missing hop with
|
|
1106
|
+
* codegraph_node rather than blindly reading.
|
|
1107
|
+
*/
|
|
1108
|
+
async handleTrace(args) {
|
|
1109
|
+
const from = this.validateString(args.from, 'from');
|
|
1110
|
+
if (typeof from !== 'string')
|
|
1111
|
+
return from;
|
|
1112
|
+
const to = this.validateString(args.to, 'to');
|
|
1113
|
+
if (typeof to !== 'string')
|
|
1114
|
+
return to;
|
|
1115
|
+
const cg = this.getCodeGraph(args.projectPath);
|
|
1116
|
+
const fromMatches = this.findAllSymbols(cg, from);
|
|
1117
|
+
if (fromMatches.nodes.length === 0)
|
|
1118
|
+
return this.textResult(`Symbol "${from}" not found in the codebase`);
|
|
1119
|
+
const toMatches = this.findAllSymbols(cg, to);
|
|
1120
|
+
if (toMatches.nodes.length === 0)
|
|
1121
|
+
return this.textResult(`Symbol "${to}" not found in the codebase`);
|
|
1122
|
+
// Trace along call edges only — a true call path. Names can map to several
|
|
1123
|
+
// nodes, so try a few from×to candidate pairs until a usable path turns up.
|
|
1124
|
+
//
|
|
1125
|
+
// MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
|
|
1126
|
+
// is almost always a spurious wander through unrelated code (django's
|
|
1127
|
+
// `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
|
|
1128
|
+
// the real execution flow — and a confident-but-wrong 15-hop trace is worse
|
|
1129
|
+
// than none. Over-cap paths are rejected and reported as "no direct path"
|
|
1130
|
+
// (which, on real code, means the flow breaks at dynamic dispatch).
|
|
1131
|
+
const edgeKinds = ['calls'];
|
|
1132
|
+
const MAX_HOPS = 7;
|
|
1133
|
+
const fromTry = fromMatches.nodes.slice(0, 3);
|
|
1134
|
+
const toTry = toMatches.nodes.slice(0, 3);
|
|
1135
|
+
let path = null;
|
|
1136
|
+
let overCap = null;
|
|
1137
|
+
for (const f of fromTry) {
|
|
1138
|
+
for (const t of toTry) {
|
|
1139
|
+
const p = cg.findPath(f.id, t.id, edgeKinds);
|
|
1140
|
+
if (!p || p.length <= 1)
|
|
1141
|
+
continue;
|
|
1142
|
+
if (p.length <= MAX_HOPS) {
|
|
1143
|
+
path = p;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
if (!overCap || p.length < overCap.length)
|
|
1147
|
+
overCap = p;
|
|
1148
|
+
}
|
|
1149
|
+
if (path)
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
if (!path) {
|
|
1153
|
+
// No static path — almost always a dynamic-dispatch break. Surface the
|
|
1154
|
+
// start symbol's outgoing calls so the agent can bridge the gap.
|
|
1155
|
+
const start = fromTry[0];
|
|
1156
|
+
const callees = cg.getCallees(start.id).slice(0, 10)
|
|
1157
|
+
.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
|
|
1158
|
+
const lines = [
|
|
1159
|
+
`No direct call path from "${from}" to "${to}".`,
|
|
1160
|
+
'',
|
|
1161
|
+
(overCap
|
|
1162
|
+
? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
|
|
1163
|
+
: '') +
|
|
1164
|
+
'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
|
|
1165
|
+
'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
|
|
1166
|
+
`Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
|
|
1167
|
+
'(includeCode=true) — its body usually shows the dynamic call to follow next.',
|
|
1168
|
+
];
|
|
1169
|
+
if (callees.length > 0) {
|
|
1170
|
+
lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
|
|
1171
|
+
}
|
|
1172
|
+
return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
|
|
1173
|
+
}
|
|
1174
|
+
const lines = [
|
|
1175
|
+
`## Trace: ${from} → ${to}`,
|
|
1176
|
+
'',
|
|
1177
|
+
`Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
|
|
1178
|
+
'',
|
|
1179
|
+
`${path.length} hops:`,
|
|
1180
|
+
'',
|
|
1181
|
+
];
|
|
1182
|
+
// Inline what each hop needs so the agent doesn't Read/Grep to get it: the
|
|
1183
|
+
// call-site source line, the registration site for dynamic-dispatch hops, AND
|
|
1184
|
+
// the hop's own body (capped per hop so the trace stays path-scoped). Earlier
|
|
1185
|
+
// versions inlined only the call-site line, which left agents calling explore
|
|
1186
|
+
// or Read for the bodies — the exact follow-up the ablation experiment measured.
|
|
1187
|
+
const fileCache = new Map();
|
|
1188
|
+
for (let i = 0; i < path.length; i++) {
|
|
1189
|
+
const step = path[i];
|
|
1190
|
+
if (step.edge) {
|
|
1191
|
+
const synth = this.synthEdgeNote(step.edge);
|
|
1192
|
+
if (synth) {
|
|
1193
|
+
lines.push(` ↓ ${synth.label}`);
|
|
1194
|
+
if (synth.registeredAt) {
|
|
1195
|
+
const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
|
|
1196
|
+
lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
// The call happens in the PREVIOUS hop's file at edge.line.
|
|
1201
|
+
const prev = path[i - 1];
|
|
1202
|
+
const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
|
|
1203
|
+
const callSrc = this.sourceLineAt(cg, ref, fileCache);
|
|
1204
|
+
lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
|
|
1208
|
+
const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
|
|
1209
|
+
if (body)
|
|
1210
|
+
lines.push(body);
|
|
1211
|
+
}
|
|
1212
|
+
// The "last mile": what the destination does next. Agents otherwise explore/Read
|
|
1213
|
+
// for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
|
|
1214
|
+
// so inlining the destination's callees is what actually stops the investigation —
|
|
1215
|
+
// sufficiency, not a "don't explore" instruction.
|
|
1216
|
+
const dest = path[path.length - 1].node;
|
|
1217
|
+
const destCallees = cg.getCallees(dest.id)
|
|
1218
|
+
.filter(c => !path.some(p => p.node.id === c.node.id))
|
|
1219
|
+
.slice(0, 6);
|
|
1220
|
+
if (destCallees.length > 0) {
|
|
1221
|
+
lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
|
|
1222
|
+
for (const c of destCallees) {
|
|
1223
|
+
lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
|
|
1224
|
+
const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
|
|
1225
|
+
if (body)
|
|
1226
|
+
lines.push(body);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
|
|
1230
|
+
return this.textResult(this.truncateOutput(lines.join('\n')));
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Describe a synthesized (dynamic-dispatch) edge for human output: how the
|
|
1234
|
+
* callback was wired up — the bridge static parsing can't see. Returns null
|
|
1235
|
+
* for ordinary static edges. Used by trace + the node trail so a synthesized
|
|
1236
|
+
* hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
|
|
1237
|
+
*/
|
|
1238
|
+
synthEdgeNote(edge) {
|
|
1239
|
+
if (!edge || edge.provenance !== 'heuristic')
|
|
1240
|
+
return null;
|
|
1241
|
+
const m = edge.metadata;
|
|
1242
|
+
const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
|
|
1243
|
+
const at = registeredAt ? ` @${registeredAt}` : '';
|
|
1244
|
+
if (m?.synthesizedBy === 'callback') {
|
|
1245
|
+
const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
|
|
1246
|
+
const field = m.field ? ` on .${String(m.field)}` : '';
|
|
1247
|
+
return {
|
|
1248
|
+
label: `callback — registered via ${via}${field} (dynamic dispatch)`,
|
|
1249
|
+
compact: `dynamic: callback via ${via}${at}`,
|
|
1250
|
+
registeredAt,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
if (m?.synthesizedBy === 'event-emitter') {
|
|
1254
|
+
const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
|
|
1255
|
+
return {
|
|
1256
|
+
label: `event ${ev} — emit → handler (dynamic dispatch)`,
|
|
1257
|
+
compact: `dynamic: event ${ev}${at}`,
|
|
1258
|
+
registeredAt,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
if (m?.synthesizedBy === 'react-render') {
|
|
1262
|
+
return {
|
|
1263
|
+
label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
|
|
1264
|
+
compact: `dynamic: React re-render via setState${at}`,
|
|
1265
|
+
registeredAt,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
if (m?.synthesizedBy === 'jsx-render') {
|
|
1269
|
+
const child = m.via ? `<${String(m.via)}>` : 'a child component';
|
|
1270
|
+
return {
|
|
1271
|
+
label: `renders ${child} (JSX child — dynamic dispatch)`,
|
|
1272
|
+
compact: `dynamic: renders ${child}`,
|
|
1273
|
+
registeredAt,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
if (m?.synthesizedBy === 'vue-handler') {
|
|
1277
|
+
const ev = m.event ? `@${String(m.event)}` : 'a template event';
|
|
1278
|
+
return {
|
|
1279
|
+
label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
|
|
1280
|
+
compact: `dynamic: Vue ${ev} handler`,
|
|
1281
|
+
registeredAt,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
if (m?.synthesizedBy === 'interface-impl') {
|
|
1285
|
+
return {
|
|
1286
|
+
label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
|
|
1287
|
+
compact: `dynamic: interface → impl${at}`,
|
|
1288
|
+
registeredAt,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Read one trimmed source line at "relpath:line" (relative to the project
|
|
1295
|
+
* root). `cache` holds split file contents so a multi-hop trace reads each
|
|
1296
|
+
* file at most once. Returns null if the file/line can't be resolved.
|
|
1297
|
+
*/
|
|
1298
|
+
sourceLineAt(cg, ref, cache) {
|
|
1299
|
+
if (!ref)
|
|
1300
|
+
return null;
|
|
1301
|
+
const i = ref.lastIndexOf(':');
|
|
1302
|
+
if (i < 0)
|
|
1303
|
+
return null;
|
|
1304
|
+
const filePath = ref.slice(0, i);
|
|
1305
|
+
const line = parseInt(ref.slice(i + 1), 10);
|
|
1306
|
+
if (!Number.isFinite(line) || line < 1)
|
|
1307
|
+
return null;
|
|
1308
|
+
let fileLines = cache.get(filePath);
|
|
1309
|
+
if (!fileLines) {
|
|
1310
|
+
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
1311
|
+
if (!abs || !(0, fs_1.existsSync)(abs))
|
|
1312
|
+
return null;
|
|
1313
|
+
try {
|
|
1314
|
+
fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
cache.set(filePath, fileLines);
|
|
1320
|
+
}
|
|
1321
|
+
const raw = fileLines[line - 1];
|
|
1322
|
+
if (raw == null)
|
|
1323
|
+
return null;
|
|
1324
|
+
const t = raw.trim();
|
|
1325
|
+
return t.length > 160 ? t.slice(0, 157) + '…' : t;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Read a hop's body — filePath lines [startLine..endLine] — for inlining into
|
|
1329
|
+
* a trace, capped (lines + chars) so the whole path stays path-scoped even on
|
|
1330
|
+
* a 7-hop chain. Dedents to the body's own indentation and marks truncation.
|
|
1331
|
+
* Shares `cache` with sourceLineAt so each file is read at most once per trace.
|
|
1332
|
+
*/
|
|
1333
|
+
sourceRangeAt(cg, filePath, startLine, endLine, cache, maxLines = 28, maxChars = 1200) {
|
|
1334
|
+
if (!Number.isFinite(startLine) || startLine < 1)
|
|
1335
|
+
return null;
|
|
1336
|
+
let fileLines = cache.get(filePath);
|
|
1337
|
+
if (!fileLines) {
|
|
1338
|
+
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
1339
|
+
if (!abs || !(0, fs_1.existsSync)(abs))
|
|
1340
|
+
return null;
|
|
1341
|
+
try {
|
|
1342
|
+
fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
cache.set(filePath, fileLines);
|
|
1348
|
+
}
|
|
1349
|
+
const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
|
|
1350
|
+
let slice = fileLines.slice(startLine - 1, end);
|
|
1351
|
+
if (slice.length === 0)
|
|
1352
|
+
return null;
|
|
1353
|
+
let omitted = 0;
|
|
1354
|
+
if (slice.length > maxLines) {
|
|
1355
|
+
omitted = slice.length - maxLines;
|
|
1356
|
+
slice = slice.slice(0, maxLines);
|
|
1357
|
+
}
|
|
1358
|
+
const nonBlank = slice.filter(l => l.trim().length > 0);
|
|
1359
|
+
const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
|
|
1360
|
+
let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
|
|
1361
|
+
if (text.length > maxChars) {
|
|
1362
|
+
text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
|
|
1363
|
+
omitted = Math.max(omitted, 1);
|
|
1364
|
+
}
|
|
1365
|
+
if (omitted > 0)
|
|
1366
|
+
text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
|
|
1367
|
+
return text;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
|
|
1371
|
+
* symbol names that usually spans the flow it's investigating (e.g.
|
|
1372
|
+
* "PmsProductController getList PmsProductService list PmsProductServiceImpl").
|
|
1373
|
+
* Surface the longest call chain AMONG those named symbols — scoped to what the
|
|
1374
|
+
* agent explicitly named, so (unlike a fuzzy relevance set) there's no
|
|
1375
|
+
* wrong-feature wandering. Rides synthesized edges, so controller→service-
|
|
1376
|
+
* interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
|
|
1377
|
+
*
|
|
1378
|
+
* Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
|
|
1379
|
+
* CO-NAMING: the agent names the class too, so we keep only `list` candidates
|
|
1380
|
+
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
|
|
1381
|
+
* dropping unrelated `OmsOrderService::list`.
|
|
1382
|
+
*/
|
|
1383
|
+
buildFlowFromNamedSymbols(cg, query) {
|
|
1384
|
+
try {
|
|
1385
|
+
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
1386
|
+
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
|
1387
|
+
// names (Class.method / Class::method) — the agent's most precise input,
|
|
1388
|
+
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
|
|
1389
|
+
// into Class, throwing the method away.)
|
|
1390
|
+
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
|
|
1391
|
+
const tokens = [...new Set(query.split(/[\s,()[\]]+/)
|
|
1392
|
+
.map((t) => t.replace(FILE_EXT, '').trim())
|
|
1393
|
+
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
|
|
1394
|
+
if (tokens.length < 2)
|
|
1395
|
+
return '';
|
|
1396
|
+
// Pool of name SEGMENTS (Class + method from every token) used to
|
|
1397
|
+
// disambiguate an ambiguous SIMPLE name: keep a candidate only if its
|
|
1398
|
+
// CONTAINER class is itself named in the query.
|
|
1399
|
+
const segPool = new Set();
|
|
1400
|
+
for (const t of tokens)
|
|
1401
|
+
for (const s of t.toLowerCase().split(/::|\./))
|
|
1402
|
+
if (s)
|
|
1403
|
+
segPool.add(s);
|
|
1404
|
+
const named = new Map();
|
|
1405
|
+
for (const t of tokens) {
|
|
1406
|
+
const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
|
|
1407
|
+
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
1408
|
+
// ambiguous simple name keeps only candidates whose container is named.
|
|
1409
|
+
const pick = cands.length <= 3
|
|
1410
|
+
? cands
|
|
1411
|
+
: cands.filter((n) => {
|
|
1412
|
+
const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
|
|
1413
|
+
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
|
1414
|
+
return !!container && segPool.has(container);
|
|
1415
|
+
});
|
|
1416
|
+
for (const n of pick.slice(0, 6))
|
|
1417
|
+
named.set(n.id, n);
|
|
1418
|
+
if (named.size > 40)
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
if (named.size < 2)
|
|
1422
|
+
return '';
|
|
1423
|
+
const MAX_HOPS = 7;
|
|
1424
|
+
let best = null;
|
|
1425
|
+
// BFS the full call graph (incl. synth edges) from each named seed, but
|
|
1426
|
+
// only ACCEPT a sink that is also named — both ends anchored to symbols the
|
|
1427
|
+
// agent named, so the chain stays on-topic while bridging intermediates
|
|
1428
|
+
// (e.g. the exact interface overload) that the token resolution missed.
|
|
1429
|
+
for (const seed of [...named.values()].slice(0, 8)) {
|
|
1430
|
+
const parent = new Map();
|
|
1431
|
+
parent.set(seed.id, { prev: null, edge: null, node: seed });
|
|
1432
|
+
const q = [{ id: seed.id, depth: 0, streak: 0 }];
|
|
1433
|
+
let deep = null, deepDepth = 0;
|
|
1434
|
+
const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
|
|
1435
|
+
for (let h = 0; h < q.length && parent.size < 1500; h++) {
|
|
1436
|
+
const { id, depth, streak } = q[h];
|
|
1437
|
+
if (id !== seed.id && named.has(id) && depth > deepDepth) {
|
|
1438
|
+
deep = id;
|
|
1439
|
+
deepDepth = depth;
|
|
1440
|
+
}
|
|
1441
|
+
if (depth >= MAX_HOPS - 1)
|
|
1442
|
+
continue;
|
|
1443
|
+
for (const c of cg.getCallees(id)) {
|
|
1444
|
+
if (c.edge.kind !== 'calls' || parent.has(c.node.id))
|
|
1445
|
+
continue;
|
|
1446
|
+
const newStreak = named.has(c.node.id) ? 0 : streak + 1;
|
|
1447
|
+
if (newStreak > MAX_BRIDGE)
|
|
1448
|
+
continue;
|
|
1449
|
+
parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
|
|
1450
|
+
q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (!deep)
|
|
1454
|
+
continue;
|
|
1455
|
+
const chain = [];
|
|
1456
|
+
let cur = deep;
|
|
1457
|
+
while (cur) {
|
|
1458
|
+
const p = parent.get(cur);
|
|
1459
|
+
if (!p)
|
|
1460
|
+
break;
|
|
1461
|
+
chain.push({ node: p.node, edge: p.edge });
|
|
1462
|
+
cur = p.prev;
|
|
1463
|
+
}
|
|
1464
|
+
chain.reverse();
|
|
1465
|
+
if (!best || chain.length > best.length)
|
|
1466
|
+
best = chain;
|
|
1467
|
+
}
|
|
1468
|
+
if (!best || best.length < 3)
|
|
1469
|
+
return '';
|
|
1470
|
+
const out = ['## Flow (call path among the symbols you queried)', ''];
|
|
1471
|
+
for (let i = 0; i < best.length; i++) {
|
|
1472
|
+
const step = best[i];
|
|
1473
|
+
if (step.edge) {
|
|
1474
|
+
const sy = this.synthEdgeNote(step.edge);
|
|
1475
|
+
out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`);
|
|
1476
|
+
}
|
|
1477
|
+
out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
|
|
1478
|
+
}
|
|
1479
|
+
out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
|
|
1480
|
+
return out.join('\n');
|
|
1481
|
+
}
|
|
1482
|
+
catch {
|
|
1483
|
+
return '';
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
767
1486
|
/**
|
|
768
1487
|
* Handle codegraph_explore — deep exploration in a single call
|
|
769
1488
|
*
|
|
@@ -805,6 +1524,43 @@ class ToolHandler {
|
|
|
805
1524
|
if (subgraph.nodes.size === 0) {
|
|
806
1525
|
return this.textResult(`No relevant code found for "${query}"`);
|
|
807
1526
|
}
|
|
1527
|
+
// Graph-aware glue: findRelevantContext builds the subgraph from name/text
|
|
1528
|
+
// search, so a method that BRIDGES named symbols — e.g. App.tsx's
|
|
1529
|
+
// triggerRender, which calls the named triggerUpdate — is never a search hit
|
|
1530
|
+
// and gets missed, forcing the agent to Read the file to trace it. Pull in
|
|
1531
|
+
// the callers/callees of the entry (root) nodes, but ONLY those that live in
|
|
1532
|
+
// files the subgraph already surfaces (where the agent reads to fill gaps),
|
|
1533
|
+
// so we add wiring without dragging in unrelated files. These get an
|
|
1534
|
+
// importance boost below so they survive the per-file cluster budget.
|
|
1535
|
+
const glueNodeIds = new Set();
|
|
1536
|
+
const subgraphFiles = new Set();
|
|
1537
|
+
for (const n of subgraph.nodes.values())
|
|
1538
|
+
subgraphFiles.add(n.filePath);
|
|
1539
|
+
const GLUE_NODE_CAP = 60;
|
|
1540
|
+
for (const rootId of subgraph.roots) {
|
|
1541
|
+
if (glueNodeIds.size >= GLUE_NODE_CAP)
|
|
1542
|
+
break;
|
|
1543
|
+
let neighbors = [];
|
|
1544
|
+
try {
|
|
1545
|
+
neighbors = [
|
|
1546
|
+
...cg.getCallers(rootId).map(c => c.node),
|
|
1547
|
+
...cg.getCallees(rootId).map(c => c.node),
|
|
1548
|
+
];
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
for (const nb of neighbors) {
|
|
1554
|
+
if (glueNodeIds.size >= GLUE_NODE_CAP)
|
|
1555
|
+
break;
|
|
1556
|
+
if (subgraph.nodes.has(nb.id))
|
|
1557
|
+
continue;
|
|
1558
|
+
if (!subgraphFiles.has(nb.filePath))
|
|
1559
|
+
continue;
|
|
1560
|
+
subgraph.nodes.set(nb.id, nb);
|
|
1561
|
+
glueNodeIds.add(nb.id);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
808
1564
|
// Step 2: Group nodes by file, score by relevance
|
|
809
1565
|
const fileGroups = new Map();
|
|
810
1566
|
const entryNodeIds = new Set(subgraph.roots);
|
|
@@ -905,6 +1661,8 @@ class ToolHandler {
|
|
|
905
1661
|
// Step 4: Read contiguous file sections
|
|
906
1662
|
lines.push('### Source Code');
|
|
907
1663
|
lines.push('');
|
|
1664
|
+
lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
|
|
1665
|
+
lines.push('');
|
|
908
1666
|
let totalChars = lines.join('\n').length;
|
|
909
1667
|
let filesIncluded = 0;
|
|
910
1668
|
let anyFileTrimmed = false;
|
|
@@ -925,6 +1683,35 @@ class ToolHandler {
|
|
|
925
1683
|
}
|
|
926
1684
|
const fileLines = fileContent.split('\n');
|
|
927
1685
|
const lang = group.nodes[0]?.language || '';
|
|
1686
|
+
// Whole-small-file rule: if a relevant file is small enough to afford,
|
|
1687
|
+
// return it ENTIRELY instead of clustering. Clustering exists to tame
|
|
1688
|
+
// god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
|
|
1689
|
+
// lossy subset of a file the agent will just Read in full anyway — costing
|
|
1690
|
+
// a round-trip and a re-read every later turn. Reserve clustering for files
|
|
1691
|
+
// too big to ship whole. Still bounded by the total maxOutputChars check.
|
|
1692
|
+
const WHOLE_FILE_MAX_LINES = 220;
|
|
1693
|
+
const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
|
|
1694
|
+
if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
|
|
1695
|
+
const body = fileContent.replace(/\n+$/, '');
|
|
1696
|
+
let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
|
|
1697
|
+
const uniqSymbols = [...new Set(group.nodes
|
|
1698
|
+
.filter(n => n.kind !== 'import' && n.kind !== 'export')
|
|
1699
|
+
.map(n => `${n.name}(${n.kind})`))];
|
|
1700
|
+
const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
|
|
1701
|
+
const omitted = uniqSymbols.length - headerNames.length;
|
|
1702
|
+
const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
|
|
1703
|
+
if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
1704
|
+
const remaining = budget.maxOutputChars - totalChars - 200;
|
|
1705
|
+
if (remaining < 500)
|
|
1706
|
+
break;
|
|
1707
|
+
wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
1708
|
+
anyFileTrimmed = true;
|
|
1709
|
+
}
|
|
1710
|
+
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
|
|
1711
|
+
totalChars += wholeSection.length + 200;
|
|
1712
|
+
filesIncluded++;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
928
1715
|
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
|
|
929
1716
|
// Sort by start line, then merge overlapping/adjacent ranges (within the
|
|
930
1717
|
// adaptive gap threshold). Include both node ranges AND edge source
|
|
@@ -953,6 +1740,8 @@ class ToolHandler {
|
|
|
953
1740
|
let importance = 1;
|
|
954
1741
|
if (entryNodeIds.has(n.id))
|
|
955
1742
|
importance = 10;
|
|
1743
|
+
else if (glueNodeIds.has(n.id))
|
|
1744
|
+
importance = 6; // bridging caller/callee of an entry
|
|
956
1745
|
else if (connectedToEntry.has(n.id))
|
|
957
1746
|
importance = 3;
|
|
958
1747
|
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
|
|
@@ -1145,7 +1934,7 @@ class ToolHandler {
|
|
|
1145
1934
|
.sort((a, b) => b[1].score - a[1].score);
|
|
1146
1935
|
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
|
|
1147
1936
|
if (remainingFiles.length > 0) {
|
|
1148
|
-
lines.push('###
|
|
1937
|
+
lines.push('### Not shown above — explore these names for their source');
|
|
1149
1938
|
lines.push('');
|
|
1150
1939
|
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
|
|
1151
1940
|
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
|
|
@@ -1163,11 +1952,11 @@ class ToolHandler {
|
|
|
1163
1952
|
if (budget.includeCompletenessSignal) {
|
|
1164
1953
|
lines.push('');
|
|
1165
1954
|
lines.push('---');
|
|
1166
|
-
lines.push(`> **Complete source
|
|
1955
|
+
lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
|
|
1167
1956
|
}
|
|
1168
1957
|
else if (anyFileTrimmed) {
|
|
1169
1958
|
lines.push('');
|
|
1170
|
-
lines.push(`> Some file sections were trimmed for size.
|
|
1959
|
+
lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
|
|
1171
1960
|
}
|
|
1172
1961
|
// Add explore budget note based on project size
|
|
1173
1962
|
if (budget.includeBudgetNote) {
|
|
@@ -1175,7 +1964,7 @@ class ToolHandler {
|
|
|
1175
1964
|
const stats = cg.getStats();
|
|
1176
1965
|
const callBudget = getExploreBudget(stats.fileCount);
|
|
1177
1966
|
lines.push('');
|
|
1178
|
-
lines.push(`> **Explore budget: ${callBudget} calls
|
|
1967
|
+
lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
|
|
1179
1968
|
}
|
|
1180
1969
|
catch {
|
|
1181
1970
|
// Stats unavailable — skip budget note
|
|
@@ -1187,12 +1976,12 @@ class ToolHandler {
|
|
|
1187
1976
|
// maxOutputChars (observed 30k against a 28k tier cap). A fat explore
|
|
1188
1977
|
// payload persists in the agent's context and is re-read as cache-input
|
|
1189
1978
|
// on every subsequent turn, so the overrun is paid many times over.
|
|
1190
|
-
const output = lines.join('\n');
|
|
1979
|
+
const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
|
|
1191
1980
|
if (output.length > budget.maxOutputChars) {
|
|
1192
1981
|
const cut = output.slice(0, budget.maxOutputChars);
|
|
1193
1982
|
const lastNewline = cut.lastIndexOf('\n');
|
|
1194
1983
|
const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
|
|
1195
|
-
return this.textResult(safe + '\n\n... (
|
|
1984
|
+
return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
|
|
1196
1985
|
}
|
|
1197
1986
|
return this.textResult(output);
|
|
1198
1987
|
}
|
|
@@ -1226,23 +2015,82 @@ class ToolHandler {
|
|
|
1226
2015
|
code = await cg.getCode(match.node.id);
|
|
1227
2016
|
}
|
|
1228
2017
|
}
|
|
1229
|
-
const
|
|
2018
|
+
const trail = this.formatTrail(cg, match.node);
|
|
2019
|
+
const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
|
|
1230
2020
|
return this.textResult(this.truncateOutput(formatted));
|
|
1231
2021
|
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Build the "trail" for a symbol: its direct callees (what it calls) and
|
|
2024
|
+
* callers (what calls it), each with file:line — so codegraph_node doubles as
|
|
2025
|
+
* the structural Grep→Read→expand primitive: a spot PLUS where to go next.
|
|
2026
|
+
* Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
|
|
2027
|
+
* entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
|
|
2028
|
+
* dynamic dispatch the static graph couldn't resolve — that absence is itself
|
|
2029
|
+
* a signal (read that one hop) rather than a dead end.
|
|
2030
|
+
*/
|
|
2031
|
+
formatTrail(cg, node) {
|
|
2032
|
+
const TRAIL_CAP = 12;
|
|
2033
|
+
const fmt = (e) => {
|
|
2034
|
+
const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
|
|
2035
|
+
const synth = this.synthEdgeNote(e.edge);
|
|
2036
|
+
return synth ? `${base} [${synth.compact}]` : base;
|
|
2037
|
+
};
|
|
2038
|
+
const collect = (edges) => {
|
|
2039
|
+
const seen = new Set([node.id]);
|
|
2040
|
+
const out = [];
|
|
2041
|
+
for (const e of edges) {
|
|
2042
|
+
if (seen.has(e.node.id))
|
|
2043
|
+
continue;
|
|
2044
|
+
seen.add(e.node.id);
|
|
2045
|
+
out.push(e);
|
|
2046
|
+
}
|
|
2047
|
+
return out;
|
|
2048
|
+
};
|
|
2049
|
+
const callees = collect(cg.getCallees(node.id));
|
|
2050
|
+
const callers = collect(cg.getCallers(node.id));
|
|
2051
|
+
if (callees.length === 0 && callers.length === 0)
|
|
2052
|
+
return '';
|
|
2053
|
+
const lines = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
|
|
2054
|
+
if (callees.length > 0) {
|
|
2055
|
+
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
|
|
2056
|
+
}
|
|
2057
|
+
if (callers.length > 0) {
|
|
2058
|
+
lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
|
|
2059
|
+
}
|
|
2060
|
+
return lines.join('\n');
|
|
2061
|
+
}
|
|
1232
2062
|
/**
|
|
1233
2063
|
* Handle codegraph_status
|
|
1234
2064
|
*/
|
|
1235
2065
|
async handleStatus(args) {
|
|
1236
|
-
|
|
2066
|
+
let cg = this.getCodeGraph(args.projectPath);
|
|
2067
|
+
// Same trick as withStalenessNotice — when an explicit projectPath
|
|
2068
|
+
// resolves to the same project as the default session cg, prefer the
|
|
2069
|
+
// default so getPendingFiles() (only populated by the default's watcher)
|
|
2070
|
+
// is non-empty when there are pending edits.
|
|
2071
|
+
if (this.cg && cg !== this.cg) {
|
|
2072
|
+
try {
|
|
2073
|
+
if ((0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot())) {
|
|
2074
|
+
cg = this.cg;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
catch { /* closed instance — leave as is */ }
|
|
2078
|
+
}
|
|
1237
2079
|
const stats = cg.getStats();
|
|
2080
|
+
// Warn when this index actually belongs to a different git working tree
|
|
2081
|
+
// (e.g. the server resolved up from a nested worktree to the main checkout).
|
|
2082
|
+
// Queries then reflect that tree's branch, not the worktree being edited.
|
|
2083
|
+
// status shows the verbose, multi-line form; the read tools get the compact
|
|
2084
|
+
// one-liner via withWorktreeNotice. Both share the cached detection.
|
|
2085
|
+
const mismatch = this.worktreeMismatchFor(args.projectPath);
|
|
1238
2086
|
const lines = [
|
|
1239
2087
|
'## CodeGraph Status',
|
|
1240
2088
|
'',
|
|
1241
|
-
`**Files indexed:** ${stats.fileCount}`,
|
|
1242
|
-
`**Total nodes:** ${stats.nodeCount}`,
|
|
1243
|
-
`**Total edges:** ${stats.edgeCount}`,
|
|
1244
|
-
`**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
|
|
1245
2089
|
];
|
|
2090
|
+
if (mismatch) {
|
|
2091
|
+
lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
|
|
2092
|
+
}
|
|
2093
|
+
lines.push(`**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
|
|
1246
2094
|
// Surface the active SQLite backend (node:sqlite, Node's built-in real
|
|
1247
2095
|
// SQLite — full WAL + FTS5, no native build).
|
|
1248
2096
|
lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
|
|
@@ -1270,6 +2118,20 @@ class ToolHandler {
|
|
|
1270
2118
|
lines.push(`- ${lang}: ${count}`);
|
|
1271
2119
|
}
|
|
1272
2120
|
}
|
|
2121
|
+
// Per-file freshness — the inverse of the auto-prepended staleness banner
|
|
2122
|
+
// (issue #403). Surfacing it inside `status` gives the agent a single
|
|
2123
|
+
// place to ask "is the index caught up?" rather than inferring from
|
|
2124
|
+
// banners on other tool calls.
|
|
2125
|
+
const pending = cg.getPendingFiles();
|
|
2126
|
+
if (pending.length > 0) {
|
|
2127
|
+
lines.push('', '### Pending sync:');
|
|
2128
|
+
const now = Date.now();
|
|
2129
|
+
for (const p of pending) {
|
|
2130
|
+
const ageMs = Math.max(0, now - p.lastSeenMs);
|
|
2131
|
+
const label = p.indexing ? 'indexing in progress' : 'pending sync';
|
|
2132
|
+
lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
1273
2135
|
return this.textResult(lines.join('\n'));
|
|
1274
2136
|
}
|
|
1275
2137
|
/**
|
|
@@ -1646,7 +2508,10 @@ class ToolHandler {
|
|
|
1646
2508
|
lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
|
|
1647
2509
|
}
|
|
1648
2510
|
else if (code) {
|
|
1649
|
-
|
|
2511
|
+
// Line-numbered (cat -n style, like codegraph_explore and Read) so the
|
|
2512
|
+
// agent can cite/edit exact lines without re-Reading the file for them.
|
|
2513
|
+
const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
|
|
2514
|
+
lines.push('', '```' + node.language, numbered, '```');
|
|
1650
2515
|
}
|
|
1651
2516
|
return lines.join('\n');
|
|
1652
2517
|
}
|