@esankhan3/code-search-mcp 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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__/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 +353 -133
- package/dist/tools/index-tools.js +3 -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 +1 -1
package/dist/tools/graph.js
CHANGED
|
@@ -1,49 +1,42 @@
|
|
|
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
|
-
|
|
19
|
+
const fileOf = (key) => key.split("::")[1] ?? "";
|
|
20
|
+
function makeNeighborGetter(store, direction, types) {
|
|
26
21
|
const cache = /* @__PURE__ */ new Map();
|
|
27
22
|
return (key) => {
|
|
28
23
|
let v = cache.get(key);
|
|
29
24
|
if (v === void 0) {
|
|
30
|
-
v = store.neighborsOf(key, direction);
|
|
25
|
+
v = store.neighborsOf(key, direction, types);
|
|
31
26
|
cache.set(key, v);
|
|
32
27
|
}
|
|
33
28
|
return v;
|
|
34
29
|
};
|
|
35
30
|
}
|
|
36
31
|
function shortestPath(getNeighbors, fromKeys, toKeys, maxDepth) {
|
|
37
|
-
const seed = fromKeys.find((k) => toKeys.has(k));
|
|
38
|
-
if (seed) return [seed];
|
|
39
32
|
const visited = new Set(fromKeys);
|
|
40
33
|
let frontier = fromKeys.map((k) => [k]);
|
|
41
34
|
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
42
35
|
const next = [];
|
|
43
36
|
for (const path of frontier) {
|
|
44
37
|
for (const nb of getNeighbors(path[path.length - 1])) {
|
|
38
|
+
if (toKeys.has(nb) && nb !== path[0]) return [...path, nb];
|
|
45
39
|
if (visited.has(nb)) continue;
|
|
46
|
-
if (toKeys.has(nb)) return [...path, nb];
|
|
47
40
|
visited.add(nb);
|
|
48
41
|
next.push([...path, nb]);
|
|
49
42
|
}
|
|
@@ -71,88 +64,210 @@ function reachable(getNeighbors, fromKeys, maxDepth, cap = 50) {
|
|
|
71
64
|
}
|
|
72
65
|
return out;
|
|
73
66
|
}
|
|
67
|
+
function endpointsIn(store, keys) {
|
|
68
|
+
const types = store.nodeTypes(keys);
|
|
69
|
+
return keys.filter((k) => types.get(k) === "external");
|
|
70
|
+
}
|
|
71
|
+
const HOP_EDGE_CAP = 1e3;
|
|
72
|
+
function dependentHops(store, scope, depth, fileLevel) {
|
|
73
|
+
const visited = new Set(scope);
|
|
74
|
+
const repos = /* @__PURE__ */ new Set();
|
|
75
|
+
const hops = [];
|
|
76
|
+
let frontier = [...scope];
|
|
77
|
+
for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
|
|
78
|
+
const d = store.dependents(frontier, HOP_EDGE_CAP);
|
|
79
|
+
const edges = d.edges.filter((e) => !visited.has(e.source));
|
|
80
|
+
const direct = /* @__PURE__ */ new Set();
|
|
81
|
+
const next = [];
|
|
82
|
+
for (const e of edges) {
|
|
83
|
+
repos.add(repoOf(e.source));
|
|
84
|
+
if (!fileLevel.has(e.target)) direct.add(e.source);
|
|
85
|
+
if (!visited.has(e.source)) {
|
|
86
|
+
visited.add(e.source);
|
|
87
|
+
next.push(e.source);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const k of next) if (!direct.has(k)) fileLevel.add(k);
|
|
91
|
+
hops.push({ hop, edges, total: d.total - (d.edges.length - edges.length) });
|
|
92
|
+
frontier = next;
|
|
93
|
+
}
|
|
94
|
+
return { hops, repos };
|
|
95
|
+
}
|
|
96
|
+
function edgeLine(e, fileLevel) {
|
|
97
|
+
const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
|
|
98
|
+
return `- \`${e.source}\` ${arrow} \`${e.target}\` (${e.type ?? "edge"})${e.evidence ? ` \u2014 ${e.evidence}` : ""}${fileLevel.has(e.target) ? " [file-level]" : ""}`;
|
|
99
|
+
}
|
|
100
|
+
async function indexedSources(project, repo, files) {
|
|
101
|
+
const cfg = resolvedKnowledgeConfig();
|
|
102
|
+
const parts = /* @__PURE__ */ new Map();
|
|
103
|
+
try {
|
|
104
|
+
const vs = (await getRetriever(project, cfg)).vectorStore;
|
|
105
|
+
await Promise.all(files.map(async (f) => {
|
|
106
|
+
const chunks = await vs.getChunksByFile(repo, f);
|
|
107
|
+
if (chunks.length > 0) parts.set(f, chunks.map((c) => c.content));
|
|
108
|
+
}));
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
const missing = new Set(files.filter((f) => !parts.has(f)));
|
|
112
|
+
if (missing.size > 0 && await getBlobStore(project, cfg).exists("chunks.json")) {
|
|
113
|
+
const found = await findChunksInProject(project, (c) => c.repoName === repo && missing.has(c.filePath), Number.POSITIVE_INFINITY, cfg);
|
|
114
|
+
for (const c of found) {
|
|
115
|
+
if (!parts.has(c.filePath)) parts.set(c.filePath, []);
|
|
116
|
+
parts.get(c.filePath).push(c.content);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return new Map([...parts].map(([f, cs]) => [f, cs.join("\n")]));
|
|
120
|
+
}
|
|
121
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
122
|
+
function identifierCount(src, name) {
|
|
123
|
+
return src.match(new RegExp(`(?<![\\w$])${escapeRegex(name)}(?![\\w$])`, "g"))?.length ?? 0;
|
|
124
|
+
}
|
|
125
|
+
const runGit = (repoPath, args) => execFileSync("git", args, {
|
|
126
|
+
cwd: repoPath,
|
|
127
|
+
encoding: "utf-8",
|
|
128
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
129
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
130
|
+
});
|
|
131
|
+
function diffHunks(repoPath, baseSha, files) {
|
|
132
|
+
const out = /* @__PURE__ */ new Map();
|
|
133
|
+
if (files.length === 0) return out;
|
|
134
|
+
let file = "";
|
|
135
|
+
for (const line of runGit(repoPath, ["diff", "-U0", "--no-color", "--no-ext-diff", baseSha, "--", ...files]).split("\n")) {
|
|
136
|
+
if (line.startsWith("+++ ")) {
|
|
137
|
+
file = line.slice(4).replace(/^b\//, "");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const m = /^@@ -(\d+)(?:,(\d+))? /.exec(line);
|
|
141
|
+
if (m && file) {
|
|
142
|
+
if (!out.has(file)) out.set(file, []);
|
|
143
|
+
out.get(file).push([Number(m[1]), m[2] === void 0 ? 1 : Number(m[2])]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function declarationRanges(src, names) {
|
|
149
|
+
const lines = src.split("\n");
|
|
150
|
+
const starts = [];
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
const n = escapeRegex(name);
|
|
153
|
+
const keyword = new RegExp(`\\b(?:function\\*?|class|interface|type|enum|struct|trait|impl|def|func|fn|const|let|var)\\s+(?:\\([^)]*\\)\\s*)?${n}(?![\\w$])`);
|
|
154
|
+
const method = new RegExp(`^\\s*(?:(?:public|private|protected|internal|static|async|override|readonly|abstract|final|virtual|export|default)\\s+)*(?:[\\w$<>\\[\\],.?]+\\s+)?${n}\\s*(?:<[^>]*>)?\\(`);
|
|
155
|
+
let found = lines.flatMap((l, i) => keyword.test(l) ? [i + 1] : []);
|
|
156
|
+
if (found.length === 0) found = lines.flatMap((l, i) => method.test(l) && !l.trimEnd().endsWith(";") ? [i + 1] : []);
|
|
157
|
+
for (const line of found) starts.push({ name, line });
|
|
158
|
+
}
|
|
159
|
+
starts.sort((a, b) => a.line - b.line);
|
|
160
|
+
return starts.map((s, i) => ({ name: s.name, start: s.line, end: (starts[i + 1]?.line ?? lines.length + 1) - 1 }));
|
|
161
|
+
}
|
|
162
|
+
function touchedEntities(ranges, hunks) {
|
|
163
|
+
const names = /* @__PURE__ */ new Set();
|
|
164
|
+
let fileLevel = false;
|
|
165
|
+
for (const [start, count] of hunks) {
|
|
166
|
+
const hit = ranges.filter((r) => count > 0 ? r.start <= start + count - 1 && r.end >= start : r.start <= start && r.end >= start + 1);
|
|
167
|
+
if (hit.length === 0) fileLevel = true;
|
|
168
|
+
for (const r of hit) names.add(r.name);
|
|
169
|
+
}
|
|
170
|
+
return { names, fileLevel };
|
|
171
|
+
}
|
|
172
|
+
const RELATIONS = ["calls", "imports", "uses", "type-ref", "inherits", "all"];
|
|
173
|
+
const relationsProp = (what) => ({
|
|
174
|
+
type: "array",
|
|
175
|
+
items: { type: "string", enum: RELATIONS },
|
|
176
|
+
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)`
|
|
177
|
+
});
|
|
178
|
+
function edgeTypes(args) {
|
|
179
|
+
const r = args.relations ?? ["calls"];
|
|
180
|
+
return r.includes("all") ? void 0 : r;
|
|
181
|
+
}
|
|
74
182
|
function registerGraphTools() {
|
|
75
183
|
return [
|
|
76
184
|
{
|
|
77
185
|
name: "get_repo_graph",
|
|
78
|
-
description: "
|
|
186
|
+
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
187
|
inputSchema: {
|
|
80
188
|
type: "object",
|
|
81
|
-
properties: {
|
|
189
|
+
properties: {
|
|
190
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
191
|
+
limit: { type: "integer", minimum: 1, description: "Max entities and max edges listed (default 50)" }
|
|
192
|
+
},
|
|
82
193
|
required: ["repo"]
|
|
83
194
|
}
|
|
84
195
|
},
|
|
85
196
|
{
|
|
86
197
|
name: "get_cross_repo_edges",
|
|
87
|
-
description: "
|
|
198
|
+
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
199
|
inputSchema: {
|
|
89
200
|
type: "object",
|
|
90
|
-
properties: { repo: { type: "string", description: "
|
|
201
|
+
properties: { repo: { type: "string", minLength: 1, description: "Only edges involving this repo (optional)" } }
|
|
91
202
|
}
|
|
92
203
|
},
|
|
93
204
|
{
|
|
94
205
|
name: "find_callers",
|
|
95
|
-
description:
|
|
206
|
+
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
207
|
inputSchema: {
|
|
97
208
|
type: "object",
|
|
98
209
|
properties: {
|
|
99
|
-
function: { type: "string", description: "
|
|
100
|
-
repo: { type: "string", description: "Limit
|
|
101
|
-
fuzzy: { type: "boolean", description: "
|
|
210
|
+
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
211
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
212
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
213
|
+
relations: relationsProp("callers")
|
|
102
214
|
},
|
|
103
215
|
required: ["function"]
|
|
104
216
|
}
|
|
105
217
|
},
|
|
106
218
|
{
|
|
107
219
|
name: "find_dependencies",
|
|
108
|
-
description:
|
|
220
|
+
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
221
|
inputSchema: {
|
|
110
222
|
type: "object",
|
|
111
223
|
properties: {
|
|
112
|
-
function: { type: "string", description: "
|
|
113
|
-
repo: { type: "string", description: "Limit to this repo (optional)" },
|
|
114
|
-
fuzzy: { type: "boolean", description: "
|
|
224
|
+
function: { type: "string", minLength: 1, description: "Symbol name or qualified id (repo::file::entity)" },
|
|
225
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
226
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
227
|
+
relations: relationsProp("dependencies")
|
|
115
228
|
},
|
|
116
229
|
required: ["function"]
|
|
117
230
|
}
|
|
118
231
|
},
|
|
119
232
|
{
|
|
120
233
|
name: "impact_analysis",
|
|
121
|
-
description: "
|
|
234
|
+
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.",
|
|
122
235
|
inputSchema: {
|
|
123
236
|
type: "object",
|
|
124
237
|
properties: {
|
|
125
|
-
file: { type: "string", description: "File path (relative to repo root)" },
|
|
126
|
-
entity: { type: "string", description: "
|
|
127
|
-
repo: { type: "string", description: "Repository name" }
|
|
238
|
+
file: { type: "string", minLength: 1, description: "File path (relative to repo root)" },
|
|
239
|
+
entity: { type: "string", minLength: 1, description: 'Entity name or full "repo::file::entity" id (optional \u2014 if omitted, analyzes everything in the file)' },
|
|
240
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
241
|
+
depth: { type: "integer", minimum: 1, maximum: 5, description: "Dependency hops to follow (default 2, max 5)" }
|
|
128
242
|
},
|
|
129
243
|
required: ["file", "repo"]
|
|
130
244
|
}
|
|
131
245
|
},
|
|
132
246
|
{
|
|
133
247
|
name: "trace_path",
|
|
134
|
-
description: 'Trace
|
|
248
|
+
description: 'Trace a call chain across multiple hops. With "to", returns the shortest path (fewest hops, unweighted 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.',
|
|
135
249
|
inputSchema: {
|
|
136
250
|
type: "object",
|
|
137
251
|
properties: {
|
|
138
|
-
from: { type: "string", description: "Starting
|
|
139
|
-
to: { type: "string", description: "Target
|
|
140
|
-
repo: { type: "string", description: "Limit resolution to this repo (optional)" },
|
|
252
|
+
from: { type: "string", minLength: 1, description: "Starting symbol name or qualified id (repo::file::entity)" },
|
|
253
|
+
to: { type: "string", minLength: 1, description: "Target symbol name or qualified id (optional \u2014 shortest path)" },
|
|
254
|
+
repo: { type: "string", minLength: 1, description: "Limit resolution to this repo (optional)" },
|
|
141
255
|
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: "
|
|
256
|
+
maxDepth: { type: "integer", minimum: 1, maximum: 10, description: "Max hops to traverse (default 4, max 10)" },
|
|
257
|
+
fuzzy: { type: "boolean", description: "Case-insensitive substring match on symbol names instead of exact (default false)" },
|
|
258
|
+
relations: relationsProp("the traversal")
|
|
144
259
|
},
|
|
145
260
|
required: ["from"]
|
|
146
261
|
}
|
|
147
262
|
},
|
|
148
263
|
{
|
|
149
264
|
name: "find_dead_code",
|
|
150
|
-
description: "List entities (functions, methods, classes) with no
|
|
265
|
+
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
266
|
inputSchema: {
|
|
152
267
|
type: "object",
|
|
153
268
|
properties: {
|
|
154
|
-
repo: { type: "string", description: "Repository name" },
|
|
155
|
-
limit: { type: "
|
|
269
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
270
|
+
limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
|
|
156
271
|
},
|
|
157
272
|
required: ["repo"]
|
|
158
273
|
}
|
|
@@ -162,59 +277,74 @@ function registerGraphTools() {
|
|
|
162
277
|
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
278
|
inputSchema: {
|
|
164
279
|
type: "object",
|
|
165
|
-
properties: { repo: { type: "string", description: "Drill into one repo profile (optional)" } }
|
|
280
|
+
properties: { repo: { type: "string", minLength: 1, description: "Drill into one repo profile (optional)" } }
|
|
166
281
|
}
|
|
167
282
|
},
|
|
168
283
|
{
|
|
169
284
|
name: "search_graph",
|
|
170
|
-
description: `Structural search over the knowledge graph: filter
|
|
285
|
+
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
286
|
inputSchema: {
|
|
172
287
|
type: "object",
|
|
173
288
|
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: "
|
|
289
|
+
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." },
|
|
290
|
+
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." },
|
|
291
|
+
file: { type: "string", minLength: 1, description: "Case-insensitive file-path substring filter (optional)" },
|
|
292
|
+
repo: { type: "string", minLength: 1, description: "Limit to this repo (optional)" },
|
|
293
|
+
minDegree: { type: "integer", minimum: 0, description: "Only nodes with at least this many connections, `contains` excluded (default 0)" },
|
|
294
|
+
limit: { type: "integer", minimum: 1, description: "Max results (default 50)" }
|
|
180
295
|
}
|
|
181
296
|
}
|
|
182
297
|
},
|
|
183
298
|
{
|
|
184
299
|
name: "detect_changes",
|
|
185
|
-
description: "Map a git diff to affected symbols and their dependents.
|
|
300
|
+
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
301
|
inputSchema: {
|
|
187
302
|
type: "object",
|
|
188
303
|
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: "
|
|
304
|
+
repo: { type: "string", minLength: 1, description: "Repository name" },
|
|
305
|
+
baseSha: { type: "string", minLength: 1, description: "Git base commit to diff against (default: last-indexed SHA)" },
|
|
306
|
+
limit: { type: "integer", minimum: 1, description: "Max dependent edges to list (default 50)" }
|
|
192
307
|
},
|
|
193
308
|
required: ["repo"]
|
|
194
309
|
}
|
|
195
310
|
}
|
|
196
311
|
];
|
|
197
312
|
}
|
|
313
|
+
const SCHEMAS = new Map(registerGraphTools().map((t) => [t.name, t.inputSchema]));
|
|
198
314
|
async function handleGraphTool(name, args, ctx) {
|
|
199
|
-
|
|
200
|
-
|
|
315
|
+
const schema = SCHEMAS.get(name);
|
|
316
|
+
if (!schema) return null;
|
|
317
|
+
const invalid = validateArgs(schema, args);
|
|
318
|
+
if (invalid) return toolError(`${name}: ${invalid}`);
|
|
319
|
+
const text = toolText;
|
|
201
320
|
const NO_GRAPH = "No system graph found. Build KB first.";
|
|
202
321
|
try {
|
|
203
322
|
const blobs = getBlobStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
204
323
|
if (name === "get_repo_graph") {
|
|
205
324
|
const repo = args.repo;
|
|
325
|
+
const limit = args.limit ?? 50;
|
|
206
326
|
const graph = await blobs.getJson(`${repo}/graph.json`);
|
|
207
|
-
if (!graph) return
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
327
|
+
if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
|
|
328
|
+
const nodes = graph.nodes ?? [];
|
|
329
|
+
const links = graph.links ?? [];
|
|
330
|
+
const byType = /* @__PURE__ */ new Map();
|
|
331
|
+
for (const l of links) byType.set(l.type ?? "edge", (byType.get(l.type ?? "edge") ?? 0) + 1);
|
|
332
|
+
const listed = links.filter((l) => l.type !== "contains");
|
|
333
|
+
const more = (n) => n > limit ? [`... and ${n - limit} more`] : [];
|
|
334
|
+
return text([
|
|
335
|
+
`# ${repo} AST Graph`,
|
|
336
|
+
"",
|
|
337
|
+
`- **Nodes:** ${nodes.length}`,
|
|
338
|
+
`- **Edges:** ${links.length}${byType.size ? ` (${[...byType].map(([t, n]) => `${t} ${n}`).join(", ")})` : ""}`,
|
|
339
|
+
"",
|
|
340
|
+
"## Entities",
|
|
341
|
+
...nodes.slice(0, limit).map((n) => `- \`${n.id}\` (${n.type})`),
|
|
342
|
+
...more(nodes.length),
|
|
343
|
+
"",
|
|
344
|
+
`## Edges (${listed.length}, \`contains\` omitted)`,
|
|
345
|
+
...listed.slice(0, limit).map((l) => `- \`${l.source}\` \u2192 \`${l.target}\` (${l.type ?? "edge"})`),
|
|
346
|
+
...more(listed.length)
|
|
347
|
+
].join("\n"));
|
|
218
348
|
}
|
|
219
349
|
if (name === "get_cross_repo_edges") {
|
|
220
350
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -222,14 +352,28 @@ ${graph.nodes?.length > 50 ? `... and ${graph.nodes.length - 50} more` : ""}`;
|
|
|
222
352
|
const repo = args.repo;
|
|
223
353
|
const { edges, total } = store.crossRepoEdges(repo, 50);
|
|
224
354
|
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
|
-
|
|
355
|
+
const between = edges.filter((e) => repoOf(e.source) !== repoOf(e.target));
|
|
356
|
+
const within = edges.filter((e) => repoOf(e.source) === repoOf(e.target));
|
|
357
|
+
const ev = (e) => e.evidence ? ` \u2014 ${e.evidence}` : "";
|
|
358
|
+
const labels = store.labelsOf(within.flatMap((e) => [e.source, e.target]));
|
|
359
|
+
const body = [
|
|
360
|
+
`# Cross-Repo Edges${repo ? ` for ${repo}` : ""}`,
|
|
361
|
+
"",
|
|
362
|
+
`${total} edges found:`
|
|
363
|
+
];
|
|
364
|
+
if (between.length) {
|
|
365
|
+
body.push("", `## Between repos (${between.length} listed)`);
|
|
366
|
+
for (const e of between) {
|
|
367
|
+
const arrow = SYMMETRIC_EDGE_TYPES.has(e.type ?? "") ? "\u2194" : "\u2192";
|
|
368
|
+
body.push(`- ${repoOf(e.source)} ${arrow} ${repoOf(e.target)} (${e.type ?? "edge"})${ev(e)} \`${fileOf(e.source)}\` ${arrow} \`${fileOf(e.target)}\``);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (within.length) {
|
|
372
|
+
body.push("", `## Within a repo \u2014 workspace packages (${within.length} listed)`);
|
|
373
|
+
for (const e of within) body.push(`- ${repoOf(e.source)}: ${labels.get(e.source)} \u2192 ${labels.get(e.target)} (${e.type ?? "edge"})${ev(e)}`);
|
|
374
|
+
}
|
|
375
|
+
if (total > edges.length) body.push("", `... and ${total - edges.length} more`);
|
|
376
|
+
return text(body.join("\n"));
|
|
233
377
|
}
|
|
234
378
|
if (name === "find_callers" || name === "find_dependencies") {
|
|
235
379
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -237,45 +381,77 @@ ${body}${total > edges.length ? `
|
|
|
237
381
|
const funcName = args.function;
|
|
238
382
|
const repoFilter = args.repo;
|
|
239
383
|
const fuzzy = args.fuzzy === true;
|
|
384
|
+
const types = edgeTypes(args);
|
|
240
385
|
const matched = store.resolveNodes(funcName, repoFilter, fuzzy);
|
|
241
386
|
if (matched.length === 0) {
|
|
242
|
-
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}
|
|
387
|
+
return text(`No entity found matching "${funcName}"${repoFilter ? ` in ${repoFilter}` : ""}.${fuzzy ? "" : " Try fuzzy:true for substring matching."}`);
|
|
243
388
|
}
|
|
244
389
|
const keys = matched.map((m) => m.key);
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
390
|
+
const fetch = (t) => name === "find_callers" ? store.callers(keys, 30, t) : store.dependencies(keys, 30, t);
|
|
391
|
+
const found = fetch(types);
|
|
392
|
+
const noun = name === "find_callers" ? "callers" : "dependencies";
|
|
393
|
+
const title = `# ${name === "find_callers" ? "Callers of" : "Dependencies of"} "${funcName}"`;
|
|
394
|
+
const relLabel = types ? types.join(", ") : "any relation";
|
|
395
|
+
if (found.length === 0) {
|
|
396
|
+
const other = /* @__PURE__ */ new Map();
|
|
397
|
+
if (types) for (const o of fetch(void 0)) other.set(o.type, (other.get(o.type) ?? 0) + 1);
|
|
398
|
+
const hint = other.size ? `
|
|
249
399
|
|
|
250
|
-
|
|
400
|
+
Other relations: ${[...other].map(([t, n]) => `${t} ${n}`).join(", ")} \u2014 pass relations: ${JSON.stringify([...other.keys()])} to list them.` : "";
|
|
401
|
+
return text(`${title}
|
|
402
|
+
|
|
403
|
+
Matched ${matched.length} entit${matched.length === 1 ? "y" : "ies"}, but no ${noun} via ${relLabel}.${hint}`);
|
|
404
|
+
}
|
|
405
|
+
const byType = /* @__PURE__ */ new Map();
|
|
406
|
+
for (const f of found) {
|
|
407
|
+
if (!byType.has(f.type)) byType.set(f.type, []);
|
|
408
|
+
byType.get(f.type).push(f.key);
|
|
251
409
|
}
|
|
252
|
-
|
|
410
|
+
const sections = [...byType].map(([t, ks]) => `## ${t} (${ks.length})
|
|
411
|
+
${ks.map((k) => `- \`${k}\``).join("\n")}`).join("\n\n");
|
|
412
|
+
return text(`${title}
|
|
413
|
+
|
|
414
|
+
${found.length} found (${relLabel}):
|
|
253
415
|
|
|
254
|
-
${
|
|
255
|
-
${unique.map((r) => `- \`${r}\``).join("\n")}`);
|
|
416
|
+
${sections}`);
|
|
256
417
|
}
|
|
257
418
|
if (name === "impact_analysis") {
|
|
258
419
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
259
420
|
if (!store) return text("No system graph found.");
|
|
260
421
|
const file = args.file;
|
|
261
422
|
const repo = args.repo;
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
423
|
+
const depth = args.depth ?? 2;
|
|
424
|
+
let entity = args.entity;
|
|
425
|
+
if (entity?.includes("::")) {
|
|
426
|
+
const prefix = `${repo}::${file}::`;
|
|
427
|
+
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}".`);
|
|
428
|
+
entity = entity.slice(prefix.length);
|
|
429
|
+
}
|
|
430
|
+
const fileKeys = store.nodesInFiles(repo, [file]);
|
|
431
|
+
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.`);
|
|
432
|
+
const scope = entity ? store.nodesInFiles(repo, [file], entity) : fileKeys;
|
|
433
|
+
if (scope.length === 0) return toolError(`No entity "${entity}" in ${repo}/${file}.`);
|
|
434
|
+
const endpoints = entity ? endpointsIn(store, fileKeys).filter((k) => !scope.includes(k)) : [];
|
|
435
|
+
const fileLevel = new Set(endpoints);
|
|
436
|
+
const { hops, repos } = dependentHops(store, [...scope, ...endpoints], depth, fileLevel);
|
|
437
|
+
const total = hops.reduce((n, h) => n + h.total, 0);
|
|
265
438
|
const body = [
|
|
266
|
-
`# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""}`,
|
|
267
|
-
"",
|
|
268
|
-
`## Entities in scope: ${fileKeys.length}`,
|
|
269
|
-
...fileKeys.slice(0, 20).map((k) => `- \`${k}\``),
|
|
270
|
-
"",
|
|
271
|
-
`## Dependents: ${total} edges from ${repos.length} repos`,
|
|
272
|
-
...dependents.map((e) => `- \`${e.source}\` \u2192 \`${e.target}\` (${e.type ?? "edge"})`),
|
|
273
|
-
total > dependents.length ? `
|
|
274
|
-
... and ${total - dependents.length} more` : "",
|
|
439
|
+
`# Impact Analysis: ${repo}/${file}${entity ? `::${entity}` : ""} (depth ${depth})`,
|
|
275
440
|
"",
|
|
276
|
-
`##
|
|
277
|
-
|
|
278
|
-
|
|
441
|
+
`## Entities in scope: ${scope.length}`,
|
|
442
|
+
...scope.slice(0, 20).map((k) => `- \`${k}\``)
|
|
443
|
+
];
|
|
444
|
+
if (endpoints.length) {
|
|
445
|
+
body.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in ${file}, not tied to ${entity})`, ...endpoints.slice(0, 20).map((k) => `- \`${k}\``));
|
|
446
|
+
}
|
|
447
|
+
body.push("", `## Dependents: ${total} edges from ${repos.size} repos`);
|
|
448
|
+
for (const h of hops) {
|
|
449
|
+
if (h.edges.length === 0) continue;
|
|
450
|
+
body.push(`### Hop ${h.hop} (${h.total})`, ...h.edges.slice(0, 30).map((e) => edgeLine(e, fileLevel)));
|
|
451
|
+
if (h.total > Math.min(h.edges.length, 30)) body.push(`... and ${h.total - Math.min(h.edges.length, 30)} more`);
|
|
452
|
+
}
|
|
453
|
+
body.push("", `## Affected repos: ${[...repos].join(", ") || "none"}`);
|
|
454
|
+
return text(body.join("\n"));
|
|
279
455
|
}
|
|
280
456
|
if (name === "trace_path") {
|
|
281
457
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -285,13 +461,13 @@ ${unique.map((r) => `- \`${r}\``).join("\n")}`);
|
|
|
285
461
|
const repo = args.repo;
|
|
286
462
|
const fuzzy = args.fuzzy === true;
|
|
287
463
|
const direction = args.direction ?? "callees";
|
|
288
|
-
const maxDepth =
|
|
464
|
+
const maxDepth = args.maxDepth ?? 4;
|
|
289
465
|
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 getNeighbors = makeNeighborGetter(store, direction);
|
|
466
|
+
if (fromKeys.length === 0) return text(`No entity found matching "${from}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
467
|
+
const getNeighbors = makeNeighborGetter(store, direction, edgeTypes(args));
|
|
292
468
|
if (to) {
|
|
293
469
|
const toKeys = new Set(store.resolveNodes(to, repo, fuzzy).map((m) => m.key));
|
|
294
|
-
if (toKeys.size === 0) return text(`No entity found matching target "${to}"
|
|
470
|
+
if (toKeys.size === 0) return text(`No entity found matching target "${to}".${fuzzy ? "" : " Try fuzzy:true."}`);
|
|
295
471
|
const path = shortestPath(getNeighbors, fromKeys, toKeys, maxDepth);
|
|
296
472
|
if (!path) return text(`No path from "${from}" to "${to}" within ${maxDepth} hops (direction: ${direction}).`);
|
|
297
473
|
const labelOf2 = store.labelsOf(path);
|
|
@@ -321,16 +497,21 @@ ${sections}`);
|
|
|
321
497
|
}
|
|
322
498
|
if (name === "find_dead_code") {
|
|
323
499
|
const repo = args.repo;
|
|
324
|
-
const limit = args.limit
|
|
500
|
+
const limit = args.limit ?? 50;
|
|
325
501
|
const graph = await blobs.getJson(`${repo}/graph.json`);
|
|
326
|
-
if (!graph) return
|
|
502
|
+
if (!graph) return toolError(`No graph found for repo "${repo}" \u2014 check the name with list_repos.`);
|
|
327
503
|
const ENTITY_TYPES = /* @__PURE__ */ new Set(["function", "method", "class", "struct", "enum", "trait"]);
|
|
328
504
|
const inDegree = /* @__PURE__ */ new Map();
|
|
329
505
|
for (const l of graph.links ?? []) {
|
|
330
506
|
if (l.type === "contains") continue;
|
|
331
507
|
inDegree.set(l.target, (inDegree.get(l.target) ?? 0) + 1);
|
|
332
508
|
}
|
|
333
|
-
const
|
|
509
|
+
const candidates = (graph.nodes ?? []).filter((n) => ENTITY_TYPES.has(n.type) && (inDegree.get(n.id) ?? 0) === 0);
|
|
510
|
+
const source = await indexedSources(ctx.projectName, repo, [...new Set(candidates.map((n) => n.file).filter(Boolean))]);
|
|
511
|
+
const dead = candidates.filter((n) => {
|
|
512
|
+
const src = source.get(n.file);
|
|
513
|
+
return src === void 0 || identifierCount(src, n.label ?? n.id) <= 1;
|
|
514
|
+
});
|
|
334
515
|
if (dead.length === 0) return text(`# Dead code in ${repo}
|
|
335
516
|
|
|
336
517
|
No zero-caller entities found.`);
|
|
@@ -349,7 +530,7 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
|
|
|
349
530
|
const repo = args.repo;
|
|
350
531
|
if (repo) {
|
|
351
532
|
const profile = await loadProfile(ctx.projectName, repo, resolvedKnowledgeConfig());
|
|
352
|
-
if (!profile) return text(
|
|
533
|
+
if (!profile) return text(missingProfileText(repo));
|
|
353
534
|
const ep = (xs) => xs?.length ? xs.map((e) => ` - ${e.type}: ${e.identifier} \u2014 ${e.description}`).join("\n") : " - (none)";
|
|
354
535
|
const body = [
|
|
355
536
|
`# ${profile.name} \u2014 ${profile.role} (${profile.domain})`,
|
|
@@ -397,9 +578,9 @@ _Note: exported APIs, reflection, and dynamic dispatch can cause false positives
|
|
|
397
578
|
|
|
398
579
|
${profiles.map((p) => `- **${p.name}** \u2014 ${p.role} (${p.domain}): ${p.description}`).join("\n")}
|
|
399
580
|
|
|
400
|
-
|
|
581
|
+
_A full architecture view needs the LLM project graph (\`anvil graph build\`)._`);
|
|
401
582
|
}
|
|
402
|
-
return text("No architecture data yet
|
|
583
|
+
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
584
|
}
|
|
404
585
|
if (name === "search_graph") {
|
|
405
586
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
@@ -410,7 +591,7 @@ _Run project-graph generation (requires LLM) for a full architecture view._`);
|
|
|
410
591
|
file: args.file,
|
|
411
592
|
repo: args.repo,
|
|
412
593
|
minDegree: args.minDegree ?? 0
|
|
413
|
-
}, args.limit
|
|
594
|
+
}, args.limit ?? 50);
|
|
414
595
|
if (total === 0) return text("No entities match the given filters.");
|
|
415
596
|
const body = rows.map((m) => `- \`${m.label}\` (${m.type}, ${m.file}) \u2014 degree ${m.degree} \`${m.key}\``).join("\n");
|
|
416
597
|
return text(`# search_graph \u2014 ${total} match${total === 1 ? "" : "es"}
|
|
@@ -421,13 +602,13 @@ ${body}${total > rows.length ? `
|
|
|
421
602
|
}
|
|
422
603
|
if (name === "detect_changes") {
|
|
423
604
|
const repo = args.repo;
|
|
424
|
-
const limit = args.limit
|
|
605
|
+
const limit = args.limit ?? 50;
|
|
425
606
|
if (!ctx.directoryPath) {
|
|
426
|
-
return
|
|
607
|
+
return toolError("detect_changes needs a local repo path \u2014 available in local/serve mode only (not remote-proxy mode).");
|
|
427
608
|
}
|
|
428
609
|
const candidate = join(ctx.directoryPath, repo);
|
|
429
610
|
const repoPath = existsSync(join(candidate, ".git")) ? candidate : ctx.directoryPath;
|
|
430
|
-
if (!existsSync(repoPath)) return
|
|
611
|
+
if (!existsSync(repoPath)) return toolError(`Repo path not found: ${repoPath}`);
|
|
431
612
|
let baseSha = args.baseSha;
|
|
432
613
|
if (!baseSha) {
|
|
433
614
|
try {
|
|
@@ -436,43 +617,82 @@ ${body}${total > rows.length ? `
|
|
|
436
617
|
} catch {
|
|
437
618
|
}
|
|
438
619
|
}
|
|
439
|
-
if (!baseSha) return
|
|
620
|
+
if (!baseSha) return toolError("No base commit available. Pass baseSha, or index the repo first so a last-indexed SHA exists.");
|
|
440
621
|
const diff = getAllChanges(repoPath, baseSha);
|
|
441
|
-
if (diff.fallbackToFull) return
|
|
622
|
+
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
623
|
const changedFiles = getChangedFilesList(diff);
|
|
443
624
|
if (changedFiles.length === 0 && diff.deleted.length === 0) return text(`No source changes since ${baseSha.slice(0, 7)}.`);
|
|
444
625
|
const store = await getGraphStore(ctx.projectName, resolvedKnowledgeConfig());
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
626
|
+
const changedKeys = [];
|
|
627
|
+
const endpoints = [];
|
|
628
|
+
const wholeFile = [];
|
|
629
|
+
const unlocated = /* @__PURE__ */ new Set();
|
|
630
|
+
let hops = [];
|
|
631
|
+
let depRepos = /* @__PURE__ */ new Set();
|
|
632
|
+
const fileLevel = /* @__PURE__ */ new Set();
|
|
449
633
|
if (store) {
|
|
450
|
-
changedKeys
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
634
|
+
changedKeys.push(...store.nodesInFiles(repo, [...diff.added, ...diff.deleted]));
|
|
635
|
+
const hunks = diffHunks(repoPath, baseSha, diff.modified);
|
|
636
|
+
for (const file of diff.modified) {
|
|
637
|
+
const fileKeys = store.nodesInFiles(repo, [file]);
|
|
638
|
+
const types = store.nodeTypes(fileKeys);
|
|
639
|
+
const modKey = `${repo}::${file}`;
|
|
640
|
+
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]));
|
|
641
|
+
endpoints.push(...fileKeys.filter((k) => types.get(k) === "external"));
|
|
642
|
+
let base;
|
|
643
|
+
try {
|
|
644
|
+
base = runGit(repoPath, ["show", `${baseSha}:${file}`]);
|
|
645
|
+
} catch {
|
|
646
|
+
wholeFile.push(file);
|
|
647
|
+
changedKeys.push(...fileKeys.filter((k) => types.get(k) !== "external"));
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const ranges = declarationRanges(base, [...entities.keys()]);
|
|
651
|
+
const { names, fileLevel: touchesFile } = touchedEntities(ranges, hunks.get(file) ?? []);
|
|
652
|
+
if (touchesFile && fileKeys.includes(modKey)) changedKeys.push(modKey);
|
|
653
|
+
const located = new Set(ranges.map((r) => r.name));
|
|
654
|
+
for (const [name2, key] of entities) {
|
|
655
|
+
if (names.has(name2)) changedKeys.push(key);
|
|
656
|
+
else if (!located.has(name2)) {
|
|
657
|
+
changedKeys.push(key);
|
|
658
|
+
unlocated.add(key);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const k of endpoints) fileLevel.add(k);
|
|
663
|
+
({ hops, repos: depRepos } = dependentHops(store, [...changedKeys, ...endpoints], 1, fileLevel));
|
|
455
664
|
}
|
|
665
|
+
const dependents = hops[0]?.edges ?? [];
|
|
666
|
+
const depTotal = hops[0]?.total ?? 0;
|
|
456
667
|
const lines = [
|
|
457
668
|
`# Changes since ${baseSha.slice(0, 7)} \u2014 ${repo}`,
|
|
458
669
|
"",
|
|
459
|
-
`## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})
|
|
460
|
-
...changedFiles.slice(0, 30).map((f) => `- ${f}`),
|
|
670
|
+
`## Changed files: ${changedFiles.length} (+${diff.added.length} / ~${diff.modified.length})`,
|
|
671
|
+
...changedFiles.slice(0, 30).map((f) => `- ${f}${wholeFile.includes(f) ? " (not readable at the base \u2014 every entity in the file)" : ""}`),
|
|
672
|
+
"",
|
|
673
|
+
`## Deleted files: ${diff.deleted.length}`,
|
|
674
|
+
...diff.deleted.slice(0, 30).map((f) => `- ${f}`),
|
|
461
675
|
"",
|
|
462
676
|
`## Affected entities: ${changedKeys.length}`,
|
|
463
|
-
...changedKeys.slice(0, 30).map((k) => `- \`${k}
|
|
677
|
+
...changedKeys.slice(0, 30).map((k) => `- \`${k}\`${unlocated.has(k) ? " (declaration not found in the file \u2014 included to be safe)" : ""}`)
|
|
678
|
+
];
|
|
679
|
+
if (endpoints.length) {
|
|
680
|
+
lines.push("", `## File-level endpoints: ${endpoints.length} (cross-repo endpoints in modified files)`, ...endpoints.slice(0, 30).map((k) => `- \`${k}\``));
|
|
681
|
+
}
|
|
682
|
+
lines.push(
|
|
464
683
|
"",
|
|
465
|
-
`## Dependents: ${depTotal} edges from ${depRepos.
|
|
466
|
-
...dependents.map((e) =>
|
|
684
|
+
`## Dependents: ${depTotal} edges from ${depRepos.size} repo(s)`,
|
|
685
|
+
...dependents.slice(0, limit).map((e) => edgeLine(e, fileLevel)),
|
|
686
|
+
...depTotal > Math.min(dependents.length, limit) ? [`... and ${depTotal - Math.min(dependents.length, limit)} more`] : [],
|
|
467
687
|
"",
|
|
468
|
-
`## Affected repos: ${depRepos.join(", ") || "none"}`
|
|
469
|
-
|
|
688
|
+
`## Affected repos: ${[...depRepos].join(", ") || "none"}`
|
|
689
|
+
);
|
|
470
690
|
return text(lines.join("\n"));
|
|
471
691
|
}
|
|
472
692
|
return null;
|
|
473
693
|
} catch (err) {
|
|
474
694
|
const msg = err instanceof Error ? err.message : String(err);
|
|
475
|
-
return
|
|
695
|
+
return toolError(`Graph tool error: ${msg}`);
|
|
476
696
|
}
|
|
477
697
|
}
|
|
478
698
|
export {
|