@esankhan3/code-search-mcp 1.0.6 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -54
- package/dist/__tests__/_harness.js +194 -0
- package/dist/__tests__/config-cli.test.js +110 -0
- package/dist/__tests__/config-resolver.test.js +1 -1
- package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
- package/dist/__tests__/daemon-runtime.test.js +145 -0
- package/dist/__tests__/graph-tools-store.test.js +314 -0
- package/dist/__tests__/graph-tools.test.js +11 -4
- package/dist/__tests__/graph-traversal.test.js +262 -0
- package/dist/__tests__/http-auth.test.js +180 -0
- package/dist/__tests__/http-index.test.js +125 -0
- package/dist/__tests__/metrics-collect.test.js +48 -0
- package/dist/__tests__/observability.test.js +3 -4
- package/dist/__tests__/remote-proxy.test.js +105 -0
- package/dist/__tests__/server-cli-args.test.js +146 -0
- package/dist/__tests__/server-lifecycle.test.js +108 -0
- package/dist/__tests__/tool-inputs.test.js +178 -0
- package/dist/backends/in-process.js +5 -2
- package/dist/backends/index.js +2 -2
- package/dist/cli/index.js +143 -76
- package/dist/core/config.js +106 -35
- package/dist/core/env-config.js +3 -5
- package/dist/core/project-name.js +12 -0
- package/dist/core/version.js +5 -0
- package/dist/daemon/index.js +106 -65
- package/dist/daemon/rpc-server.js +38 -18
- package/dist/index.js +113 -89
- package/dist/middleware/auth.js +34 -66
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/observability/logger.js +10 -2
- package/dist/observability/metrics.js +7 -6
- package/dist/projects/registry.js +13 -15
- package/dist/resources/resources.js +17 -0
- package/dist/server.js +216 -129
- package/dist/tools/graph.js +448 -165
- package/dist/tools/index-tools.js +6 -2
- package/dist/tools/profile.js +14 -8
- package/dist/tools/search.js +61 -51
- package/dist/tools/validate.js +48 -0
- package/dist/transports/http-transport.js +94 -55
- package/dist/transports/remote-proxy.js +58 -97
- package/package.json +2 -2
package/dist/tools/graph.js
CHANGED
|
@@ -1,158 +1,318 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
getBlobStore,
|
|
5
6
|
getGraphStore,
|
|
7
|
+
getRetriever,
|
|
8
|
+
findChunksInProject,
|
|
6
9
|
loadAllProfiles,
|
|
7
10
|
loadProfile,
|
|
8
11
|
getAllChanges,
|
|
9
|
-
getChangedFilesList
|
|
12
|
+
getChangedFilesList,
|
|
13
|
+
SYMMETRIC_EDGE_TYPES
|
|
10
14
|
} from "@esankhan3/anvil-knowledge-core";
|
|
11
15
|
import { resolvedKnowledgeConfig } from "../core/env-config.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"get_cross_repo_edges",
|
|
15
|
-
"find_callers",
|
|
16
|
-
"find_dependencies",
|
|
17
|
-
"impact_analysis",
|
|
18
|
-
"trace_path",
|
|
19
|
-
"find_dead_code",
|
|
20
|
-
"get_architecture",
|
|
21
|
-
"search_graph",
|
|
22
|
-
"detect_changes"
|
|
23
|
-
];
|
|
16
|
+
import { missingProfileText } from "./profile.js";
|
|
17
|
+
import { toolError, toolText, validateArgs } from "./validate.js";
|
|
24
18
|
const repoOf = (key) => key.split("::")[0] ?? "";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
const fileOf = (key) => key.split("::")[1] ?? "";
|
|
20
|
+
const TRAVERSAL_MAX_NODES = 5e4;
|
|
21
|
+
const TRAVERSAL_BUDGET_MS = 1e3;
|
|
22
|
+
const NEIGHBOR_BATCH = 400;
|
|
23
|
+
class TraversalBudget {
|
|
24
|
+
deadline = Date.now() + TRAVERSAL_BUDGET_MS;
|
|
25
|
+
/** Distinct nodes discovered so far. */
|
|
26
|
+
explored = 0;
|
|
27
|
+
/** Set once a limit stopped the traversal. */
|
|
28
|
+
exhausted = false;
|
|
29
|
+
/** Room for another query? Marks the budget exhausted when there isn't. */
|
|
30
|
+
hasRoom() {
|
|
31
|
+
if (this.explored >= TRAVERSAL_MAX_NODES || Date.now() > this.deadline) this.exhausted = true;
|
|
32
|
+
return !this.exhausted;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function expandLevel(store, frontier, direction, types, budget, visit) {
|
|
36
|
+
for (let i = 0; i < frontier.length; i += NEIGHBOR_BATCH) {
|
|
37
|
+
if (!budget.hasRoom()) return false;
|
|
38
|
+
const limit = TRAVERSAL_MAX_NODES - budget.explored;
|
|
39
|
+
const rows = store.neighborEdges(frontier.slice(i, i + NEIGHBOR_BATCH), direction, types, limit);
|
|
40
|
+
for (const [key, nb] of rows) if (visit(key, nb)) return true;
|
|
41
|
+
if (rows.length >= limit) {
|
|
42
|
+
budget.exhausted = true;
|
|
43
|
+
return false;
|
|
32
44
|
}
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
35
47
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
const REVERSE = { callees: "callers", callers: "callees", both: "both" };
|
|
49
|
+
function shortestPath(store, fromKeys, toKeys, maxDepth, direction, types, budget) {
|
|
50
|
+
const fwd = new Map(fromKeys.map((k) => [k, null]));
|
|
51
|
+
const bwd = new Map(toKeys.map((k) => [k, null]));
|
|
52
|
+
const chain = (m, k) => {
|
|
53
|
+
const out = [k];
|
|
54
|
+
for (let p = m.get(k); p != null; p = m.get(p)) out.push(p);
|
|
55
|
+
return out;
|
|
56
|
+
};
|
|
57
|
+
budget.explored = fwd.size + bwd.size;
|
|
58
|
+
let fFront = [...fwd.keys()];
|
|
59
|
+
let bFront = [...bwd.keys()];
|
|
60
|
+
let depth = 0;
|
|
61
|
+
let path = null;
|
|
62
|
+
while (depth < maxDepth && fFront.length > 0 && bFront.length > 0) {
|
|
63
|
+
const forward = fFront.length <= bFront.length;
|
|
64
|
+
const [mine, other] = forward ? [fwd, bwd] : [bwd, fwd];
|
|
42
65
|
const next = [];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
expandLevel(store, forward ? fFront : bFront, forward ? direction : REVERSE[direction], types, budget, (k, nb) => {
|
|
67
|
+
if (other.has(nb)) {
|
|
68
|
+
const p = forward ? [...chain(fwd, k).reverse(), ...chain(bwd, nb)] : [...chain(fwd, nb).reverse(), ...chain(bwd, k)];
|
|
69
|
+
if (p[0] !== p[p.length - 1]) {
|
|
70
|
+
path = p;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
49
73
|
}
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
if (!mine.has(nb)) {
|
|
75
|
+
mine.set(nb, k);
|
|
76
|
+
next.push(nb);
|
|
77
|
+
budget.explored++;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
if (path || budget.exhausted) break;
|
|
82
|
+
if (forward) fFront = next;
|
|
83
|
+
else bFront = next;
|
|
84
|
+
depth++;
|
|
52
85
|
}
|
|
53
|
-
return
|
|
86
|
+
return { path, depth };
|
|
54
87
|
}
|
|
55
|
-
function reachable(
|
|
88
|
+
function reachable(store, fromKeys, maxDepth, direction, types, budget, cap = 50) {
|
|
56
89
|
const visited = new Set(fromKeys);
|
|
57
90
|
const out = [];
|
|
91
|
+
budget.explored = visited.size;
|
|
58
92
|
let frontier = [...fromKeys];
|
|
59
93
|
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
|
60
94
|
const next = [];
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
95
|
+
const full = expandLevel(store, frontier, direction, types, budget, (_k, nb) => {
|
|
96
|
+
if (visited.has(nb)) return false;
|
|
97
|
+
visited.add(nb);
|
|
98
|
+
budget.explored++;
|
|
99
|
+
out.push({ key: nb, depth });
|
|
100
|
+
next.push(nb);
|
|
101
|
+
return out.length >= cap;
|
|
102
|
+
});
|
|
103
|
+
if (full || budget.exhausted) break;
|
|
104
|
+
frontier = next;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function endpointsIn(store, keys) {
|
|
109
|
+
const types = store.nodeTypes(keys);
|
|
110
|
+
return keys.filter((k) => types.get(k) === "external");
|
|
111
|
+
}
|
|
112
|
+
const HOP_EDGE_CAP = 1e3;
|
|
113
|
+
function dependentHops(store, scope, depth, fileLevel, budget) {
|
|
114
|
+
const visited = new Set(scope);
|
|
115
|
+
const repos = /* @__PURE__ */ new Set();
|
|
116
|
+
const hops = [];
|
|
117
|
+
let frontier = [...scope];
|
|
118
|
+
for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
|
|
119
|
+
budget.explored = visited.size;
|
|
120
|
+
if (!budget.hasRoom()) break;
|
|
121
|
+
const d = store.dependents(frontier, HOP_EDGE_CAP, TRAVERSAL_MAX_NODES);
|
|
122
|
+
const edges = d.edges.filter((e) => !visited.has(e.source));
|
|
123
|
+
const direct = /* @__PURE__ */ new Set();
|
|
124
|
+
const next = [];
|
|
125
|
+
for (const e of edges) {
|
|
126
|
+
repos.add(repoOf(e.source));
|
|
127
|
+
if (!fileLevel.has(e.target)) direct.add(e.source);
|
|
128
|
+
if (!visited.has(e.source)) {
|
|
129
|
+
visited.add(e.source);
|
|
130
|
+
next.push(e.source);
|
|
68
131
|
}
|
|
69
132
|
}
|
|
133
|
+
for (const k of next) if (!direct.has(k)) fileLevel.add(k);
|
|
134
|
+
hops.push({ hop, edges, total: d.total - (d.edges.length - edges.length), truncated: d.truncated });
|
|
70
135
|
frontier = next;
|
|
71
136
|
}
|
|
137
|
+
return { hops, repos };
|
|
138
|
+
}
|
|
139
|
+
const atLeast = (truncated) => truncated ? "+" : "";
|
|
140
|
+
const AT_LEAST_NOTE = `_N+ = at least N: a hop counts at most ${TRAVERSAL_MAX_NODES} dependent edges._`;
|
|
141
|
+
function edgeLine(e, fileLevel) {
|
|
142
|
+
const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
|
|
143
|
+
return `- \`${e.source}\` ${arrow} \`${e.target}\` (${e.type ?? "edge"})${e.evidence ? ` \u2014 ${e.evidence}` : ""}${fileLevel.has(e.target) ? " [file-level]" : ""}`;
|
|
144
|
+
}
|
|
145
|
+
async function indexedSources(project, repo, files) {
|
|
146
|
+
const cfg = resolvedKnowledgeConfig();
|
|
147
|
+
const parts = /* @__PURE__ */ new Map();
|
|
148
|
+
try {
|
|
149
|
+
const vs = (await getRetriever(project, cfg)).vectorStore;
|
|
150
|
+
await Promise.all(files.map(async (f) => {
|
|
151
|
+
const chunks = await vs.getChunksByFile(repo, f);
|
|
152
|
+
if (chunks.length > 0) parts.set(f, chunks.map((c) => c.content));
|
|
153
|
+
}));
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
const missing = new Set(files.filter((f) => !parts.has(f)));
|
|
157
|
+
if (missing.size > 0 && await getBlobStore(project, cfg).exists("chunks.json")) {
|
|
158
|
+
const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg);
|
|
159
|
+
for (const c of found) {
|
|
160
|
+
if (!parts.has(c.filePath)) parts.set(c.filePath, []);
|
|
161
|
+
parts.get(c.filePath).push(c.content);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return new Map([...parts].map(([f, cs]) => [f, cs.join("\n")]));
|
|
165
|
+
}
|
|
166
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
function identifierCount(src, name) {
|
|
168
|
+
return src.match(new RegExp(`(?<![\\w$])${escapeRegex(name)}(?![\\w$])`, "g"))?.length ?? 0;
|
|
169
|
+
}
|
|
170
|
+
const runGit = (repoPath, args) => execFileSync("git", args, {
|
|
171
|
+
cwd: repoPath,
|
|
172
|
+
encoding: "utf-8",
|
|
173
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
174
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
175
|
+
});
|
|
176
|
+
function diffHunks(repoPath, baseSha, files) {
|
|
177
|
+
const out = /* @__PURE__ */ new Map();
|
|
178
|
+
if (files.length === 0) return out;
|
|
179
|
+
let file = "";
|
|
180
|
+
for (const line of runGit(repoPath, ["diff", "-U0", "--no-color", "--no-ext-diff", baseSha, "--", ...files]).split("\n")) {
|
|
181
|
+
if (line.startsWith("+++ ")) {
|
|
182
|
+
file = line.slice(4).replace(/^b\//, "");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
|
|
186
|
+
if (m && file) {
|
|
187
|
+
if (!out.has(file)) out.set(file, []);
|
|
188
|
+
out.get(file).push([Number(m[1]), m[2] === void 0 ? 1 : Number(m[2])]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
72
191
|
return out;
|
|
73
192
|
}
|
|
193
|
+
function declarationRanges(src, names) {
|
|
194
|
+
const lines = src.split("\n");
|
|
195
|
+
const starts = [];
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
const n = escapeRegex(name);
|
|
198
|
+
const keyword = new RegExp(`\\b(?:function\\*?|class|interface|type|enum|struct|trait|impl|def|func|fn|const|let|var)\\s+(?:\\([^)]*\\)\\s*)?${n}(?![\\w$])`);
|
|
199
|
+
const method = new RegExp(`^\\s*(?:(?:public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|export|default)\\s+)*(?:[\\w$<>\\[\\],.?]+\\s+)?${n}\\s*(?:<[^>]*>)?\\(`);
|
|
200
|
+
let found = lines.flatMap((l, i) => keyword.test(l) ? [i + 1] : []);
|
|
201
|
+
if (found.length === 0) found = lines.flatMap((l, i) => method.test(l) && !l.trimEnd().endsWith(";") ? [i + 1] : []);
|
|
202
|
+
for (const line of found) starts.push({ name, line });
|
|
203
|
+
}
|
|
204
|
+
starts.sort((a, b) => a.line - b.line);
|
|
205
|
+
return starts.map((s, i) => ({ name: s.name, start: s.line, end: (starts[i + 1]?.line ?? lines.length + 1) - 1 }));
|
|
206
|
+
}
|
|
207
|
+
function touchedEntities(ranges, hunks) {
|
|
208
|
+
const names = /* @__PURE__ */ new Set();
|
|
209
|
+
let fileLevel = false;
|
|
210
|
+
for (const [start, count] of hunks) {
|
|
211
|
+
const hit = ranges.filter((r) => count > 0 ? r.start <= start + count - 1 && r.end >= start : r.start <= start && r.end >= start + 1);
|
|
212
|
+
if (hit.length === 0) fileLevel = true;
|
|
213
|
+
for (const r of hit) names.add(r.name);
|
|
214
|
+
}
|
|
215
|
+
return { names, fileLevel };
|
|
216
|
+
}
|
|
217
|
+
const RELATIONS = ["calls", "imports", "uses", "type-ref", "inherits", "all"];
|
|
218
|
+
const relationsProp = (what) => ({
|
|
219
|
+
type: "array",
|
|
220
|
+
items: { type: "string", enum: RELATIONS },
|
|
221
|
+
description: `Edge types to follow for ${what} (default ["calls"]): calls; imports (a file imports a file); uses (a file imports this symbol by name); type-ref; inherits; or "all" (every edge type except contains, cross-repo edges included)`
|
|
222
|
+
});
|
|
223
|
+
function edgeTypes(args) {
|
|
224
|
+
const r = args.relations ?? ["calls"];
|
|
225
|
+
return r.includes("all") ? void 0 : r;
|
|
226
|
+
}
|
|
74
227
|
function registerGraphTools() {
|
|
75
228
|
return [
|
|
76
229
|
{
|
|
77
230
|
name: "get_repo_graph",
|
|
78
|
-
description: "
|
|
231
|
+
description: "List a repo's AST graph: node and edge counts, entities (id + type) and edges (source \u2192 target + relation; `contains` omitted), each list capped by `limit`.",
|
|
79
232
|
inputSchema: {
|
|
80
233
|
type: "object",
|
|
81
|
-
properties: {
|
|
234
|
+
properties: {
|
|
235
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
236
|
+
limit: { type: "integer", minimum: 1, description: "Max entities and max edges listed (default 50)" }
|
|
237
|
+
},
|
|
82
238
|
required: ["repo"]
|
|
83
239
|
}
|
|
84
240
|
},
|
|
85
241
|
{
|
|
86
242
|
name: "get_cross_repo_edges",
|
|
87
|
-
description: "
|
|
243
|
+
description: "Connections between repos \u2014 HTTP routes, gRPC services, event/Kafka topics, database tables, Redis keys, S3 buckets, OpenAPI contracts, shared types / constants / env vars / npm deps \u2014 each with the evidence that produced it (route, topic, table, package). Same-repo workspace-package dependencies are listed in their own section. Up to 50 edges.",
|
|
88
244
|
inputSchema: {
|
|
89
245
|
type: "object",
|
|
90
|
-
properties: { repo: { type: "string", description: "
|
|
246
|
+
properties: { repo: { type: "string", minLength: 1, description: "Only edges involving this repo (optional)" } }
|
|
91
247
|
}
|
|
92
248
|
},
|
|
93
249
|
{
|
|
94
250
|
name: "find_callers",
|
|
95
|
-
description:
|
|
251
|
+
description: 'Find the callers of a symbol \u2014 entities with a `calls` edge into it. The name is matched exactly (case-sensitive; a full "repo::file::entity" id works too) unless fuzzy:true. Imports, type references and inheritance are not calls: they are listed, by relation, only when requested via `relations`.',
|
|
96
252
|
inputSchema: {
|
|
97
253
|
type: "object",
|
|
98
254
|
properties: {
|
|
99
|
-
function: { type: "string", description: "
|
|
100
|
-
repo: { type: "string", description: "Limit
|
|
101
|
-
fuzzy: { type: "boolean", description: "
|
|
255
|
+
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
256
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
257
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
258
|
+
relations: relationsProp("callers")
|
|
102
259
|
},
|
|
103
260
|
required: ["function"]
|
|
104
261
|
}
|
|
105
262
|
},
|
|
106
263
|
{
|
|
107
264
|
name: "find_dependencies",
|
|
108
|
-
description:
|
|
265
|
+
description: 'Find what a symbol calls \u2014 targets of its `calls` edges. The name is matched exactly (case-sensitive; a full "repo::file::entity" id works too) unless fuzzy:true. Type references, inheritance and imports are listed, by relation, only when requested via `relations`.',
|
|
109
266
|
inputSchema: {
|
|
110
267
|
type: "object",
|
|
111
268
|
properties: {
|
|
112
|
-
function: { type: "string", description: "
|
|
113
|
-
repo: { type: "string", description: "Limit to this repo (optional)" },
|
|
114
|
-
fuzzy: { type: "boolean", description: "
|
|
269
|
+
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
270
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
271
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
272
|
+
relations: relationsProp("dependencies")
|
|
115
273
|
},
|
|
116
274
|
required: ["function"]
|
|
117
275
|
}
|
|
118
276
|
},
|
|
119
277
|
{
|
|
120
278
|
name: "impact_analysis",
|
|
121
|
-
description: "
|
|
279
|
+
description: "What is affected if a file or entity changes: everything that depends on it, transitively up to `depth` hops \u2014 over every relation (calls, imports, uses, type-refs, inheritance, cross-repo edges); shared-resource edges (topics, tables, Redis keys, buckets, shared types / constants / env vars / npm deps) count both ways. With `entity`, the cross-repo endpoints declared in the same file (routes, topics, tables \u2026) are included and their dependents marked [file-level], since the graph can't tie them to one entity. A hop counts at most 50,000 edges (shown as 50000+); hops stop after 1 s, with a note.",
|
|
122
280
|
inputSchema: {
|
|
123
281
|
type: "object",
|
|
124
282
|
properties: {
|
|
125
|
-
file: { type: "string", description: "File path (relative to repo root)" },
|
|
126
|
-
entity: { type: "string", description: "
|
|
127
|
-
repo: { type: "string", description: "Repository name" }
|
|
283
|
+
file: { type: "string", minLength: 1, description: "File path (relative to repo root)" },
|
|
284
|
+
entity: { type: "string", minLength: 1, description: 'Entity name or full "repo::file::entity" id (optional \u2014 if omitted, analyzes everything in the file)' },
|
|
285
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
286
|
+
depth: { type: "integer", minimum: 1, maximum: 5, description: "Dependency hops to follow (default 2, max 5)" }
|
|
128
287
|
},
|
|
129
288
|
required: ["file", "repo"]
|
|
130
289
|
}
|
|
131
290
|
},
|
|
132
291
|
{
|
|
133
292
|
name: "trace_path",
|
|
134
|
-
description: 'Trace
|
|
293
|
+
description: 'Trace a call chain across multiple hops. With "to", returns the shortest path (fewest hops, bidirectional BFS) between two symbols; otherwise lists what is reachable, by depth (up to 50 nodes). Follows `calls` edges by default (`relations` for imports, type-refs, inheritance) and skips edges with confidence below 0.7. Names are matched exactly (a full "repo::file::entity" id works too) unless fuzzy:true. A search stops after 50,000 nodes or 1 s and says so \u2014 then narrow it or lower maxDepth.',
|
|
135
294
|
inputSchema: {
|
|
136
295
|
type: "object",
|
|
137
296
|
properties: {
|
|
138
|
-
from: { type: "string", description: "Starting
|
|
139
|
-
to: { type: "string", description: "Target
|
|
140
|
-
repo: { type: "string", description: "Limit resolution to this repo (optional)" },
|
|
297
|
+
from: { type: "string", minLength: 1, description: "Starting symbol name or qualified id (repo::file::entity)" },
|
|
298
|
+
to: { type: "string", minLength: 1, description: "Target symbol name or qualified id (optional \u2014 shortest path)" },
|
|
299
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
141
300
|
direction: { type: "string", enum: ["callees", "callers", "both"], description: "Traverse callees (what it calls), callers (who calls it), or both. Default callees." },
|
|
142
|
-
maxDepth: { type: "
|
|
143
|
-
fuzzy: { type: "boolean", description: "
|
|
301
|
+
maxDepth: { type: "integer", minimum: 1, maximum: 10, description: "Max hops to traverse (default 4, max 10)" },
|
|
302
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
303
|
+
relations: relationsProp("the traversal")
|
|
144
304
|
},
|
|
145
305
|
required: ["from"]
|
|
146
306
|
}
|
|
147
307
|
},
|
|
148
308
|
{
|
|
149
309
|
name: "find_dead_code",
|
|
150
|
-
description: "List entities (functions, methods, classes) with no
|
|
310
|
+
description: "List entities (functions, methods, classes, structs, enums, traits) with no incoming edge in the repo graph and no other mention of their name in their own file \u2014 likely dead code. Heuristic: exported APIs, reflection, and dynamic dispatch produce false positives.",
|
|
151
311
|
inputSchema: {
|
|
152
312
|
type: "object",
|
|
153
313
|
properties: {
|
|
154
|
-
repo: { type: "string", description: "Repository name" },
|
|
155
|
-
limit: { type: "
|
|
314
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
315
|
+
limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
|
|
156
316
|
},
|
|
157
317
|
required: ["repo"]
|
|
158
318
|
}
|
|
@@ -162,59 +322,74 @@ function registerGraphTools() {
|
|
|
162
322
|
description: "High-level architecture overview of the project \u2014 repo roles, key flows, and how services connect. Built from the LLM-generated project graph and repo profiles.",
|
|
163
323
|
inputSchema: {
|
|
164
324
|
type: "object",
|
|
165
|
-
properties: { repo: { type: "string", description: "Drill into one repo profile (optional)" } }
|
|
325
|
+
properties: { repo: { type: "string", minLength: 1, description: "Drill into one repo profile (optional)" } }
|
|
166
326
|
}
|
|
167
327
|
},
|
|
168
328
|
{
|
|
169
329
|
name: "search_graph",
|
|
170
|
-
description: `Structural search over the knowledge graph: filter
|
|
330
|
+
description: `Structural search over the knowledge graph: filter nodes by name (case-insensitive regex over symbol names), type, file, and repo; rank by connectivity (degree). Answers "which functions matter most in this module" \u2014 a structural question search/grep can't.`,
|
|
171
331
|
inputSchema: {
|
|
172
332
|
type: "object",
|
|
173
333
|
properties: {
|
|
174
|
-
name: { type: "string", description: "
|
|
175
|
-
type: { type: "string", description: "
|
|
176
|
-
file: { type: "string", description: "
|
|
177
|
-
repo: { type: "string", description: "Limit to this repo (optional)" },
|
|
178
|
-
minDegree: { type: "
|
|
179
|
-
limit: { type: "
|
|
334
|
+
name: { type: "string", minLength: 1, description: "Case-insensitive regex over symbol names (a plain substring works too). Files and cross-repo endpoints are skipped unless `type` is module / external. Optional." },
|
|
335
|
+
type: { type: "string", minLength: 1, description: "Node type: function | method | class | interface | type | const | struct | enum | trait | impl | package | module (a file) | external (a cross-repo endpoint). Optional." },
|
|
336
|
+
file: { type: "string", minLength: 1, description: "Case-insensitive file-path substring filter (optional)" },
|
|
337
|
+
repo: { type: "string", minLength: 1, description: "Limit to this repo (optional)" },
|
|
338
|
+
minDegree: { type: "integer", minimum: 0, description: "Only nodes with at least this many connections, `contains` excluded (default 0)" },
|
|
339
|
+
limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
|
|
180
340
|
}
|
|
181
341
|
}
|
|
182
342
|
},
|
|
183
343
|
{
|
|
184
344
|
name: "detect_changes",
|
|
185
|
-
description: "Map a git diff to affected symbols and their dependents.
|
|
345
|
+
description: "Map a git diff to affected symbols and their dependents. Diffs the working tree against a base commit (default: the last-indexed SHA) and reports the entities whose line range at the base (from their declaration line to the next declaration) overlaps a changed hunk (every entity of an added or deleted file), the cross-repo endpoints of modified files (file-level), and what directly depends on them \u2014 a blast-radius / code-review tool. Requires a local repo (local/serve mode).",
|
|
186
346
|
inputSchema: {
|
|
187
347
|
type: "object",
|
|
188
348
|
properties: {
|
|
189
|
-
repo: { type: "string", description: "Repository name" },
|
|
190
|
-
baseSha: { type: "string", description: "Git base commit to diff against (default: last-indexed SHA)" },
|
|
191
|
-
limit: { type: "
|
|
349
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
350
|
+
baseSha: { type: "string", minLength: 1, description: "Git base commit to diff against (default: last-indexed SHA)" },
|
|
351
|
+
limit: { type: "integer", minimum: 1, description: "Max dependent edges to list (default 50)" }
|
|
192
352
|
},
|
|
193
353
|
required: ["repo"]
|
|
194
354
|
}
|
|
195
355
|
}
|
|
196
356
|
];
|
|
197
357
|
}
|
|
358
|
+
const SCHEMAS = new Map(registerGraphTools().map((t) => [t.name, t.inputSchema]));
|
|
198
359
|
async function handleGraphTool(name, args, ctx) {
|
|
199
|
-
|
|
200
|
-
|
|
360
|
+
const schema = SCHEMAS.get(name);
|
|
361
|
+
if (!schema) return null;
|
|
362
|
+
const invalid = validateArgs(schema, args);
|
|
363
|
+
if (invalid) return toolError(`${name}: ${invalid}`);
|
|
364
|
+
const text = toolText;
|
|
201
365
|
const NO_GRAPH = "No system graph found. Build KB first.";
|
|
202
366
|
try {
|
|
203
367
|
const blobs = getBlobStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
204
368
|
if (name === "get_repo_graph") {
|
|
205
369
|
const repo = args.repo;
|
|
370
|
+
const limit = args.limit ?? 50;
|
|
206
371
|
const graph = await blobs.getJson(`${repo}/graph.json`);
|
|
207
|
-
if (!graph) return
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
372
|
+
if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
|
|
373
|
+
const nodes = graph.nodes ?? [];
|
|
374
|
+
const links = graph.links ?? [];
|
|
375
|
+
const byType = /* @__PURE__ */ new Map();
|
|
376
|
+
for (const l of links) byType.set(l.type ?? "edge", (byType.get(l.type ?? "edge") ?? 0) + 1);
|
|
377
|
+
const listed = links.filter((l) => l.type !== "contains");
|
|
378
|
+
const more = (n) => n > limit ? [`... and ${n - limit} more`] : [];
|
|
379
|
+
return text([
|
|
380
|
+
`# ${repo} AST Graph`,
|
|
381
|
+
"",
|
|
382
|
+
`- **Nodes:** ${nodes.length}`,
|
|
383
|
+
`- **Edges:** ${links.length}${byType.size ? ` (${[...byType].map(([t, n]) => `${t} ${n}`).join(", ")})` : ""}`,
|
|
384
|
+
"",
|
|
385
|
+
"## Entities",
|
|
386
|
+
...nodes.slice(0, limit).map((n) => `- \`${n.id}\` (${n.type})`),
|
|
387
|
+
...more(nodes.length),
|
|
388
|
+
"",
|
|
389
|
+
`## Edges (${listed.length}, \`contains\` omitted)`,
|
|
390
|
+
...listed.slice(0, limit).map((l) => `- \`${l.source}\` \u2192 \`${l.target}\` (${l.type ?? "edge"})`),
|
|
391
|
+
...more(listed.length)
|
|
392
|
+
].join("\n"));
|
|
218
393
|
}
|
|
219
394
|
if (name === "get_cross_repo_edges") {
|
|
220
395
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -222,14 +397,28 @@ ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
|
|
|
222
397
|
const repo = args.repo;
|
|
223
398
|
const { edges, total } = store.crossRepoEdges(repo, 50);
|
|
224
399
|
if (total === 0) return text(repo ? `No cross-repo edges found for "${repo}"` : "No cross-repo edges found");
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
${
|
|
231
|
-
|
|
232
|
-
|
|
400
|
+
const between = edges.filter((e) => repoOf(e.source) !== repoOf(e.target));
|
|
401
|
+
const within = edges.filter((e) => repoOf(e.source) === repoOf(e.target));
|
|
402
|
+
const ev = (e) => e.evidence ? ` \u2014 ${e.evidence}` : "";
|
|
403
|
+
const labels = store.labelsOf(within.flatMap((e) => [e.source, e.target]));
|
|
404
|
+
const body = [
|
|
405
|
+
`# Cross-Repo Edges${repo ? ` for ${repo}` : ""}`,
|
|
406
|
+
"",
|
|
407
|
+
`${total} edges found:`
|
|
408
|
+
];
|
|
409
|
+
if (between.length) {
|
|
410
|
+
body.push("", `## Between repos (${between.length} listed)`);
|
|
411
|
+
for (const e of between) {
|
|
412
|
+
const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
|
|
413
|
+
body.push(`- ${repoOf(e.source)} ${arrow} ${repoOf(e.target)} (${e.type ?? "edge"})${ev(e)} \`${fileOf(e.source)}\` ${arrow} \`${fileOf(e.target)}\``);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (within.length) {
|
|
417
|
+
body.push("", `## Within a repo \u2014 workspace packages (${within.length} listed)`);
|
|
418
|
+
for (const e of within) body.push(`- ${repoOf(e.source)}: ${labels.get(e.source)} \u2192 ${labels.get(e.target)} (${e.type ?? "edge"})${ev(e)}`);
|
|
419
|
+
}
|
|
420
|
+
if (total > edges.length) body.push("", `... and ${total - edges.length} more`);
|
|
421
|
+
return text(body.join("\n"));
|
|
233
422
|
}
|
|
234
423
|
if (name === "find_callers" || name === "find_dependencies") {
|
|
235
424
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -237,45 +426,83 @@ ${body}${total > edges.length ? `
|
|
|
237
426
|
const funcName = args.function;
|
|
238
427
|
const repoFilter = args.repo;
|
|
239
428
|
const fuzzy = args.fuzzy === true;
|
|
429
|
+
const types = edgeTypes(args);
|
|
240
430
|
const matched = store.resolveNodes(funcName, repoFilter, fuzzy);
|
|
241
431
|
if (matched.length === 0) {
|
|
242
|
-
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}
|
|
432
|
+
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}`);
|
|
243
433
|
}
|
|
244
434
|
const keys = matched.map((m) => m.key);
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
435
|
+
const fetch = (t) => name === "find_callers" ? store.callers(keys, 30, t) : store.dependencies(keys, 30, t);
|
|
436
|
+
const found = fetch(types);
|
|
437
|
+
const noun = name === "find_callers" ? "callers" : "dependencies";
|
|
438
|
+
const title = `# ${name === "find_callers" ? "Callers of" : "Dependencies of"} "${funcName}"`;
|
|
439
|
+
const relLabel = types ? types.join(", ") : "any relation";
|
|
440
|
+
if (found.length === 0) {
|
|
441
|
+
const other = /* @__PURE__ */ new Map();
|
|
442
|
+
if (types) for (const o of fetch(void 0)) other.set(o.type, (other.get(o.type) ?? 0) + 1);
|
|
443
|
+
const hint = other.size ? `
|
|
444
|
+
|
|
445
|
+
Other relations: ${[...other].map(([t, n]) => `${t} ${n}`).join(", ")} \u2014 pass relations: ${JSON.stringify([...other.keys()])} to list them.` : "";
|
|
446
|
+
return text(`${title}
|
|
249
447
|
|
|
250
|
-
Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${
|
|
448
|
+
Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}`);
|
|
251
449
|
}
|
|
252
|
-
|
|
450
|
+
const byType = /* @__PURE__ */ new Map();
|
|
451
|
+
for (const f of found) {
|
|
452
|
+
if (!byType.has(f.type)) byType.set(f.type, []);
|
|
453
|
+
byType.get(f.type).push(f.key);
|
|
454
|
+
}
|
|
455
|
+
const sections = [...byType].map(([t, ks]) => `## ${t} (${ks.length})
|
|
456
|
+
${ks.map((k) => `- \`${k}\``).join("\n")}`).join("\n\n");
|
|
457
|
+
return text(`${title}
|
|
458
|
+
|
|
459
|
+
${found.length} found (${relLabel}):
|
|
253
460
|
|
|
254
|
-
${
|
|
255
|
-
${unique.map((r) => `- \`${r}\``).join("\n")}`);
|
|
461
|
+
${sections}`);
|
|
256
462
|
}
|
|
257
463
|
if (name === "impact_analysis") {
|
|
258
464
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
259
465
|
if (!store) return text("No system graph found.");
|
|
260
466
|
const file = args.file;
|
|
261
467
|
const repo = args.repo;
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
468
|
+
const depth = args.depth ?? 2;
|
|
469
|
+
let entity = args.entity;
|
|
470
|
+
if (entity?.includes("::")) {
|
|
471
|
+
const prefix = `${repo}::${file}::`;
|
|
472
|
+
if (!entity.startsWith(prefix)) return toolError(`impact_analysis: entity id "${entity}" is not in ${repo}/${file} \u2014 pass a bare name or an id starting with "${prefix}".`);
|
|
473
|
+
entity = entity.slice(prefix.length);
|
|
474
|
+
}
|
|
475
|
+
const fileKeys = store.nodesInFiles(repo, [file]);
|
|
476
|
+
if (fileKeys.length === 0) return toolError(`No graph nodes for ${repo}/${file} \u2014 check the repo name and that the path is repo-relative and indexed.`);
|
|
477
|
+
const scope = entity ? store.nodesInFiles(repo, [file], entity) : fileKeys;
|
|
478
|
+
if (scope.length === 0) return toolError(`No entity "${entity}" in ${repo}/${file}.`);
|
|
479
|
+
const endpoints = entity ? endpointsIn(store, fileKeys).filter((k) => !scope.includes(k)) : [];
|
|
480
|
+
const fileLevel = new Set(endpoints);
|
|
481
|
+
const budget = new TraversalBudget();
|
|
482
|
+
const { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel, budget);
|
|
483
|
+
const total = hops.reduce((n, h) => n + h.total, 0);
|
|
484
|
+
const truncated = hops.some((h) => h.truncated);
|
|
265
485
|
const body = [
|
|
266
|
-
`# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""}`,
|
|
267
|
-
"",
|
|
268
|
-
`## Entities in scope: ${fileKeys.length}`,
|
|
269
|
-
...fileKeys.slice(0, 20).map((k) => `- \`${k}\``),
|
|
486
|
+
`# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""} (depth ${depth})`,
|
|
270
487
|
"",
|
|
271
|
-
`##
|
|
272
|
-
...
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
"",
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
488
|
+
`## Entities in scope: ${scope.length}`,
|
|
489
|
+
...scope.slice(0, 20).map((k) => `- \`${k}\``)
|
|
490
|
+
];
|
|
491
|
+
if (endpoints.length) {
|
|
492
|
+
body.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in ${file}, not tied to ${entity})`, ...endpoints.slice(0, 20).map((k) => `- \`${k}\``));
|
|
493
|
+
}
|
|
494
|
+
body.push("", `## Dependents: ${total}${atLeast(truncated)} edges from ${repos.size} repos`);
|
|
495
|
+
for (const h of hops) {
|
|
496
|
+
if (h.edges.length === 0) continue;
|
|
497
|
+
body.push(`### Hop ${h.hop} (${h.total}${atLeast(h.truncated)})`, ...h.edges.slice(0, 30).map((e) => edgeLine(e, fileLevel)));
|
|
498
|
+
if (h.total > Math.min(h.edges.length, 30)) body.push(`... and ${h.total - Math.min(h.edges.length, 30)}${atLeast(h.truncated)} more`);
|
|
499
|
+
}
|
|
500
|
+
if (truncated) body.push("", AT_LEAST_NOTE);
|
|
501
|
+
if (budget.exhausted) {
|
|
502
|
+
body.push("", `_Stopped before hop ${hops.length + 1} at the traversal budget (explored ${budget.explored} nodes) \u2014 lower depth or pass an entity._`);
|
|
503
|
+
}
|
|
504
|
+
body.push("", `## Affected repos: ${[...repos].join(", ") || "none"}`);
|
|
505
|
+
return text(body.join("\n"));
|
|
279
506
|
}
|
|
280
507
|
if (name === "trace_path") {
|
|
281
508
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -285,14 +512,18 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}`);
|
|
|
285
512
|
const repo = args.repo;
|
|
286
513
|
const fuzzy = args.fuzzy === true;
|
|
287
514
|
const direction = args.direction ?? "callees";
|
|
288
|
-
const maxDepth =
|
|
515
|
+
const maxDepth = args.maxDepth ?? 4;
|
|
289
516
|
const fromKeys = store.resolveNodes(from, repo, fuzzy).map((m) => m.key);
|
|
290
|
-
if (fromKeys.length === 0) return text(`No entity found matching "${from}"
|
|
291
|
-
const
|
|
517
|
+
if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
518
|
+
const types = edgeTypes(args);
|
|
519
|
+
const budget = new TraversalBudget();
|
|
292
520
|
if (to) {
|
|
293
|
-
const toKeys =
|
|
294
|
-
if (toKeys.
|
|
295
|
-
const path = shortestPath(
|
|
521
|
+
const toKeys = store.resolveNodes(to, repo, fuzzy).map((m) => m.key);
|
|
522
|
+
if (toKeys.length === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
523
|
+
const { path, depth } = shortestPath(store, fromKeys, toKeys, maxDepth, direction, types, budget);
|
|
524
|
+
if (!path && budget.exhausted) {
|
|
525
|
+
return text(`No path from "${from}" to "${to}" found within the traversal budget (explored ${budget.explored} nodes, depth ${depth} of ${maxDepth}, direction: ${direction}) \u2014 narrow the search (repo, exact names, relations) or lower maxDepth.`);
|
|
526
|
+
}
|
|
296
527
|
if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).`);
|
|
297
528
|
const labelOf2 = store.labelsOf(path);
|
|
298
529
|
const arrow = direction === "callers" ? " \u2190 " : " \u2192 ";
|
|
@@ -303,8 +534,11 @@ ${rendered}
|
|
|
303
534
|
|
|
304
535
|
${path.map((k) => `- \`${k}\``).join("\n")}`);
|
|
305
536
|
}
|
|
306
|
-
const nodes = reachable(
|
|
307
|
-
|
|
537
|
+
const nodes = reachable(store, fromKeys, maxDepth, direction, types, budget);
|
|
538
|
+
const stopped = budget.exhausted ? `
|
|
539
|
+
|
|
540
|
+
_Stopped early at the traversal budget (explored ${budget.explored} nodes) \u2014 narrow the search or lower maxDepth._` : "";
|
|
541
|
+
if (nodes.length === 0) return text(`No ${direction} reachable from "${from}" within ${maxDepth} hops.${stopped}`);
|
|
308
542
|
const labelOf = store.labelsOf(nodes.map((n) => n.key));
|
|
309
543
|
const byDepth = /* @__PURE__ */ new Map();
|
|
310
544
|
for (const { key, depth } of nodes) {
|
|
@@ -317,20 +551,25 @@ ${ks.map((k) => `- ${k}`).join("\n")}`).join("\n\n");
|
|
|
317
551
|
|
|
318
552
|
${nodes.length} nodes:
|
|
319
553
|
|
|
320
|
-
${sections}`);
|
|
554
|
+
${sections}${stopped}`);
|
|
321
555
|
}
|
|
322
556
|
if (name === "find_dead_code") {
|
|
323
557
|
const repo = args.repo;
|
|
324
|
-
const limit = args.limit
|
|
558
|
+
const limit = args.limit ?? 50;
|
|
325
559
|
const graph = await blobs.getJson(`${repo}/graph.json`);
|
|
326
|
-
if (!graph) return
|
|
560
|
+
if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
|
|
327
561
|
const ENTITY_TYPES = /* @__PURE__ */ new Set(["function", "method", "class", "struct", "enum", "trait"]);
|
|
328
562
|
const inDegree = /* @__PURE__ */ new Map();
|
|
329
563
|
for (const l of graph.links ?? []) {
|
|
330
564
|
if (l.type === "contains") continue;
|
|
331
565
|
inDegree.set(l.target, (inDegree.get(l.target) ?? 0) + 1);
|
|
332
566
|
}
|
|
333
|
-
const
|
|
567
|
+
const candidates = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
|
|
568
|
+
const source = await indexedSources(ctx.projectName, repo, [...new Set(candidates.map((n) => n.file).filter(Boolean))]);
|
|
569
|
+
const dead = candidates.filter((n) => {
|
|
570
|
+
const src = source.get(n.file);
|
|
571
|
+
return src === void 0 || identifierCount(src, n.label ?? n.id) <= 1;
|
|
572
|
+
});
|
|
334
573
|
if (dead.length === 0) return text(`# Dead code in ${repo}
|
|
335
574
|
|
|
336
575
|
No zero-caller entities found.`);
|
|
@@ -349,7 +588,7 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
|
|
|
349
588
|
const repo = args.repo;
|
|
350
589
|
if (repo) {
|
|
351
590
|
const profile = await loadProfile(ctx.projectName, repo, resolvedKnowledgeConfig());
|
|
352
|
-
if (!profile) return text(
|
|
591
|
+
if (!profile) return text(missingProfileText(repo));
|
|
353
592
|
const ep = (xs) => xs?.length ? xs.map((e) => ` - ${e.type}: ${e.identifier} \u2014 ${e.description}`).join("\n") : " - (none)";
|
|
354
593
|
const body = [
|
|
355
594
|
`# ${profile.name} \u2014 ${profile.role} (${profile.domain})`,
|
|
@@ -397,9 +636,9 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
|
|
|
397
636
|
|
|
398
637
|
${profiles.map((p) => `- **${p.name}** \u2014 ${p.role} (${p.domain}): ${p.description}`).join("\n")}
|
|
399
638
|
|
|
400
|
-
|
|
639
|
+
_A full architecture view needs the LLM project graph (\`anvil graph build\`)._`);
|
|
401
640
|
}
|
|
402
|
-
return text("No architecture data yet
|
|
641
|
+
return text("No architecture data yet: it needs repo profiles (generated during indexing when an LLM is configured) or the LLM project graph (`anvil graph build`).");
|
|
403
642
|
}
|
|
404
643
|
if (name === "search_graph") {
|
|
405
644
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -410,7 +649,7 @@ _Run project-graph generation (requires LLM) for a full architecture view._`);
|
|
|
410
649
|
file: args.file,
|
|
411
650
|
repo: args.repo,
|
|
412
651
|
minDegree: args.minDegree ?? 0
|
|
413
|
-
}, args.limit
|
|
652
|
+
}, args.limit ?? 50);
|
|
414
653
|
if (total === 0) return text("No entities match the given filters.");
|
|
415
654
|
const body = rows.map((m) => `- \`${m.label}\` (${m.type}, ${m.file}) \u2014 degree ${m.degree} \`${m.key}\``).join("\n");
|
|
416
655
|
return text(`# search_graph \u2014 ${total} match${total === 1 ? "" : "es"}
|
|
@@ -421,13 +660,13 @@ ${body}${total > rows.length ? `
|
|
|
421
660
|
}
|
|
422
661
|
if (name === "detect_changes") {
|
|
423
662
|
const repo = args.repo;
|
|
424
|
-
const limit = args.limit
|
|
663
|
+
const limit = args.limit ?? 50;
|
|
425
664
|
if (!ctx.directoryPath) {
|
|
426
|
-
return
|
|
665
|
+
return toolError("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
|
|
427
666
|
}
|
|
428
667
|
const candidate = join(ctx.directoryPath, repo);
|
|
429
668
|
const repoPath = existsSync(join(candidate, ".git")) ? candidate : ctx.directoryPath;
|
|
430
|
-
if (!existsSync(repoPath)) return
|
|
669
|
+
if (!existsSync(repoPath)) return toolError(`Repo path not found: ${repoPath}`);
|
|
431
670
|
let baseSha = args.baseSha;
|
|
432
671
|
if (!baseSha) {
|
|
433
672
|
try {
|
|
@@ -436,43 +675,87 @@ ${body}${total > rows.length ? `
|
|
|
436
675
|
} catch {
|
|
437
676
|
}
|
|
438
677
|
}
|
|
439
|
-
if (!baseSha) return
|
|
678
|
+
if (!baseSha) return toolError("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
|
|
440
679
|
const diff = getAllChanges(repoPath, baseSha);
|
|
441
|
-
if (diff.fallbackToFull) return
|
|
680
|
+
if (diff.fallbackToFull) return toolError(`git diff against ${baseSha.slice(0, 7)} failed or is too large to map incrementally. Check the base SHA.`);
|
|
442
681
|
const changedFiles = getChangedFilesList(diff);
|
|
443
682
|
if (changedFiles.length === 0 && diff.deleted.length === 0) return text(`No source changes since ${baseSha.slice(0, 7)}.`);
|
|
444
683
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
684
|
+
const changedKeys = [];
|
|
685
|
+
const endpoints = [];
|
|
686
|
+
const wholeFile = [];
|
|
687
|
+
const unlocated = /* @__PURE__ */ new Set();
|
|
688
|
+
let hops = [];
|
|
689
|
+
let depRepos = /* @__PURE__ */ new Set();
|
|
690
|
+
const fileLevel = /* @__PURE__ */ new Set();
|
|
691
|
+
let budget = null;
|
|
449
692
|
if (store) {
|
|
450
|
-
changedKeys
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
693
|
+
changedKeys.push(...store.nodesInFiles(repo, [...diff.added, ...diff.deleted]));
|
|
694
|
+
const hunks = diffHunks(repoPath, baseSha, diff.modified);
|
|
695
|
+
for (const file of diff.modified) {
|
|
696
|
+
const fileKeys = store.nodesInFiles(repo, [file]);
|
|
697
|
+
const types = store.nodeTypes(fileKeys);
|
|
698
|
+
const modKey = `${repo}::${file}`;
|
|
699
|
+
const entities = new Map(fileKeys.filter((k) => k.startsWith(`${modKey}::`) && !["module", "external", "package"].includes(types.get(k) ?? "")).map((k) => [k.slice(modKey.length + 2), k]));
|
|
700
|
+
endpoints.push(...fileKeys.filter((k) => types.get(k) === "external"));
|
|
701
|
+
let base;
|
|
702
|
+
try {
|
|
703
|
+
base = runGit(repoPath, ["show", `${baseSha}:${file}`]);
|
|
704
|
+
} catch {
|
|
705
|
+
wholeFile.push(file);
|
|
706
|
+
changedKeys.push(...fileKeys.filter((k) => types.get(k) !== "external"));
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const ranges = declarationRanges(base, [...entities.keys()]);
|
|
710
|
+
const { names, fileLevel: touchesFile } = touchedEntities(ranges, hunks.get(file) ?? []);
|
|
711
|
+
if (touchesFile && fileKeys.includes(modKey)) changedKeys.push(modKey);
|
|
712
|
+
const located = new Set(ranges.map((r) => r.name));
|
|
713
|
+
for (const [name2, key] of entities) {
|
|
714
|
+
if (names.has(name2)) changedKeys.push(key);
|
|
715
|
+
else if (!located.has(name2)) {
|
|
716
|
+
changedKeys.push(key);
|
|
717
|
+
unlocated.add(key);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const k of endpoints) fileLevel.add(k);
|
|
722
|
+
budget = new TraversalBudget();
|
|
723
|
+
({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel, budget));
|
|
455
724
|
}
|
|
725
|
+
const dependents = hops[0]?.edges ?? [];
|
|
726
|
+
const depTotal = hops[0]?.total ?? 0;
|
|
727
|
+
const plus = atLeast(hops[0]?.truncated ?? false);
|
|
456
728
|
const lines = [
|
|
457
729
|
`# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
|
|
458
730
|
"",
|
|
459
|
-
`## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})
|
|
460
|
-
...changedFiles.slice(0, 30).map((f) => `- ${f}`),
|
|
731
|
+
`## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})`,
|
|
732
|
+
...changedFiles.slice(0, 30).map((f) => `- ${f}${wholeFile.includes(f) ? " (not readable at the base \u2014 every entity in the file)" : ""}`),
|
|
733
|
+
"",
|
|
734
|
+
`## Deleted files: ${diff.deleted.length}`,
|
|
735
|
+
...diff.deleted.slice(0, 30).map((f) => `- ${f}`),
|
|
461
736
|
"",
|
|
462
737
|
`## Affected entities: ${changedKeys.length}`,
|
|
463
|
-
...changedKeys.slice(0, 30).map((k) => `- \`${k}
|
|
738
|
+
...changedKeys.slice(0, 30).map((k) => `- \`${k}\`${unlocated.has(k) ? " (declaration not found in the file \u2014 included to be safe)" : ""}`)
|
|
739
|
+
];
|
|
740
|
+
if (endpoints.length) {
|
|
741
|
+
lines.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in modified files)`, ...endpoints.slice(0, 30).map((k) => `- \`${k}\``));
|
|
742
|
+
}
|
|
743
|
+
lines.push(
|
|
464
744
|
"",
|
|
465
|
-
`## Dependents: ${depTotal} edges from ${depRepos.
|
|
466
|
-
...dependents.map((e) =>
|
|
745
|
+
`## Dependents: ${depTotal}${plus} edges from ${depRepos.size} repo(s)`,
|
|
746
|
+
...dependents.slice(0, limit).map((e) => edgeLine(e, fileLevel)),
|
|
747
|
+
...depTotal > Math.min(dependents.length, limit) ? [`... and ${depTotal - Math.min(dependents.length, limit)}${plus} more`] : [],
|
|
748
|
+
...plus ? ["", AT_LEAST_NOTE] : [],
|
|
749
|
+
...budget?.exhausted ? ["", `_Dependents not looked up: ${budget.explored} affected nodes exceed the traversal budget \u2014 diff against a closer baseSha._`] : [],
|
|
467
750
|
"",
|
|
468
|
-
`## Affected repos: ${depRepos.join(", ") || "none"}`
|
|
469
|
-
|
|
751
|
+
`## Affected repos: ${[...depRepos].join(", ") || "none"}`
|
|
752
|
+
);
|
|
470
753
|
return text(lines.join("\n"));
|
|
471
754
|
}
|
|
472
755
|
return null;
|
|
473
756
|
} catch (err) {
|
|
474
757
|
const msg = err instanceof Error ? err.message : String(err);
|
|
475
|
-
return
|
|
758
|
+
return toolError(`Graph tool error: ${msg}`);
|
|
476
759
|
}
|
|
477
760
|
}
|
|
478
761
|
export {
|