@vohongtho.infotech/code-intel 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -2927,9 +2927,9 @@ var init_orphan_files = __esm({
2927
2927
  // src/health/health-score.ts
2928
2928
  var health_score_exports = {};
2929
2929
  __export(health_score_exports, {
2930
- computeHealthReport: () => computeHealthReport
2930
+ computeHealthReport: () => computeHealthReport2
2931
2931
  });
2932
- function computeHealthReport(graph, godNodeConfig) {
2932
+ function computeHealthReport2(graph, godNodeConfig) {
2933
2933
  const deadCode = detectDeadCode(graph);
2934
2934
  const cycles = detectCircularDeps(graph);
2935
2935
  const godNodes = detectGodNodes(graph, godNodeConfig);
@@ -6330,7 +6330,8 @@ var NODE_TABLE_MAP = {
6330
6330
  constant: "const_nodes",
6331
6331
  route: "route_nodes",
6332
6332
  cluster: "cluster_nodes",
6333
- flow: "flow_nodes"
6333
+ flow: "flow_nodes",
6334
+ vulnerability: "vuln_nodes"
6334
6335
  };
6335
6336
  var ALL_NODE_TABLES = [...new Set(Object.values(NODE_TABLE_MAP))];
6336
6337
  function getCreateNodeTableDDL(tableName) {
@@ -6860,6 +6861,624 @@ async function queryGroup(group, query, limit = 20) {
6860
6861
 
6861
6862
  // src/mcp-server/server.ts
6862
6863
  init_tracing();
6864
+
6865
+ // src/query/explain-relationship.ts
6866
+ function explainRelationship(graph, from, to) {
6867
+ const allNodes = [...graph.allNodes()];
6868
+ const fromNode = allNodes.find((n) => n.name === from);
6869
+ if (!fromNode) {
6870
+ const firstChar = from[0]?.toLowerCase() ?? "";
6871
+ const fromLower = from.toLowerCase();
6872
+ const suggestions = allNodes.filter((n) => n.name.toLowerCase().startsWith(firstChar) || n.name.toLowerCase().includes(fromLower)).slice(0, 5).map((n) => n.name);
6873
+ return { error: `Symbol not found: ${from}`, suggestions };
6874
+ }
6875
+ const toNode = allNodes.find((n) => n.name === to);
6876
+ if (!toNode) {
6877
+ const firstChar = to[0]?.toLowerCase() ?? "";
6878
+ const toLower = to.toLowerCase();
6879
+ const suggestions = allNodes.filter((n) => n.name.toLowerCase().startsWith(firstChar) || n.name.toLowerCase().includes(toLower)).slice(0, 5).map((n) => n.name);
6880
+ return { error: `Symbol not found: ${to}`, suggestions };
6881
+ }
6882
+ const paths = [];
6883
+ const queue = [{
6884
+ id: fromNode.id,
6885
+ nodeNames: [fromNode.name],
6886
+ lastEdgeKind: "",
6887
+ visited: /* @__PURE__ */ new Set([fromNode.id])
6888
+ }];
6889
+ while (queue.length > 0 && paths.length < 10) {
6890
+ const entry = queue.shift();
6891
+ const { id, nodeNames, visited } = entry;
6892
+ if (nodeNames.length > 6) continue;
6893
+ for (const edge of graph.findEdgesFrom(id)) {
6894
+ const targetNode = graph.getNode(edge.target);
6895
+ if (!targetNode) continue;
6896
+ if (visited.has(edge.target)) continue;
6897
+ const newNames = [...nodeNames, targetNode.name];
6898
+ if (edge.target === toNode.id) {
6899
+ paths.push({ hops: newNames.length - 1, nodes: newNames, edgeKind: edge.kind });
6900
+ if (paths.length >= 10) break;
6901
+ continue;
6902
+ }
6903
+ if (newNames.length < 6) {
6904
+ const newVisited = new Set(visited);
6905
+ newVisited.add(edge.target);
6906
+ queue.push({ id: edge.target, nodeNames: newNames, lastEdgeKind: edge.kind, visited: newVisited });
6907
+ }
6908
+ }
6909
+ }
6910
+ const fromImports = /* @__PURE__ */ new Set();
6911
+ for (const edge of graph.findEdgesFrom(fromNode.id)) {
6912
+ if (edge.kind === "imports") fromImports.add(edge.target);
6913
+ }
6914
+ const sharedImportIds = [];
6915
+ for (const edge of graph.findEdgesFrom(toNode.id)) {
6916
+ if (edge.kind === "imports" && fromImports.has(edge.target)) {
6917
+ sharedImportIds.push(edge.target);
6918
+ }
6919
+ }
6920
+ const sharedImports = sharedImportIds.map((id) => graph.getNode(id)?.name ?? id);
6921
+ let heritage = null;
6922
+ for (const edge of graph.findEdgesFrom(fromNode.id)) {
6923
+ if ((edge.kind === "extends" || edge.kind === "implements") && edge.target === toNode.id) {
6924
+ heritage = `${from} ${edge.kind} ${to}`;
6925
+ break;
6926
+ }
6927
+ }
6928
+ if (!heritage) {
6929
+ for (const edge of graph.findEdgesFrom(toNode.id)) {
6930
+ if ((edge.kind === "extends" || edge.kind === "implements") && edge.target === fromNode.id) {
6931
+ heritage = `${to} ${edge.kind} ${from}`;
6932
+ break;
6933
+ }
6934
+ }
6935
+ }
6936
+ const sharedStr = sharedImports.length > 0 ? sharedImports.join(", ") : "none";
6937
+ const heritageStr = heritage ?? "none";
6938
+ const connectionStr = paths.length === 0 ? "No connection found." : `${from} \u2192 ${to} via ${paths.length} path(s).`;
6939
+ const summary = `${connectionStr} Shared imports: [${sharedStr}]. Heritage: ${heritageStr}.`;
6940
+ return { paths, sharedImports, heritage, summary };
6941
+ }
6942
+
6943
+ // src/query/pr-impact.ts
6944
+ function parseDiffFiles(diff) {
6945
+ const files = [];
6946
+ for (const line of diff.split("\n")) {
6947
+ const match = line.match(/^\+\+\+ b\/(.+)/);
6948
+ if (match) {
6949
+ files.push(match[1]);
6950
+ }
6951
+ }
6952
+ return files;
6953
+ }
6954
+ function computePRImpact(graph, changedFiles, maxHops) {
6955
+ const changedSymbolIds = /* @__PURE__ */ new Set();
6956
+ for (const node of graph.allNodes()) {
6957
+ if (!node.filePath) continue;
6958
+ for (const changedFile of changedFiles) {
6959
+ if (node.filePath === changedFile || node.filePath.endsWith(changedFile) || changedFile.endsWith(node.filePath)) {
6960
+ changedSymbolIds.add(node.id);
6961
+ break;
6962
+ }
6963
+ }
6964
+ }
6965
+ const allBlastRadiusNodes = /* @__PURE__ */ new Set();
6966
+ const changedSymbols = [];
6967
+ for (const symbolId of changedSymbolIds) {
6968
+ const symbolNode = graph.getNode(symbolId);
6969
+ if (!symbolNode) continue;
6970
+ const blastRadius = /* @__PURE__ */ new Set();
6971
+ const queue = [{ id: symbolId, depth: 0 }];
6972
+ const visited = /* @__PURE__ */ new Set();
6973
+ while (queue.length > 0) {
6974
+ const { id, depth } = queue.shift();
6975
+ if (visited.has(id) || depth > maxHops) continue;
6976
+ visited.add(id);
6977
+ if (id !== symbolId) blastRadius.add(id);
6978
+ for (const edge of graph.findEdgesTo(id)) {
6979
+ if (edge.kind === "calls" || edge.kind === "imports") {
6980
+ queue.push({ id: edge.source, depth: depth + 1 });
6981
+ }
6982
+ }
6983
+ }
6984
+ for (const id of blastRadius) allBlastRadiusNodes.add(id);
6985
+ const blastCount = blastRadius.size;
6986
+ let risk;
6987
+ if (blastCount > 50) {
6988
+ risk = "HIGH";
6989
+ } else if (blastCount >= 10) {
6990
+ risk = "MEDIUM";
6991
+ } else {
6992
+ risk = "LOW";
6993
+ }
6994
+ let callerCount = 0;
6995
+ for (const edge of graph.findEdgesTo(symbolId)) {
6996
+ if (edge.kind === "calls") callerCount++;
6997
+ }
6998
+ let testCoverage = false;
6999
+ for (const edge of graph.findEdgesTo(symbolId)) {
7000
+ if (edge.kind === "imports") {
7001
+ const callerNode = graph.getNode(edge.source);
7002
+ if (callerNode?.filePath && (callerNode.filePath.includes(".test.") || callerNode.filePath.includes(".spec."))) {
7003
+ testCoverage = true;
7004
+ break;
7005
+ }
7006
+ }
7007
+ }
7008
+ changedSymbols.push({ name: symbolNode.name, risk, callerCount, testCoverage });
7009
+ }
7010
+ const impactedSymbols = [];
7011
+ for (const id of allBlastRadiusNodes) {
7012
+ if (changedSymbolIds.has(id)) continue;
7013
+ const node = graph.getNode(id);
7014
+ if (node) {
7015
+ impactedSymbols.push({ name: node.name, filePath: node.filePath });
7016
+ }
7017
+ }
7018
+ const riskSummary = { HIGH: 0, MEDIUM: 0, LOW: 0 };
7019
+ for (const s of changedSymbols) {
7020
+ riskSummary[s.risk]++;
7021
+ }
7022
+ const coverageGaps = [];
7023
+ for (const s of changedSymbols) {
7024
+ if ((s.risk === "HIGH" || s.risk === "MEDIUM") && !s.testCoverage) {
7025
+ coverageGaps.push(`${s.name} has no test coverage`);
7026
+ }
7027
+ }
7028
+ const fileImpactCount = /* @__PURE__ */ new Map();
7029
+ for (const sym of impactedSymbols) {
7030
+ if (sym.filePath) {
7031
+ fileImpactCount.set(sym.filePath, (fileImpactCount.get(sym.filePath) ?? 0) + 1);
7032
+ }
7033
+ }
7034
+ const filesToReview = [...fileImpactCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([fp]) => fp);
7035
+ return {
7036
+ changedSymbols,
7037
+ impactedSymbols,
7038
+ riskSummary,
7039
+ coverageGaps,
7040
+ filesToReview,
7041
+ crossRepoImpact: null
7042
+ };
7043
+ }
7044
+
7045
+ // src/query/similar-symbols.ts
7046
+ function levenshtein(a, b) {
7047
+ const m = a.length;
7048
+ const n = b.length;
7049
+ const dp = Array.from(
7050
+ { length: m + 1 },
7051
+ (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0)
7052
+ );
7053
+ for (let i = 1; i <= m; i++) {
7054
+ for (let j = 1; j <= n; j++) {
7055
+ if (a[i - 1] === b[j - 1]) {
7056
+ dp[i][j] = dp[i - 1][j - 1];
7057
+ } else {
7058
+ dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
7059
+ }
7060
+ }
7061
+ }
7062
+ return dp[m][n];
7063
+ }
7064
+ function findSimilarSymbols(graph, symbolName, limit) {
7065
+ const clampedLimit = Math.min(Math.max(1, limit), 50);
7066
+ const allNodes = [...graph.allNodes()];
7067
+ const targetNode = allNodes.find((n) => n.name === symbolName);
7068
+ if (!targetNode) {
7069
+ return { similar: [] };
7070
+ }
7071
+ let targetCluster = null;
7072
+ for (const edge of graph.findEdgesFrom(targetNode.id)) {
7073
+ if (edge.kind === "belongs_to") {
7074
+ const clusterNode = graph.getNode(edge.target);
7075
+ if (clusterNode) {
7076
+ targetCluster = clusterNode.name;
7077
+ break;
7078
+ }
7079
+ }
7080
+ }
7081
+ if (!targetCluster) {
7082
+ for (const edge of graph.findEdgesTo(targetNode.id)) {
7083
+ if (edge.kind === "belongs_to") {
7084
+ const clusterNode = graph.getNode(edge.source);
7085
+ if (clusterNode) {
7086
+ targetCluster = clusterNode.name;
7087
+ break;
7088
+ }
7089
+ }
7090
+ }
7091
+ }
7092
+ const results = [];
7093
+ for (const node of allNodes) {
7094
+ if (node.id === targetNode.id) continue;
7095
+ const maxLen = Math.max(symbolName.length, node.name.length);
7096
+ const nameSim = maxLen === 0 ? 1 : 1 - levenshtein(symbolName, node.name) / maxLen;
7097
+ const structuralSim = node.kind === targetNode.kind ? 0.5 : 0;
7098
+ const combined = 0.5 * nameSim + 0.5 * structuralSim;
7099
+ const reasons = [];
7100
+ if (nameSim >= 0.6) reasons.push("similar name");
7101
+ if (node.kind === targetNode.kind) reasons.push("same kind");
7102
+ if (targetCluster !== null) {
7103
+ let nodeCluster = null;
7104
+ for (const edge of graph.findEdgesFrom(node.id)) {
7105
+ if (edge.kind === "belongs_to") {
7106
+ const clusterNode = graph.getNode(edge.target);
7107
+ if (clusterNode) {
7108
+ nodeCluster = clusterNode.name;
7109
+ break;
7110
+ }
7111
+ }
7112
+ }
7113
+ if (!nodeCluster) {
7114
+ for (const edge of graph.findEdgesTo(node.id)) {
7115
+ if (edge.kind === "belongs_to") {
7116
+ const clusterNode = graph.getNode(edge.source);
7117
+ if (clusterNode) {
7118
+ nodeCluster = clusterNode.name;
7119
+ break;
7120
+ }
7121
+ }
7122
+ }
7123
+ }
7124
+ if (nodeCluster !== null && nodeCluster === targetCluster) {
7125
+ reasons.push("same module");
7126
+ }
7127
+ }
7128
+ if (targetNode.metadata?.["cluster"] !== void 0 && node.metadata?.["cluster"] !== void 0 && node.metadata["cluster"] === targetNode.metadata["cluster"]) {
7129
+ if (!reasons.includes("same module")) reasons.push("same module");
7130
+ }
7131
+ results.push({ name: node.name, similarity: combined, reasons });
7132
+ }
7133
+ results.sort((a, b) => b.similarity - a.similarity);
7134
+ return { similar: results.slice(0, clampedLimit) };
7135
+ }
7136
+
7137
+ // src/query/health-report.ts
7138
+ function computeHealthReport(graph, scope) {
7139
+ const wholeRepo = scope === ".";
7140
+ function inScope(filePath) {
7141
+ if (wholeRepo) return true;
7142
+ return filePath.startsWith(scope) || filePath.includes(scope);
7143
+ }
7144
+ const scopedNodes = [...graph.allNodes()].filter((n) => inScope(n.filePath));
7145
+ const deadCodeKinds = /* @__PURE__ */ new Set(["function", "method", "class"]);
7146
+ const deadCode = [];
7147
+ for (const node of scopedNodes) {
7148
+ if (!deadCodeKinds.has(node.kind)) continue;
7149
+ if (node.exported === true) continue;
7150
+ let hasIncoming = false;
7151
+ for (const _edge of graph.findEdgesTo(node.id)) {
7152
+ hasIncoming = true;
7153
+ break;
7154
+ }
7155
+ if (!hasIncoming) {
7156
+ deadCode.push({ name: node.name, filePath: node.filePath, kind: node.kind });
7157
+ if (deadCode.length >= 20) break;
7158
+ }
7159
+ }
7160
+ const cycles = [];
7161
+ const scopedNodeIds = new Set(scopedNodes.map((n) => n.id));
7162
+ const importAdj = /* @__PURE__ */ new Map();
7163
+ for (const node of scopedNodes) {
7164
+ importAdj.set(node.id, []);
7165
+ }
7166
+ for (const edge of graph.findEdgesByKind("imports")) {
7167
+ if (scopedNodeIds.has(edge.source) && scopedNodeIds.has(edge.target)) {
7168
+ importAdj.get(edge.source).push(edge.target);
7169
+ }
7170
+ }
7171
+ const visited = /* @__PURE__ */ new Set();
7172
+ const inStack = /* @__PURE__ */ new Set();
7173
+ const stackPath = [];
7174
+ function dfs(nodeId) {
7175
+ if (cycles.length >= 5) return;
7176
+ visited.add(nodeId);
7177
+ inStack.add(nodeId);
7178
+ stackPath.push(nodeId);
7179
+ for (const neighborId of importAdj.get(nodeId) ?? []) {
7180
+ if (cycles.length >= 5) break;
7181
+ if (inStack.has(neighborId)) {
7182
+ const cycleStart = stackPath.indexOf(neighborId);
7183
+ const cyclePath = stackPath.slice(cycleStart).map((id) => {
7184
+ const node = graph.getNode(id);
7185
+ return node ? node.name : id;
7186
+ });
7187
+ cycles.push(cyclePath);
7188
+ } else if (!visited.has(neighborId)) {
7189
+ dfs(neighborId);
7190
+ }
7191
+ }
7192
+ stackPath.pop();
7193
+ inStack.delete(nodeId);
7194
+ }
7195
+ for (const node of scopedNodes) {
7196
+ if (cycles.length >= 5) break;
7197
+ if (!visited.has(node.id)) {
7198
+ dfs(node.id);
7199
+ }
7200
+ }
7201
+ const godNodes = [];
7202
+ for (const node of scopedNodes) {
7203
+ let edgeCount = 0;
7204
+ for (const _edge of graph.findEdgesFrom(node.id)) {
7205
+ edgeCount++;
7206
+ }
7207
+ if (edgeCount > 10) {
7208
+ godNodes.push({ name: node.name, edgeCount, filePath: node.filePath });
7209
+ }
7210
+ }
7211
+ godNodes.sort((a, b) => b.edgeCount - a.edgeCount);
7212
+ godNodes.splice(10);
7213
+ const filePathToNodes = /* @__PURE__ */ new Map();
7214
+ for (const node of scopedNodes) {
7215
+ if (!node.filePath) continue;
7216
+ let arr = filePathToNodes.get(node.filePath);
7217
+ if (!arr) {
7218
+ arr = [];
7219
+ filePathToNodes.set(node.filePath, arr);
7220
+ }
7221
+ arr.push(node.id);
7222
+ }
7223
+ const orphanFiles = [];
7224
+ for (const [filePath, nodeIds] of filePathToNodes) {
7225
+ if (orphanFiles.length >= 10) break;
7226
+ let hasAnyEdge = false;
7227
+ for (const nodeId of nodeIds) {
7228
+ let hasOut = false;
7229
+ for (const _edge of graph.findEdgesFrom(nodeId)) {
7230
+ hasOut = true;
7231
+ break;
7232
+ }
7233
+ let hasIn = false;
7234
+ for (const _edge of graph.findEdgesTo(nodeId)) {
7235
+ hasIn = true;
7236
+ break;
7237
+ }
7238
+ if (hasOut || hasIn) {
7239
+ hasAnyEdge = true;
7240
+ break;
7241
+ }
7242
+ }
7243
+ if (!hasAnyEdge) {
7244
+ orphanFiles.push(filePath);
7245
+ }
7246
+ }
7247
+ const hotspotCandidates = [];
7248
+ for (const node of scopedNodes) {
7249
+ const visitedBfs = /* @__PURE__ */ new Set();
7250
+ const queue = [{ id: node.id, depth: 0 }];
7251
+ while (queue.length > 0) {
7252
+ const item = queue.shift();
7253
+ if (item.depth > 5 || visitedBfs.has(item.id)) continue;
7254
+ visitedBfs.add(item.id);
7255
+ for (const edge of graph.findEdgesTo(item.id)) {
7256
+ if (edge.kind === "calls" || edge.kind === "imports") {
7257
+ if (!visitedBfs.has(edge.source)) {
7258
+ queue.push({ id: edge.source, depth: item.depth + 1 });
7259
+ }
7260
+ }
7261
+ }
7262
+ }
7263
+ const blastRadius = visitedBfs.size - 1;
7264
+ hotspotCandidates.push({ name: node.name, blastRadius, filePath: node.filePath });
7265
+ }
7266
+ hotspotCandidates.sort((a, b) => b.blastRadius - a.blastRadius);
7267
+ const complexityHotspots = hotspotCandidates.slice(0, 5);
7268
+ const healthScore = Math.max(
7269
+ 0,
7270
+ Math.min(100, 100 - deadCode.length * 2 - cycles.length * 5 - godNodes.length * 3)
7271
+ );
7272
+ return {
7273
+ healthScore,
7274
+ deadCode,
7275
+ cycles,
7276
+ godNodes,
7277
+ orphanFiles,
7278
+ complexityHotspots
7279
+ };
7280
+ }
7281
+
7282
+ // src/query/suggest-tests.ts
7283
+ function getSuggestedCases(symbolName) {
7284
+ const lower = symbolName.toLowerCase();
7285
+ if (/parse|validate|check|verify/.test(lower)) {
7286
+ return [
7287
+ "Valid input \u2192 success",
7288
+ "Invalid input \u2192 throws error",
7289
+ "Edge case: empty/null input \u2192 handled gracefully"
7290
+ ];
7291
+ }
7292
+ if (/create|add|insert|save/.test(lower)) {
7293
+ return [
7294
+ "Success: valid data \u2192 created",
7295
+ "Duplicate: existing item \u2192 error or no-op",
7296
+ "Missing required fields \u2192 validation error"
7297
+ ];
7298
+ }
7299
+ if (/delete|remove|destroy/.test(lower)) {
7300
+ return [
7301
+ "Existing item \u2192 deleted successfully",
7302
+ "Non-existent item \u2192 no error or 404",
7303
+ "Unauthorized access \u2192 rejected"
7304
+ ];
7305
+ }
7306
+ if (/get|find|fetch|load/.test(lower)) {
7307
+ return [
7308
+ "Found: returns correct data",
7309
+ "Not found: returns null or throws",
7310
+ "Empty collection: returns []"
7311
+ ];
7312
+ }
7313
+ return [
7314
+ "Happy path: valid input \u2192 expected output",
7315
+ "Error case: invalid input \u2192 error handled",
7316
+ "Edge case: boundary values \u2192 correct behavior"
7317
+ ];
7318
+ }
7319
+ function suggestTests(graph, symbolName) {
7320
+ let targetNode = void 0;
7321
+ for (const node of graph.allNodes()) {
7322
+ if (node.name === symbolName) {
7323
+ targetNode = node;
7324
+ break;
7325
+ }
7326
+ }
7327
+ if (!targetNode) {
7328
+ return { error: `Symbol not found: ${symbolName}` };
7329
+ }
7330
+ const targetId = targetNode.id;
7331
+ const callPaths = [];
7332
+ const pathQueue = [{ id: targetId, path: [symbolName], depth: 0 }];
7333
+ while (pathQueue.length > 0 && callPaths.length < 5) {
7334
+ const { id, path: path28, depth } = pathQueue.shift();
7335
+ let hasCallers = false;
7336
+ for (const edge of graph.findEdgesTo(id)) {
7337
+ if (edge.kind !== "calls") continue;
7338
+ const callerNode = graph.getNode(edge.source);
7339
+ if (!callerNode) continue;
7340
+ hasCallers = true;
7341
+ const newPath = [callerNode.name, ...path28];
7342
+ if (depth + 1 >= 3 || callPaths.length >= 5) {
7343
+ if (callPaths.length < 5) callPaths.push(newPath);
7344
+ continue;
7345
+ }
7346
+ pathQueue.push({ id: edge.source, path: newPath, depth: depth + 1 });
7347
+ }
7348
+ if (!hasCallers && path28.length > 1) {
7349
+ callPaths.push(path28);
7350
+ }
7351
+ }
7352
+ if (callPaths.length === 0) {
7353
+ for (const edge of graph.findEdgesTo(targetId)) {
7354
+ if (edge.kind !== "calls") continue;
7355
+ const callerNode = graph.getNode(edge.source);
7356
+ if (!callerNode) continue;
7357
+ callPaths.push([callerNode.name, symbolName]);
7358
+ if (callPaths.length >= 5) break;
7359
+ }
7360
+ }
7361
+ const existingTestFiles = /* @__PURE__ */ new Set();
7362
+ for (const edge of graph.findEdgesTo(targetId)) {
7363
+ if (edge.kind !== "imports") continue;
7364
+ const importerNode = graph.getNode(edge.source);
7365
+ if (!importerNode) continue;
7366
+ if (importerNode.filePath.includes(".test.") || importerNode.filePath.includes(".spec.")) {
7367
+ existingTestFiles.add(importerNode.filePath);
7368
+ }
7369
+ }
7370
+ const existingTests = [...existingTestFiles];
7371
+ const untestedCallers = [];
7372
+ for (const edge of graph.findEdgesTo(targetId)) {
7373
+ if (edge.kind !== "calls") continue;
7374
+ const callerNode = graph.getNode(edge.source);
7375
+ if (!callerNode) continue;
7376
+ if (callerNode.filePath.includes(".test.") || callerNode.filePath.includes(".spec.")) {
7377
+ continue;
7378
+ }
7379
+ let callerHasTest = false;
7380
+ for (const callerImportEdge of graph.findEdgesTo(callerNode.id)) {
7381
+ if (callerImportEdge.kind !== "imports") continue;
7382
+ const importerOfCaller = graph.getNode(callerImportEdge.source);
7383
+ if (!importerOfCaller) continue;
7384
+ if (importerOfCaller.filePath.includes(".test.") || importerOfCaller.filePath.includes(".spec.")) {
7385
+ callerHasTest = true;
7386
+ break;
7387
+ }
7388
+ }
7389
+ if (!callerHasTest) {
7390
+ untestedCallers.push(callerNode.name);
7391
+ }
7392
+ }
7393
+ const suggestedCases = getSuggestedCases(symbolName);
7394
+ return {
7395
+ callPaths,
7396
+ suggestedCases,
7397
+ existingTests,
7398
+ untestedCallers
7399
+ };
7400
+ }
7401
+
7402
+ // src/query/cluster-summary.ts
7403
+ function getPathPrefix(filePath) {
7404
+ const parts = filePath.replace(/\\/g, "/").split("/");
7405
+ return parts.slice(0, 2).join("/");
7406
+ }
7407
+ function summarizeCluster(graph, cluster) {
7408
+ const clusterNodes = [...graph.allNodes()].filter(
7409
+ (n) => n.filePath.startsWith(cluster) || n.metadata?.["cluster"] === cluster
7410
+ );
7411
+ if (clusterNodes.length === 0) {
7412
+ return { error: `Cluster not found: ${cluster}` };
7413
+ }
7414
+ const clusterNodeIds = new Set(clusterNodes.map((n) => n.id));
7415
+ const callerCountMap = /* @__PURE__ */ new Map();
7416
+ for (const node of clusterNodes) {
7417
+ let count = 0;
7418
+ for (const _edge of graph.findEdgesTo(node.id)) {
7419
+ count++;
7420
+ }
7421
+ callerCountMap.set(node.id, count);
7422
+ }
7423
+ const sortedByCallers = [...clusterNodes].sort(
7424
+ (a, b) => (callerCountMap.get(b.id) ?? 0) - (callerCountMap.get(a.id) ?? 0)
7425
+ );
7426
+ const keySymbols = sortedByCallers.slice(0, 5).map((n) => ({
7427
+ name: n.name,
7428
+ callerCount: callerCountMap.get(n.id) ?? 0
7429
+ }));
7430
+ const depsSet = /* @__PURE__ */ new Set();
7431
+ for (const node of clusterNodes) {
7432
+ for (const edge of graph.findEdgesFrom(node.id)) {
7433
+ if (edge.kind !== "imports") continue;
7434
+ const targetNode = graph.getNode(edge.target);
7435
+ if (!targetNode) continue;
7436
+ if (!clusterNodeIds.has(targetNode.id)) {
7437
+ const prefix = getPathPrefix(targetNode.filePath);
7438
+ depsSet.add(prefix);
7439
+ }
7440
+ }
7441
+ }
7442
+ const dependencies = [...depsSet];
7443
+ const dependentsSet = /* @__PURE__ */ new Set();
7444
+ for (const node of clusterNodes) {
7445
+ for (const edge of graph.findEdgesTo(node.id)) {
7446
+ if (edge.kind !== "imports") continue;
7447
+ const sourceNode = graph.getNode(edge.source);
7448
+ if (!sourceNode) continue;
7449
+ if (!clusterNodeIds.has(sourceNode.id)) {
7450
+ const prefix = getPathPrefix(sourceNode.filePath);
7451
+ dependentsSet.add(prefix);
7452
+ }
7453
+ }
7454
+ }
7455
+ const dependents = [...dependentsSet];
7456
+ const healthResult = computeHealthReport(graph, cluster);
7457
+ const health = { score: healthResult.healthScore };
7458
+ const symbolCount = {};
7459
+ for (const node of clusterNodes) {
7460
+ symbolCount[node.kind] = (symbolCount[node.kind] ?? 0) + 1;
7461
+ }
7462
+ let purpose;
7463
+ const topNode = sortedByCallers[0];
7464
+ if (topNode?.metadata?.["summary"] && typeof topNode.metadata["summary"] === "string") {
7465
+ purpose = topNode.metadata["summary"];
7466
+ } else {
7467
+ const clusterName = cluster.split("/").pop() ?? cluster;
7468
+ purpose = `Handles ${clusterName.replace(/[-_/]/g, " ")} functionality`;
7469
+ }
7470
+ return {
7471
+ cluster,
7472
+ purpose,
7473
+ keySymbols,
7474
+ dependencies,
7475
+ dependents,
7476
+ health,
7477
+ symbolCount
7478
+ };
7479
+ }
7480
+
7481
+ // src/mcp-server/server.ts
6863
7482
  function createMcpServer(graph, repoName, workspaceRoot) {
6864
7483
  const server = new Server(
6865
7484
  { name: "code-intel", version: "0.1.0" },
@@ -6889,7 +7508,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
6889
7508
  type: "object",
6890
7509
  properties: {
6891
7510
  query: { type: "string", description: "Search query (symbol name, keyword, or partial match)" },
6892
- limit: { type: "number", description: "Max results to return (default: 20)" },
7511
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
7512
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
6893
7513
  ..._tokenProp
6894
7514
  },
6895
7515
  required: ["query"]
@@ -6932,6 +7552,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
6932
7552
  type: "object",
6933
7553
  properties: {
6934
7554
  file_path: { type: "string", description: 'File path (partial match is supported, e.g. "auth/login.ts")' },
7555
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
7556
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
6935
7557
  ..._tokenProp
6936
7558
  },
6937
7559
  required: ["file_path"]
@@ -6961,7 +7583,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
6961
7583
  type: "string",
6962
7584
  description: "Filter by node kind: function | class | interface | method | type_alias | constant | enum (optional)"
6963
7585
  },
6964
- limit: { type: "number", description: "Max results (default: 100)" },
7586
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
7587
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
6965
7588
  ..._tokenProp
6966
7589
  }
6967
7590
  }
@@ -6978,7 +7601,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
6978
7601
  inputSchema: {
6979
7602
  type: "object",
6980
7603
  properties: {
6981
- limit: { type: "number", description: "Max clusters to return (default: 50)" },
7604
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
7605
+ limit: { type: "number", description: "Max clusters per page (default: 50, max: 500)" },
6982
7606
  ..._tokenProp
6983
7607
  }
6984
7608
  }
@@ -6989,7 +7613,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
6989
7613
  inputSchema: {
6990
7614
  type: "object",
6991
7615
  properties: {
6992
- limit: { type: "number", description: "Max flows to return (default: 50)" },
7616
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
7617
+ limit: { type: "number", description: "Max flows per page (default: 50, max: 500)" },
6993
7618
  ..._tokenProp
6994
7619
  }
6995
7620
  }
@@ -7111,6 +7736,91 @@ function createMcpServer(graph, repoName, workspaceRoot) {
7111
7736
  },
7112
7737
  required: ["name"]
7113
7738
  }
7739
+ },
7740
+ // ── Reasoning / analysis tools ────────────────────────────────────────
7741
+ {
7742
+ name: "explain_relationship",
7743
+ description: "Explain how two symbols are connected: directed paths, shared imports, and heritage (extends/implements). Returns up to 10 paths with at most 5 hops each.",
7744
+ inputSchema: {
7745
+ type: "object",
7746
+ properties: {
7747
+ from: { type: "string", description: "Source symbol name" },
7748
+ to: { type: "string", description: "Target symbol name" },
7749
+ ..._tokenProp
7750
+ },
7751
+ required: ["from", "to"]
7752
+ }
7753
+ },
7754
+ {
7755
+ name: "pr_impact",
7756
+ description: "Given changed files or a unified diff, compute full blast radius with risk scores (HIGH/MEDIUM/LOW), test coverage gaps, and top files to review.",
7757
+ inputSchema: {
7758
+ type: "object",
7759
+ properties: {
7760
+ changedFiles: {
7761
+ type: "array",
7762
+ items: { type: "string" },
7763
+ description: "List of changed file paths (relative or absolute)"
7764
+ },
7765
+ diff: {
7766
+ type: "string",
7767
+ description: "Raw unified diff text. Changed files are extracted automatically."
7768
+ },
7769
+ maxHops: {
7770
+ type: "number",
7771
+ description: "Maximum BFS depth for blast radius (default: 5)"
7772
+ },
7773
+ ..._tokenProp
7774
+ }
7775
+ }
7776
+ },
7777
+ {
7778
+ name: "similar_symbols",
7779
+ description: "Find symbols with similar names or structure using Levenshtein distance and kind matching. Useful for finding related functions, classes, or interfaces.",
7780
+ inputSchema: {
7781
+ type: "object",
7782
+ properties: {
7783
+ symbol: { type: "string", description: "Symbol name to find similar symbols for" },
7784
+ limit: { type: "number", description: "Maximum number of results (default: 10, max: 50)" },
7785
+ ..._tokenProp
7786
+ },
7787
+ required: ["symbol"]
7788
+ }
7789
+ },
7790
+ {
7791
+ name: "health_report",
7792
+ description: "Code health signals for a scope: dead code, cycles, god nodes, orphan files, complexity hotspots",
7793
+ inputSchema: {
7794
+ type: "object",
7795
+ properties: {
7796
+ scope: { type: "string", description: "Directory scope, e.g. 'src/api/' or '.' for whole repo" },
7797
+ ..._tokenProp
7798
+ }
7799
+ }
7800
+ },
7801
+ {
7802
+ name: "suggest_tests",
7803
+ description: "Suggest test cases for a symbol: call paths, suggested cases, existing tests, untested callers",
7804
+ inputSchema: {
7805
+ type: "object",
7806
+ properties: {
7807
+ symbol: { type: "string", description: "Symbol name to generate test suggestions for" },
7808
+ ..._tokenProp
7809
+ },
7810
+ required: ["symbol"]
7811
+ }
7812
+ },
7813
+ {
7814
+ name: "cluster_summary",
7815
+ description: "Rich summary of a module/cluster: purpose, key symbols, dependencies, health",
7816
+ inputSchema: {
7817
+ type: "object",
7818
+ properties: {
7819
+ cluster: { type: "string", description: "Cluster path e.g. 'src/auth'" },
7820
+ ..._tokenProp
7821
+ },
7822
+ required: ["cluster"]
7823
+ }
7114
7824
  }
7115
7825
  ]
7116
7826
  }));
@@ -7181,8 +7891,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7181
7891
  for (const edge of graph.allEdges()) {
7182
7892
  edgeCounts[edge.kind] = (edgeCounts[edge.kind] ?? 0) + 1;
7183
7893
  }
7184
- const { computeHealthReport: computeHealthReport2 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
7185
- const healthReport = computeHealthReport2(graph);
7894
+ const { computeHealthReport: computeHealthReport3 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
7895
+ const healthReport = computeHealthReport3(graph);
7186
7896
  const health = {
7187
7897
  score: Math.round(healthReport.score),
7188
7898
  grade: healthReport.grade,
@@ -7207,10 +7917,37 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7207
7917
  // ── search ─────────────────────────────────────────────────────────────
7208
7918
  case "search": {
7209
7919
  const query = a.query;
7210
- const limit = a.limit ?? 20;
7920
+ const offset = a.offset ?? 0;
7921
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
7211
7922
  const vdbPath = workspaceRoot ? getVectorDbPath(workspaceRoot) : void 0;
7212
- const { results, searchMode } = await hybridSearch(graph, query, limit, { vectorDbPath: vdbPath });
7213
- return { content: [{ type: "text", text: JSON.stringify({ results, searchMode, suggested_next_tools: ["inspect", "query", "blast_radius"] }, null, 2) }] };
7923
+ const fetchLimit = Math.min(offset + effectiveLimit, 500);
7924
+ const { results: allResults, searchMode } = await hybridSearch(graph, query, fetchLimit, { vectorDbPath: vdbPath });
7925
+ const total = allResults.length;
7926
+ const results = allResults.slice(offset, offset + effectiveLimit);
7927
+ const hasMore = offset + effectiveLimit < total;
7928
+ const suggestNextTools = [];
7929
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
7930
+ if (suggestEnabled && results.length > 0) {
7931
+ const topName = results[0].name;
7932
+ suggestNextTools.push(
7933
+ { tool: "inspect", reason: "Inspect the top result in detail", input: { symbol: topName } },
7934
+ { tool: "similar_symbols", reason: "Find symbols similar to the top result", input: { symbol: topName } }
7935
+ );
7936
+ }
7937
+ return {
7938
+ content: [{
7939
+ type: "text",
7940
+ text: JSON.stringify({
7941
+ results,
7942
+ searchMode,
7943
+ total,
7944
+ offset,
7945
+ limit: effectiveLimit,
7946
+ hasMore,
7947
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
7948
+ }, null, 2)
7949
+ }]
7950
+ };
7214
7951
  }
7215
7952
  // ── inspect ────────────────────────────────────────────────────────────
7216
7953
  case "inspect": {
@@ -7219,6 +7956,26 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7219
7956
  if (!node) return { content: [{ type: "text", text: `Symbol "${symbolName}" not found. Try search first.` }] };
7220
7957
  const incoming = [...graph.findEdgesTo(node.id)];
7221
7958
  const outgoing = [...graph.findEdgesFrom(node.id)];
7959
+ const callers = incoming.filter((e) => e.kind === "calls").map((e) => ({
7960
+ id: e.source,
7961
+ name: graph.getNode(e.source)?.name,
7962
+ file: graph.getNode(e.source)?.filePath
7963
+ }));
7964
+ const callees = outgoing.filter((e) => e.kind === "calls").map((e) => ({
7965
+ id: e.target,
7966
+ name: graph.getNode(e.target)?.name,
7967
+ file: graph.getNode(e.target)?.filePath
7968
+ }));
7969
+ const cluster = incoming.filter((e) => e.kind === "belongs_to").map((e) => graph.getNode(e.target)?.name)[0];
7970
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
7971
+ const suggestNextTools = [];
7972
+ if (suggestEnabled) {
7973
+ const topCallerName = callers[0]?.name;
7974
+ suggestNextTools.push(
7975
+ ...topCallerName ? [{ tool: "explain_relationship", reason: "Explain connection to a related symbol", input: { from: node.name, to: topCallerName } }] : [],
7976
+ ...cluster ? [{ tool: "cluster_summary", reason: "Summarize the module this symbol belongs to", input: { cluster } }] : [{ tool: "cluster_summary", reason: "Summarize the module this symbol belongs to", input: { cluster: node.filePath } }]
7977
+ );
7978
+ }
7222
7979
  return {
7223
7980
  content: [{
7224
7981
  type: "text",
@@ -7232,16 +7989,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7232
7989
  endLine: node.endLine,
7233
7990
  exported: node.exported
7234
7991
  },
7235
- callers: incoming.filter((e) => e.kind === "calls").map((e) => ({
7236
- id: e.source,
7237
- name: graph.getNode(e.source)?.name,
7238
- file: graph.getNode(e.source)?.filePath
7239
- })),
7240
- callees: outgoing.filter((e) => e.kind === "calls").map((e) => ({
7241
- id: e.target,
7242
- name: graph.getNode(e.target)?.name,
7243
- file: graph.getNode(e.target)?.filePath
7244
- })),
7992
+ callers,
7993
+ callees,
7245
7994
  imports: incoming.filter((e) => e.kind === "imports").map((e) => graph.getNode(e.source)?.name),
7246
7995
  importedBy: outgoing.filter((e) => e.kind === "imports").map((e) => graph.getNode(e.target)?.name),
7247
7996
  extends: outgoing.filter((e) => e.kind === "extends").map((e) => graph.getNode(e.target)?.name),
@@ -7250,8 +7999,9 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7250
7999
  name: graph.getNode(e.target)?.name,
7251
8000
  kind: graph.getNode(e.target)?.kind
7252
8001
  })),
7253
- cluster: incoming.filter((e) => e.kind === "belongs_to").map((e) => graph.getNode(e.target)?.name)[0],
7254
- content: node.content?.slice(0, 500)
8002
+ cluster,
8003
+ content: node.content?.slice(0, 500),
8004
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
7255
8005
  }, null, 2)
7256
8006
  }]
7257
8007
  };
@@ -7287,6 +8037,16 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7287
8037
  return n ? { id, name: n.name, kind: n.kind, filePath: n.filePath } : { id };
7288
8038
  });
7289
8039
  const risk = affected.size > 10 ? "HIGH" : affected.size > 5 ? "MEDIUM" : "LOW";
8040
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
8041
+ const suggestNextTools = [];
8042
+ if (suggestEnabled) {
8043
+ const highestRiskSymbol = node.name;
8044
+ const firstFilePath = affectedDetails[0]?.filePath ?? "";
8045
+ suggestNextTools.push(
8046
+ { tool: "suggest_tests", reason: "Generate tests for the highest-risk symbol", input: { symbol: highestRiskSymbol } },
8047
+ { tool: "pr_impact", reason: "Compute full PR impact for changed files", input: { changedFiles: [firstFilePath] } }
8048
+ );
8049
+ }
7290
8050
  return {
7291
8051
  content: [{
7292
8052
  type: "text",
@@ -7294,7 +8054,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7294
8054
  target: node.name,
7295
8055
  affectedCount: affected.size,
7296
8056
  riskLevel: risk,
7297
- affected: affectedDetails
8057
+ affected: affectedDetails,
8058
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
7298
8059
  }, null, 2)
7299
8060
  }]
7300
8061
  };
@@ -7302,17 +8063,27 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7302
8063
  // ── file_symbols ───────────────────────────────────────────────────────
7303
8064
  case "file_symbols": {
7304
8065
  const filePath = a.file_path;
7305
- const matches = [];
8066
+ const offset = a.offset ?? 0;
8067
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
8068
+ const allMatches = [];
7306
8069
  for (const node of graph.allNodes()) {
7307
8070
  if (node.filePath && node.filePath.includes(filePath)) {
7308
- matches.push({ kind: node.kind, name: node.name, startLine: node.startLine, exported: node.exported });
8071
+ allMatches.push({ kind: node.kind, name: node.name, startLine: node.startLine, exported: node.exported });
7309
8072
  }
7310
8073
  }
7311
- if (matches.length === 0) {
8074
+ if (allMatches.length === 0) {
7312
8075
  return { content: [{ type: "text", text: `No symbols found for file path matching "${filePath}".` }] };
7313
8076
  }
7314
- matches.sort((a2, b) => (a2.startLine ?? 0) - (b.startLine ?? 0));
7315
- return { content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] };
8077
+ allMatches.sort((a2, b) => (a2.startLine ?? 0) - (b.startLine ?? 0));
8078
+ const total = allMatches.length;
8079
+ const matches = allMatches.slice(offset, offset + effectiveLimit);
8080
+ const hasMore = offset + effectiveLimit < total;
8081
+ return {
8082
+ content: [{
8083
+ type: "text",
8084
+ text: JSON.stringify({ symbols: matches, total, offset, limit: effectiveLimit, hasMore }, null, 2)
8085
+ }]
8086
+ };
7316
8087
  }
7317
8088
  // ── find_path ──────────────────────────────────────────────────────────
7318
8089
  case "find_path": {
@@ -7358,15 +8129,23 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7358
8129
  // ── list_exports ───────────────────────────────────────────────────────
7359
8130
  case "list_exports": {
7360
8131
  const kindFilter = a.kind;
7361
- const limit = a.limit ?? 100;
7362
- const exports$1 = [];
8132
+ const offset = a.offset ?? 0;
8133
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
8134
+ const allExports = [];
7363
8135
  for (const node of graph.allNodes()) {
7364
8136
  if (!node.exported) continue;
7365
8137
  if (kindFilter && node.kind !== kindFilter) continue;
7366
- exports$1.push({ kind: node.kind, name: node.name, filePath: node.filePath, startLine: node.startLine });
7367
- if (exports$1.length >= limit) break;
8138
+ allExports.push({ kind: node.kind, name: node.name, filePath: node.filePath, startLine: node.startLine });
7368
8139
  }
7369
- return { content: [{ type: "text", text: JSON.stringify({ total: exports$1.length, exports: exports$1 }, null, 2) }] };
8140
+ const total = allExports.length;
8141
+ const exports$1 = allExports.slice(offset, offset + effectiveLimit);
8142
+ const hasMore = offset + effectiveLimit < total;
8143
+ return {
8144
+ content: [{
8145
+ type: "text",
8146
+ text: JSON.stringify({ exports: exports$1, total, offset, limit: effectiveLimit, hasMore }, null, 2)
8147
+ }]
8148
+ };
7370
8149
  }
7371
8150
  // ── routes ─────────────────────────────────────────────────────────────
7372
8151
  case "routes": {
@@ -7380,8 +8159,9 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7380
8159
  }
7381
8160
  // ── clusters ───────────────────────────────────────────────────────────
7382
8161
  case "clusters": {
7383
- const limit = a.limit ?? 50;
7384
- const clusters = [];
8162
+ const offset = a.offset ?? 0;
8163
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
8164
+ const allClusters = [];
7385
8165
  for (const node of graph.allNodes()) {
7386
8166
  if (node.kind === "cluster") {
7387
8167
  const members = [];
@@ -7393,35 +8173,50 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7393
8173
  }
7394
8174
  }
7395
8175
  }
7396
- clusters.push({
8176
+ allClusters.push({
7397
8177
  id: node.id,
7398
8178
  name: node.name,
7399
8179
  memberCount: node.metadata?.memberCount ?? members.length,
7400
8180
  topSymbols: members.slice(0, 10)
7401
8181
  });
7402
- if (clusters.length >= limit) break;
7403
8182
  }
7404
8183
  }
7405
- return { content: [{ type: "text", text: JSON.stringify(clusters, null, 2) }] };
8184
+ const total = allClusters.length;
8185
+ const clusters = allClusters.slice(offset, offset + effectiveLimit);
8186
+ const hasMore = offset + effectiveLimit < total;
8187
+ return {
8188
+ content: [{
8189
+ type: "text",
8190
+ text: JSON.stringify({ clusters, total, offset, limit: effectiveLimit, hasMore }, null, 2)
8191
+ }]
8192
+ };
7406
8193
  }
7407
8194
  // ── flows ──────────────────────────────────────────────────────────────
7408
8195
  case "flows": {
7409
- const limit = a.limit ?? 50;
7410
- const flows = [];
8196
+ const offset = a.offset ?? 0;
8197
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
8198
+ const allFlows = [];
7411
8199
  for (const node of graph.allNodes()) {
7412
8200
  if (node.kind === "flow") {
7413
8201
  const steps = node.metadata?.steps;
7414
- flows.push({
8202
+ allFlows.push({
7415
8203
  id: node.id,
7416
8204
  name: node.name,
7417
8205
  entryPoint: node.metadata?.entryPoint,
7418
8206
  steps: steps ?? [],
7419
8207
  stepCount: Array.isArray(steps) ? steps.length : 0
7420
8208
  });
7421
- if (flows.length >= limit) break;
7422
8209
  }
7423
8210
  }
7424
- return { content: [{ type: "text", text: JSON.stringify(flows, null, 2) }] };
8211
+ const total = allFlows.length;
8212
+ const flows = allFlows.slice(offset, offset + effectiveLimit);
8213
+ const hasMore = offset + effectiveLimit < total;
8214
+ return {
8215
+ content: [{
8216
+ type: "text",
8217
+ text: JSON.stringify({ flows, total, offset, limit: effectiveLimit, hasMore }, null, 2)
8218
+ }]
8219
+ };
7425
8220
  }
7426
8221
  // ── detect_changes ─────────────────────────────────────────────────────
7427
8222
  case "detect_changes": {
@@ -7671,6 +8466,57 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
7671
8466
  }]
7672
8467
  };
7673
8468
  }
8469
+ // ── explain_relationship ───────────────────────────────────────────────
8470
+ case "explain_relationship": {
8471
+ const fromName = a.from;
8472
+ const toName = a.to;
8473
+ const result = explainRelationship(graph, fromName, toName);
8474
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8475
+ }
8476
+ // ── pr_impact ──────────────────────────────────────────────────────────
8477
+ case "pr_impact": {
8478
+ const maxHops = a.maxHops ?? 5;
8479
+ let changedFiles = a.changedFiles ?? [];
8480
+ if (a.diff && typeof a.diff === "string") {
8481
+ const diffFiles = parseDiffFiles(a.diff);
8482
+ changedFiles = [.../* @__PURE__ */ new Set([...changedFiles, ...diffFiles])];
8483
+ }
8484
+ if (changedFiles.length === 0) {
8485
+ return {
8486
+ content: [{
8487
+ type: "text",
8488
+ text: JSON.stringify({ error: 'No changed files provided. Supply "changedFiles" or "diff".' })
8489
+ }]
8490
+ };
8491
+ }
8492
+ const result = computePRImpact(graph, changedFiles, maxHops);
8493
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8494
+ }
8495
+ // ── similar_symbols ────────────────────────────────────────────────────
8496
+ case "similar_symbols": {
8497
+ const symbolName = a.symbol;
8498
+ const limit = a.limit ?? 10;
8499
+ const result = findSimilarSymbols(graph, symbolName, limit);
8500
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8501
+ }
8502
+ // ── health_report ──────────────────────────────────────────────────────
8503
+ case "health_report": {
8504
+ const scope = a.scope ?? ".";
8505
+ const result = computeHealthReport(graph, scope);
8506
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8507
+ }
8508
+ // ── suggest_tests ──────────────────────────────────────────────────────
8509
+ case "suggest_tests": {
8510
+ const sym = a.symbol;
8511
+ const result = suggestTests(graph, sym);
8512
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8513
+ }
8514
+ // ── cluster_summary ────────────────────────────────────────────────────
8515
+ case "cluster_summary": {
8516
+ const cluster = a.cluster;
8517
+ const result = summarizeCluster(graph, cluster);
8518
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8519
+ }
7674
8520
  default:
7675
8521
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
7676
8522
  }