@colbymchenry/codegraph-darwin-x64 0.9.8 → 1.0.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/lib/dist/bin/codegraph.d.ts +1 -0
- package/lib/dist/bin/codegraph.d.ts.map +1 -1
- package/lib/dist/bin/codegraph.js +247 -39
- package/lib/dist/bin/codegraph.js.map +1 -1
- package/lib/dist/context/index.d.ts +9 -0
- package/lib/dist/context/index.d.ts.map +1 -1
- package/lib/dist/context/index.js +102 -6
- package/lib/dist/context/index.js.map +1 -1
- package/lib/dist/context/markers.d.ts +19 -0
- package/lib/dist/context/markers.d.ts.map +1 -0
- package/lib/dist/context/markers.js +22 -0
- package/lib/dist/context/markers.js.map +1 -0
- package/lib/dist/db/index.d.ts.map +1 -1
- package/lib/dist/db/index.js +2 -1
- package/lib/dist/db/index.js.map +1 -1
- package/lib/dist/db/migrations.d.ts +1 -1
- package/lib/dist/db/migrations.d.ts.map +1 -1
- package/lib/dist/db/migrations.js +10 -1
- package/lib/dist/db/migrations.js.map +1 -1
- package/lib/dist/db/queries.d.ts +43 -0
- package/lib/dist/db/queries.d.ts.map +1 -1
- package/lib/dist/db/queries.js +103 -7
- package/lib/dist/db/queries.js.map +1 -1
- package/lib/dist/db/schema.sql +1 -0
- package/lib/dist/db/sqlite-adapter.d.ts +7 -0
- package/lib/dist/db/sqlite-adapter.d.ts.map +1 -1
- package/lib/dist/db/sqlite-adapter.js +3 -0
- package/lib/dist/db/sqlite-adapter.js.map +1 -1
- package/lib/dist/directory.d.ts +34 -2
- package/lib/dist/directory.d.ts.map +1 -1
- package/lib/dist/directory.js +129 -35
- package/lib/dist/directory.js.map +1 -1
- package/lib/dist/extraction/astro-extractor.d.ts +79 -0
- package/lib/dist/extraction/astro-extractor.d.ts.map +1 -0
- package/lib/dist/extraction/astro-extractor.js +320 -0
- package/lib/dist/extraction/astro-extractor.js.map +1 -0
- package/lib/dist/extraction/extraction-version.d.ts +25 -0
- package/lib/dist/extraction/extraction-version.d.ts.map +1 -0
- package/lib/dist/extraction/extraction-version.js +28 -0
- package/lib/dist/extraction/extraction-version.js.map +1 -0
- package/lib/dist/extraction/function-ref.d.ts +118 -0
- package/lib/dist/extraction/function-ref.d.ts.map +1 -0
- package/lib/dist/extraction/function-ref.js +727 -0
- package/lib/dist/extraction/function-ref.js.map +1 -0
- package/lib/dist/extraction/generated-detection.d.ts.map +1 -1
- package/lib/dist/extraction/generated-detection.js +3 -0
- package/lib/dist/extraction/generated-detection.js.map +1 -1
- package/lib/dist/extraction/grammars.d.ts +7 -1
- package/lib/dist/extraction/grammars.d.ts.map +1 -1
- package/lib/dist/extraction/grammars.js +52 -4
- package/lib/dist/extraction/grammars.js.map +1 -1
- package/lib/dist/extraction/index.d.ts +34 -0
- package/lib/dist/extraction/index.d.ts.map +1 -1
- package/lib/dist/extraction/index.js +346 -62
- package/lib/dist/extraction/index.js.map +1 -1
- package/lib/dist/extraction/languages/c-cpp.d.ts +8 -0
- package/lib/dist/extraction/languages/c-cpp.d.ts.map +1 -1
- package/lib/dist/extraction/languages/c-cpp.js +87 -28
- package/lib/dist/extraction/languages/c-cpp.js.map +1 -1
- package/lib/dist/extraction/languages/csharp.d.ts +22 -0
- package/lib/dist/extraction/languages/csharp.d.ts.map +1 -1
- package/lib/dist/extraction/languages/csharp.js +84 -2
- package/lib/dist/extraction/languages/csharp.js.map +1 -1
- package/lib/dist/extraction/languages/dart.d.ts.map +1 -1
- package/lib/dist/extraction/languages/dart.js +161 -1
- package/lib/dist/extraction/languages/dart.js.map +1 -1
- package/lib/dist/extraction/languages/go.d.ts.map +1 -1
- package/lib/dist/extraction/languages/go.js +43 -2
- package/lib/dist/extraction/languages/go.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/java.d.ts.map +1 -1
- package/lib/dist/extraction/languages/java.js +42 -1
- package/lib/dist/extraction/languages/java.js.map +1 -1
- package/lib/dist/extraction/languages/javascript.d.ts.map +1 -1
- package/lib/dist/extraction/languages/javascript.js +16 -0
- package/lib/dist/extraction/languages/javascript.js.map +1 -1
- package/lib/dist/extraction/languages/kotlin.d.ts.map +1 -1
- package/lib/dist/extraction/languages/kotlin.js +69 -0
- package/lib/dist/extraction/languages/kotlin.js.map +1 -1
- package/lib/dist/extraction/languages/objc.d.ts.map +1 -1
- package/lib/dist/extraction/languages/objc.js +42 -0
- package/lib/dist/extraction/languages/objc.js.map +1 -1
- package/lib/dist/extraction/languages/pascal.d.ts.map +1 -1
- package/lib/dist/extraction/languages/pascal.js +11 -0
- package/lib/dist/extraction/languages/pascal.js.map +1 -1
- package/lib/dist/extraction/languages/php.d.ts.map +1 -1
- package/lib/dist/extraction/languages/php.js +90 -1
- package/lib/dist/extraction/languages/php.js.map +1 -1
- package/lib/dist/extraction/languages/r.d.ts +3 -0
- package/lib/dist/extraction/languages/r.d.ts.map +1 -0
- package/lib/dist/extraction/languages/r.js +314 -0
- package/lib/dist/extraction/languages/r.js.map +1 -0
- package/lib/dist/extraction/languages/ruby.d.ts.map +1 -1
- package/lib/dist/extraction/languages/ruby.js +35 -0
- package/lib/dist/extraction/languages/ruby.js.map +1 -1
- package/lib/dist/extraction/languages/rust.d.ts.map +1 -1
- package/lib/dist/extraction/languages/rust.js +35 -2
- package/lib/dist/extraction/languages/rust.js.map +1 -1
- package/lib/dist/extraction/languages/scala.d.ts.map +1 -1
- package/lib/dist/extraction/languages/scala.js +61 -1
- package/lib/dist/extraction/languages/scala.js.map +1 -1
- package/lib/dist/extraction/languages/swift.d.ts.map +1 -1
- package/lib/dist/extraction/languages/swift.js +61 -0
- package/lib/dist/extraction/languages/swift.js.map +1 -1
- package/lib/dist/extraction/languages/typescript.d.ts +13 -0
- package/lib/dist/extraction/languages/typescript.d.ts.map +1 -1
- package/lib/dist/extraction/languages/typescript.js +38 -0
- package/lib/dist/extraction/languages/typescript.js.map +1 -1
- package/lib/dist/extraction/liquid-extractor.d.ts +7 -0
- package/lib/dist/extraction/liquid-extractor.d.ts.map +1 -1
- package/lib/dist/extraction/liquid-extractor.js +53 -9
- package/lib/dist/extraction/liquid-extractor.js.map +1 -1
- package/lib/dist/extraction/razor-extractor.d.ts +42 -0
- package/lib/dist/extraction/razor-extractor.d.ts.map +1 -0
- package/lib/dist/extraction/razor-extractor.js +285 -0
- package/lib/dist/extraction/razor-extractor.js.map +1 -0
- package/lib/dist/extraction/svelte-extractor.d.ts.map +1 -1
- package/lib/dist/extraction/svelte-extractor.js +6 -3
- package/lib/dist/extraction/svelte-extractor.js.map +1 -1
- package/lib/dist/extraction/tree-sitter-helpers.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter-helpers.js +59 -10
- package/lib/dist/extraction/tree-sitter-helpers.js.map +1 -1
- package/lib/dist/extraction/tree-sitter-types.d.ts +33 -0
- package/lib/dist/extraction/tree-sitter-types.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.d.ts +237 -0
- package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.js +1820 -68
- package/lib/dist/extraction/tree-sitter.js.map +1 -1
- package/lib/dist/extraction/vue-extractor.d.ts +15 -0
- package/lib/dist/extraction/vue-extractor.d.ts.map +1 -1
- package/lib/dist/extraction/vue-extractor.js +94 -3
- package/lib/dist/extraction/vue-extractor.js.map +1 -1
- package/lib/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/lib/dist/extraction/wasm/tree-sitter-r.wasm +0 -0
- package/lib/dist/graph/queries.d.ts.map +1 -1
- package/lib/dist/graph/queries.js +13 -40
- package/lib/dist/graph/queries.js.map +1 -1
- package/lib/dist/graph/traversal.d.ts.map +1 -1
- package/lib/dist/graph/traversal.js +16 -4
- package/lib/dist/graph/traversal.js.map +1 -1
- package/lib/dist/index.d.ts +41 -3
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +99 -9
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/installer/index.d.ts.map +1 -1
- package/lib/dist/installer/index.js +52 -2
- package/lib/dist/installer/index.js.map +1 -1
- package/lib/dist/installer/instructions-template.d.ts +34 -11
- package/lib/dist/installer/instructions-template.d.ts.map +1 -1
- package/lib/dist/installer/instructions-template.js +44 -12
- package/lib/dist/installer/instructions-template.js.map +1 -1
- package/lib/dist/installer/targets/claude.d.ts.map +1 -1
- package/lib/dist/installer/targets/claude.js +6 -10
- package/lib/dist/installer/targets/claude.js.map +1 -1
- package/lib/dist/installer/targets/codex.js +4 -6
- package/lib/dist/installer/targets/codex.js.map +1 -1
- package/lib/dist/installer/targets/gemini.js +4 -6
- package/lib/dist/installer/targets/gemini.js.map +1 -1
- package/lib/dist/installer/targets/opencode.d.ts +9 -1
- package/lib/dist/installer/targets/opencode.d.ts.map +1 -1
- package/lib/dist/installer/targets/opencode.js +91 -40
- package/lib/dist/installer/targets/opencode.js.map +1 -1
- package/lib/dist/installer/targets/shared.d.ts +14 -0
- package/lib/dist/installer/targets/shared.d.ts.map +1 -1
- package/lib/dist/installer/targets/shared.js +19 -2
- package/lib/dist/installer/targets/shared.js.map +1 -1
- package/lib/dist/mcp/daemon.d.ts +60 -1
- package/lib/dist/mcp/daemon.d.ts.map +1 -1
- package/lib/dist/mcp/daemon.js +221 -8
- package/lib/dist/mcp/daemon.js.map +1 -1
- package/lib/dist/mcp/dynamic-boundaries.d.ts +41 -0
- package/lib/dist/mcp/dynamic-boundaries.d.ts.map +1 -0
- package/lib/dist/mcp/dynamic-boundaries.js +359 -0
- package/lib/dist/mcp/dynamic-boundaries.js.map +1 -0
- package/lib/dist/mcp/index.d.ts.map +1 -1
- package/lib/dist/mcp/index.js +18 -9
- package/lib/dist/mcp/index.js.map +1 -1
- package/lib/dist/mcp/ppid-watchdog.d.ts +44 -0
- package/lib/dist/mcp/ppid-watchdog.d.ts.map +1 -0
- package/lib/dist/mcp/ppid-watchdog.js +27 -0
- package/lib/dist/mcp/ppid-watchdog.js.map +1 -0
- package/lib/dist/mcp/proxy.d.ts +6 -0
- package/lib/dist/mcp/proxy.d.ts.map +1 -1
- package/lib/dist/mcp/proxy.js +153 -24
- package/lib/dist/mcp/proxy.js.map +1 -1
- package/lib/dist/mcp/server-instructions.d.ts +12 -1
- package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
- package/lib/dist/mcp/server-instructions.js +58 -32
- package/lib/dist/mcp/server-instructions.js.map +1 -1
- package/lib/dist/mcp/session.d.ts +2 -0
- package/lib/dist/mcp/session.d.ts.map +1 -1
- package/lib/dist/mcp/session.js +49 -2
- package/lib/dist/mcp/session.js.map +1 -1
- package/lib/dist/mcp/stdin-teardown.d.ts +27 -0
- package/lib/dist/mcp/stdin-teardown.d.ts.map +1 -0
- package/lib/dist/mcp/stdin-teardown.js +49 -0
- package/lib/dist/mcp/stdin-teardown.js.map +1 -0
- package/lib/dist/mcp/tools.d.ts +110 -49
- package/lib/dist/mcp/tools.d.ts.map +1 -1
- package/lib/dist/mcp/tools.js +1222 -972
- package/lib/dist/mcp/tools.js.map +1 -1
- package/lib/dist/mcp/transport.d.ts.map +1 -1
- package/lib/dist/mcp/transport.js +18 -2
- package/lib/dist/mcp/transport.js.map +1 -1
- package/lib/dist/resolution/callback-synthesizer.d.ts +3 -3
- package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -1
- package/lib/dist/resolution/callback-synthesizer.js +549 -21
- package/lib/dist/resolution/callback-synthesizer.js.map +1 -1
- package/lib/dist/resolution/frameworks/astro.d.ts +9 -0
- package/lib/dist/resolution/frameworks/astro.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/astro.js +169 -0
- package/lib/dist/resolution/frameworks/astro.js.map +1 -0
- package/lib/dist/resolution/frameworks/expo-modules.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/expo-modules.js +6 -1
- package/lib/dist/resolution/frameworks/expo-modules.js.map +1 -1
- package/lib/dist/resolution/frameworks/index.d.ts +1 -0
- package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/index.js +5 -1
- package/lib/dist/resolution/frameworks/index.js.map +1 -1
- package/lib/dist/resolution/frameworks/java.js +6 -1
- package/lib/dist/resolution/frameworks/java.js.map +1 -1
- package/lib/dist/resolution/frameworks/python.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/python.js +7 -3
- package/lib/dist/resolution/frameworks/python.js.map +1 -1
- package/lib/dist/resolution/frameworks/react-native.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/react-native.js +53 -3
- package/lib/dist/resolution/frameworks/react-native.js.map +1 -1
- package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/react.js +15 -3
- package/lib/dist/resolution/frameworks/react.js.map +1 -1
- package/lib/dist/resolution/frameworks/svelte.js +5 -1
- package/lib/dist/resolution/frameworks/svelte.js.map +1 -1
- package/lib/dist/resolution/frameworks/vue.js +24 -27
- package/lib/dist/resolution/frameworks/vue.js.map +1 -1
- package/lib/dist/resolution/import-resolver.d.ts +10 -0
- package/lib/dist/resolution/import-resolver.d.ts.map +1 -1
- package/lib/dist/resolution/import-resolver.js +564 -2
- package/lib/dist/resolution/import-resolver.js.map +1 -1
- package/lib/dist/resolution/index.d.ts +80 -0
- package/lib/dist/resolution/index.d.ts.map +1 -1
- package/lib/dist/resolution/index.js +457 -7
- package/lib/dist/resolution/index.js.map +1 -1
- package/lib/dist/resolution/name-matcher.d.ts +61 -0
- package/lib/dist/resolution/name-matcher.d.ts.map +1 -1
- package/lib/dist/resolution/name-matcher.js +590 -14
- package/lib/dist/resolution/name-matcher.js.map +1 -1
- package/lib/dist/resolution/types.d.ts +27 -3
- package/lib/dist/resolution/types.d.ts.map +1 -1
- package/lib/dist/resolution/workspace-packages.d.ts +48 -0
- package/lib/dist/resolution/workspace-packages.d.ts.map +1 -0
- package/lib/dist/resolution/workspace-packages.js +208 -0
- package/lib/dist/resolution/workspace-packages.js.map +1 -0
- package/lib/dist/search/query-utils.d.ts +35 -1
- package/lib/dist/search/query-utils.d.ts.map +1 -1
- package/lib/dist/search/query-utils.js +109 -10
- package/lib/dist/search/query-utils.js.map +1 -1
- package/lib/dist/sync/watcher.d.ts +124 -32
- package/lib/dist/sync/watcher.d.ts.map +1 -1
- package/lib/dist/sync/watcher.js +326 -111
- package/lib/dist/sync/watcher.js.map +1 -1
- package/lib/dist/telemetry/index.d.ts +146 -0
- package/lib/dist/telemetry/index.d.ts.map +1 -0
- package/lib/dist/telemetry/index.js +544 -0
- package/lib/dist/telemetry/index.js.map +1 -0
- package/lib/dist/types.d.ts +25 -2
- package/lib/dist/types.d.ts.map +1 -1
- package/lib/dist/types.js +3 -0
- package/lib/dist/types.js.map +1 -1
- package/lib/dist/upgrade/index.d.ts +132 -0
- package/lib/dist/upgrade/index.d.ts.map +1 -0
- package/lib/dist/upgrade/index.js +462 -0
- package/lib/dist/upgrade/index.js.map +1 -0
- package/lib/dist/utils.d.ts +30 -24
- package/lib/dist/utils.d.ts.map +1 -1
- package/lib/dist/utils.js +64 -48
- package/lib/dist/utils.js.map +1 -1
- package/lib/node_modules/.package-lock.json +1 -29
- package/lib/package.json +1 -2
- package/package.json +1 -1
- package/lib/node_modules/chokidar/LICENSE +0 -21
- package/lib/node_modules/chokidar/README.md +0 -305
- package/lib/node_modules/chokidar/esm/handler.d.ts +0 -90
- package/lib/node_modules/chokidar/esm/handler.js +0 -629
- package/lib/node_modules/chokidar/esm/index.d.ts +0 -215
- package/lib/node_modules/chokidar/esm/index.js +0 -798
- package/lib/node_modules/chokidar/esm/package.json +0 -1
- package/lib/node_modules/chokidar/handler.d.ts +0 -90
- package/lib/node_modules/chokidar/handler.js +0 -635
- package/lib/node_modules/chokidar/index.d.ts +0 -215
- package/lib/node_modules/chokidar/index.js +0 -804
- package/lib/node_modules/chokidar/package.json +0 -69
- package/lib/node_modules/readdirp/LICENSE +0 -21
- package/lib/node_modules/readdirp/README.md +0 -120
- package/lib/node_modules/readdirp/esm/index.d.ts +0 -108
- package/lib/node_modules/readdirp/esm/index.js +0 -257
- package/lib/node_modules/readdirp/esm/package.json +0 -1
- package/lib/node_modules/readdirp/index.d.ts +0 -108
- package/lib/node_modules/readdirp/index.js +0 -263
- package/lib/node_modules/readdirp/package.json +0 -70
package/lib/dist/mcp/tools.js
CHANGED
|
@@ -4,41 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Defines the tools exposed by the CodeGraph MCP server.
|
|
6
6
|
*/
|
|
7
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
-
if (k2 === undefined) k2 = k;
|
|
9
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
-
}
|
|
13
|
-
Object.defineProperty(o, k2, desc);
|
|
14
|
-
}) : (function(o, m, k, k2) {
|
|
15
|
-
if (k2 === undefined) k2 = k;
|
|
16
|
-
o[k2] = m[k];
|
|
17
|
-
}));
|
|
18
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
-
}) : function(o, v) {
|
|
21
|
-
o["default"] = v;
|
|
22
|
-
});
|
|
23
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
-
var ownKeys = function(o) {
|
|
25
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
-
var ar = [];
|
|
27
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
-
return ar;
|
|
29
|
-
};
|
|
30
|
-
return ownKeys(o);
|
|
31
|
-
};
|
|
32
|
-
return function (mod) {
|
|
33
|
-
if (mod && mod.__esModule) return mod;
|
|
34
|
-
var result = {};
|
|
35
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
-
__setModuleDefault(result, mod);
|
|
37
|
-
return result;
|
|
38
|
-
};
|
|
39
|
-
})();
|
|
40
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
-
exports.ToolHandler = exports.tools = void 0;
|
|
8
|
+
exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.NotIndexedError = void 0;
|
|
42
9
|
exports.getExploreBudget = getExploreBudget;
|
|
43
10
|
exports.getExploreOutputBudget = getExploreOutputBudget;
|
|
44
11
|
exports.formatStaleBanner = formatStaleBanner;
|
|
@@ -52,12 +19,32 @@ const directory_1 = require("../directory");
|
|
|
52
19
|
// sync + cached (CommonJS build).
|
|
53
20
|
const loadCodeGraph = () => require('../index').default;
|
|
54
21
|
const worktree_1 = require("../sync/worktree");
|
|
55
|
-
const
|
|
22
|
+
const query_utils_1 = require("../search/query-utils");
|
|
56
23
|
const fs_1 = require("fs");
|
|
57
24
|
const utils_1 = require("../utils");
|
|
58
25
|
const generated_detection_1 = require("../extraction/generated-detection");
|
|
59
|
-
const
|
|
60
|
-
|
|
26
|
+
const dynamic_boundaries_1 = require("./dynamic-boundaries");
|
|
27
|
+
/**
|
|
28
|
+
* An expected, recoverable "codegraph can't serve this" condition — most
|
|
29
|
+
* importantly a project with no index. The dispatch catch converts these to
|
|
30
|
+
* SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
|
|
31
|
+
* early in a session teaches the agent the toolset is broken and it stops
|
|
32
|
+
* calling codegraph entirely (observed repeatedly), which is exactly wrong
|
|
33
|
+
* for conditions the agent can simply work around (use built-in tools for
|
|
34
|
+
* that codebase / pass projectPath). isError is reserved for "stop trying"
|
|
35
|
+
* cases: security refusals ({@link PathRefusalError}) and genuine
|
|
36
|
+
* malfunctions.
|
|
37
|
+
*/
|
|
38
|
+
class NotIndexedError extends Error {
|
|
39
|
+
}
|
|
40
|
+
exports.NotIndexedError = NotIndexedError;
|
|
41
|
+
/**
|
|
42
|
+
* A security refusal (sensitive system path). Stays `isError: true` WITHOUT
|
|
43
|
+
* retry guidance — abandoning this path is the desired agent reaction.
|
|
44
|
+
*/
|
|
45
|
+
class PathRefusalError extends Error {
|
|
46
|
+
}
|
|
47
|
+
exports.PathRefusalError = PathRefusalError;
|
|
61
48
|
const path_1 = require("path");
|
|
62
49
|
/** Maximum output length to prevent context bloat (characters) */
|
|
63
50
|
const MAX_OUTPUT_LENGTH = 15000;
|
|
@@ -114,13 +101,24 @@ function getExploreBudget(fileCount) {
|
|
|
114
101
|
return 5;
|
|
115
102
|
}
|
|
116
103
|
function getExploreOutputBudget(fileCount) {
|
|
104
|
+
// Tiered budget, scaled to project size. The budget is a CEILING (relevance
|
|
105
|
+
// still gates WHAT is included), and it MUST stay under the agent's INLINE
|
|
106
|
+
// tool-result cap (~25K chars). Above that, the host externalizes the result
|
|
107
|
+
// to a file the agent then Reads back — re-introducing a read AND the
|
|
108
|
+
// cache-write cost — which is exactly what a 35K vscode explore did in the
|
|
109
|
+
// n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
|
|
110
|
+
// of ~100-line flow windows the agent would have grep-located and read (it
|
|
111
|
+
// natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
|
|
112
|
+
// files. Concentration onto the flow emerges from this cap + the named-file-
|
|
113
|
+
// first sort dropping peripheral files. Invariant: a larger tier must never
|
|
114
|
+
// get a smaller `maxCharsPerFile` than a smaller tier.
|
|
117
115
|
if (fileCount < 150) {
|
|
118
116
|
return {
|
|
119
117
|
// ITER3: revert iter2's aggressive body shrink (forced Read fallback —
|
|
120
118
|
// the per-file 2.5K cap pushed the agent to Read instead of node).
|
|
121
119
|
// Back to the iter1 shape (13K/4/3.8K) but keep the test-file
|
|
122
|
-
// hard-exclude. The cost lever for this tier lives in
|
|
123
|
-
//
|
|
120
|
+
// hard-exclude. The cost lever for this tier lives in steering the
|
|
121
|
+
// agent to stop after 1-2 calls, not in this budget.
|
|
124
122
|
maxOutputChars: 13000,
|
|
125
123
|
defaultMaxFiles: 4,
|
|
126
124
|
maxCharsPerFile: 3800,
|
|
@@ -152,13 +150,11 @@ function getExploreOutputBudget(fileCount) {
|
|
|
152
150
|
}
|
|
153
151
|
if (fileCount < 5000) {
|
|
154
152
|
return {
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
maxOutputChars: 28000,
|
|
161
|
-
defaultMaxFiles: 10,
|
|
153
|
+
// ~150-line per-file window (the native read unit) × ~6 files, capped at
|
|
154
|
+
// the ~24K inline ceiling so the response is never externalized. Per-file
|
|
155
|
+
// stays ≥ the <500 tier (3800) — monotonic.
|
|
156
|
+
maxOutputChars: 24000,
|
|
157
|
+
defaultMaxFiles: 8,
|
|
162
158
|
maxCharsPerFile: 6500,
|
|
163
159
|
gapThreshold: 12,
|
|
164
160
|
maxSymbolsInFileHeader: 10,
|
|
@@ -170,10 +166,14 @@ function getExploreOutputBudget(fileCount) {
|
|
|
170
166
|
excludeLowValueFiles: false,
|
|
171
167
|
};
|
|
172
168
|
}
|
|
169
|
+
// Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
|
|
170
|
+
// externalizes — see vscode). More files indexed → more CALLS via
|
|
171
|
+
// getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
|
|
172
|
+
// tiers) gives the central file a ~180-line orientation window.
|
|
173
173
|
if (fileCount < 15000) {
|
|
174
174
|
return {
|
|
175
|
-
maxOutputChars:
|
|
176
|
-
defaultMaxFiles:
|
|
175
|
+
maxOutputChars: 24000,
|
|
176
|
+
defaultMaxFiles: 8,
|
|
177
177
|
maxCharsPerFile: 7000,
|
|
178
178
|
gapThreshold: 15,
|
|
179
179
|
maxSymbolsInFileHeader: 15,
|
|
@@ -186,8 +186,8 @@ function getExploreOutputBudget(fileCount) {
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
return {
|
|
189
|
-
maxOutputChars:
|
|
190
|
-
defaultMaxFiles:
|
|
189
|
+
maxOutputChars: 24000,
|
|
190
|
+
defaultMaxFiles: 8,
|
|
191
191
|
maxCharsPerFile: 7000,
|
|
192
192
|
gapThreshold: 15,
|
|
193
193
|
maxSymbolsInFileHeader: 15,
|
|
@@ -244,55 +244,6 @@ function numberSourceLines(slice, firstLineNumber) {
|
|
|
244
244
|
}
|
|
245
245
|
return out.join('\n');
|
|
246
246
|
}
|
|
247
|
-
/**
|
|
248
|
-
* Mark a Claude session as having consulted MCP tools.
|
|
249
|
-
* This enables Grep/Glob/Bash commands that would otherwise be blocked.
|
|
250
|
-
*
|
|
251
|
-
* Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
|
|
252
|
-
* tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
|
|
253
|
-
* machine any other local user can pre-create `codegraph-consulted-<hash>` as
|
|
254
|
-
* a symlink pointing at a file the victim owns. The old `writeFileSync` would
|
|
255
|
-
* happily follow that link and overwrite the target's contents with the ISO
|
|
256
|
-
* timestamp string (CWE-59). The session-id hash provides the predictability
|
|
257
|
-
* gate, but it's defense-in-depth: if a session id ever surfaces in logs,
|
|
258
|
-
* argv, or telemetry the attack becomes trivial, and the right fix is to not
|
|
259
|
-
* follow links from /tmp paths in the first place.
|
|
260
|
-
*/
|
|
261
|
-
function markSessionConsulted(sessionId) {
|
|
262
|
-
try {
|
|
263
|
-
const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
|
|
264
|
-
const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
|
|
265
|
-
// Refuse to follow a pre-planted symlink at the marker path (CWE-59).
|
|
266
|
-
// O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
|
|
267
|
-
// `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
|
|
268
|
-
// drops it and openSync would follow the link. This lstat check closes that
|
|
269
|
-
// gap cross-platform; ENOENT (path is free) falls through to create it.
|
|
270
|
-
try {
|
|
271
|
-
if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
catch {
|
|
275
|
-
// No existing entry (or stat failed) — nothing to refuse; proceed.
|
|
276
|
-
}
|
|
277
|
-
// O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
|
|
278
|
-
// O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
|
|
279
|
-
// mode 0o600 prevents readback by other local users (the marker payload is
|
|
280
|
-
// benign, but narrowing the exposure costs nothing).
|
|
281
|
-
const flags = fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_TRUNC | fs_1.constants.O_NOFOLLOW;
|
|
282
|
-
const fd = (0, fs_1.openSync)(markerPath, flags, 0o600);
|
|
283
|
-
try {
|
|
284
|
-
(0, fs_1.writeSync)(fd, new Date().toISOString());
|
|
285
|
-
}
|
|
286
|
-
finally {
|
|
287
|
-
(0, fs_1.closeSync)(fd);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
// Silently fail - don't break MCP on marker write failure. ELOOP from a
|
|
292
|
-
// planted symlink lands here too, which is the intended behavior: refuse
|
|
293
|
-
// to write rather than overwrite an attacker-chosen target.
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
247
|
/**
|
|
297
248
|
* Per-file staleness banner emitted at the top of a tool response when the
|
|
298
249
|
* file watcher has pending events for files referenced by the response.
|
|
@@ -339,15 +290,16 @@ const projectPathProperty = {
|
|
|
339
290
|
/**
|
|
340
291
|
* All CodeGraph MCP tools
|
|
341
292
|
*
|
|
342
|
-
* Designed for minimal context usage - use
|
|
343
|
-
* and only use other tools for
|
|
293
|
+
* Designed for minimal context usage - use codegraph_explore as the primary tool
|
|
294
|
+
* (one call usually answers the whole question), and only use other tools for
|
|
295
|
+
* targeted follow-up queries.
|
|
344
296
|
*
|
|
345
297
|
* All tools support cross-project queries via the optional `projectPath` parameter.
|
|
346
298
|
*/
|
|
347
299
|
exports.tools = [
|
|
348
300
|
{
|
|
349
301
|
name: 'codegraph_search',
|
|
350
|
-
description: 'Quick symbol search by name. Returns locations only (no code). Use
|
|
302
|
+
description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_explore instead to get the actual source / understand an area in one call.',
|
|
351
303
|
inputSchema: {
|
|
352
304
|
type: 'object',
|
|
353
305
|
properties: {
|
|
@@ -370,34 +322,9 @@ exports.tools = [
|
|
|
370
322
|
required: ['query'],
|
|
371
323
|
},
|
|
372
324
|
},
|
|
373
|
-
{
|
|
374
|
-
name: 'codegraph_context',
|
|
375
|
-
description: 'PRIMARY TOOL — call FIRST for any "how does X work"/architecture/bug question. Returns entry points + related symbols + key code in one call; usually answers without further search/Read/Grep. Provides CODE context, not product requirements.',
|
|
376
|
-
inputSchema: {
|
|
377
|
-
type: 'object',
|
|
378
|
-
properties: {
|
|
379
|
-
task: {
|
|
380
|
-
type: 'string',
|
|
381
|
-
description: 'Description of the task, bug, or feature to build context for',
|
|
382
|
-
},
|
|
383
|
-
maxNodes: {
|
|
384
|
-
type: 'number',
|
|
385
|
-
description: 'Maximum symbols to include (default: 20)',
|
|
386
|
-
default: 20,
|
|
387
|
-
},
|
|
388
|
-
includeCode: {
|
|
389
|
-
type: 'boolean',
|
|
390
|
-
description: 'Include code snippets for key symbols (default: true)',
|
|
391
|
-
default: true,
|
|
392
|
-
},
|
|
393
|
-
projectPath: projectPathProperty,
|
|
394
|
-
},
|
|
395
|
-
required: ['task'],
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
325
|
{
|
|
399
326
|
name: 'codegraph_callers',
|
|
400
|
-
description: 'List functions that call <symbol>. For
|
|
327
|
+
description: 'List functions that call <symbol>. For the full flow, use codegraph_explore.',
|
|
401
328
|
inputSchema: {
|
|
402
329
|
type: 'object',
|
|
403
330
|
properties: {
|
|
@@ -405,6 +332,10 @@ exports.tools = [
|
|
|
405
332
|
type: 'string',
|
|
406
333
|
description: 'Name of the function, method, or class to find callers for',
|
|
407
334
|
},
|
|
335
|
+
file: {
|
|
336
|
+
type: 'string',
|
|
337
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)',
|
|
338
|
+
},
|
|
408
339
|
limit: {
|
|
409
340
|
type: 'number',
|
|
410
341
|
description: 'Maximum number of callers to return (default: 20)',
|
|
@@ -417,7 +348,7 @@ exports.tools = [
|
|
|
417
348
|
},
|
|
418
349
|
{
|
|
419
350
|
name: 'codegraph_callees',
|
|
420
|
-
description: 'List functions that <symbol> calls. For
|
|
351
|
+
description: 'List functions that <symbol> calls. For the full flow, use codegraph_explore.',
|
|
421
352
|
inputSchema: {
|
|
422
353
|
type: 'object',
|
|
423
354
|
properties: {
|
|
@@ -425,6 +356,10 @@ exports.tools = [
|
|
|
425
356
|
type: 'string',
|
|
426
357
|
description: 'Name of the function, method, or class to find callees for',
|
|
427
358
|
},
|
|
359
|
+
file: {
|
|
360
|
+
type: 'string',
|
|
361
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
|
|
362
|
+
},
|
|
428
363
|
limit: {
|
|
429
364
|
type: 'number',
|
|
430
365
|
description: 'Maximum number of callees to return (default: 20)',
|
|
@@ -445,6 +380,10 @@ exports.tools = [
|
|
|
445
380
|
type: 'string',
|
|
446
381
|
description: 'Name of the symbol to analyze impact for',
|
|
447
382
|
},
|
|
383
|
+
file: {
|
|
384
|
+
type: 'string',
|
|
385
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
|
|
386
|
+
},
|
|
448
387
|
depth: {
|
|
449
388
|
type: 'number',
|
|
450
389
|
description: 'How many levels of dependencies to traverse (default: 2)',
|
|
@@ -457,33 +396,54 @@ exports.tools = [
|
|
|
457
396
|
},
|
|
458
397
|
{
|
|
459
398
|
name: 'codegraph_node',
|
|
460
|
-
description: '
|
|
399
|
+
description: 'Two modes. (1) READ A FILE — use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file\'s current on-disk source with line numbers, exactly the shape Read gives you (`<n>\\t<line>`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name — its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition\'s body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use codegraph_explore for several related symbols or the full flow.',
|
|
461
400
|
inputSchema: {
|
|
462
401
|
type: 'object',
|
|
463
402
|
properties: {
|
|
464
403
|
symbol: {
|
|
465
404
|
type: 'string',
|
|
466
|
-
description: 'Name of the symbol to
|
|
405
|
+
description: 'Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.',
|
|
467
406
|
},
|
|
468
407
|
includeCode: {
|
|
469
408
|
type: 'boolean',
|
|
470
|
-
description: '
|
|
409
|
+
description: 'Symbol mode: include the symbol\'s full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.',
|
|
410
|
+
default: false,
|
|
411
|
+
},
|
|
412
|
+
file: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: 'A file path or basename (e.g. "harness.rs", "src/auth/session.ts"). Pass it ALONE (no symbol) to READ the file like the Read tool — its full source with line numbers + which files depend on it. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.',
|
|
415
|
+
},
|
|
416
|
+
offset: {
|
|
417
|
+
type: 'number',
|
|
418
|
+
description: 'File mode: 1-based line to start reading from, exactly like Read\'s offset. Defaults to the start of the file.',
|
|
419
|
+
},
|
|
420
|
+
limit: {
|
|
421
|
+
type: 'number',
|
|
422
|
+
description: 'File mode: maximum number of lines to return, exactly like Read\'s limit. Defaults to the whole file (capped at 2000 lines, like Read).',
|
|
423
|
+
},
|
|
424
|
+
symbolsOnly: {
|
|
425
|
+
type: 'boolean',
|
|
426
|
+
description: 'File mode: return just the file\'s symbol map + dependents (a cheap structural overview) instead of its source.',
|
|
471
427
|
default: false,
|
|
472
428
|
},
|
|
429
|
+
line: {
|
|
430
|
+
type: 'number',
|
|
431
|
+
description: 'Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
|
|
432
|
+
},
|
|
473
433
|
projectPath: projectPathProperty,
|
|
474
434
|
},
|
|
475
|
-
required: [
|
|
435
|
+
required: [],
|
|
476
436
|
},
|
|
477
437
|
},
|
|
478
438
|
{
|
|
479
439
|
name: 'codegraph_explore',
|
|
480
|
-
description: '
|
|
440
|
+
description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
|
|
481
441
|
inputSchema: {
|
|
482
442
|
type: 'object',
|
|
483
443
|
properties: {
|
|
484
444
|
query: {
|
|
485
445
|
type: 'string',
|
|
486
|
-
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts").
|
|
446
|
+
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior codegraph_search needed.',
|
|
487
447
|
},
|
|
488
448
|
maxFiles: {
|
|
489
449
|
type: 'number',
|
|
@@ -538,25 +498,6 @@ exports.tools = [
|
|
|
538
498
|
},
|
|
539
499
|
},
|
|
540
500
|
},
|
|
541
|
-
{
|
|
542
|
-
name: 'codegraph_trace',
|
|
543
|
-
description: 'Call path between two symbols — "how does <from> reach <to>?" Returns the chain with each hop\'s body inlined plus the destination\'s callees, in ONE call. Ideal for flow questions (update→render, request→handler, QuerySet→SQL). If no static path exists the chain broke at dynamic dispatch — the failure response inlines both endpoints + their TO-file siblings.',
|
|
544
|
-
inputSchema: {
|
|
545
|
-
type: 'object',
|
|
546
|
-
properties: {
|
|
547
|
-
from: {
|
|
548
|
-
type: 'string',
|
|
549
|
-
description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
|
|
550
|
-
},
|
|
551
|
-
to: {
|
|
552
|
-
type: 'string',
|
|
553
|
-
description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
|
|
554
|
-
},
|
|
555
|
-
projectPath: projectPathProperty,
|
|
556
|
-
},
|
|
557
|
-
required: ['from', 'to'],
|
|
558
|
-
},
|
|
559
|
-
},
|
|
560
501
|
];
|
|
561
502
|
/**
|
|
562
503
|
* Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
|
|
@@ -566,11 +507,35 @@ exports.tools = [
|
|
|
566
507
|
*/
|
|
567
508
|
function getStaticTools() {
|
|
568
509
|
const raw = process.env.CODEGRAPH_MCP_TOOLS;
|
|
569
|
-
if (!raw || !raw.trim())
|
|
570
|
-
return exports.tools;
|
|
510
|
+
if (!raw || !raw.trim()) {
|
|
511
|
+
return exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
|
|
512
|
+
}
|
|
571
513
|
const allow = new Set(raw.split(',').map(s => s.trim().replace(/^codegraph_/, '')).filter(Boolean));
|
|
572
514
|
return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : exports.tools;
|
|
573
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* The MCP tools served by DEFAULT (short names). The other defined tools
|
|
518
|
+
* (callees, impact, files, status) remain fully functional — handlers stay,
|
|
519
|
+
* the library API and CLI are untouched, and `CODEGRAPH_MCP_TOOLS` re-enables
|
|
520
|
+
* any of them — they just aren't LISTED to agents anymore.
|
|
521
|
+
*
|
|
522
|
+
* Evidence for the cut (the "adapt the tool to the agent" principle —
|
|
523
|
+
* fewer tools = fewer mis-picks, and presence itself steers):
|
|
524
|
+
* - `codegraph_impact` appears in ZERO recorded eval runs ever — its
|
|
525
|
+
* blast-radius info already arrives inline on explore (the "Blast radius"
|
|
526
|
+
* section) and node (the dependents note), so agents never need the
|
|
527
|
+
* standalone tool.
|
|
528
|
+
* - `codegraph_callees` is redundant by construction: a symbol's body (which
|
|
529
|
+
* node returns) IS its callee list, plus the caller/callee trail.
|
|
530
|
+
* - `codegraph_files` / `codegraph_status`: the tiny-repo audit (see
|
|
531
|
+
* getTools) found they "reduce to one grep"; staleness banners already
|
|
532
|
+
* inline the pending-sync info on every read tool, and the CLI covers
|
|
533
|
+
* diagnostics.
|
|
534
|
+
* - `codegraph_callers` stays: exhaustive call-site enumeration (every
|
|
535
|
+
* caller with file:line, callback registrations labeled, one section per
|
|
536
|
+
* same-named definition) is the one job explore/node don't replicate.
|
|
537
|
+
*/
|
|
538
|
+
const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers']);
|
|
574
539
|
/**
|
|
575
540
|
* Tool handler that executes tools against a CodeGraph instance
|
|
576
541
|
*
|
|
@@ -636,7 +601,7 @@ class ToolHandler {
|
|
|
636
601
|
* Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
|
|
637
602
|
* trim the tool surface without rebuilding the client config; the ablated
|
|
638
603
|
* tool is then truly absent from ListTools rather than merely denied on call.
|
|
639
|
-
* Matching is on the short form, so "
|
|
604
|
+
* Matching is on the short form, so "node" and "codegraph_node" both work.
|
|
640
605
|
*/
|
|
641
606
|
toolAllowlist() {
|
|
642
607
|
const raw = process.env.CODEGRAPH_MCP_TOOLS;
|
|
@@ -659,18 +624,22 @@ class ToolHandler {
|
|
|
659
624
|
*/
|
|
660
625
|
getTools() {
|
|
661
626
|
const allow = this.toolAllowlist();
|
|
627
|
+
// No explicit allowlist → the default 4-tool surface (see
|
|
628
|
+
// DEFAULT_MCP_TOOLS for the evidence). An allowlist replaces the
|
|
629
|
+
// default entirely, so any defined tool can be re-enabled.
|
|
662
630
|
let visible = allow
|
|
663
631
|
? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
|
|
664
|
-
: exports.tools;
|
|
632
|
+
: exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
|
|
665
633
|
if (!this.cg)
|
|
666
634
|
return visible;
|
|
667
635
|
try {
|
|
668
636
|
const stats = this.cg.getStats();
|
|
669
637
|
const budget = getExploreBudget(stats.fileCount);
|
|
670
638
|
// Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
|
|
671
|
-
// files, only expose the
|
|
672
|
-
//
|
|
673
|
-
//
|
|
639
|
+
// files, only expose the core trio (search, node, explore) — one
|
|
640
|
+
// below even the 4-tool default: at this scale callers, too, reduces
|
|
641
|
+
// to one grep. (Historical note: the audit below ran when context and
|
|
642
|
+
// trace still existed; its "5 core tools" are today's trio.)
|
|
674
643
|
//
|
|
675
644
|
// n=2 audits ruled out cutting below 5 tools:
|
|
676
645
|
// - 3-tool gate (search + context + trace): cost regressed on
|
|
@@ -691,11 +660,9 @@ class ToolHandler {
|
|
|
691
660
|
// so it deserves the same gating.
|
|
692
661
|
const TINY_REPO_FILE_THRESHOLD = 500;
|
|
693
662
|
const TINY_REPO_CORE_TOOLS = new Set([
|
|
663
|
+
'codegraph_explore',
|
|
694
664
|
'codegraph_search',
|
|
695
|
-
'codegraph_context',
|
|
696
665
|
'codegraph_node',
|
|
697
|
-
'codegraph_explore',
|
|
698
|
-
'codegraph_trace',
|
|
699
666
|
]);
|
|
700
667
|
if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
|
|
701
668
|
visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
|
|
@@ -727,13 +694,15 @@ class ToolHandler {
|
|
|
727
694
|
if (!projectPath) {
|
|
728
695
|
if (!this.cg) {
|
|
729
696
|
const searched = this.defaultProjectHint ?? process.cwd();
|
|
730
|
-
throw new
|
|
697
|
+
throw new NotIndexedError('No CodeGraph project is loaded for this session.\n' +
|
|
731
698
|
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
|
|
732
|
-
'
|
|
699
|
+
'If this project IS indexed, this is a working-directory detection issue: ' +
|
|
733
700
|
"the MCP client launched the server outside your project and didn't report the " +
|
|
734
701
|
'workspace root. Fix it either way:\n' +
|
|
735
702
|
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
|
|
736
|
-
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
|
|
703
|
+
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
|
|
704
|
+
'If the project simply has no index, continue with your built-in tools (Read/Grep/Glob) ' +
|
|
705
|
+
"and don't call codegraph again this session — the user can run 'codegraph init' to enable it.");
|
|
737
706
|
}
|
|
738
707
|
return this.cg;
|
|
739
708
|
}
|
|
@@ -749,13 +718,16 @@ class ToolHandler {
|
|
|
749
718
|
if ((0, fs_1.existsSync)(projectPath)) {
|
|
750
719
|
const pathError = (0, utils_1.validateProjectPath)(projectPath);
|
|
751
720
|
if (pathError) {
|
|
752
|
-
throw new
|
|
721
|
+
throw new PathRefusalError(pathError);
|
|
753
722
|
}
|
|
754
723
|
}
|
|
755
724
|
// Walk up parent directories to find nearest .codegraph/
|
|
756
725
|
const resolvedRoot = (0, directory_1.findNearestCodeGraphRoot)(projectPath);
|
|
757
726
|
if (!resolvedRoot) {
|
|
758
|
-
throw new
|
|
727
|
+
throw new NotIndexedError(`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
|
|
728
|
+
'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
|
|
729
|
+
"for that codebase instead, and don't call codegraph for it again this session. " +
|
|
730
|
+
"Indexing is the user's decision — they can run 'codegraph init' in that project to enable it.");
|
|
759
731
|
}
|
|
760
732
|
// If the path resolves to the default project, reuse the already-open
|
|
761
733
|
// default instance rather than opening a SECOND connection to the same DB.
|
|
@@ -1004,9 +976,6 @@ class ToolHandler {
|
|
|
1004
976
|
case 'codegraph_search':
|
|
1005
977
|
result = await this.handleSearch(args);
|
|
1006
978
|
break;
|
|
1007
|
-
case 'codegraph_context':
|
|
1008
|
-
result = await this.handleContext(args);
|
|
1009
|
-
break;
|
|
1010
979
|
case 'codegraph_callers':
|
|
1011
980
|
result = await this.handleCallers(args);
|
|
1012
981
|
break;
|
|
@@ -1030,9 +999,6 @@ class ToolHandler {
|
|
|
1030
999
|
case 'codegraph_files':
|
|
1031
1000
|
result = await this.handleFiles(args);
|
|
1032
1001
|
break;
|
|
1033
|
-
case 'codegraph_trace':
|
|
1034
|
-
result = await this.handleTrace(args);
|
|
1035
|
-
break;
|
|
1036
1002
|
default:
|
|
1037
1003
|
return this.errorResult(`Unknown tool: ${toolName}`);
|
|
1038
1004
|
}
|
|
@@ -1040,7 +1006,19 @@ class ToolHandler {
|
|
|
1040
1006
|
return this.withStalenessNotice(withWorktree, args.projectPath);
|
|
1041
1007
|
}
|
|
1042
1008
|
catch (err) {
|
|
1043
|
-
|
|
1009
|
+
// Expected condition, not a malfunction: answer as a SUCCESS so the
|
|
1010
|
+
// agent keeps trusting the toolset for projects that ARE indexed.
|
|
1011
|
+
// (An isError here teaches session-long abandonment — see NotIndexedError.)
|
|
1012
|
+
if (err instanceof NotIndexedError) {
|
|
1013
|
+
return this.textResult(err.message);
|
|
1014
|
+
}
|
|
1015
|
+
// Security refusal: a clean error, no retry encouragement.
|
|
1016
|
+
if (err instanceof PathRefusalError) {
|
|
1017
|
+
return this.errorResult(err.message);
|
|
1018
|
+
}
|
|
1019
|
+
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
1020
|
+
'This is an internal codegraph error — retry the call once; if it persists, ' +
|
|
1021
|
+
'continue without codegraph for this task.');
|
|
1044
1022
|
}
|
|
1045
1023
|
}
|
|
1046
1024
|
/**
|
|
@@ -1051,7 +1029,11 @@ class ToolHandler {
|
|
|
1051
1029
|
if (typeof query !== 'string')
|
|
1052
1030
|
return query;
|
|
1053
1031
|
const cg = this.getCodeGraph(args.projectPath);
|
|
1054
|
-
const
|
|
1032
|
+
const rawKind = args.kind;
|
|
1033
|
+
// The schema enum says 'type' (what agents naturally reach for); the
|
|
1034
|
+
// NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
|
|
1035
|
+
// matched nothing — a filter value we advertise must work.
|
|
1036
|
+
const kind = rawKind === 'type' ? 'type_alias' : rawKind;
|
|
1055
1037
|
const rawLimit = Number(args.limit) || 10;
|
|
1056
1038
|
const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
|
|
1057
1039
|
const results = cg.searchNodes(query, {
|
|
@@ -1073,259 +1055,41 @@ class ToolHandler {
|
|
|
1073
1055
|
return this.textResult(this.truncateOutput(formatted));
|
|
1074
1056
|
}
|
|
1075
1057
|
/**
|
|
1076
|
-
*
|
|
1058
|
+
* Group symbol matches into DISTINCT DEFINITIONS — one group per
|
|
1059
|
+
* (filePath, qualifiedName), so same-file overloads stay together while
|
|
1060
|
+
* unrelated same-named classes across a monorepo's apps (#764: one
|
|
1061
|
+
* `UserService` per NestJS app) are kept apart. Optionally narrowed by a
|
|
1062
|
+
* `file` path/suffix first.
|
|
1077
1063
|
*/
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
}
|
|
1087
|
-
const cg = this.getCodeGraph(args.projectPath);
|
|
1088
|
-
// On tiny repos (<150 files), trim maxNodes hard — the entire repo
|
|
1089
|
-
// is grep-able in a turn so a 20-node context is wasted budget.
|
|
1090
|
-
// 8 covers the typical 1-3 entry-point + their immediate neighbors
|
|
1091
|
-
// without dragging in the rest of the small codebase.
|
|
1092
|
-
let defaultMaxNodes = 20;
|
|
1093
|
-
let isTinyRepo = false;
|
|
1094
|
-
let isSmallRepo = false;
|
|
1095
|
-
try {
|
|
1096
|
-
const stats = cg.getStats();
|
|
1097
|
-
if (stats.fileCount < 150) {
|
|
1098
|
-
defaultMaxNodes = 8;
|
|
1099
|
-
isTinyRepo = true;
|
|
1064
|
+
groupDefinitions(nodes, fileFilter) {
|
|
1065
|
+
let pool = nodes;
|
|
1066
|
+
let filteredOut = false;
|
|
1067
|
+
if (fileFilter) {
|
|
1068
|
+
const wanted = fileFilter.replace(/^\.\//, '');
|
|
1069
|
+
const narrowed = pool.filter((n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`));
|
|
1070
|
+
if (narrowed.length > 0) {
|
|
1071
|
+
pool = narrowed;
|
|
1100
1072
|
}
|
|
1101
|
-
else
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
catch {
|
|
1106
|
-
// stats failure — fall back to the standard default
|
|
1107
|
-
}
|
|
1108
|
-
const maxNodes = args.maxNodes || defaultMaxNodes;
|
|
1109
|
-
const includeCode = args.includeCode !== false;
|
|
1110
|
-
const context = await cg.buildContext(task, {
|
|
1111
|
-
maxNodes,
|
|
1112
|
-
includeCode,
|
|
1113
|
-
format: 'markdown',
|
|
1114
|
-
});
|
|
1115
|
-
// Detect if this looks like a feature request (vs bug fix or exploration)
|
|
1116
|
-
const isFeatureQuery = this.looksLikeFeatureRequest(task);
|
|
1117
|
-
const reminder = isFeatureQuery
|
|
1118
|
-
? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
|
|
1119
|
-
: '';
|
|
1120
|
-
// Auto-trace for flow queries: when the task is asking "how does X
|
|
1121
|
-
// reach/flow/propagate from A to B", run the trace internally and
|
|
1122
|
-
// append its body to the context response. Saves the agent the
|
|
1123
|
-
// follow-up codegraph_trace call that was the #2 cost driver on
|
|
1124
|
-
// multi-module flow questions (Q3 / etcd Q2 in the audit).
|
|
1125
|
-
const flowTrace = await this.maybeInlineFlowTrace(task, cg);
|
|
1126
|
-
// Iter3 — sufficiency steering on small repos.
|
|
1127
|
-
//
|
|
1128
|
-
// Measured economics on tiny (<150) and small (<500) projects: every
|
|
1129
|
-
// additional MCP tool call costs ~$0.02-0.05 in cache-write tokens
|
|
1130
|
-
// (5K-15K per response at $3.75/1M). The agent reflexively follows
|
|
1131
|
-
// codegraph_context with explore/node even when the context response
|
|
1132
|
-
// is already sufficient — that pattern drove the cost gap that
|
|
1133
|
-
// smaller bodies (iter2) failed to close (smaller bodies just shifted
|
|
1134
|
-
// the agent to Read instead). Direct directive on small-repo
|
|
1135
|
-
// responses: tell the agent the context call IS the comprehensive
|
|
1136
|
-
// pass for a project of this size and that follow-ups should be
|
|
1137
|
-
// narrow (trace from→to, node single-symbol) — not another broad
|
|
1138
|
-
// explore that re-bundles the same content.
|
|
1139
|
-
// ITER4: unified strong directive for both tiny (<150) and small
|
|
1140
|
-
// (<500) tiers — measured iter3 result was that the soft <500
|
|
1141
|
-
// wording was IGNORED on sinatra (5 tool calls, +92% loss) while
|
|
1142
|
-
// the strong <150 wording was followed on cobra/slim (3 calls,
|
|
1143
|
-
// -21%/-22% wins). The single-file-framework problem (sinatra)
|
|
1144
|
-
// is structurally the same as cobra's; both deserve the same
|
|
1145
|
-
// sufficiency steering.
|
|
1146
|
-
let smallRepoTail = '';
|
|
1147
|
-
let smallRepoRouteInline = '';
|
|
1148
|
-
if (isTinyRepo || isSmallRepo) {
|
|
1149
|
-
// Iter12: backend-computed routing manifest for routing queries.
|
|
1150
|
-
// Builds a URL → handler map directly from the graph (each route
|
|
1151
|
-
// node has a `references` edge to its handler), then inlines the
|
|
1152
|
-
// top handler file's source. The agent gets the canonical
|
|
1153
|
-
// routing answer in one MCP call — no need to parse framework
|
|
1154
|
-
// DSL or grep for handlers.
|
|
1155
|
-
//
|
|
1156
|
-
// Replaces iter10's raw route-file inline. The manifest is more
|
|
1157
|
-
// information-dense (parsed URL→handler map vs raw config DSL)
|
|
1158
|
-
// and we still inline the top handler file's source so the agent
|
|
1159
|
-
// has the implementation bodies inline too.
|
|
1160
|
-
const isRouteQuery = /\b(route|routes|routing|request|handler|endpoint|api|controller|middleware|dispatch|invok)/i.test(task);
|
|
1161
|
-
if (isRouteQuery) {
|
|
1162
|
-
try {
|
|
1163
|
-
const manifest = cg.getRoutingManifest(40);
|
|
1164
|
-
if (manifest) {
|
|
1165
|
-
// 1) Compact URL→handler list (~30-60 lines, ~1-2KB).
|
|
1166
|
-
const lines = [
|
|
1167
|
-
`\n\n## Routing manifest (${manifest.totalRoutes} routes, top handler file holds ${manifest.topHandlerFileCount})`,
|
|
1168
|
-
'',
|
|
1169
|
-
'| URL | Handler | Location |',
|
|
1170
|
-
'|---|---|---|',
|
|
1171
|
-
];
|
|
1172
|
-
for (const e of manifest.entries) {
|
|
1173
|
-
lines.push(`| \`${e.url}\` | \`${e.handler}\` | ${e.handlerFile}:${e.handlerLine} |`);
|
|
1174
|
-
}
|
|
1175
|
-
// 2) Inline the top handler file's source.
|
|
1176
|
-
if (manifest.topHandlerFile && manifest.topHandlerFileCount >= 2) {
|
|
1177
|
-
try {
|
|
1178
|
-
const fullPath = pathModule.join(cg.getProjectRoot(), manifest.topHandlerFile);
|
|
1179
|
-
const stat = (0, fs_1.statSync)(fullPath);
|
|
1180
|
-
if (stat.size > 0 && stat.size <= 16000) {
|
|
1181
|
-
const source = (0, fs_1.readFileSync)(fullPath, 'utf-8');
|
|
1182
|
-
const capped = source.length > 7000 ? source.slice(0, 7000) + '\n... (truncated)' : source;
|
|
1183
|
-
const ext = (manifest.topHandlerFile.match(/\.([a-z]+)$/i)?.[1] || '').toLowerCase();
|
|
1184
|
-
const lang = ext === 'rb' ? 'ruby' : ext === 'py' ? 'python' :
|
|
1185
|
-
ext === 'go' ? 'go' : ext === 'rs' ? 'rust' :
|
|
1186
|
-
ext === 'js' || ext === 'jsx' ? 'javascript' :
|
|
1187
|
-
ext === 'ts' || ext === 'tsx' ? 'typescript' :
|
|
1188
|
-
ext === 'java' ? 'java' : ext === 'kt' ? 'kotlin' :
|
|
1189
|
-
ext === 'cs' ? 'csharp' : ext === 'php' ? 'php' :
|
|
1190
|
-
ext === 'swift' ? 'swift' : ext === 'yml' || ext === 'yaml' ? 'yaml' : '';
|
|
1191
|
-
lines.push('');
|
|
1192
|
-
lines.push(`### Top handler file (\`${manifest.topHandlerFile}\` — ${manifest.topHandlerFileCount}/${manifest.totalRoutes} routes, full source inlined — do NOT Read)`);
|
|
1193
|
-
lines.push('');
|
|
1194
|
-
lines.push('```' + lang);
|
|
1195
|
-
lines.push(capped);
|
|
1196
|
-
lines.push('```');
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
catch { /* file read failed, skip the source inline */ }
|
|
1200
|
-
}
|
|
1201
|
-
smallRepoRouteInline = lines.join('\n');
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
catch {
|
|
1205
|
-
// Manifest build failed — drop silently
|
|
1206
|
-
}
|
|
1073
|
+
else {
|
|
1074
|
+
filteredOut = true;
|
|
1207
1075
|
}
|
|
1208
|
-
const sizeQualifier = isTinyRepo ? 'under 150' : 'under 500';
|
|
1209
|
-
const routingClause = smallRepoRouteInline
|
|
1210
|
-
? ' The URL→handler manifest and top handler file are also inlined above — answer routing questions from them.'
|
|
1211
|
-
: '';
|
|
1212
|
-
smallRepoTail = `\n\n---\n> **This project is small** (${sizeQualifier} indexed files). The entry points and code above cover the relevant surface — **do NOT call codegraph_explore as a follow-up; its content will largely duplicate this response**. If you need a specific flow, call \`codegraph_trace from→to\`. If you need one specific symbol's body, call \`codegraph_node <name>\`.${routingClause} Otherwise, answer from what is above.`;
|
|
1213
|
-
}
|
|
1214
|
-
// buildContext returns string when format is 'markdown'
|
|
1215
|
-
if (typeof context === 'string') {
|
|
1216
|
-
return this.textResult(this.truncateOutput(context + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
|
|
1217
1076
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
*
|
|
1227
|
-
* Conservative by design: only fires when the task has both a clear flow
|
|
1228
|
-
* keyword AND at least two distinct PascalCase / camelCase identifiers.
|
|
1229
|
-
* False positives waste a graph query; false negatives just fall back to
|
|
1230
|
-
* the agent calling trace itself (existing path-proximity wiring handles
|
|
1231
|
-
* disambiguation either way).
|
|
1232
|
-
*/
|
|
1233
|
-
async maybeInlineFlowTrace(task, cg) {
|
|
1234
|
-
const lower = task.toLowerCase();
|
|
1235
|
-
const FLOW_KEYWORDS = [
|
|
1236
|
-
'trace ',
|
|
1237
|
-
'from ',
|
|
1238
|
-
'reach ',
|
|
1239
|
-
'flow ',
|
|
1240
|
-
'propagat',
|
|
1241
|
-
'how does ',
|
|
1242
|
-
'how do ',
|
|
1243
|
-
];
|
|
1244
|
-
if (!FLOW_KEYWORDS.some((k) => lower.includes(k)))
|
|
1245
|
-
return '';
|
|
1246
|
-
// Extract candidate symbols — PascalCase or camelCase identifiers ≥3 chars.
|
|
1247
|
-
// Filter out common non-symbol words and the flow keywords themselves.
|
|
1248
|
-
const STOP_WORDS = new Set([
|
|
1249
|
-
'how', 'does', 'the', 'and', 'from', 'through', 'reach', 'reaches',
|
|
1250
|
-
'flow', 'path', 'trace', 'cross', 'module', 'modules', 'where',
|
|
1251
|
-
'update', 'updates', 'updated', 'when', 'what', 'this', 'that',
|
|
1252
|
-
]);
|
|
1253
|
-
const ids = [];
|
|
1254
|
-
const seen = new Set();
|
|
1255
|
-
const re = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+[A-Z][a-z]*(?:[A-Z][a-z]*)*)\b/g;
|
|
1256
|
-
let m;
|
|
1257
|
-
while ((m = re.exec(task)) !== null) {
|
|
1258
|
-
const sym = m[1];
|
|
1259
|
-
if (sym.length < 3)
|
|
1260
|
-
continue;
|
|
1261
|
-
const key = sym.toLowerCase();
|
|
1262
|
-
if (STOP_WORDS.has(key) || seen.has(key))
|
|
1263
|
-
continue;
|
|
1264
|
-
seen.add(key);
|
|
1265
|
-
ids.push(sym);
|
|
1266
|
-
}
|
|
1267
|
-
if (ids.length < 2)
|
|
1268
|
-
return '';
|
|
1269
|
-
// The first two distinct symbols, in order of appearance, are the most
|
|
1270
|
-
// likely from/to endpoints — "from X ... through to Y" naturally places
|
|
1271
|
-
// them in that order in the prose. If the trace fails to connect, it
|
|
1272
|
-
// still returns the inlined endpoint bodies (the trace-failure rewrite).
|
|
1273
|
-
const fromSym = ids[0];
|
|
1274
|
-
const toSym = ids[1];
|
|
1275
|
-
let traceResult;
|
|
1276
|
-
try {
|
|
1277
|
-
traceResult = await this.handleTrace({
|
|
1278
|
-
from: fromSym,
|
|
1279
|
-
to: toSym,
|
|
1280
|
-
projectPath: cg.getProjectRoot(),
|
|
1281
|
-
});
|
|
1282
|
-
}
|
|
1283
|
-
catch {
|
|
1284
|
-
return '';
|
|
1077
|
+
const byDef = new Map();
|
|
1078
|
+
for (const n of pool) {
|
|
1079
|
+
const key = `${n.filePath}|${n.qualifiedName}`;
|
|
1080
|
+
const group = byDef.get(key);
|
|
1081
|
+
if (group)
|
|
1082
|
+
group.push(n);
|
|
1083
|
+
else
|
|
1084
|
+
byDef.set(key, [n]);
|
|
1285
1085
|
}
|
|
1286
|
-
|
|
1287
|
-
// standard tool-result shape used elsewhere in this file.
|
|
1288
|
-
const body = traceResult.content
|
|
1289
|
-
?.map((c) => (c.type === 'text' ? c.text : ''))
|
|
1290
|
-
.filter(Boolean)
|
|
1291
|
-
.join('\n')
|
|
1292
|
-
.trim();
|
|
1293
|
-
if (!body)
|
|
1294
|
-
return '';
|
|
1295
|
-
return [
|
|
1296
|
-
'',
|
|
1297
|
-
'## Inline flow trace',
|
|
1298
|
-
'',
|
|
1299
|
-
`Auto-traced \`${fromSym}\` → \`${toSym}\` because the query looks like a flow question. No follow-up codegraph_trace is needed for this pair.`,
|
|
1300
|
-
'',
|
|
1301
|
-
body,
|
|
1302
|
-
].join('\n');
|
|
1086
|
+
return { groups: [...byDef.values()], filteredOut };
|
|
1303
1087
|
}
|
|
1304
|
-
/**
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
'add', 'create', 'implement', 'build', 'enable', 'allow',
|
|
1310
|
-
'new feature', 'support for', 'ability to', 'want to',
|
|
1311
|
-
'should be able', 'need to add', 'swap', 'edit', 'modify'
|
|
1312
|
-
];
|
|
1313
|
-
const bugKeywords = [
|
|
1314
|
-
'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
|
|
1315
|
-
'not working', 'fails', 'undefined', 'null'
|
|
1316
|
-
];
|
|
1317
|
-
const explorationKeywords = [
|
|
1318
|
-
'how does', 'where is', 'what is', 'find', 'show me',
|
|
1319
|
-
'explain', 'understand', 'explore'
|
|
1320
|
-
];
|
|
1321
|
-
const lowerTask = task.toLowerCase();
|
|
1322
|
-
// If it's clearly a bug or exploration, not a feature
|
|
1323
|
-
if (bugKeywords.some(k => lowerTask.includes(k)))
|
|
1324
|
-
return false;
|
|
1325
|
-
if (explorationKeywords.some(k => lowerTask.includes(k)))
|
|
1326
|
-
return false;
|
|
1327
|
-
// If it matches feature keywords, it's likely a feature request
|
|
1328
|
-
return featureKeywords.some(k => lowerTask.includes(k));
|
|
1088
|
+
/** Section heading for one distinct definition in grouped output. */
|
|
1089
|
+
definitionHeading(group) {
|
|
1090
|
+
const head = group[0];
|
|
1091
|
+
const line = head.startLine ? `:${head.startLine}` : '';
|
|
1092
|
+
return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
|
|
1329
1093
|
}
|
|
1330
1094
|
/**
|
|
1331
1095
|
* Handle codegraph_callers
|
|
@@ -1336,26 +1100,64 @@ class ToolHandler {
|
|
|
1336
1100
|
return symbol;
|
|
1337
1101
|
const cg = this.getCodeGraph(args.projectPath);
|
|
1338
1102
|
const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
|
|
1103
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
1339
1104
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
1340
1105
|
if (allMatches.nodes.length === 0) {
|
|
1341
1106
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
1342
1107
|
}
|
|
1343
|
-
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1108
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1109
|
+
const filterNote = filteredOut
|
|
1110
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1111
|
+
: '';
|
|
1112
|
+
const collect = (defNodes) => {
|
|
1113
|
+
const seen = new Set();
|
|
1114
|
+
const callers = [];
|
|
1115
|
+
const labels = new Map();
|
|
1116
|
+
for (const node of defNodes) {
|
|
1117
|
+
for (const c of cg.getCallers(node.id)) {
|
|
1118
|
+
if (!seen.has(c.node.id)) {
|
|
1119
|
+
seen.add(c.node.id);
|
|
1120
|
+
callers.push(c.node);
|
|
1121
|
+
const label = this.edgeLabel(c.edge);
|
|
1122
|
+
if (label)
|
|
1123
|
+
labels.set(c.node.id, label);
|
|
1124
|
+
}
|
|
1351
1125
|
}
|
|
1352
1126
|
}
|
|
1127
|
+
return { callers, labels };
|
|
1128
|
+
};
|
|
1129
|
+
// Single definition (or same-file overloads): the familiar flat list.
|
|
1130
|
+
if (groups.length === 1) {
|
|
1131
|
+
const { callers, labels } = collect(groups[0]);
|
|
1132
|
+
if (callers.length === 0) {
|
|
1133
|
+
return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
|
|
1134
|
+
}
|
|
1135
|
+
// A successful `file` narrowing makes the multi-symbol aggregation note
|
|
1136
|
+
// stale — suppress it.
|
|
1137
|
+
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
|
1138
|
+
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
|
|
1139
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
1140
|
+
}
|
|
1141
|
+
// Multiple DISTINCT definitions (#764): one section per definition so an
|
|
1142
|
+
// agent never mistakes one app's callers for another's. Narrow with
|
|
1143
|
+
// `file` to focus a single definition.
|
|
1144
|
+
const lines = [
|
|
1145
|
+
`## Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
|
|
1146
|
+
];
|
|
1147
|
+
for (const group of groups) {
|
|
1148
|
+
const { callers, labels } = collect(group);
|
|
1149
|
+
lines.push('', this.definitionHeading(group));
|
|
1150
|
+
if (callers.length === 0) {
|
|
1151
|
+
lines.push('- (no callers)');
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
for (const node of callers.slice(0, limit)) {
|
|
1155
|
+
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1156
|
+
const label = labels.get(node.id);
|
|
1157
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
1158
|
+
}
|
|
1353
1159
|
}
|
|
1354
|
-
|
|
1355
|
-
return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
|
|
1356
|
-
}
|
|
1357
|
-
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
|
|
1358
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
1160
|
+
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
|
1359
1161
|
}
|
|
1360
1162
|
/**
|
|
1361
1163
|
* Handle codegraph_callees
|
|
@@ -1366,26 +1168,61 @@ class ToolHandler {
|
|
|
1366
1168
|
return symbol;
|
|
1367
1169
|
const cg = this.getCodeGraph(args.projectPath);
|
|
1368
1170
|
const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
|
|
1171
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
1369
1172
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
1370
1173
|
if (allMatches.nodes.length === 0) {
|
|
1371
1174
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
1372
1175
|
}
|
|
1373
|
-
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1176
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1177
|
+
const filterNote = filteredOut
|
|
1178
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1179
|
+
: '';
|
|
1180
|
+
const collect = (defNodes) => {
|
|
1181
|
+
const seen = new Set();
|
|
1182
|
+
const callees = [];
|
|
1183
|
+
const labels = new Map();
|
|
1184
|
+
for (const node of defNodes) {
|
|
1185
|
+
for (const c of cg.getCallees(node.id)) {
|
|
1186
|
+
if (!seen.has(c.node.id)) {
|
|
1187
|
+
seen.add(c.node.id);
|
|
1188
|
+
callees.push(c.node);
|
|
1189
|
+
const label = this.edgeLabel(c.edge);
|
|
1190
|
+
if (label)
|
|
1191
|
+
labels.set(c.node.id, label);
|
|
1192
|
+
}
|
|
1381
1193
|
}
|
|
1382
1194
|
}
|
|
1195
|
+
return { callees, labels };
|
|
1196
|
+
};
|
|
1197
|
+
if (groups.length === 1) {
|
|
1198
|
+
const { callees, labels } = collect(groups[0]);
|
|
1199
|
+
if (callees.length === 0) {
|
|
1200
|
+
return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
|
|
1201
|
+
}
|
|
1202
|
+
// A successful `file` narrowing makes the multi-symbol aggregation note
|
|
1203
|
+
// stale — suppress it.
|
|
1204
|
+
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
|
1205
|
+
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
|
|
1206
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
1207
|
+
}
|
|
1208
|
+
// Multiple DISTINCT definitions (#764): per-definition sections.
|
|
1209
|
+
const lines = [
|
|
1210
|
+
`## Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
|
|
1211
|
+
];
|
|
1212
|
+
for (const group of groups) {
|
|
1213
|
+
const { callees, labels } = collect(group);
|
|
1214
|
+
lines.push('', this.definitionHeading(group));
|
|
1215
|
+
if (callees.length === 0) {
|
|
1216
|
+
lines.push('- (no callees)');
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
for (const node of callees.slice(0, limit)) {
|
|
1220
|
+
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1221
|
+
const label = labels.get(node.id);
|
|
1222
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
1223
|
+
}
|
|
1383
1224
|
}
|
|
1384
|
-
|
|
1385
|
-
return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
|
|
1386
|
-
}
|
|
1387
|
-
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
|
|
1388
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
1225
|
+
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
|
1389
1226
|
}
|
|
1390
1227
|
/**
|
|
1391
1228
|
* Handle codegraph_impact
|
|
@@ -1396,323 +1233,51 @@ class ToolHandler {
|
|
|
1396
1233
|
return symbol;
|
|
1397
1234
|
const cg = this.getCodeGraph(args.projectPath);
|
|
1398
1235
|
const depth = (0, utils_1.clamp)(args.depth || 2, 1, 10);
|
|
1236
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
1399
1237
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
1400
1238
|
if (allMatches.nodes.length === 0) {
|
|
1401
1239
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
1402
1240
|
}
|
|
1403
|
-
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
seenEdges.add(key);
|
|
1416
|
-
mergedEdges.push(e);
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
const mergedImpact = {
|
|
1421
|
-
nodes: mergedNodes,
|
|
1422
|
-
edges: mergedEdges,
|
|
1423
|
-
roots: allMatches.nodes.map(n => n.id),
|
|
1424
|
-
};
|
|
1425
|
-
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
|
|
1426
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
1427
|
-
}
|
|
1428
|
-
/**
|
|
1429
|
-
* Handle codegraph_trace — shortest CALL PATH between two symbols.
|
|
1430
|
-
*
|
|
1431
|
-
* Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
|
|
1432
|
-
* each hop annotated with file:line and the call-site line. This is the
|
|
1433
|
-
* capability grep/Read structurally cannot provide. When no static path
|
|
1434
|
-
* exists, the chain has almost certainly broken at dynamic dispatch
|
|
1435
|
-
* (callbacks, descriptors, metaclasses) — we say so and surface the start
|
|
1436
|
-
* symbol's outgoing calls so the agent bridges the one missing hop with
|
|
1437
|
-
* codegraph_node rather than blindly reading.
|
|
1438
|
-
*/
|
|
1439
|
-
async handleTrace(args) {
|
|
1440
|
-
const from = this.validateString(args.from, 'from');
|
|
1441
|
-
if (typeof from !== 'string')
|
|
1442
|
-
return from;
|
|
1443
|
-
const to = this.validateString(args.to, 'to');
|
|
1444
|
-
if (typeof to !== 'string')
|
|
1445
|
-
return to;
|
|
1446
|
-
const cg = this.getCodeGraph(args.projectPath);
|
|
1447
|
-
const fromMatches = this.findAllSymbols(cg, from);
|
|
1448
|
-
if (fromMatches.nodes.length === 0)
|
|
1449
|
-
return this.textResult(`Symbol "${from}" not found in the codebase`);
|
|
1450
|
-
const toMatches = this.findAllSymbols(cg, to);
|
|
1451
|
-
if (toMatches.nodes.length === 0)
|
|
1452
|
-
return this.textResult(`Symbol "${to}" not found in the codebase`);
|
|
1453
|
-
// Trace along call edges only — a true call path. Names can map to several
|
|
1454
|
-
// nodes, so try a few from×to candidate pairs until a usable path turns up.
|
|
1455
|
-
//
|
|
1456
|
-
// MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
|
|
1457
|
-
// is almost always a spurious wander through unrelated code (django's
|
|
1458
|
-
// `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
|
|
1459
|
-
// the real execution flow — and a confident-but-wrong 15-hop trace is worse
|
|
1460
|
-
// than none. Over-cap paths are rejected and reported as "no direct path"
|
|
1461
|
-
// (which, on real code, means the flow breaks at dynamic dispatch).
|
|
1462
|
-
const edgeKinds = ['calls'];
|
|
1463
|
-
const MAX_HOPS = 7;
|
|
1464
|
-
// Path-proximity pairing: in a multi-module repo a symbol name like
|
|
1465
|
-
// `EndBlocker` exists in 20+ modules. FTS picks one almost arbitrarily;
|
|
1466
|
-
// the WRONG pair (e.g. simapp's wrapper EndBlocker paired with gov's Tally)
|
|
1467
|
-
// has no static path, falls through to the dynamic-dispatch failure branch,
|
|
1468
|
-
// and surfaces unrelated bodies — exactly the cosmos-Q3 trace failure mode.
|
|
1469
|
-
// Score every from×to combo by shared file-path prefix length; try the
|
|
1470
|
-
// most-co-located pair first (e.g. `x/gov/abci.go::EndBlocker` ×
|
|
1471
|
-
// `x/gov/keeper/tally.go::Tally` share `x/gov/`).
|
|
1472
|
-
//
|
|
1473
|
-
// Consider the FULL candidate set, not just the FTS top-5: the right
|
|
1474
|
-
// EndBlocker for a gov-module flow may rank 8th in FTS but share the
|
|
1475
|
-
// entire `x/gov/` prefix with the destination. Path-proximity supersedes
|
|
1476
|
-
// FTS for this disambiguation. Findpath trials are still capped by
|
|
1477
|
-
// FINDPATH_PAIR_BUDGET below to bound graph traversal cost.
|
|
1478
|
-
const sharedDirPrefixLen = (a, b) => {
|
|
1479
|
-
const aDir = a.replace(/[^/]+$/, '');
|
|
1480
|
-
const bDir = b.replace(/[^/]+$/, '');
|
|
1481
|
-
let i = 0;
|
|
1482
|
-
while (i < aDir.length && i < bDir.length && aDir[i] === bDir[i])
|
|
1483
|
-
i++;
|
|
1484
|
-
return i;
|
|
1485
|
-
};
|
|
1486
|
-
// Cosmos-Q3 surfaced a second-order failure: `enterprise/group/x/group/`
|
|
1487
|
-
// SHARES MORE of its path with `enterprise/group/x/group/keeper/tally.go`
|
|
1488
|
-
// (24 chars) than `x/gov/abci.go` shares with `x/gov/keeper/tally.go`
|
|
1489
|
-
// (6 chars), so pure shared-prefix prefers the side-experiment module
|
|
1490
|
-
// over the canonical one — even though the user's question is clearly
|
|
1491
|
-
// about the main gov module. Penalize candidates living under prefixes
|
|
1492
|
-
// that conventionally hold extensions / experiments / vendored code, so
|
|
1493
|
-
// the canonical-path pair wins even when its shared prefix is short.
|
|
1494
|
-
const isLessCanonicalPath = (p) => /^(enterprise|contrib|examples?|sample|playground|vendor|third[_-]?party|deprecated|legacy)\//i.test(p);
|
|
1495
|
-
const LESS_CANONICAL_PENALTY = 100; // any canonical candidate beats any less-canonical one
|
|
1496
|
-
const scorePair = (a, b) => sharedDirPrefixLen(a, b)
|
|
1497
|
-
- (isLessCanonicalPath(a) ? LESS_CANONICAL_PENALTY : 0)
|
|
1498
|
-
- (isLessCanonicalPath(b) ? LESS_CANONICAL_PENALTY : 0);
|
|
1499
|
-
const fromCands = fromMatches.nodes;
|
|
1500
|
-
const toCands = toMatches.nodes;
|
|
1501
|
-
// Candidate relevance: an overloaded name (Alamofire has 44 `request`s, most
|
|
1502
|
-
// of them EMPTY EventMonitor protocol-conformance stubs `func request(…){}`)
|
|
1503
|
-
// floods the pool with no-op decls. Shared-dir-prefix alone then MISLEADS —
|
|
1504
|
-
// two unrelated `Source/Features/` delegate stubs outscore the real
|
|
1505
|
-
// `Source/Core/Session.request` × `Source/Core/…task` pair the agent meant,
|
|
1506
|
-
// so trace resolves to stubs, finds no path, and the agent reads by line.
|
|
1507
|
-
// Penalize empty stubs and test-file symbols so a substantive entry point
|
|
1508
|
-
// wins; among real methods this is ~flat, so path-proximity still decides
|
|
1509
|
-
// (cosmos EndBlocker disambiguation is unaffected — none of its candidates
|
|
1510
|
-
// are stubs/tests).
|
|
1511
|
-
const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
|
|
1512
|
-
const nodeRelevance = (n) => {
|
|
1513
|
-
const bodyLines = Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
|
|
1514
|
-
let s = Math.min(bodyLines, 20); // a substantive body is more likely the meant symbol
|
|
1515
|
-
if (bodyLines <= 1)
|
|
1516
|
-
s -= 40; // empty/one-line stub (protocol no-op, decl-only) — almost never the trace endpoint
|
|
1517
|
-
if (isTestPath(n.filePath))
|
|
1518
|
-
s -= 150; // a Source/ symbol is meant over a Tests/ same-named one
|
|
1519
|
-
return s;
|
|
1520
|
-
};
|
|
1521
|
-
const pairs = [];
|
|
1522
|
-
for (const f of fromCands) {
|
|
1523
|
-
for (const t of toCands) {
|
|
1524
|
-
pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) + nodeRelevance(f) + nodeRelevance(t) });
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
// Sort by shared prefix desc, then by FTS order (already encoded in the
|
|
1528
|
-
// pairs' insertion order — both for f and t). The tiebreaker preserves
|
|
1529
|
-
// findAllSymbols' generated-file-last ranking.
|
|
1530
|
-
pairs.sort((a, b) => b.score - a.score);
|
|
1531
|
-
// Cap how many graph-path probes we attempt so a 50×50 cross-product
|
|
1532
|
-
// doesn't blow up on a god-named symbol like `Get` (well-named flows have
|
|
1533
|
-
// their good pair near the top of the sort anyway).
|
|
1534
|
-
const FINDPATH_PAIR_BUDGET = 20;
|
|
1535
|
-
const fromTry = fromCands;
|
|
1536
|
-
const toTry = toCands;
|
|
1537
|
-
let path = null;
|
|
1538
|
-
let overCap = null;
|
|
1539
|
-
let bestPair = null;
|
|
1540
|
-
let triedPairs = 0;
|
|
1541
|
-
for (const { f, t } of pairs) {
|
|
1542
|
-
if (path)
|
|
1543
|
-
break;
|
|
1544
|
-
if (triedPairs >= FINDPATH_PAIR_BUDGET)
|
|
1545
|
-
break;
|
|
1546
|
-
triedPairs++;
|
|
1547
|
-
const p = cg.findPath(f.id, t.id, edgeKinds);
|
|
1548
|
-
if (p && p.length > 1) {
|
|
1549
|
-
if (p.length <= MAX_HOPS) {
|
|
1550
|
-
path = p;
|
|
1551
|
-
bestPair = { f, t };
|
|
1552
|
-
break;
|
|
1553
|
-
}
|
|
1554
|
-
if (!overCap || p.length < overCap.length) {
|
|
1555
|
-
overCap = p;
|
|
1556
|
-
bestPair = { f, t };
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
else if (!bestPair) {
|
|
1560
|
-
// No path yet — remember the top-scored pair so the failure branch
|
|
1561
|
-
// surfaces the most-co-located candidates' bodies, not whatever FTS
|
|
1562
|
-
// happened to put first.
|
|
1563
|
-
bestPair = { f, t };
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
if (!path) {
|
|
1567
|
-
// No static path — almost always a dynamic-dispatch break. INSTEAD of
|
|
1568
|
-
// telling the agent to chase the gap with codegraph_node/callers/callees
|
|
1569
|
-
// (which fans out into 3-4 follow-up tool calls + a Read), inline the
|
|
1570
|
-
// material those would have returned right here. Measured on cosmos-Q3:
|
|
1571
|
-
// the failed-trace + subsequent fan-out used to cost ~2× a single
|
|
1572
|
-
// sufficient trace call; this branch closes that gap.
|
|
1573
|
-
// Prefer the path-proximity-best pair we identified above (e.g. gov's
|
|
1574
|
-
// EndBlocker × gov's Tally) over the FTS top-pick (simapp's wrapper).
|
|
1575
|
-
const start = bestPair?.f ?? fromTry[0];
|
|
1576
|
-
const end = bestPair?.t ?? toTry[0];
|
|
1577
|
-
const fileCache = new Map();
|
|
1578
|
-
const lines = [
|
|
1579
|
-
`No direct static call path from "${from}" to "${to}" — the chain almost certainly breaks at dynamic dispatch (a callback / interface dispatch / framework hook / metaclass). Both endpoint bodies + their immediate neighbors are inlined below; answer from them — a follow-up codegraph_node/callers/callees on these would just return what is already here.`,
|
|
1580
|
-
'',
|
|
1581
|
-
];
|
|
1582
|
-
if (overCap) {
|
|
1583
|
-
lines.push(`> Indirect chain of ${overCap.length} hops exists but is over the ${MAX_HOPS}-hop cap (usually a BFS wander through unrelated code, not the real execution flow).`, '');
|
|
1584
|
-
}
|
|
1585
|
-
// Track which node IDs we've already inlined a body for so we don't
|
|
1586
|
-
// double-emit when a callee of FROM is also surfaced separately.
|
|
1587
|
-
const inlinedBodies = new Set();
|
|
1588
|
-
const inlineBody = (n, lineCap, charCap) => {
|
|
1589
|
-
if (inlinedBodies.has(n.id))
|
|
1590
|
-
return false;
|
|
1591
|
-
inlinedBodies.add(n.id);
|
|
1592
|
-
const body = this.sourceRangeAt(cg, n.filePath, n.startLine, n.endLine, fileCache, lineCap, charCap);
|
|
1593
|
-
if (body) {
|
|
1594
|
-
lines.push(body);
|
|
1595
|
-
return true;
|
|
1596
|
-
}
|
|
1597
|
-
return false;
|
|
1598
|
-
};
|
|
1599
|
-
const inlineEndpoint = (label, node) => {
|
|
1600
|
-
lines.push(`### ${label}: \`${node.name}\` (${node.filePath}:${node.startLine}-${node.endLine})`);
|
|
1601
|
-
inlineBody(node, 120, 3600);
|
|
1602
|
-
const callers = cg.getCallers(node.id).slice(0, 6);
|
|
1603
|
-
if (callers.length > 0) {
|
|
1604
|
-
lines.push(`**Callers of \`${node.name}\`:** ` +
|
|
1605
|
-
callers.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
|
|
1606
|
-
}
|
|
1607
|
-
const callees = cg.getCallees(node.id).slice(0, 8);
|
|
1608
|
-
if (callees.length > 0) {
|
|
1609
|
-
lines.push(`**\`${node.name}\` calls:** ` +
|
|
1610
|
-
callees.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
|
|
1611
|
-
}
|
|
1612
|
-
lines.push('');
|
|
1613
|
-
};
|
|
1614
|
-
inlineEndpoint('FROM', start);
|
|
1615
|
-
if (end.id !== start.id)
|
|
1616
|
-
inlineEndpoint('TO', end);
|
|
1617
|
-
// Inline the OTHER top-level functions/methods in TO's file — that's
|
|
1618
|
-
// where the missing dynamic-dispatch flow usually lives. Concrete
|
|
1619
|
-
// measurement from cosmos-Q1: `msgServer.Send` statically calls only
|
|
1620
|
-
// utility functions (`StringToBytes`, `Wrapf`); its real next-hop
|
|
1621
|
-
// `SendCoins` is invoked via an embedded-interface call (`k.Keeper.SendCoins`)
|
|
1622
|
-
// that static parsing CAN'T see. The flow IS in the same file as the
|
|
1623
|
-
// destination (`x/bank/keeper/send.go`: SendCoins → subUnlockedCoins →
|
|
1624
|
-
// addCoins → setBalance). Pre-inlining those file-mates is what
|
|
1625
|
-
// replaces the agent's "trace fail → search SendCoins → node SendCoins
|
|
1626
|
-
// → trace again" fan-out.
|
|
1627
|
-
const NEIGHBOR_LINES = 40;
|
|
1628
|
-
const NEIGHBOR_CHARS = 1200;
|
|
1629
|
-
const NEIGHBOR_K = 5;
|
|
1630
|
-
const fileSiblings = (anchor) => {
|
|
1631
|
-
// Functions and methods in the same file as the anchor, excluding
|
|
1632
|
-
// the anchor itself and anything we've already inlined. Sort by
|
|
1633
|
-
// distance from the anchor's startLine so the closest symbols come
|
|
1634
|
-
// first (the flow is usually adjacent in the file).
|
|
1635
|
-
const sameFile = cg
|
|
1636
|
-
.getNodesByKind('function')
|
|
1637
|
-
.filter((n) => n.filePath === anchor.filePath)
|
|
1638
|
-
.concat(cg.getNodesByKind('method').filter((n) => n.filePath === anchor.filePath));
|
|
1639
|
-
return sameFile
|
|
1640
|
-
.filter((n) => n.id !== anchor.id && !inlinedBodies.has(n.id))
|
|
1641
|
-
.sort((a, b) => Math.abs(a.startLine - anchor.startLine) - Math.abs(b.startLine - anchor.startLine))
|
|
1642
|
-
.slice(0, NEIGHBOR_K);
|
|
1643
|
-
};
|
|
1644
|
-
const renderSiblings = (label, siblings) => {
|
|
1645
|
-
if (siblings.length === 0)
|
|
1646
|
-
return;
|
|
1647
|
-
lines.push(`### ${label}`);
|
|
1648
|
-
for (const sib of siblings) {
|
|
1649
|
-
lines.push('');
|
|
1650
|
-
lines.push(`- \`${sib.name}\` (${sib.filePath}:${sib.startLine}-${sib.endLine})`);
|
|
1651
|
-
inlineBody(sib, NEIGHBOR_LINES, NEIGHBOR_CHARS);
|
|
1241
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1242
|
+
const filterNote = filteredOut
|
|
1243
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1244
|
+
: '';
|
|
1245
|
+
const impactOf = (defNodes) => {
|
|
1246
|
+
const mergedNodes = new Map();
|
|
1247
|
+
const mergedEdges = [];
|
|
1248
|
+
const seenEdges = new Set();
|
|
1249
|
+
for (const node of defNodes) {
|
|
1250
|
+
const impact = cg.getImpactRadius(node.id, depth);
|
|
1251
|
+
for (const [id, n] of impact.nodes) {
|
|
1252
|
+
mergedNodes.set(id, n);
|
|
1652
1253
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
}
|
|
1659
|
-
const lines = [
|
|
1660
|
-
`## Trace: ${from} → ${to}`,
|
|
1661
|
-
'',
|
|
1662
|
-
`Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
|
|
1663
|
-
'',
|
|
1664
|
-
`${path.length} hops:`,
|
|
1665
|
-
'',
|
|
1666
|
-
];
|
|
1667
|
-
// Inline what each hop needs so the agent doesn't Read/Grep to get it: the
|
|
1668
|
-
// call-site source line, the registration site for dynamic-dispatch hops, AND
|
|
1669
|
-
// the hop's own body (capped per hop so the trace stays path-scoped). Earlier
|
|
1670
|
-
// versions inlined only the call-site line, which left agents calling explore
|
|
1671
|
-
// or Read for the bodies — the exact follow-up the ablation experiment measured.
|
|
1672
|
-
const fileCache = new Map();
|
|
1673
|
-
for (let i = 0; i < path.length; i++) {
|
|
1674
|
-
const step = path[i];
|
|
1675
|
-
if (step.edge) {
|
|
1676
|
-
const synth = this.synthEdgeNote(step.edge);
|
|
1677
|
-
if (synth) {
|
|
1678
|
-
lines.push(` ↓ ${synth.label}`);
|
|
1679
|
-
if (synth.registeredAt) {
|
|
1680
|
-
const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
|
|
1681
|
-
lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
|
|
1254
|
+
for (const e of impact.edges) {
|
|
1255
|
+
const key = `${e.source}->${e.target}:${e.kind}`;
|
|
1256
|
+
if (!seenEdges.has(key)) {
|
|
1257
|
+
seenEdges.add(key);
|
|
1258
|
+
mergedEdges.push(e);
|
|
1682
1259
|
}
|
|
1683
1260
|
}
|
|
1684
|
-
else {
|
|
1685
|
-
// The call happens in the PREVIOUS hop's file at edge.line.
|
|
1686
|
-
const prev = path[i - 1];
|
|
1687
|
-
const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
|
|
1688
|
-
const callSrc = this.sourceLineAt(cg, ref, fileCache);
|
|
1689
|
-
lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
|
|
1690
|
-
}
|
|
1691
1261
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
|
|
1702
|
-
const
|
|
1703
|
-
.
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
lines.push(body);
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
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.');
|
|
1715
|
-
return this.textResult(this.truncateOutput(lines.join('\n')));
|
|
1262
|
+
return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
|
|
1263
|
+
};
|
|
1264
|
+
// Single definition (or same-file overloads): the familiar merged report.
|
|
1265
|
+
if (groups.length === 1) {
|
|
1266
|
+
const formatted = this.formatImpact(symbol, impactOf(groups[0])) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
|
|
1267
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
1268
|
+
}
|
|
1269
|
+
// Multiple DISTINCT definitions (#764): a blast radius PER definition —
|
|
1270
|
+
// merging unrelated same-named classes (one UserService per monorepo app)
|
|
1271
|
+
// overstated impact and confused agents. Narrow with `file`.
|
|
1272
|
+
const sections = [
|
|
1273
|
+
`## Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
|
|
1274
|
+
];
|
|
1275
|
+
for (const group of groups) {
|
|
1276
|
+
const head = group[0];
|
|
1277
|
+
const line = head.startLine ? `:${head.startLine}` : '';
|
|
1278
|
+
sections.push('', this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group)));
|
|
1279
|
+
}
|
|
1280
|
+
return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
|
|
1716
1281
|
}
|
|
1717
1282
|
/**
|
|
1718
1283
|
* Describe a synthesized (dynamic-dispatch) edge for human output: how the
|
|
@@ -1783,82 +1348,6 @@ class ToolHandler {
|
|
|
1783
1348
|
}
|
|
1784
1349
|
return null;
|
|
1785
1350
|
}
|
|
1786
|
-
/**
|
|
1787
|
-
* Read one trimmed source line at "relpath:line" (relative to the project
|
|
1788
|
-
* root). `cache` holds split file contents so a multi-hop trace reads each
|
|
1789
|
-
* file at most once. Returns null if the file/line can't be resolved.
|
|
1790
|
-
*/
|
|
1791
|
-
sourceLineAt(cg, ref, cache) {
|
|
1792
|
-
if (!ref)
|
|
1793
|
-
return null;
|
|
1794
|
-
const i = ref.lastIndexOf(':');
|
|
1795
|
-
if (i < 0)
|
|
1796
|
-
return null;
|
|
1797
|
-
const filePath = ref.slice(0, i);
|
|
1798
|
-
const line = parseInt(ref.slice(i + 1), 10);
|
|
1799
|
-
if (!Number.isFinite(line) || line < 1)
|
|
1800
|
-
return null;
|
|
1801
|
-
let fileLines = cache.get(filePath);
|
|
1802
|
-
if (!fileLines) {
|
|
1803
|
-
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
1804
|
-
if (!abs || !(0, fs_1.existsSync)(abs))
|
|
1805
|
-
return null;
|
|
1806
|
-
try {
|
|
1807
|
-
fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
|
|
1808
|
-
}
|
|
1809
|
-
catch {
|
|
1810
|
-
return null;
|
|
1811
|
-
}
|
|
1812
|
-
cache.set(filePath, fileLines);
|
|
1813
|
-
}
|
|
1814
|
-
const raw = fileLines[line - 1];
|
|
1815
|
-
if (raw == null)
|
|
1816
|
-
return null;
|
|
1817
|
-
const t = raw.trim();
|
|
1818
|
-
return t.length > 160 ? t.slice(0, 157) + '…' : t;
|
|
1819
|
-
}
|
|
1820
|
-
/**
|
|
1821
|
-
* Read a hop's body — filePath lines [startLine..endLine] — for inlining into
|
|
1822
|
-
* a trace, capped (lines + chars) so the whole path stays path-scoped even on
|
|
1823
|
-
* a 7-hop chain. Dedents to the body's own indentation and marks truncation.
|
|
1824
|
-
* Shares `cache` with sourceLineAt so each file is read at most once per trace.
|
|
1825
|
-
*/
|
|
1826
|
-
sourceRangeAt(cg, filePath, startLine, endLine, cache, maxLines = 28, maxChars = 1200) {
|
|
1827
|
-
if (!Number.isFinite(startLine) || startLine < 1)
|
|
1828
|
-
return null;
|
|
1829
|
-
let fileLines = cache.get(filePath);
|
|
1830
|
-
if (!fileLines) {
|
|
1831
|
-
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
1832
|
-
if (!abs || !(0, fs_1.existsSync)(abs))
|
|
1833
|
-
return null;
|
|
1834
|
-
try {
|
|
1835
|
-
fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
|
|
1836
|
-
}
|
|
1837
|
-
catch {
|
|
1838
|
-
return null;
|
|
1839
|
-
}
|
|
1840
|
-
cache.set(filePath, fileLines);
|
|
1841
|
-
}
|
|
1842
|
-
const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
|
|
1843
|
-
let slice = fileLines.slice(startLine - 1, end);
|
|
1844
|
-
if (slice.length === 0)
|
|
1845
|
-
return null;
|
|
1846
|
-
let omitted = 0;
|
|
1847
|
-
if (slice.length > maxLines) {
|
|
1848
|
-
omitted = slice.length - maxLines;
|
|
1849
|
-
slice = slice.slice(0, maxLines);
|
|
1850
|
-
}
|
|
1851
|
-
const nonBlank = slice.filter(l => l.trim().length > 0);
|
|
1852
|
-
const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
|
|
1853
|
-
let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
|
|
1854
|
-
if (text.length > maxChars) {
|
|
1855
|
-
text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
|
|
1856
|
-
omitted = Math.max(omitted, 1);
|
|
1857
|
-
}
|
|
1858
|
-
if (omitted > 0)
|
|
1859
|
-
text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
|
|
1860
|
-
return text;
|
|
1861
|
-
}
|
|
1862
1351
|
/**
|
|
1863
1352
|
* Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
|
|
1864
1353
|
* symbol names that usually spans the flow it's investigating (e.g.
|
|
@@ -1881,7 +1370,7 @@ class ToolHandler {
|
|
|
1881
1370
|
// names (Class.method / Class::method) — the agent's most precise input,
|
|
1882
1371
|
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
|
|
1883
1372
|
// into Class, throwing the method away.)
|
|
1884
|
-
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;
|
|
1373
|
+
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|astro)$/i;
|
|
1885
1374
|
const tokens = [...new Set(query.split(/[\s,()[\]]+/)
|
|
1886
1375
|
.map((t) => t.replace(FILE_EXT, '').trim())
|
|
1887
1376
|
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
|
|
@@ -1902,6 +1391,10 @@ class ToolHandler {
|
|
|
1902
1391
|
// (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
|
|
1903
1392
|
// so naming it doesn't keep every backend variant full and flood the budget.
|
|
1904
1393
|
const uniqueNamedNodeIds = new Set();
|
|
1394
|
+
// token → resolved node ids: drives the token-coverage check that gates
|
|
1395
|
+
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
|
1396
|
+
// lands on the main chain — overloads off the chain don't count against).
|
|
1397
|
+
const tokenNodes = new Map();
|
|
1905
1398
|
for (const t of tokens) {
|
|
1906
1399
|
const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
|
|
1907
1400
|
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
@@ -1914,7 +1407,9 @@ class ToolHandler {
|
|
|
1914
1407
|
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
|
1915
1408
|
return !!container && segPool.has(container);
|
|
1916
1409
|
});
|
|
1917
|
-
|
|
1410
|
+
const kept = pick.slice(0, 6);
|
|
1411
|
+
tokenNodes.set(t, kept.map((n) => n.id));
|
|
1412
|
+
for (const n of kept) {
|
|
1918
1413
|
named.set(n.id, n);
|
|
1919
1414
|
if (specific)
|
|
1920
1415
|
uniqueNamedNodeIds.add(n.id);
|
|
@@ -1922,8 +1417,19 @@ class ToolHandler {
|
|
|
1922
1417
|
if (named.size > 40)
|
|
1923
1418
|
break;
|
|
1924
1419
|
}
|
|
1925
|
-
if (named.size < 2)
|
|
1926
|
-
|
|
1420
|
+
if (named.size < 2) {
|
|
1421
|
+
// The agent named a flow but only one side resolved (the other end is
|
|
1422
|
+
// anonymous / runtime-registered / not extracted). The resolved side's
|
|
1423
|
+
// body may still hold the dynamic-dispatch site that EXPLAINS the gap —
|
|
1424
|
+
// surface that instead of silently returning nothing.
|
|
1425
|
+
if (named.size === 0)
|
|
1426
|
+
return EMPTY;
|
|
1427
|
+
const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
|
|
1428
|
+
if (!boundaries)
|
|
1429
|
+
return EMPTY;
|
|
1430
|
+
const text = boundaries + '> Full source for these symbols is below.\n';
|
|
1431
|
+
return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
|
|
1432
|
+
}
|
|
1927
1433
|
const MAX_HOPS = 7;
|
|
1928
1434
|
let best = null;
|
|
1929
1435
|
// BFS the full call graph (incl. synth edges) from each named seed, but
|
|
@@ -1971,6 +1477,43 @@ class ToolHandler {
|
|
|
1971
1477
|
}
|
|
1972
1478
|
const hasMain = !!best && best.length >= 3;
|
|
1973
1479
|
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
|
1480
|
+
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
|
1481
|
+
// asked about did not fully connect: some token resolved to nodes but
|
|
1482
|
+
// none of them sit on the main chain (or there is no chain at all). A
|
|
1483
|
+
// healthy flow skips this entirely. Scan order: the chain's dead end
|
|
1484
|
+
// first (where the partial flow stops), then the disconnected symbols,
|
|
1485
|
+
// agent-specific (unique-named) ones first.
|
|
1486
|
+
let boundaryText = '';
|
|
1487
|
+
{
|
|
1488
|
+
const uncovered = [];
|
|
1489
|
+
if (!hasMain) {
|
|
1490
|
+
// No rendered chain — but a 2-node chain still CONNECTS its two
|
|
1491
|
+
// endpoints (e.g. via one synthesized hop, surfaced below as a
|
|
1492
|
+
// dynamic-dispatch link). Only nodes off that short chain are
|
|
1493
|
+
// unexplained breaks worth scanning.
|
|
1494
|
+
for (const n of named.values())
|
|
1495
|
+
if (!pathIds.has(n.id))
|
|
1496
|
+
uncovered.push(n);
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
for (const ids of tokenNodes.values()) {
|
|
1500
|
+
if (ids.length === 0 || ids.some((id) => pathIds.has(id)))
|
|
1501
|
+
continue;
|
|
1502
|
+
for (const id of ids) {
|
|
1503
|
+
const n = named.get(id);
|
|
1504
|
+
if (n)
|
|
1505
|
+
uncovered.push(n);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (uncovered.length > 0) {
|
|
1510
|
+
const scanList = [];
|
|
1511
|
+
if (hasMain)
|
|
1512
|
+
scanList.push(best[best.length - 1].node);
|
|
1513
|
+
scanList.push(...uncovered.sort((a, b) => (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
|
|
1514
|
+
boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1974
1517
|
// Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
|
|
1975
1518
|
// symbol — the indirect hops an agent would otherwise grep/Read to
|
|
1976
1519
|
// reconstruct ("where do the appended `validators` actually run?"). The
|
|
@@ -1989,8 +1532,13 @@ class ToolHandler {
|
|
|
1989
1532
|
break;
|
|
1990
1533
|
if (edge.provenance !== 'heuristic' || other.id === n.id)
|
|
1991
1534
|
continue;
|
|
1992
|
-
|
|
1993
|
-
|
|
1535
|
+
// "Already in the main chain" only applies when a chain RENDERS
|
|
1536
|
+
// (hasMain). A 2-node chain populates pathIds but renders nothing,
|
|
1537
|
+
// so a direct synthesized hop between two named symbols (custom
|
|
1538
|
+
// EventBus emit→handler, #687) was invisible — too short for Flow,
|
|
1539
|
+
// skipped here as in-chain. Surface it.
|
|
1540
|
+
if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target))
|
|
1541
|
+
continue;
|
|
1994
1542
|
const src = edge.source === n.id ? n : other;
|
|
1995
1543
|
const tgt = edge.source === n.id ? other : n;
|
|
1996
1544
|
const key = `${src.name}>${tgt.name}`;
|
|
@@ -2001,7 +1549,7 @@ class ToolHandler {
|
|
|
2001
1549
|
synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
|
|
2002
1550
|
}
|
|
2003
1551
|
}
|
|
2004
|
-
if (!hasMain && synthLines.length === 0)
|
|
1552
|
+
if (!hasMain && synthLines.length === 0 && !boundaryText)
|
|
2005
1553
|
return EMPTY;
|
|
2006
1554
|
const out = [];
|
|
2007
1555
|
if (hasMain) {
|
|
@@ -2019,7 +1567,9 @@ class ToolHandler {
|
|
|
2019
1567
|
if (synthLines.length) {
|
|
2020
1568
|
out.push('## Dynamic-dispatch links among your symbols', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
|
|
2021
1569
|
}
|
|
2022
|
-
|
|
1570
|
+
if (boundaryText)
|
|
1571
|
+
out.push(boundaryText);
|
|
1572
|
+
out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
|
|
2023
1573
|
// namedNodeIds = every callable the agent explicitly named (a superset of
|
|
2024
1574
|
// the spine). A file holding one is something the agent asked to SEE, so it
|
|
2025
1575
|
// must keep full source even if it's an off-spine polymorphic sibling — the
|
|
@@ -2031,6 +1581,304 @@ class ToolHandler {
|
|
|
2031
1581
|
return EMPTY;
|
|
2032
1582
|
}
|
|
2033
1583
|
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Dynamic-boundary surfacing (#687): when the flow among the agent's named
|
|
1586
|
+
* symbols does not fully connect, scan the disconnected symbols' bodies for
|
|
1587
|
+
* dynamic-dispatch sites (computed member calls, getattr, reflection, typed
|
|
1588
|
+
* message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
|
|
1589
|
+
* site, the form, and (when a key is statically visible) candidate targets —
|
|
1590
|
+
* instead of guessing edges. The answer to "how does A reach B" when no
|
|
1591
|
+
* static path exists IS the dispatch site: that's where the flow continues
|
|
1592
|
+
* at runtime. Query-time, deterministic, zero graph mutation; a fully
|
|
1593
|
+
* connected flow never reaches this method.
|
|
1594
|
+
*/
|
|
1595
|
+
buildDynamicBoundaries(cg, scanList, named) {
|
|
1596
|
+
const MAX_NOTES = 4; // boundary bullets per explore
|
|
1597
|
+
const MAX_SCAN = 8; // bodies scanned
|
|
1598
|
+
const MAX_TOTAL_CHARS = 200_000;
|
|
1599
|
+
let projectRoot;
|
|
1600
|
+
try {
|
|
1601
|
+
projectRoot = cg.getProjectRoot();
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
return '';
|
|
1605
|
+
}
|
|
1606
|
+
const notes = [];
|
|
1607
|
+
const seenNode = new Set();
|
|
1608
|
+
const seenSite = new Set();
|
|
1609
|
+
let scanned = 0, charsScanned = 0;
|
|
1610
|
+
for (const node of scanList) {
|
|
1611
|
+
if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS)
|
|
1612
|
+
break;
|
|
1613
|
+
if (seenNode.has(node.id) || !node.startLine || !node.endLine)
|
|
1614
|
+
continue;
|
|
1615
|
+
seenNode.add(node.id);
|
|
1616
|
+
const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, node.filePath);
|
|
1617
|
+
if (!absPath || !(0, fs_1.existsSync)(absPath))
|
|
1618
|
+
continue;
|
|
1619
|
+
let content;
|
|
1620
|
+
try {
|
|
1621
|
+
content = (0, fs_1.readFileSync)(absPath, 'utf-8');
|
|
1622
|
+
}
|
|
1623
|
+
catch {
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
|
|
1627
|
+
scanned++;
|
|
1628
|
+
charsScanned += body.length;
|
|
1629
|
+
for (const m of (0, dynamic_boundaries_1.scanDynamicDispatch)(body, node.language || '', node.startLine)) {
|
|
1630
|
+
if (notes.length >= MAX_NOTES)
|
|
1631
|
+
break;
|
|
1632
|
+
const siteKey = `${node.filePath}:${m.line}:${m.form}`;
|
|
1633
|
+
if (seenSite.has(siteKey))
|
|
1634
|
+
continue;
|
|
1635
|
+
seenSite.add(siteKey);
|
|
1636
|
+
const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
|
|
1637
|
+
notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
|
|
1638
|
+
if (m.key) {
|
|
1639
|
+
const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
|
|
1640
|
+
if (cand)
|
|
1641
|
+
notes.push(` ${cand}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
if (notes.length === 0)
|
|
1646
|
+
return '';
|
|
1647
|
+
return [
|
|
1648
|
+
'## Dynamic boundaries (the static path ends at runtime dispatch)',
|
|
1649
|
+
'',
|
|
1650
|
+
...notes,
|
|
1651
|
+
'',
|
|
1652
|
+
'> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore or codegraph_node on a candidate; source for the sites above is included below.',
|
|
1653
|
+
'',
|
|
1654
|
+
].join('\n');
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
|
1658
|
+
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
|
1659
|
+
* `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
|
|
1660
|
+
* normalized-containment post-filter (FTS camel-splitting is fuzzier than a
|
|
1661
|
+
* candidate list should be). Symbols the agent already named sort first and
|
|
1662
|
+
* are marked — that's the "you were right, here's the wiring" case.
|
|
1663
|
+
*/
|
|
1664
|
+
boundaryCandidates(cg, key, keyIsType, named, selfId) {
|
|
1665
|
+
const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
|
|
1666
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1667
|
+
const keyNorm = norm(key);
|
|
1668
|
+
if (keyNorm.length < 3)
|
|
1669
|
+
return '';
|
|
1670
|
+
const cands = new Map();
|
|
1671
|
+
const consider = (n) => {
|
|
1672
|
+
if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
|
|
1673
|
+
return;
|
|
1674
|
+
const nameNorm = norm(n.name || '');
|
|
1675
|
+
if (nameNorm.length < 3)
|
|
1676
|
+
return;
|
|
1677
|
+
if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
|
|
1678
|
+
return;
|
|
1679
|
+
cands.set(n.id, n);
|
|
1680
|
+
};
|
|
1681
|
+
const cap = key.charAt(0).toUpperCase() + key.slice(1);
|
|
1682
|
+
const probes = keyIsType
|
|
1683
|
+
? [`${key}Handler`, key]
|
|
1684
|
+
: [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
|
|
1685
|
+
for (const p of probes) {
|
|
1686
|
+
try {
|
|
1687
|
+
for (const n of cg.getNodesByName(p))
|
|
1688
|
+
consider(n);
|
|
1689
|
+
}
|
|
1690
|
+
catch { /* exact probe miss is fine */ }
|
|
1691
|
+
}
|
|
1692
|
+
let raw = 0;
|
|
1693
|
+
try {
|
|
1694
|
+
const results = cg.searchNodes(key, { limit: 12 });
|
|
1695
|
+
raw = results.length;
|
|
1696
|
+
for (const r of results)
|
|
1697
|
+
consider(r.node);
|
|
1698
|
+
}
|
|
1699
|
+
catch { /* FTS syntax edge — exact probes already ran */ }
|
|
1700
|
+
if (cands.size === 0) {
|
|
1701
|
+
return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
|
|
1702
|
+
}
|
|
1703
|
+
// A constructor candidate duplicates its class: extractors emit ctors as
|
|
1704
|
+
// METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
|
|
1705
|
+
const all = [...cands.values()];
|
|
1706
|
+
const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
|
|
1707
|
+
const namedNames = new Set([...named.values()].map((n) => n.name));
|
|
1708
|
+
const isNamed = (n) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
|
|
1709
|
+
const list = all
|
|
1710
|
+
.filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
|
|
1711
|
+
.sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
|
|
1712
|
+
.slice(0, 4)
|
|
1713
|
+
.map((n) => {
|
|
1714
|
+
// Typed-bus convention: the runtime target is the candidate class's
|
|
1715
|
+
// Handle/Execute/Consume method — name the exact node, not just the class.
|
|
1716
|
+
let display = n.qualifiedName || n.name;
|
|
1717
|
+
let at = `${n.filePath}:${n.startLine}`;
|
|
1718
|
+
if (keyIsType && n.kind === 'class') {
|
|
1719
|
+
try {
|
|
1720
|
+
const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
|
|
1721
|
+
const method = cg.getOutgoingEdges(n.id)
|
|
1722
|
+
.filter((e) => e.kind === 'contains')
|
|
1723
|
+
.map((e) => { try {
|
|
1724
|
+
return cg.getNode(e.target);
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
return null;
|
|
1728
|
+
} })
|
|
1729
|
+
.find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
|
|
1730
|
+
if (method) {
|
|
1731
|
+
display = `${n.name}.${method.name}`;
|
|
1732
|
+
at = `${method.filePath}:${method.startLine}`;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
catch { /* class without resolvable members — show the class itself */ }
|
|
1736
|
+
}
|
|
1737
|
+
return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
|
|
1738
|
+
});
|
|
1739
|
+
return `candidates for key \`${key}\`: ${list.join(', ')}`;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Compact "blast radius" for the entry symbols of an explore result: who
|
|
1743
|
+
* depends on each (callers) and which test files cover it — LOCATIONS ONLY,
|
|
1744
|
+
* no source, so the agent knows what to update / re-verify before editing
|
|
1745
|
+
* without reaching for a separate impact call. Always-on, but skips symbols
|
|
1746
|
+
* that have no dependents (nothing to warn about), and returns '' when none
|
|
1747
|
+
* qualify so a leaf-only exploration stays clean.
|
|
1748
|
+
*/
|
|
1749
|
+
buildBlastRadiusSection(cg, subgraph) {
|
|
1750
|
+
const ROOT_CAP = 5; // only the symbols the query actually targeted
|
|
1751
|
+
const FILE_CAP = 4; // caller files listed per symbol before "+N more"
|
|
1752
|
+
const MEANINGFUL = new Set([
|
|
1753
|
+
'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
|
|
1754
|
+
'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
|
|
1755
|
+
]);
|
|
1756
|
+
const rel = (p) => p.replace(/\\/g, '/');
|
|
1757
|
+
const roots = subgraph.roots
|
|
1758
|
+
.map((id) => subgraph.nodes.get(id))
|
|
1759
|
+
.filter((n) => !!n && MEANINGFUL.has(n.kind))
|
|
1760
|
+
.slice(0, ROOT_CAP);
|
|
1761
|
+
if (roots.length === 0)
|
|
1762
|
+
return '';
|
|
1763
|
+
const entries = [];
|
|
1764
|
+
for (const root of roots) {
|
|
1765
|
+
let callers = [];
|
|
1766
|
+
try {
|
|
1767
|
+
callers = cg.getCallers(root.id);
|
|
1768
|
+
}
|
|
1769
|
+
catch { /* skip this root */ }
|
|
1770
|
+
const seen = new Set();
|
|
1771
|
+
const uniq = [];
|
|
1772
|
+
for (const c of callers) {
|
|
1773
|
+
if (c?.node && !seen.has(c.node.id)) {
|
|
1774
|
+
seen.add(c.node.id);
|
|
1775
|
+
uniq.push(c.node);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (uniq.length === 0)
|
|
1779
|
+
continue; // no blast radius → nothing to flag
|
|
1780
|
+
const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
|
|
1781
|
+
const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
|
|
1782
|
+
const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
|
|
1783
|
+
const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
|
|
1784
|
+
const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
|
|
1785
|
+
const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
|
|
1786
|
+
const tests = testFiles.length > 0
|
|
1787
|
+
? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
|
|
1788
|
+
: '; ⚠️ no covering tests found';
|
|
1789
|
+
entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
|
|
1790
|
+
}
|
|
1791
|
+
if (entries.length === 0)
|
|
1792
|
+
return '';
|
|
1793
|
+
return [
|
|
1794
|
+
'### Blast radius — what depends on these (update/verify before editing)',
|
|
1795
|
+
'',
|
|
1796
|
+
...entries,
|
|
1797
|
+
'',
|
|
1798
|
+
].join('\n');
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Graph-connectivity relevance via Random-Walk-with-Restart (personalized
|
|
1802
|
+
* PageRank) from the query's matched SEED nodes over the call/reference graph.
|
|
1803
|
+
*
|
|
1804
|
+
* This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
|
|
1805
|
+
* codegraph's home turf: relevance by STRUCTURE, not words. A file whose
|
|
1806
|
+
* symbols are call-connected to the matched cluster accrues walk mass and
|
|
1807
|
+
* ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
|
|
1808
|
+
* "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
|
|
1809
|
+
* — gets only its own restart probability and ranks ~0. Immune to the
|
|
1810
|
+
* tokenization trap that fools term matching, deterministic, no embeddings.
|
|
1811
|
+
*
|
|
1812
|
+
* Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
|
|
1813
|
+
* power iteration to convergence. Bounded to the already-relevant subgraph, so
|
|
1814
|
+
* it's a few hundred nodes × ~25 iterations — negligible cost.
|
|
1815
|
+
*/
|
|
1816
|
+
computeGraphRelevance(nodeIds, edges, seedIds) {
|
|
1817
|
+
const out = new Map();
|
|
1818
|
+
const n = nodeIds.length;
|
|
1819
|
+
if (n === 0)
|
|
1820
|
+
return out;
|
|
1821
|
+
const idx = new Map();
|
|
1822
|
+
for (let i = 0; i < n; i++)
|
|
1823
|
+
idx.set(nodeIds[i], i);
|
|
1824
|
+
const RANK_EDGES = new Set([
|
|
1825
|
+
'calls', 'references', 'extends', 'implements', 'overrides',
|
|
1826
|
+
'instantiates', 'returns', 'type_of', 'imports',
|
|
1827
|
+
]);
|
|
1828
|
+
const adj = Array.from({ length: n }, () => []);
|
|
1829
|
+
for (const e of edges) {
|
|
1830
|
+
if (!RANK_EDGES.has(e.kind))
|
|
1831
|
+
continue;
|
|
1832
|
+
const i = idx.get(e.source);
|
|
1833
|
+
const j = idx.get(e.target);
|
|
1834
|
+
if (i === undefined || j === undefined || i === j)
|
|
1835
|
+
continue;
|
|
1836
|
+
adj[i].push(j);
|
|
1837
|
+
adj[j].push(i); // undirected — reachable either direction
|
|
1838
|
+
}
|
|
1839
|
+
// Restart vector: uniform over seeds present in the candidate set. (Falls
|
|
1840
|
+
// back to uniform-over-all if no seed landed in the set, so we never return
|
|
1841
|
+
// all-zero.)
|
|
1842
|
+
const r = new Array(n).fill(0);
|
|
1843
|
+
let rsum = 0;
|
|
1844
|
+
for (const id of seedIds) {
|
|
1845
|
+
const i = idx.get(id);
|
|
1846
|
+
if (i !== undefined) {
|
|
1847
|
+
r[i] = 1;
|
|
1848
|
+
rsum += 1;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (rsum === 0) {
|
|
1852
|
+
for (let i = 0; i < n; i++)
|
|
1853
|
+
r[i] = 1;
|
|
1854
|
+
rsum = n;
|
|
1855
|
+
}
|
|
1856
|
+
for (let i = 0; i < n; i++)
|
|
1857
|
+
r[i] /= rsum;
|
|
1858
|
+
const alpha = 0.25;
|
|
1859
|
+
let s = r.slice();
|
|
1860
|
+
for (let iter = 0; iter < 25; iter++) {
|
|
1861
|
+
const next = new Array(n).fill(0);
|
|
1862
|
+
for (let i = 0; i < n; i++) {
|
|
1863
|
+
const si = s[i];
|
|
1864
|
+
if (si === 0)
|
|
1865
|
+
continue;
|
|
1866
|
+
const d = adj[i].length;
|
|
1867
|
+
if (d === 0) {
|
|
1868
|
+
next[i] += si;
|
|
1869
|
+
continue;
|
|
1870
|
+
} // dangling: keep its mass
|
|
1871
|
+
const share = si / d;
|
|
1872
|
+
for (const j of adj[i])
|
|
1873
|
+
next[j] += share;
|
|
1874
|
+
}
|
|
1875
|
+
for (let i = 0; i < n; i++)
|
|
1876
|
+
s[i] = (1 - alpha) * next[i] + alpha * r[i];
|
|
1877
|
+
}
|
|
1878
|
+
for (let i = 0; i < n; i++)
|
|
1879
|
+
out.set(nodeIds[i], s[i]);
|
|
1880
|
+
return out;
|
|
1881
|
+
}
|
|
2034
1882
|
/**
|
|
2035
1883
|
* Handle codegraph_explore — deep exploration in a single call
|
|
2036
1884
|
*
|
|
@@ -2120,25 +1968,61 @@ class ToolHandler {
|
|
|
2120
1968
|
// agent explicitly named is in the subgraph and its file is scored.
|
|
2121
1969
|
const namedSeedIds = new Set();
|
|
2122
1970
|
{
|
|
2123
|
-
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;
|
|
1971
|
+
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|astro)$/i;
|
|
2124
1972
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
2125
1973
|
const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
|
|
2126
1974
|
const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
|
|
2127
1975
|
const tokens = [...new Set(query.split(/[\s,()[\]]+/)
|
|
2128
1976
|
.map((t) => t.replace(FILE_EXT, '').trim())
|
|
2129
1977
|
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
|
|
1978
|
+
// PascalCase tokens in the query are type/file disambiguators — when the
|
|
1979
|
+
// agent writes "DataRequest task validate", the `task`/`validate` it wants
|
|
1980
|
+
// are DataRequest's, NOT the same-named overloads in Validation.swift /
|
|
1981
|
+
// Concurrency.swift / the abstract base. Used below to bias overloaded
|
|
1982
|
+
// names toward the file/class the query also names. EXCLUDE the project
|
|
1983
|
+
// name (a PascalCase token a user naturally includes) — it names the whole
|
|
1984
|
+
// repo, so biasing toward it just pulls overloads to whichever stack
|
|
1985
|
+
// embeds it, re-burying the rest (#720).
|
|
1986
|
+
const projectNameTokens = cg.getProjectNameTokens();
|
|
1987
|
+
const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has((0, query_utils_1.normalizeNameToken)(o)));
|
|
1988
|
+
const inNamedContext = (n) => typeTokens.some((ct) => {
|
|
1989
|
+
const lc = ct.toLowerCase();
|
|
1990
|
+
return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
|
|
1991
|
+
});
|
|
2130
1992
|
for (const t of tokens) {
|
|
2131
|
-
|
|
1993
|
+
// Enumerate ALL defs of a bare token via the direct index, not FTS — a
|
|
1994
|
+
// 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
|
|
1995
|
+
// below the FTS cut, so findAllSymbols would never see it and the
|
|
1996
|
+
// type-token bias below couldn't pick the harness.rs one. (Same fix as
|
|
1997
|
+
// codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
|
|
1998
|
+
const isQual = /[.\/]|::/.test(t);
|
|
1999
|
+
const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
|
|
2000
|
+
const cands = raw
|
|
2132
2001
|
.filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
|
|
2133
2002
|
.sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
|
|
2134
|
-
// A specific name (<=3 defs) injects all its defs
|
|
2135
|
-
// (`
|
|
2136
|
-
//
|
|
2137
|
-
|
|
2138
|
-
|
|
2003
|
+
// A specific name (<=3 defs) injects all its defs. An overloaded name
|
|
2004
|
+
// (`validate` = 10, `request` = 44) would flood the subgraph, so inject
|
|
2005
|
+
// only: the overloads whose file/class the query ALSO names (the agent
|
|
2006
|
+
// told us which one it wants — DataRequest's, not Validation.swift's),
|
|
2007
|
+
// capped; else fall back to the single most-substantive def. This is the
|
|
2008
|
+
// explore-side mirror of codegraph_node's overload disambiguation.
|
|
2009
|
+
let picks;
|
|
2010
|
+
if (cands.length <= 3) {
|
|
2011
|
+
picks = cands;
|
|
2012
|
+
}
|
|
2013
|
+
else {
|
|
2014
|
+
const ctx = cands.filter(inNamedContext);
|
|
2015
|
+
picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
|
|
2016
|
+
}
|
|
2017
|
+
for (const n of picks) {
|
|
2018
|
+
if (!subgraph.nodes.has(n.id))
|
|
2139
2019
|
subgraph.nodes.set(n.id, n);
|
|
2140
|
-
|
|
2141
|
-
|
|
2020
|
+
// Mark as a named seed EVEN IF the FTS gather already had it — being
|
|
2021
|
+
// "named by the agent" is independent of whether search happened to
|
|
2022
|
+
// surface it, and it drives the +50 score, the gate, and the
|
|
2023
|
+
// named-file sort below. (Previously only NEW injections were marked,
|
|
2024
|
+
// so a named symbol FTS already gathered never sorted to the top.)
|
|
2025
|
+
namedSeedIds.add(n.id);
|
|
2142
2026
|
}
|
|
2143
2027
|
}
|
|
2144
2028
|
}
|
|
@@ -2157,6 +2041,12 @@ class ToolHandler {
|
|
|
2157
2041
|
// Skip import/export nodes — they add noise without information
|
|
2158
2042
|
if (node.kind === 'import' || node.kind === 'export')
|
|
2159
2043
|
continue;
|
|
2044
|
+
// SECURITY (#383): never render the on-disk source of a config-leaf
|
|
2045
|
+
// (Spring application.{yml,properties} key) — its line is `key = <secret>`,
|
|
2046
|
+
// so whole-file/cluster rendering here would push secrets into context
|
|
2047
|
+
// unbidden. The key still appears in the flow/symbol listing above.
|
|
2048
|
+
if ((0, utils_1.isConfigLeafNode)(node))
|
|
2049
|
+
continue;
|
|
2160
2050
|
const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
|
|
2161
2051
|
group.nodes.push(node);
|
|
2162
2052
|
// Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
|
|
@@ -2218,21 +2108,107 @@ class ToolHandler {
|
|
|
2218
2108
|
}
|
|
2219
2109
|
}
|
|
2220
2110
|
}
|
|
2221
|
-
//
|
|
2111
|
+
// Secondary signal: how many DISTINCT query terms each file matches (path +
|
|
2112
|
+
// symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
|
|
2113
|
+
// connectivity below. (Term counting alone tied the real central file with
|
|
2114
|
+
// incidental same-word matches; it's a weak text signal, not the ranker.)
|
|
2115
|
+
const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
|
|
2116
|
+
const fileTermHits = new Map();
|
|
2117
|
+
for (const [fp, group] of relevantFiles) {
|
|
2118
|
+
const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
|
|
2119
|
+
let hits = 0;
|
|
2120
|
+
for (const t of uniqueQueryTerms)
|
|
2121
|
+
if (hay.includes(t))
|
|
2122
|
+
hits++;
|
|
2123
|
+
fileTermHits.set(fp, hits);
|
|
2124
|
+
}
|
|
2125
|
+
// PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
|
|
2126
|
+
// matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
|
|
2127
|
+
// walk mass. This is the signal text search lacks: the real cluster
|
|
2128
|
+
// (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
|
|
2129
|
+
// text match (LensSwitcher.swift, matched "switch" but calls nothing in the
|
|
2130
|
+
// flow) gets only its restart probability → ~0, and is dropped by the gate.
|
|
2131
|
+
const nodeRwr = this.computeGraphRelevance([...subgraph.nodes.keys()], subgraph.edges, entryNodeIds);
|
|
2132
|
+
const fileGraphScore = new Map();
|
|
2133
|
+
for (const node of subgraph.nodes.values()) {
|
|
2134
|
+
fileGraphScore.set(node.filePath, (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0));
|
|
2135
|
+
}
|
|
2136
|
+
const maxGraph = Math.max(0, ...fileGraphScore.values());
|
|
2137
|
+
// Central file(s): the 1-2 most graph-central files that also match the
|
|
2138
|
+
// query textually (so a connected hub-utility with no term match isn't
|
|
2139
|
+
// mistaken for the subject). The heart of the answer — they earn the larger
|
|
2140
|
+
// WHOLE-FILE ceiling below (a god-file central file still exceeds it and
|
|
2141
|
+
// falls to generous full-method sectioning — never a whole dump).
|
|
2142
|
+
const centralFiles = new Set([...fileGraphScore.entries()]
|
|
2143
|
+
.filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
|
|
2144
|
+
.sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
|
|
2145
|
+
.slice(0, 2)
|
|
2146
|
+
.map(([f]) => f));
|
|
2147
|
+
// Files that DEFINE a symbol the agent named (or a subgraph root). These are
|
|
2148
|
+
// the highest-relevance files there are — the agent asked for them by name —
|
|
2149
|
+
// so the connectivity gate below must never drop them, even when their RWR
|
|
2150
|
+
// mass is low (a leaf family file like codec.ts is call-connected to little
|
|
2151
|
+
// but is exactly what the agent queried). Without this protection the gate
|
|
2152
|
+
// prunes a named file and the agent Reads it back.
|
|
2153
|
+
const entryFiles = new Set();
|
|
2154
|
+
for (const id of entryNodeIds) {
|
|
2155
|
+
const n = subgraph.nodes.get(id);
|
|
2156
|
+
if (n)
|
|
2157
|
+
entryFiles.add(n.filePath);
|
|
2158
|
+
}
|
|
2159
|
+
// Relevance gate (so the generous budget is a CEILING, not a target): keep a
|
|
2160
|
+
// file only if it is STRUCTURALLY relevant by ANY of:
|
|
2161
|
+
// - graph score within a fraction of the top (it's on/near the flow), OR
|
|
2162
|
+
// - central (a query entry-point lives here), OR
|
|
2163
|
+
// - it DEFINES a symbol the agent named (entryFiles), OR
|
|
2164
|
+
// - it matches >= 2 DISTINCT named query terms — a strong text signal that
|
|
2165
|
+
// the agent is asking about this file even when nothing calls it (codec.ts:
|
|
2166
|
+
// the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
|
|
2167
|
+
// RWR mass — graph alone wrongly drops it).
|
|
2168
|
+
// A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
|
|
2169
|
+
// dropped, so the budget never fills with incidental files. Guarded so it
|
|
2170
|
+
// never prunes below 2.
|
|
2171
|
+
if (maxGraph > 0) {
|
|
2172
|
+
const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
|
|
2173
|
+
|| centralFiles.has(fp)
|
|
2174
|
+
|| entryFiles.has(fp)
|
|
2175
|
+
|| (fileTermHits.get(fp) ?? 0) >= 2);
|
|
2176
|
+
if (gated.length >= 2)
|
|
2177
|
+
relevantFiles = gated;
|
|
2178
|
+
}
|
|
2179
|
+
// Sort files: graph-central first, then distinct-term match, then the
|
|
2180
|
+
// existing low-value/generated/score tiebreaks.
|
|
2181
|
+
// Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
|
|
2182
|
+
// graph connectivity — because the agent asked for them by name. Without
|
|
2183
|
+
// this, a named leaf override reached only by dynamic dispatch (Alamofire's
|
|
2184
|
+
// `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
|
|
2185
|
+
// connectivity abstract base (`Request.swift`) and the same-named overloads
|
|
2186
|
+
// in other files (`Validation.swift`), falls outside the budget, and the
|
|
2187
|
+
// agent Reads it. The named file is the answer — rank it at the top.
|
|
2188
|
+
const namedSeedFiles = new Set();
|
|
2189
|
+
for (const id of namedSeedIds) {
|
|
2190
|
+
const n = subgraph.nodes.get(id);
|
|
2191
|
+
if (n)
|
|
2192
|
+
namedSeedFiles.add(n.filePath);
|
|
2193
|
+
}
|
|
2222
2194
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
2223
2195
|
const aPath = a[0].toLowerCase();
|
|
2224
2196
|
const bPath = b[0].toLowerCase();
|
|
2225
|
-
//
|
|
2226
|
-
const
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
const
|
|
2233
|
-
const
|
|
2234
|
-
if (
|
|
2235
|
-
return
|
|
2197
|
+
// Agent-named files first (it asked for a symbol defined here by name).
|
|
2198
|
+
const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
|
|
2199
|
+
const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
|
|
2200
|
+
if (aNamed !== bNamed)
|
|
2201
|
+
return bNamed - aNamed;
|
|
2202
|
+
// Graph connectivity is the next key (small epsilon so near-ties fall
|
|
2203
|
+
// through to the text signal rather than coin-flipping on float noise).
|
|
2204
|
+
const aG = fileGraphScore.get(a[0]) ?? 0;
|
|
2205
|
+
const bG = fileGraphScore.get(b[0]) ?? 0;
|
|
2206
|
+
if (Math.abs(aG - bG) > maxGraph * 0.01)
|
|
2207
|
+
return bG - aG;
|
|
2208
|
+
const aHits = fileTermHits.get(a[0]) ?? 0;
|
|
2209
|
+
const bHits = fileTermHits.get(b[0]) ?? 0;
|
|
2210
|
+
if (aHits !== bHits)
|
|
2211
|
+
return bHits - aHits;
|
|
2236
2212
|
const aLow = isLowValue(aPath);
|
|
2237
2213
|
const bLow = isLowValue(bPath);
|
|
2238
2214
|
if (aLow !== bLow)
|
|
@@ -2258,6 +2234,12 @@ class ToolHandler {
|
|
|
2258
2234
|
`Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
|
|
2259
2235
|
'',
|
|
2260
2236
|
];
|
|
2237
|
+
// Blast radius (always-on, compact): for the entry symbols, who depends on
|
|
2238
|
+
// them + which tests cover them — locations only, no source — so the agent
|
|
2239
|
+
// knows what to update/verify before editing without a separate call.
|
|
2240
|
+
const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
|
|
2241
|
+
if (blastRadius)
|
|
2242
|
+
lines.push(blastRadius);
|
|
2261
2243
|
// Relationship map — show how symbols connect
|
|
2262
2244
|
const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
|
|
2263
2245
|
);
|
|
@@ -2412,7 +2394,7 @@ class ToolHandler {
|
|
|
2412
2394
|
// so leave it to the normal full render.
|
|
2413
2395
|
const namedBodyChars = group.nodes
|
|
2414
2396
|
.filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
|
|
2415
|
-
.reduce((s, n) => s + fileLines.slice(n.startLine - 1,
|
|
2397
|
+
.reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
|
|
2416
2398
|
const onSpineGodFile = hasSpineNode
|
|
2417
2399
|
&& namedBodyChars > budget.maxCharsPerFile
|
|
2418
2400
|
&& group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
|
|
@@ -2432,14 +2414,19 @@ class ToolHandler {
|
|
|
2432
2414
|
: flow.pathNodeIds.has(n.id) ? 0
|
|
2433
2415
|
: flow.uniqueNamedNodeIds.has(n.id) ? 1
|
|
2434
2416
|
: (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
|
|
2435
|
-
|
|
2417
|
+
// One ~250-line WINDOW per file. syms are taken by priority (spine first,
|
|
2418
|
+
// then uniquely-named, then family-base), and the cap applies to ALL of
|
|
2419
|
+
// them — including the spine — so a big-spine god-file (tokio's worker.rs:
|
|
2420
|
+
// run→run_task→next_task→steal_work) can't eat the whole response and
|
|
2421
|
+
// starve the co-flow file (harness.rs's poll). The native agent windows
|
|
2422
|
+
// such a file too (~190 lines at a time), so this mimics, not truncates.
|
|
2423
|
+
// Always emit ≥1 (never an empty section).
|
|
2424
|
+
const bodyCap = budget.maxCharsPerFile * 1.5;
|
|
2436
2425
|
const bodyIds = new Set();
|
|
2437
2426
|
let bodyChars = 0;
|
|
2438
2427
|
for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
|
|
2439
|
-
const sz = fileLines.slice(n.startLine - 1,
|
|
2440
|
-
|
|
2441
|
-
// off-path extras (unique-named, family base), never the flow path itself.
|
|
2442
|
-
if (prio(n) > 0 && bodyChars + sz > bodyCap && bodyIds.size > 0)
|
|
2428
|
+
const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
|
|
2429
|
+
if (bodyChars + sz > bodyCap && bodyIds.size > 0)
|
|
2443
2430
|
continue;
|
|
2444
2431
|
bodyIds.add(n.id);
|
|
2445
2432
|
bodyChars += sz;
|
|
@@ -2455,7 +2442,7 @@ class ToolHandler {
|
|
|
2455
2442
|
if (n.startLine <= coveredUntil)
|
|
2456
2443
|
continue;
|
|
2457
2444
|
if (bodyIds.has(n.id)) {
|
|
2458
|
-
const end =
|
|
2445
|
+
const end = n.endLine;
|
|
2459
2446
|
const body = fileLines.slice(n.startLine - 1, end).join('\n');
|
|
2460
2447
|
skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
|
|
2461
2448
|
coveredUntil = end;
|
|
@@ -2503,14 +2490,30 @@ class ToolHandler {
|
|
|
2503
2490
|
continue;
|
|
2504
2491
|
}
|
|
2505
2492
|
}
|
|
2506
|
-
// Whole-
|
|
2507
|
-
//
|
|
2508
|
-
//
|
|
2509
|
-
//
|
|
2510
|
-
//
|
|
2493
|
+
// Whole-file rule: if a relevant file is small enough to afford, return it
|
|
2494
|
+
// ENTIRELY instead of clustering. Clustering exists to tame god-files
|
|
2495
|
+
// (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
|
|
2496
|
+
// subset of a file the agent will just Read in full anyway — costing a
|
|
2497
|
+
// round-trip and a re-read every later turn. Reserve clustering for files
|
|
2511
2498
|
// too big to ship whole. Still bounded by the total maxOutputChars check.
|
|
2512
|
-
|
|
2513
|
-
|
|
2499
|
+
//
|
|
2500
|
+
// CENTRAL files (where the query's entry points live) get a larger — but
|
|
2501
|
+
// bounded — ceiling: they're the heart of the answer, the file(s) the agent
|
|
2502
|
+
// would Read whole, so a genuinely small one comes back whole rather than as
|
|
2503
|
+
// thin clusters. A LARGE central file (the 791-line org-user store) exceeds
|
|
2504
|
+
// the ceiling and falls through to sectioning/clustering below — full method
|
|
2505
|
+
// bodies + signatures — so we never dump (or overflow on) a whole god-file.
|
|
2506
|
+
const isCentralFile = centralFiles.has(filePath);
|
|
2507
|
+
// Central files get a slightly larger whole-file window than peripheral ones,
|
|
2508
|
+
// but a TIGHT one (~1.5× the per-file cap): the native read of a central file
|
|
2509
|
+
// is a ~150–250 line orientation window, NOT the whole file. A flat "whole
|
|
2510
|
+
// central file" both overflowed the inline cap AND starved the co-flow files
|
|
2511
|
+
// (worker.rs ate the budget, dropping harness.rs's poll). A larger central
|
|
2512
|
+
// file falls through to per-method windowing/clustering below.
|
|
2513
|
+
const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
|
|
2514
|
+
const WHOLE_FILE_MAX_CHARS = isCentralFile
|
|
2515
|
+
? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
|
|
2516
|
+
: budget.maxCharsPerFile * 3;
|
|
2514
2517
|
if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
|
|
2515
2518
|
const body = fileContent.replace(/\n+$/, '');
|
|
2516
2519
|
let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
|
|
@@ -2520,12 +2523,12 @@ class ToolHandler {
|
|
|
2520
2523
|
const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
|
|
2521
2524
|
const omitted = uniqSymbols.length - headerNames.length;
|
|
2522
2525
|
const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
|
|
2523
|
-
if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
2526
|
+
if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
2527
|
+
// Don't slice a whole file mid-method: an incidental file that doesn't
|
|
2528
|
+
// fit is skipped; a necessary one (below) renders in full. Half a file
|
|
2529
|
+
// forces the Read this is meant to prevent.
|
|
2528
2530
|
anyFileTrimmed = true;
|
|
2531
|
+
continue;
|
|
2529
2532
|
}
|
|
2530
2533
|
lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
|
|
2531
2534
|
totalChars += wholeSection.length + 200;
|
|
@@ -2706,7 +2709,6 @@ class ToolHandler {
|
|
|
2706
2709
|
// Emit chosen clusters in source order so the file reads top-to-bottom.
|
|
2707
2710
|
let fileSection = '';
|
|
2708
2711
|
const allSymbols = [];
|
|
2709
|
-
let fileTrimmed = false;
|
|
2710
2712
|
for (let i = 0; i < clusters.length; i++) {
|
|
2711
2713
|
if (!chosenIndices.has(i))
|
|
2712
2714
|
continue;
|
|
@@ -2717,13 +2719,12 @@ class ToolHandler {
|
|
|
2717
2719
|
fileSection += section;
|
|
2718
2720
|
allSymbols.push(...cluster.symbols);
|
|
2719
2721
|
}
|
|
2720
|
-
//
|
|
2721
|
-
//
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
if (chosenIndices.size < clusters.length || fileTrimmed) {
|
|
2722
|
+
// A chosen cluster is a COMPLETE method-range — we never cut through a body.
|
|
2723
|
+
// An oversize single cluster (a long monolithic function) renders in FULL:
|
|
2724
|
+
// half a method is useless (the agent just Reads the rest for the other half),
|
|
2725
|
+
// which is the very fallback explore exists to prevent. A pathological file is
|
|
2726
|
+
// bounded by the per-file cluster SELECTION above + the total hard ceiling.
|
|
2727
|
+
if (chosenIndices.size < clusters.length) {
|
|
2727
2728
|
anyFileTrimmed = true;
|
|
2728
2729
|
}
|
|
2729
2730
|
// Dedupe + cap the symbols list shown in the per-file header. Some
|
|
@@ -2755,11 +2756,11 @@ class ToolHandler {
|
|
|
2755
2756
|
// (DataRequest/Validation) all render, instead of the cap dropping whichever
|
|
2756
2757
|
// phase the file order happened to put last.
|
|
2757
2758
|
if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
fileSection = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
|
|
2759
|
+
// Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
|
|
2760
|
+
// Keep scanning for necessary files (which bypass this cap and render in
|
|
2761
|
+
// full, bounded by the hard ceiling).
|
|
2762
2762
|
anyFileTrimmed = true;
|
|
2763
|
+
continue;
|
|
2763
2764
|
}
|
|
2764
2765
|
lines.push(fileHeader);
|
|
2765
2766
|
lines.push('');
|
|
@@ -2816,26 +2817,26 @@ class ToolHandler {
|
|
|
2816
2817
|
// Stats unavailable — skip budget note
|
|
2817
2818
|
}
|
|
2818
2819
|
}
|
|
2819
|
-
//
|
|
2820
|
-
//
|
|
2821
|
-
//
|
|
2822
|
-
//
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
// only incidental ones, all bounded by maxFiles + per-file true-spine — so
|
|
2828
|
-
// this is a SAFETY ceiling above that necessary content, not a hard cut
|
|
2829
|
-
// through it. Cutting at a flat maxOutputChars here undid the whole point:
|
|
2830
|
-
// Alamofire's loop assembles build+validators-exec+validate (~15K) and a 13K
|
|
2831
|
-
// slice dropped the validate phase the agent then Read. Allow necessary
|
|
2832
|
-
// overflow up to 1.5× (still bounds a pathological monolith).
|
|
2820
|
+
// Final ceiling — an ABSOLUTE inline cap, not a multiple of the budget. The
|
|
2821
|
+
// render loop renders necessary (named/spine) files even a bit past
|
|
2822
|
+
// maxOutputChars and caps only incidental ones, so this is the last safety.
|
|
2823
|
+
// It MUST stay under the host's inline tool-result limit (~25K chars): above
|
|
2824
|
+
// that the result is externalized to a file the agent Reads back (a 35K
|
|
2825
|
+
// vscode explore did exactly this in the n=4 A/B). So allow a little
|
|
2826
|
+
// necessary overflow above the 24K budget, but hard-stop at 25K — never into
|
|
2827
|
+
// externalize territory.
|
|
2833
2828
|
const output = flow.text + lines.join('\n');
|
|
2834
|
-
const hardCeiling = Math.round(budget.maxOutputChars * 1.5);
|
|
2829
|
+
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
2835
2830
|
if (output.length > hardCeiling) {
|
|
2831
|
+
// Cut at a FILE-SECTION boundary (the last `#### ` header before the
|
|
2832
|
+
// ceiling) so we drop whole trailing file-sections rather than slicing
|
|
2833
|
+
// through a method body — a half-rendered method just forces the Read this
|
|
2834
|
+
// tool exists to prevent. Fall back to a line boundary only if no section
|
|
2835
|
+
// header sits in the back half (degenerate single-giant-section case).
|
|
2836
2836
|
const cut = output.slice(0, hardCeiling);
|
|
2837
|
-
const
|
|
2838
|
-
const
|
|
2837
|
+
const lastSection = cut.lastIndexOf('\n#### ');
|
|
2838
|
+
const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
|
|
2839
|
+
const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
|
|
2839
2840
|
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.)');
|
|
2840
2841
|
}
|
|
2841
2842
|
return this.textResult(output);
|
|
@@ -2844,35 +2845,262 @@ class ToolHandler {
|
|
|
2844
2845
|
* Handle codegraph_node
|
|
2845
2846
|
*/
|
|
2846
2847
|
async handleNode(args) {
|
|
2847
|
-
const symbol = this.validateString(args.symbol, 'symbol');
|
|
2848
|
-
if (typeof symbol !== 'string')
|
|
2849
|
-
return symbol;
|
|
2850
2848
|
const cg = this.getCodeGraph(args.projectPath);
|
|
2851
2849
|
// Default to false to minimize context usage
|
|
2852
2850
|
const includeCode = args.includeCode === true;
|
|
2853
|
-
const
|
|
2854
|
-
|
|
2851
|
+
const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
|
|
2852
|
+
const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
|
|
2853
|
+
const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : undefined;
|
|
2854
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : undefined;
|
|
2855
|
+
const symbolsOnly = args.symbolsOnly === true;
|
|
2856
|
+
const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
|
|
2857
|
+
// FILE READ MODE: a `file` with no `symbol` reads that file like the Read
|
|
2858
|
+
// tool — its current on-disk source with line numbers, narrowable with
|
|
2859
|
+
// `offset`/`limit` exactly as Read does — PLUS a one-line blast-radius
|
|
2860
|
+
// header (which files depend on it). `symbolsOnly` returns just the
|
|
2861
|
+
// structural map instead. Backed by the index: same bytes Read gives you.
|
|
2862
|
+
if (!symbolRaw && fileHint) {
|
|
2863
|
+
return this.handleFileView(cg, fileHint, { offset, limit, symbolsOnly });
|
|
2864
|
+
}
|
|
2865
|
+
const symbol = this.validateString(args.symbol, 'symbol');
|
|
2866
|
+
if (typeof symbol !== 'string')
|
|
2867
|
+
return symbol;
|
|
2868
|
+
let matches = this.findSymbolMatches(cg, symbol);
|
|
2869
|
+
if (matches.length === 0) {
|
|
2855
2870
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
2856
2871
|
}
|
|
2872
|
+
// Disambiguate a heavily-overloaded name to a specific definition the caller
|
|
2873
|
+
// pinned by file/line (the `file:line` a trail or another tool showed it) —
|
|
2874
|
+
// so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
|
|
2875
|
+
// instead of Reading. file matches by path suffix/substring; line prefers the
|
|
2876
|
+
// def whose body contains it, else the nearest start. Only narrows (never
|
|
2877
|
+
// empties — if a hint matches nothing it's ignored).
|
|
2878
|
+
if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
|
|
2879
|
+
const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
|
|
2880
|
+
let narrowed = matches;
|
|
2881
|
+
if (fileHint) {
|
|
2882
|
+
const fh = norm(fileHint);
|
|
2883
|
+
const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
|
|
2884
|
+
if (byFile.length > 0)
|
|
2885
|
+
narrowed = byFile;
|
|
2886
|
+
}
|
|
2887
|
+
if (lineHint !== undefined && narrowed.length > 1) {
|
|
2888
|
+
const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
|
|
2889
|
+
narrowed = containing.length > 0
|
|
2890
|
+
? containing
|
|
2891
|
+
: [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
|
|
2892
|
+
}
|
|
2893
|
+
if (narrowed.length > 0)
|
|
2894
|
+
matches = narrowed;
|
|
2895
|
+
}
|
|
2896
|
+
// Single definition — the common case.
|
|
2897
|
+
if (matches.length === 1) {
|
|
2898
|
+
return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0], includeCode)));
|
|
2899
|
+
}
|
|
2900
|
+
// Multiple definitions share this name — overloads, or same-named methods on
|
|
2901
|
+
// different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
|
|
2902
|
+
// `reset`). Returning ONE forces the agent to guess, and when it guesses
|
|
2903
|
+
// wrong it READS the file to find the right overload — the dominant
|
|
2904
|
+
// codegraph_node read cause on Swift/Go. So return them ALL: pack as many
|
|
2905
|
+
// FULL bodies as fit a char budget (the agent gets the one it needs in this
|
|
2906
|
+
// one call, no follow-up parameter to learn), and list any remainder by
|
|
2907
|
+
// file:line so a large overload set can't overflow the per-tool cap.
|
|
2908
|
+
const header = `**${matches.length} definitions named "${symbol}"**`;
|
|
2909
|
+
if (!includeCode) {
|
|
2910
|
+
const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
|
|
2911
|
+
return this.textResult(this.truncateOutput([header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n')));
|
|
2912
|
+
}
|
|
2913
|
+
const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
|
|
2914
|
+
// The CHAR budget is the real limiter — keep the count cap high so a set of
|
|
2915
|
+
// SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
|
|
2916
|
+
// render in full rather than relegating the one the agent wanted to a
|
|
2917
|
+
// bodiless list. Only a set of many LARGE bodies hits the char budget first.
|
|
2918
|
+
const HARD_CAP = 16;
|
|
2919
|
+
const rendered = [];
|
|
2920
|
+
const listed = [];
|
|
2921
|
+
let used = 0;
|
|
2922
|
+
for (const n of matches) {
|
|
2923
|
+
if (rendered.length >= HARD_CAP) {
|
|
2924
|
+
listed.push(n);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
const section = await this.renderNodeSection(cg, n, true);
|
|
2928
|
+
// Always emit the first; emit the rest only while within the char budget.
|
|
2929
|
+
if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
|
|
2930
|
+
rendered.push(section);
|
|
2931
|
+
used += section.length;
|
|
2932
|
+
}
|
|
2933
|
+
else {
|
|
2934
|
+
listed.push(n);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
const out = [
|
|
2938
|
+
header,
|
|
2939
|
+
`Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
|
|
2940
|
+
'',
|
|
2941
|
+
rendered.join('\n\n---\n\n'),
|
|
2942
|
+
];
|
|
2943
|
+
if (listed.length) {
|
|
2944
|
+
const LIST_CAP = 20;
|
|
2945
|
+
const shownList = listed.slice(0, LIST_CAP);
|
|
2946
|
+
out.push('', '### Other definitions', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
|
|
2947
|
+
if (listed.length > LIST_CAP)
|
|
2948
|
+
out.push(`- … +${listed.length - LIST_CAP} more`);
|
|
2949
|
+
out.push('', `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
|
|
2950
|
+
}
|
|
2951
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and
|
|
2955
|
+
* read it like the Read tool — its current on-disk source with line numbers,
|
|
2956
|
+
* narrowable with `offset`/`limit` exactly as Read's are — preceded by a
|
|
2957
|
+
* one-line blast-radius header (which files depend on it). `symbolsOnly`
|
|
2958
|
+
* returns just the structural map (symbols + dependents) instead of source.
|
|
2959
|
+
*
|
|
2960
|
+
* Parity goal: the numbered source block is byte-for-byte the shape Read
|
|
2961
|
+
* returns (`<n>\t<line>`, no padding), so the agent treats it as a Read — only
|
|
2962
|
+
* faster (served from the index) and with the blast radius attached. Security:
|
|
2963
|
+
* yaml/properties files are summarized by key, never dumped (#383); reads go
|
|
2964
|
+
* through validatePathWithinRoot (#527).
|
|
2965
|
+
*/
|
|
2966
|
+
async handleFileView(cg, fileArg, opts = {}) {
|
|
2967
|
+
const normalize = (p) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
|
|
2968
|
+
const wantLower = normalize(fileArg).toLowerCase();
|
|
2969
|
+
const allFiles = cg.getFiles();
|
|
2970
|
+
if (allFiles.length === 0)
|
|
2971
|
+
return this.textResult('No files indexed. Run `codegraph index` first.');
|
|
2972
|
+
let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
|
|
2973
|
+
let candidates = [];
|
|
2974
|
+
if (!resolved) {
|
|
2975
|
+
candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
|
|
2976
|
+
if (candidates.length === 1)
|
|
2977
|
+
resolved = candidates[0];
|
|
2978
|
+
}
|
|
2979
|
+
if (!resolved && candidates.length === 0) {
|
|
2980
|
+
candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
|
|
2981
|
+
if (candidates.length === 1)
|
|
2982
|
+
resolved = candidates[0];
|
|
2983
|
+
}
|
|
2984
|
+
if (!resolved && candidates.length > 1) {
|
|
2985
|
+
return this.textResult([`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
|
|
2986
|
+
...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'));
|
|
2987
|
+
}
|
|
2988
|
+
if (!resolved) {
|
|
2989
|
+
return this.textResult(`No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`);
|
|
2990
|
+
}
|
|
2991
|
+
const filePath = resolved.path;
|
|
2992
|
+
const nodes = cg.getNodesInFile(filePath)
|
|
2993
|
+
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
|
|
2994
|
+
.sort((a, b) => a.startLine - b.startLine);
|
|
2995
|
+
const dependents = cg.getFileDependents(filePath);
|
|
2996
|
+
// Compact, one-line blast radius (codegraph's value-add over a plain Read).
|
|
2997
|
+
const depSummary = dependents.length
|
|
2998
|
+
? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
|
|
2999
|
+
: 'no other indexed file depends on it';
|
|
3000
|
+
// Symbol-map renderer — for symbolsOnly, the config fallback, and read errors.
|
|
3001
|
+
const symbolMap = (heading, limit = 200) => {
|
|
3002
|
+
const lines = [heading];
|
|
3003
|
+
for (const n of nodes.slice(0, limit)) {
|
|
3004
|
+
const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
|
|
3005
|
+
lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
|
|
3006
|
+
}
|
|
3007
|
+
if (nodes.length > limit)
|
|
3008
|
+
lines.push(`- … +${nodes.length - limit} more`);
|
|
3009
|
+
return lines;
|
|
3010
|
+
};
|
|
3011
|
+
// symbolsOnly → the cheap structural overview, no source.
|
|
3012
|
+
if (opts.symbolsOnly) {
|
|
3013
|
+
const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
|
|
3014
|
+
if (nodes.length)
|
|
3015
|
+
out.push(...symbolMap('### Symbols'));
|
|
3016
|
+
else
|
|
3017
|
+
out.push('_No indexed symbols in this file._');
|
|
3018
|
+
out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
|
|
3019
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3020
|
+
}
|
|
3021
|
+
// SECURITY (#383): never dump a raw config/data file — a yaml/properties
|
|
3022
|
+
// line is `key: <secret>`. Summarize by key and point to a real Read.
|
|
3023
|
+
if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
|
|
3024
|
+
const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
|
|
3025
|
+
if (nodes.length)
|
|
3026
|
+
out.push(...symbolMap('### Keys (values withheld for safety)'));
|
|
3027
|
+
out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
|
|
3028
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3029
|
+
}
|
|
3030
|
+
// Read the current bytes from disk through the security chokepoint
|
|
3031
|
+
// (validatePathWithinRoot: blocks `../` traversal and symlink escapes, #527).
|
|
3032
|
+
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
3033
|
+
let content = null;
|
|
3034
|
+
if (abs) {
|
|
3035
|
+
try {
|
|
3036
|
+
content = (0, fs_1.readFileSync)(abs, 'utf-8');
|
|
3037
|
+
}
|
|
3038
|
+
catch {
|
|
3039
|
+
content = null;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
if (content === null) {
|
|
3043
|
+
const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
|
|
3044
|
+
if (nodes.length)
|
|
3045
|
+
out.push(...symbolMap('### Symbols'));
|
|
3046
|
+
out.push('', `> Read \`${filePath}\` directly for its current content.`);
|
|
3047
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3048
|
+
}
|
|
3049
|
+
// Split exactly as Read does — keep the trailing empty line a final newline
|
|
3050
|
+
// produces (Read numbers it too), so line numbers line up byte-for-byte.
|
|
3051
|
+
const fileLines = content.split('\n');
|
|
3052
|
+
const total = fileLines.length;
|
|
3053
|
+
// Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
|
|
3054
|
+
// (1-based start line; max line count). Default: the whole file, capped like
|
|
3055
|
+
// Read at 2000 lines and bounded by a char budget that tracks explore's
|
|
3056
|
+
// proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
|
|
3057
|
+
// paginates too) — never the silent 15k truncateOutput chop.
|
|
3058
|
+
const CHAR_BUDGET = 38000;
|
|
3059
|
+
const DEFAULT_LIMIT = 2000;
|
|
3060
|
+
const offset = Math.max(1, opts.offset ?? 1);
|
|
3061
|
+
if (offset > total) {
|
|
3062
|
+
return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
|
|
3063
|
+
}
|
|
3064
|
+
const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
|
|
3065
|
+
const start = offset - 1; // 0-based
|
|
3066
|
+
const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
|
|
3067
|
+
// Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
|
|
3068
|
+
const numbered = [];
|
|
3069
|
+
let used = header.length + 8;
|
|
3070
|
+
let i = start;
|
|
3071
|
+
for (; i < total && numbered.length < maxLines; i++) {
|
|
3072
|
+
const ln = `${i + 1}\t${fileLines[i]}`;
|
|
3073
|
+
if (used + ln.length + 1 > CHAR_BUDGET && numbered.length > 0)
|
|
3074
|
+
break;
|
|
3075
|
+
numbered.push(ln);
|
|
3076
|
+
used += ln.length + 1;
|
|
3077
|
+
}
|
|
3078
|
+
const shownEnd = start + numbered.length;
|
|
3079
|
+
const complete = offset === 1 && shownEnd >= total;
|
|
3080
|
+
const out = [header, '', ...numbered];
|
|
3081
|
+
if (!complete) {
|
|
3082
|
+
out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`codegraph_node <symbol>\` for one symbol in full)`);
|
|
3083
|
+
}
|
|
3084
|
+
// Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
|
|
3085
|
+
return this.textResult(out.join('\n'));
|
|
3086
|
+
}
|
|
3087
|
+
/** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
|
|
3088
|
+
async renderNodeSection(cg, node, includeCode) {
|
|
2857
3089
|
let code = null;
|
|
2858
3090
|
let outline = null;
|
|
2859
3091
|
if (includeCode) {
|
|
2860
3092
|
// For container symbols (class/interface/struct/…), the full body is the
|
|
2861
|
-
// sum of every method body — a wall of source
|
|
2862
|
-
//
|
|
2863
|
-
//
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
|
|
2867
|
-
outline = this.buildContainerOutline(cg, match.node);
|
|
3093
|
+
// sum of every method body — a wall of source. Return a structural outline
|
|
3094
|
+
// (members + signatures + line numbers) instead; leaf symbols return their
|
|
3095
|
+
// full body.
|
|
3096
|
+
if (CONTAINER_NODE_KINDS.has(node.kind)) {
|
|
3097
|
+
outline = this.buildContainerOutline(cg, node);
|
|
2868
3098
|
}
|
|
2869
3099
|
if (!outline) {
|
|
2870
|
-
code = await cg.getCode(
|
|
3100
|
+
code = await cg.getCode(node.id);
|
|
2871
3101
|
}
|
|
2872
3102
|
}
|
|
2873
|
-
|
|
2874
|
-
const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
|
|
2875
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
3103
|
+
return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
|
|
2876
3104
|
}
|
|
2877
3105
|
/**
|
|
2878
3106
|
* Build the "trail" for a symbol: its direct callees (what it calls) and
|
|
@@ -3212,51 +3440,55 @@ class ToolHandler {
|
|
|
3212
3440
|
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
|
3213
3441
|
return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
|
|
3214
3442
|
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3443
|
+
/**
|
|
3444
|
+
* Find ALL definitions matching a name, ranked, so codegraph_node can return
|
|
3445
|
+
* every overload instead of guessing one (the wrong guess → a Read). Keepers
|
|
3446
|
+
* rank before generated stubs (.pb.go etc.); stable within a group preserves
|
|
3447
|
+
* FTS order. Returns [] when nothing matches; a qualified lookup that finds no
|
|
3448
|
+
* exact match returns [] rather than a misleading fuzzy file hit (#173); a
|
|
3449
|
+
* bare name with no exact match falls back to the single top fuzzy result.
|
|
3450
|
+
*/
|
|
3451
|
+
findSymbolMatches(cg, symbol) {
|
|
3219
3452
|
const isQualified = /[.\/]|::/.test(symbol);
|
|
3220
|
-
|
|
3453
|
+
// For a bare name, enumerate EVERY exact-name definition via the direct index
|
|
3454
|
+
// (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
|
|
3455
|
+
// caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
|
|
3456
|
+
// so it could be neither rendered nor pinned by the file/line disambiguator —
|
|
3457
|
+
// and the agent Read it. With the full set, the multi-overload render + the
|
|
3458
|
+
// file/line filter can both reach it.
|
|
3459
|
+
if (!isQualified) {
|
|
3460
|
+
const exact = cg.getNodesByName(symbol);
|
|
3461
|
+
if (exact.length > 0) {
|
|
3462
|
+
return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
|
|
3463
|
+
}
|
|
3464
|
+
// No exact match — use the single top fuzzy result (e.g. a file basename).
|
|
3465
|
+
const fuzzy = cg.searchNodes(symbol, { limit: 10 });
|
|
3466
|
+
return fuzzy[0] ? [fuzzy[0].node] : [];
|
|
3467
|
+
}
|
|
3468
|
+
// Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
|
|
3469
|
+
const limit = 50;
|
|
3221
3470
|
let results = cg.searchNodes(symbol, { limit });
|
|
3222
|
-
// FTS strips colons
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3471
|
+
// FTS strips colons, so `stage_apply::run` searches the literal
|
|
3472
|
+
// `stage_applyrun` and finds nothing. Re-search by the bare last part and
|
|
3473
|
+
// let `matchesSymbol` filter by qualifier.
|
|
3225
3474
|
if (isQualified && results.length === 0) {
|
|
3226
3475
|
const tail = lastQualifierPart(symbol);
|
|
3227
3476
|
if (tail && tail !== symbol)
|
|
3228
3477
|
results = cg.searchNodes(tail, { limit });
|
|
3229
3478
|
}
|
|
3230
|
-
if (results.length === 0
|
|
3231
|
-
return
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
|
|
3244
|
-
const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
|
|
3245
|
-
return aGen - bGen;
|
|
3246
|
-
});
|
|
3247
|
-
// Multiple exact matches - pick first, note the others
|
|
3248
|
-
const picked = ranked[0].node;
|
|
3249
|
-
const others = ranked.slice(1).map(r => `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`);
|
|
3250
|
-
const note = `\n\n> **Note:** ${ranked.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
|
|
3251
|
-
return { node: picked, note };
|
|
3252
|
-
}
|
|
3253
|
-
// No exact match. For qualified lookups, don't silently fall back
|
|
3254
|
-
// to a fuzzy result — the user typed a specific qualifier, and
|
|
3255
|
-
// resolving `stage_apply::nonexistent_fn` to the unrelated
|
|
3256
|
-
// `stage_apply.rs` file would be actively misleading (#173).
|
|
3257
|
-
if (isQualified)
|
|
3258
|
-
return null;
|
|
3259
|
-
return { node: results[0].node, note: '' };
|
|
3479
|
+
if (results.length === 0)
|
|
3480
|
+
return [];
|
|
3481
|
+
const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
|
|
3482
|
+
if (exactMatches.length === 0) {
|
|
3483
|
+
// No exact match — a qualified lookup must not fall back to a fuzzy file
|
|
3484
|
+
// hit (#173); a bare name may use the single top fuzzy result.
|
|
3485
|
+
return isQualified ? [] : results[0] ? [results[0].node] : [];
|
|
3486
|
+
}
|
|
3487
|
+
// Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
|
|
3488
|
+
// query prefers the keeper implementation over the protobuf-generated stub.
|
|
3489
|
+
return [...exactMatches]
|
|
3490
|
+
.sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
|
|
3491
|
+
.map((r) => r.node);
|
|
3260
3492
|
}
|
|
3261
3493
|
/**
|
|
3262
3494
|
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
|
|
@@ -3320,15 +3552,36 @@ class ToolHandler {
|
|
|
3320
3552
|
}
|
|
3321
3553
|
return lines.join('\n');
|
|
3322
3554
|
}
|
|
3323
|
-
formatNodeList(nodes, title) {
|
|
3555
|
+
formatNodeList(nodes, title, labels) {
|
|
3324
3556
|
const lines = [`## ${title} (${nodes.length} found)`, ''];
|
|
3325
3557
|
for (const node of nodes) {
|
|
3326
3558
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
3327
|
-
// Compact: just name, kind, location
|
|
3328
|
-
|
|
3559
|
+
// Compact: just name, kind, location — plus the relationship when it
|
|
3560
|
+
// isn't a plain call (callback registration, instantiation, …).
|
|
3561
|
+
const label = labels?.get(node.id);
|
|
3562
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
3329
3563
|
}
|
|
3330
3564
|
return lines.join('\n');
|
|
3331
3565
|
}
|
|
3566
|
+
/**
|
|
3567
|
+
* Relationship label for a non-`calls` edge in callers/callees lists. A
|
|
3568
|
+
* function-as-value edge (#756) is the high-signal one: `callers(cb)`
|
|
3569
|
+
* showing "via callback registration" tells the agent this is where the
|
|
3570
|
+
* callback is WIRED, not where it's invoked.
|
|
3571
|
+
*/
|
|
3572
|
+
edgeLabel(edge) {
|
|
3573
|
+
if (edge.kind === 'calls')
|
|
3574
|
+
return null;
|
|
3575
|
+
if (edge.metadata?.fnRef === true)
|
|
3576
|
+
return 'callback registration';
|
|
3577
|
+
if (edge.kind === 'instantiates')
|
|
3578
|
+
return 'instantiation';
|
|
3579
|
+
if (edge.kind === 'imports')
|
|
3580
|
+
return 'import';
|
|
3581
|
+
if (edge.kind === 'references')
|
|
3582
|
+
return 'reference';
|
|
3583
|
+
return edge.kind;
|
|
3584
|
+
}
|
|
3332
3585
|
formatImpact(symbol, impact) {
|
|
3333
3586
|
const nodeCount = impact.nodes.size;
|
|
3334
3587
|
// Compact format: just list affected symbols grouped by file
|
|
@@ -3398,9 +3651,6 @@ class ToolHandler {
|
|
|
3398
3651
|
}
|
|
3399
3652
|
return lines.join('\n');
|
|
3400
3653
|
}
|
|
3401
|
-
formatTaskContext(context) {
|
|
3402
|
-
return context.summary || 'No context found';
|
|
3403
|
-
}
|
|
3404
3654
|
textResult(text) {
|
|
3405
3655
|
return {
|
|
3406
3656
|
content: [{ type: 'text', text }],
|