@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/cli/main.js CHANGED
@@ -3058,7 +3058,8 @@ var init_schema = __esm({
3058
3058
  constant: "const_nodes",
3059
3059
  route: "route_nodes",
3060
3060
  cluster: "cluster_nodes",
3061
- flow: "flow_nodes"
3061
+ flow: "flow_nodes",
3062
+ vulnerability: "vuln_nodes"
3062
3063
  };
3063
3064
  ALL_NODE_TABLES = [...new Set(Object.values(NODE_TABLE_MAP))];
3064
3065
  }
@@ -5516,9 +5517,9 @@ var init_orphan_files = __esm({
5516
5517
  // src/health/health-score.ts
5517
5518
  var health_score_exports = {};
5518
5519
  __export(health_score_exports, {
5519
- computeHealthReport: () => computeHealthReport
5520
+ computeHealthReport: () => computeHealthReport2
5520
5521
  });
5521
- function computeHealthReport(graph, godNodeConfig) {
5522
+ function computeHealthReport2(graph, godNodeConfig) {
5522
5523
  const deadCode = detectDeadCode(graph);
5523
5524
  const cycles = detectCircularDeps(graph);
5524
5525
  const godNodes = detectGodNodes(graph, godNodeConfig);
@@ -9590,6 +9591,624 @@ init_metadata();
9590
9591
  init_group_registry();
9591
9592
  init_tracing();
9592
9593
  init_metrics();
9594
+
9595
+ // src/query/explain-relationship.ts
9596
+ function explainRelationship(graph, from, to) {
9597
+ const allNodes = [...graph.allNodes()];
9598
+ const fromNode = allNodes.find((n) => n.name === from);
9599
+ if (!fromNode) {
9600
+ const firstChar = from[0]?.toLowerCase() ?? "";
9601
+ const fromLower = from.toLowerCase();
9602
+ const suggestions = allNodes.filter((n) => n.name.toLowerCase().startsWith(firstChar) || n.name.toLowerCase().includes(fromLower)).slice(0, 5).map((n) => n.name);
9603
+ return { error: `Symbol not found: ${from}`, suggestions };
9604
+ }
9605
+ const toNode = allNodes.find((n) => n.name === to);
9606
+ if (!toNode) {
9607
+ const firstChar = to[0]?.toLowerCase() ?? "";
9608
+ const toLower = to.toLowerCase();
9609
+ const suggestions = allNodes.filter((n) => n.name.toLowerCase().startsWith(firstChar) || n.name.toLowerCase().includes(toLower)).slice(0, 5).map((n) => n.name);
9610
+ return { error: `Symbol not found: ${to}`, suggestions };
9611
+ }
9612
+ const paths = [];
9613
+ const queue = [{
9614
+ id: fromNode.id,
9615
+ nodeNames: [fromNode.name],
9616
+ lastEdgeKind: "",
9617
+ visited: /* @__PURE__ */ new Set([fromNode.id])
9618
+ }];
9619
+ while (queue.length > 0 && paths.length < 10) {
9620
+ const entry = queue.shift();
9621
+ const { id, nodeNames, visited } = entry;
9622
+ if (nodeNames.length > 6) continue;
9623
+ for (const edge of graph.findEdgesFrom(id)) {
9624
+ const targetNode = graph.getNode(edge.target);
9625
+ if (!targetNode) continue;
9626
+ if (visited.has(edge.target)) continue;
9627
+ const newNames = [...nodeNames, targetNode.name];
9628
+ if (edge.target === toNode.id) {
9629
+ paths.push({ hops: newNames.length - 1, nodes: newNames, edgeKind: edge.kind });
9630
+ if (paths.length >= 10) break;
9631
+ continue;
9632
+ }
9633
+ if (newNames.length < 6) {
9634
+ const newVisited = new Set(visited);
9635
+ newVisited.add(edge.target);
9636
+ queue.push({ id: edge.target, nodeNames: newNames, lastEdgeKind: edge.kind, visited: newVisited });
9637
+ }
9638
+ }
9639
+ }
9640
+ const fromImports = /* @__PURE__ */ new Set();
9641
+ for (const edge of graph.findEdgesFrom(fromNode.id)) {
9642
+ if (edge.kind === "imports") fromImports.add(edge.target);
9643
+ }
9644
+ const sharedImportIds = [];
9645
+ for (const edge of graph.findEdgesFrom(toNode.id)) {
9646
+ if (edge.kind === "imports" && fromImports.has(edge.target)) {
9647
+ sharedImportIds.push(edge.target);
9648
+ }
9649
+ }
9650
+ const sharedImports = sharedImportIds.map((id) => graph.getNode(id)?.name ?? id);
9651
+ let heritage = null;
9652
+ for (const edge of graph.findEdgesFrom(fromNode.id)) {
9653
+ if ((edge.kind === "extends" || edge.kind === "implements") && edge.target === toNode.id) {
9654
+ heritage = `${from} ${edge.kind} ${to}`;
9655
+ break;
9656
+ }
9657
+ }
9658
+ if (!heritage) {
9659
+ for (const edge of graph.findEdgesFrom(toNode.id)) {
9660
+ if ((edge.kind === "extends" || edge.kind === "implements") && edge.target === fromNode.id) {
9661
+ heritage = `${to} ${edge.kind} ${from}`;
9662
+ break;
9663
+ }
9664
+ }
9665
+ }
9666
+ const sharedStr = sharedImports.length > 0 ? sharedImports.join(", ") : "none";
9667
+ const heritageStr = heritage ?? "none";
9668
+ const connectionStr = paths.length === 0 ? "No connection found." : `${from} \u2192 ${to} via ${paths.length} path(s).`;
9669
+ const summary = `${connectionStr} Shared imports: [${sharedStr}]. Heritage: ${heritageStr}.`;
9670
+ return { paths, sharedImports, heritage, summary };
9671
+ }
9672
+
9673
+ // src/query/pr-impact.ts
9674
+ function parseDiffFiles(diff) {
9675
+ const files = [];
9676
+ for (const line of diff.split("\n")) {
9677
+ const match = line.match(/^\+\+\+ b\/(.+)/);
9678
+ if (match) {
9679
+ files.push(match[1]);
9680
+ }
9681
+ }
9682
+ return files;
9683
+ }
9684
+ function computePRImpact(graph, changedFiles, maxHops) {
9685
+ const changedSymbolIds = /* @__PURE__ */ new Set();
9686
+ for (const node of graph.allNodes()) {
9687
+ if (!node.filePath) continue;
9688
+ for (const changedFile of changedFiles) {
9689
+ if (node.filePath === changedFile || node.filePath.endsWith(changedFile) || changedFile.endsWith(node.filePath)) {
9690
+ changedSymbolIds.add(node.id);
9691
+ break;
9692
+ }
9693
+ }
9694
+ }
9695
+ const allBlastRadiusNodes = /* @__PURE__ */ new Set();
9696
+ const changedSymbols = [];
9697
+ for (const symbolId of changedSymbolIds) {
9698
+ const symbolNode = graph.getNode(symbolId);
9699
+ if (!symbolNode) continue;
9700
+ const blastRadius = /* @__PURE__ */ new Set();
9701
+ const queue = [{ id: symbolId, depth: 0 }];
9702
+ const visited = /* @__PURE__ */ new Set();
9703
+ while (queue.length > 0) {
9704
+ const { id, depth } = queue.shift();
9705
+ if (visited.has(id) || depth > maxHops) continue;
9706
+ visited.add(id);
9707
+ if (id !== symbolId) blastRadius.add(id);
9708
+ for (const edge of graph.findEdgesTo(id)) {
9709
+ if (edge.kind === "calls" || edge.kind === "imports") {
9710
+ queue.push({ id: edge.source, depth: depth + 1 });
9711
+ }
9712
+ }
9713
+ }
9714
+ for (const id of blastRadius) allBlastRadiusNodes.add(id);
9715
+ const blastCount = blastRadius.size;
9716
+ let risk;
9717
+ if (blastCount > 50) {
9718
+ risk = "HIGH";
9719
+ } else if (blastCount >= 10) {
9720
+ risk = "MEDIUM";
9721
+ } else {
9722
+ risk = "LOW";
9723
+ }
9724
+ let callerCount = 0;
9725
+ for (const edge of graph.findEdgesTo(symbolId)) {
9726
+ if (edge.kind === "calls") callerCount++;
9727
+ }
9728
+ let testCoverage = false;
9729
+ for (const edge of graph.findEdgesTo(symbolId)) {
9730
+ if (edge.kind === "imports") {
9731
+ const callerNode = graph.getNode(edge.source);
9732
+ if (callerNode?.filePath && (callerNode.filePath.includes(".test.") || callerNode.filePath.includes(".spec."))) {
9733
+ testCoverage = true;
9734
+ break;
9735
+ }
9736
+ }
9737
+ }
9738
+ changedSymbols.push({ name: symbolNode.name, risk, callerCount, testCoverage });
9739
+ }
9740
+ const impactedSymbols = [];
9741
+ for (const id of allBlastRadiusNodes) {
9742
+ if (changedSymbolIds.has(id)) continue;
9743
+ const node = graph.getNode(id);
9744
+ if (node) {
9745
+ impactedSymbols.push({ name: node.name, filePath: node.filePath });
9746
+ }
9747
+ }
9748
+ const riskSummary = { HIGH: 0, MEDIUM: 0, LOW: 0 };
9749
+ for (const s of changedSymbols) {
9750
+ riskSummary[s.risk]++;
9751
+ }
9752
+ const coverageGaps = [];
9753
+ for (const s of changedSymbols) {
9754
+ if ((s.risk === "HIGH" || s.risk === "MEDIUM") && !s.testCoverage) {
9755
+ coverageGaps.push(`${s.name} has no test coverage`);
9756
+ }
9757
+ }
9758
+ const fileImpactCount = /* @__PURE__ */ new Map();
9759
+ for (const sym of impactedSymbols) {
9760
+ if (sym.filePath) {
9761
+ fileImpactCount.set(sym.filePath, (fileImpactCount.get(sym.filePath) ?? 0) + 1);
9762
+ }
9763
+ }
9764
+ const filesToReview = [...fileImpactCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([fp]) => fp);
9765
+ return {
9766
+ changedSymbols,
9767
+ impactedSymbols,
9768
+ riskSummary,
9769
+ coverageGaps,
9770
+ filesToReview,
9771
+ crossRepoImpact: null
9772
+ };
9773
+ }
9774
+
9775
+ // src/query/similar-symbols.ts
9776
+ function levenshtein(a, b) {
9777
+ const m = a.length;
9778
+ const n = b.length;
9779
+ const dp = Array.from(
9780
+ { length: m + 1 },
9781
+ (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0)
9782
+ );
9783
+ for (let i = 1; i <= m; i++) {
9784
+ for (let j = 1; j <= n; j++) {
9785
+ if (a[i - 1] === b[j - 1]) {
9786
+ dp[i][j] = dp[i - 1][j - 1];
9787
+ } else {
9788
+ dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
9789
+ }
9790
+ }
9791
+ }
9792
+ return dp[m][n];
9793
+ }
9794
+ function findSimilarSymbols(graph, symbolName, limit) {
9795
+ const clampedLimit = Math.min(Math.max(1, limit), 50);
9796
+ const allNodes = [...graph.allNodes()];
9797
+ const targetNode = allNodes.find((n) => n.name === symbolName);
9798
+ if (!targetNode) {
9799
+ return { similar: [] };
9800
+ }
9801
+ let targetCluster = null;
9802
+ for (const edge of graph.findEdgesFrom(targetNode.id)) {
9803
+ if (edge.kind === "belongs_to") {
9804
+ const clusterNode = graph.getNode(edge.target);
9805
+ if (clusterNode) {
9806
+ targetCluster = clusterNode.name;
9807
+ break;
9808
+ }
9809
+ }
9810
+ }
9811
+ if (!targetCluster) {
9812
+ for (const edge of graph.findEdgesTo(targetNode.id)) {
9813
+ if (edge.kind === "belongs_to") {
9814
+ const clusterNode = graph.getNode(edge.source);
9815
+ if (clusterNode) {
9816
+ targetCluster = clusterNode.name;
9817
+ break;
9818
+ }
9819
+ }
9820
+ }
9821
+ }
9822
+ const results = [];
9823
+ for (const node of allNodes) {
9824
+ if (node.id === targetNode.id) continue;
9825
+ const maxLen = Math.max(symbolName.length, node.name.length);
9826
+ const nameSim = maxLen === 0 ? 1 : 1 - levenshtein(symbolName, node.name) / maxLen;
9827
+ const structuralSim = node.kind === targetNode.kind ? 0.5 : 0;
9828
+ const combined = 0.5 * nameSim + 0.5 * structuralSim;
9829
+ const reasons = [];
9830
+ if (nameSim >= 0.6) reasons.push("similar name");
9831
+ if (node.kind === targetNode.kind) reasons.push("same kind");
9832
+ if (targetCluster !== null) {
9833
+ let nodeCluster = null;
9834
+ for (const edge of graph.findEdgesFrom(node.id)) {
9835
+ if (edge.kind === "belongs_to") {
9836
+ const clusterNode = graph.getNode(edge.target);
9837
+ if (clusterNode) {
9838
+ nodeCluster = clusterNode.name;
9839
+ break;
9840
+ }
9841
+ }
9842
+ }
9843
+ if (!nodeCluster) {
9844
+ for (const edge of graph.findEdgesTo(node.id)) {
9845
+ if (edge.kind === "belongs_to") {
9846
+ const clusterNode = graph.getNode(edge.source);
9847
+ if (clusterNode) {
9848
+ nodeCluster = clusterNode.name;
9849
+ break;
9850
+ }
9851
+ }
9852
+ }
9853
+ }
9854
+ if (nodeCluster !== null && nodeCluster === targetCluster) {
9855
+ reasons.push("same module");
9856
+ }
9857
+ }
9858
+ if (targetNode.metadata?.["cluster"] !== void 0 && node.metadata?.["cluster"] !== void 0 && node.metadata["cluster"] === targetNode.metadata["cluster"]) {
9859
+ if (!reasons.includes("same module")) reasons.push("same module");
9860
+ }
9861
+ results.push({ name: node.name, similarity: combined, reasons });
9862
+ }
9863
+ results.sort((a, b) => b.similarity - a.similarity);
9864
+ return { similar: results.slice(0, clampedLimit) };
9865
+ }
9866
+
9867
+ // src/query/health-report.ts
9868
+ function computeHealthReport(graph, scope) {
9869
+ const wholeRepo = scope === ".";
9870
+ function inScope(filePath) {
9871
+ if (wholeRepo) return true;
9872
+ return filePath.startsWith(scope) || filePath.includes(scope);
9873
+ }
9874
+ const scopedNodes = [...graph.allNodes()].filter((n) => inScope(n.filePath));
9875
+ const deadCodeKinds = /* @__PURE__ */ new Set(["function", "method", "class"]);
9876
+ const deadCode = [];
9877
+ for (const node of scopedNodes) {
9878
+ if (!deadCodeKinds.has(node.kind)) continue;
9879
+ if (node.exported === true) continue;
9880
+ let hasIncoming = false;
9881
+ for (const _edge of graph.findEdgesTo(node.id)) {
9882
+ hasIncoming = true;
9883
+ break;
9884
+ }
9885
+ if (!hasIncoming) {
9886
+ deadCode.push({ name: node.name, filePath: node.filePath, kind: node.kind });
9887
+ if (deadCode.length >= 20) break;
9888
+ }
9889
+ }
9890
+ const cycles = [];
9891
+ const scopedNodeIds = new Set(scopedNodes.map((n) => n.id));
9892
+ const importAdj = /* @__PURE__ */ new Map();
9893
+ for (const node of scopedNodes) {
9894
+ importAdj.set(node.id, []);
9895
+ }
9896
+ for (const edge of graph.findEdgesByKind("imports")) {
9897
+ if (scopedNodeIds.has(edge.source) && scopedNodeIds.has(edge.target)) {
9898
+ importAdj.get(edge.source).push(edge.target);
9899
+ }
9900
+ }
9901
+ const visited = /* @__PURE__ */ new Set();
9902
+ const inStack = /* @__PURE__ */ new Set();
9903
+ const stackPath = [];
9904
+ function dfs(nodeId) {
9905
+ if (cycles.length >= 5) return;
9906
+ visited.add(nodeId);
9907
+ inStack.add(nodeId);
9908
+ stackPath.push(nodeId);
9909
+ for (const neighborId of importAdj.get(nodeId) ?? []) {
9910
+ if (cycles.length >= 5) break;
9911
+ if (inStack.has(neighborId)) {
9912
+ const cycleStart = stackPath.indexOf(neighborId);
9913
+ const cyclePath = stackPath.slice(cycleStart).map((id) => {
9914
+ const node = graph.getNode(id);
9915
+ return node ? node.name : id;
9916
+ });
9917
+ cycles.push(cyclePath);
9918
+ } else if (!visited.has(neighborId)) {
9919
+ dfs(neighborId);
9920
+ }
9921
+ }
9922
+ stackPath.pop();
9923
+ inStack.delete(nodeId);
9924
+ }
9925
+ for (const node of scopedNodes) {
9926
+ if (cycles.length >= 5) break;
9927
+ if (!visited.has(node.id)) {
9928
+ dfs(node.id);
9929
+ }
9930
+ }
9931
+ const godNodes = [];
9932
+ for (const node of scopedNodes) {
9933
+ let edgeCount = 0;
9934
+ for (const _edge of graph.findEdgesFrom(node.id)) {
9935
+ edgeCount++;
9936
+ }
9937
+ if (edgeCount > 10) {
9938
+ godNodes.push({ name: node.name, edgeCount, filePath: node.filePath });
9939
+ }
9940
+ }
9941
+ godNodes.sort((a, b) => b.edgeCount - a.edgeCount);
9942
+ godNodes.splice(10);
9943
+ const filePathToNodes = /* @__PURE__ */ new Map();
9944
+ for (const node of scopedNodes) {
9945
+ if (!node.filePath) continue;
9946
+ let arr = filePathToNodes.get(node.filePath);
9947
+ if (!arr) {
9948
+ arr = [];
9949
+ filePathToNodes.set(node.filePath, arr);
9950
+ }
9951
+ arr.push(node.id);
9952
+ }
9953
+ const orphanFiles = [];
9954
+ for (const [filePath, nodeIds] of filePathToNodes) {
9955
+ if (orphanFiles.length >= 10) break;
9956
+ let hasAnyEdge = false;
9957
+ for (const nodeId of nodeIds) {
9958
+ let hasOut = false;
9959
+ for (const _edge of graph.findEdgesFrom(nodeId)) {
9960
+ hasOut = true;
9961
+ break;
9962
+ }
9963
+ let hasIn = false;
9964
+ for (const _edge of graph.findEdgesTo(nodeId)) {
9965
+ hasIn = true;
9966
+ break;
9967
+ }
9968
+ if (hasOut || hasIn) {
9969
+ hasAnyEdge = true;
9970
+ break;
9971
+ }
9972
+ }
9973
+ if (!hasAnyEdge) {
9974
+ orphanFiles.push(filePath);
9975
+ }
9976
+ }
9977
+ const hotspotCandidates = [];
9978
+ for (const node of scopedNodes) {
9979
+ const visitedBfs = /* @__PURE__ */ new Set();
9980
+ const queue = [{ id: node.id, depth: 0 }];
9981
+ while (queue.length > 0) {
9982
+ const item = queue.shift();
9983
+ if (item.depth > 5 || visitedBfs.has(item.id)) continue;
9984
+ visitedBfs.add(item.id);
9985
+ for (const edge of graph.findEdgesTo(item.id)) {
9986
+ if (edge.kind === "calls" || edge.kind === "imports") {
9987
+ if (!visitedBfs.has(edge.source)) {
9988
+ queue.push({ id: edge.source, depth: item.depth + 1 });
9989
+ }
9990
+ }
9991
+ }
9992
+ }
9993
+ const blastRadius = visitedBfs.size - 1;
9994
+ hotspotCandidates.push({ name: node.name, blastRadius, filePath: node.filePath });
9995
+ }
9996
+ hotspotCandidates.sort((a, b) => b.blastRadius - a.blastRadius);
9997
+ const complexityHotspots = hotspotCandidates.slice(0, 5);
9998
+ const healthScore = Math.max(
9999
+ 0,
10000
+ Math.min(100, 100 - deadCode.length * 2 - cycles.length * 5 - godNodes.length * 3)
10001
+ );
10002
+ return {
10003
+ healthScore,
10004
+ deadCode,
10005
+ cycles,
10006
+ godNodes,
10007
+ orphanFiles,
10008
+ complexityHotspots
10009
+ };
10010
+ }
10011
+
10012
+ // src/query/suggest-tests.ts
10013
+ function getSuggestedCases(symbolName) {
10014
+ const lower = symbolName.toLowerCase();
10015
+ if (/parse|validate|check|verify/.test(lower)) {
10016
+ return [
10017
+ "Valid input \u2192 success",
10018
+ "Invalid input \u2192 throws error",
10019
+ "Edge case: empty/null input \u2192 handled gracefully"
10020
+ ];
10021
+ }
10022
+ if (/create|add|insert|save/.test(lower)) {
10023
+ return [
10024
+ "Success: valid data \u2192 created",
10025
+ "Duplicate: existing item \u2192 error or no-op",
10026
+ "Missing required fields \u2192 validation error"
10027
+ ];
10028
+ }
10029
+ if (/delete|remove|destroy/.test(lower)) {
10030
+ return [
10031
+ "Existing item \u2192 deleted successfully",
10032
+ "Non-existent item \u2192 no error or 404",
10033
+ "Unauthorized access \u2192 rejected"
10034
+ ];
10035
+ }
10036
+ if (/get|find|fetch|load/.test(lower)) {
10037
+ return [
10038
+ "Found: returns correct data",
10039
+ "Not found: returns null or throws",
10040
+ "Empty collection: returns []"
10041
+ ];
10042
+ }
10043
+ return [
10044
+ "Happy path: valid input \u2192 expected output",
10045
+ "Error case: invalid input \u2192 error handled",
10046
+ "Edge case: boundary values \u2192 correct behavior"
10047
+ ];
10048
+ }
10049
+ function suggestTests(graph, symbolName) {
10050
+ let targetNode = void 0;
10051
+ for (const node of graph.allNodes()) {
10052
+ if (node.name === symbolName) {
10053
+ targetNode = node;
10054
+ break;
10055
+ }
10056
+ }
10057
+ if (!targetNode) {
10058
+ return { error: `Symbol not found: ${symbolName}` };
10059
+ }
10060
+ const targetId = targetNode.id;
10061
+ const callPaths = [];
10062
+ const pathQueue = [{ id: targetId, path: [symbolName], depth: 0 }];
10063
+ while (pathQueue.length > 0 && callPaths.length < 5) {
10064
+ const { id, path: path31, depth } = pathQueue.shift();
10065
+ let hasCallers = false;
10066
+ for (const edge of graph.findEdgesTo(id)) {
10067
+ if (edge.kind !== "calls") continue;
10068
+ const callerNode = graph.getNode(edge.source);
10069
+ if (!callerNode) continue;
10070
+ hasCallers = true;
10071
+ const newPath = [callerNode.name, ...path31];
10072
+ if (depth + 1 >= 3 || callPaths.length >= 5) {
10073
+ if (callPaths.length < 5) callPaths.push(newPath);
10074
+ continue;
10075
+ }
10076
+ pathQueue.push({ id: edge.source, path: newPath, depth: depth + 1 });
10077
+ }
10078
+ if (!hasCallers && path31.length > 1) {
10079
+ callPaths.push(path31);
10080
+ }
10081
+ }
10082
+ if (callPaths.length === 0) {
10083
+ for (const edge of graph.findEdgesTo(targetId)) {
10084
+ if (edge.kind !== "calls") continue;
10085
+ const callerNode = graph.getNode(edge.source);
10086
+ if (!callerNode) continue;
10087
+ callPaths.push([callerNode.name, symbolName]);
10088
+ if (callPaths.length >= 5) break;
10089
+ }
10090
+ }
10091
+ const existingTestFiles = /* @__PURE__ */ new Set();
10092
+ for (const edge of graph.findEdgesTo(targetId)) {
10093
+ if (edge.kind !== "imports") continue;
10094
+ const importerNode = graph.getNode(edge.source);
10095
+ if (!importerNode) continue;
10096
+ if (importerNode.filePath.includes(".test.") || importerNode.filePath.includes(".spec.")) {
10097
+ existingTestFiles.add(importerNode.filePath);
10098
+ }
10099
+ }
10100
+ const existingTests = [...existingTestFiles];
10101
+ const untestedCallers = [];
10102
+ for (const edge of graph.findEdgesTo(targetId)) {
10103
+ if (edge.kind !== "calls") continue;
10104
+ const callerNode = graph.getNode(edge.source);
10105
+ if (!callerNode) continue;
10106
+ if (callerNode.filePath.includes(".test.") || callerNode.filePath.includes(".spec.")) {
10107
+ continue;
10108
+ }
10109
+ let callerHasTest = false;
10110
+ for (const callerImportEdge of graph.findEdgesTo(callerNode.id)) {
10111
+ if (callerImportEdge.kind !== "imports") continue;
10112
+ const importerOfCaller = graph.getNode(callerImportEdge.source);
10113
+ if (!importerOfCaller) continue;
10114
+ if (importerOfCaller.filePath.includes(".test.") || importerOfCaller.filePath.includes(".spec.")) {
10115
+ callerHasTest = true;
10116
+ break;
10117
+ }
10118
+ }
10119
+ if (!callerHasTest) {
10120
+ untestedCallers.push(callerNode.name);
10121
+ }
10122
+ }
10123
+ const suggestedCases = getSuggestedCases(symbolName);
10124
+ return {
10125
+ callPaths,
10126
+ suggestedCases,
10127
+ existingTests,
10128
+ untestedCallers
10129
+ };
10130
+ }
10131
+
10132
+ // src/query/cluster-summary.ts
10133
+ function getPathPrefix(filePath) {
10134
+ const parts = filePath.replace(/\\/g, "/").split("/");
10135
+ return parts.slice(0, 2).join("/");
10136
+ }
10137
+ function summarizeCluster(graph, cluster) {
10138
+ const clusterNodes = [...graph.allNodes()].filter(
10139
+ (n) => n.filePath.startsWith(cluster) || n.metadata?.["cluster"] === cluster
10140
+ );
10141
+ if (clusterNodes.length === 0) {
10142
+ return { error: `Cluster not found: ${cluster}` };
10143
+ }
10144
+ const clusterNodeIds = new Set(clusterNodes.map((n) => n.id));
10145
+ const callerCountMap = /* @__PURE__ */ new Map();
10146
+ for (const node of clusterNodes) {
10147
+ let count = 0;
10148
+ for (const _edge of graph.findEdgesTo(node.id)) {
10149
+ count++;
10150
+ }
10151
+ callerCountMap.set(node.id, count);
10152
+ }
10153
+ const sortedByCallers = [...clusterNodes].sort(
10154
+ (a, b) => (callerCountMap.get(b.id) ?? 0) - (callerCountMap.get(a.id) ?? 0)
10155
+ );
10156
+ const keySymbols = sortedByCallers.slice(0, 5).map((n) => ({
10157
+ name: n.name,
10158
+ callerCount: callerCountMap.get(n.id) ?? 0
10159
+ }));
10160
+ const depsSet = /* @__PURE__ */ new Set();
10161
+ for (const node of clusterNodes) {
10162
+ for (const edge of graph.findEdgesFrom(node.id)) {
10163
+ if (edge.kind !== "imports") continue;
10164
+ const targetNode = graph.getNode(edge.target);
10165
+ if (!targetNode) continue;
10166
+ if (!clusterNodeIds.has(targetNode.id)) {
10167
+ const prefix = getPathPrefix(targetNode.filePath);
10168
+ depsSet.add(prefix);
10169
+ }
10170
+ }
10171
+ }
10172
+ const dependencies = [...depsSet];
10173
+ const dependentsSet = /* @__PURE__ */ new Set();
10174
+ for (const node of clusterNodes) {
10175
+ for (const edge of graph.findEdgesTo(node.id)) {
10176
+ if (edge.kind !== "imports") continue;
10177
+ const sourceNode = graph.getNode(edge.source);
10178
+ if (!sourceNode) continue;
10179
+ if (!clusterNodeIds.has(sourceNode.id)) {
10180
+ const prefix = getPathPrefix(sourceNode.filePath);
10181
+ dependentsSet.add(prefix);
10182
+ }
10183
+ }
10184
+ }
10185
+ const dependents = [...dependentsSet];
10186
+ const healthResult = computeHealthReport(graph, cluster);
10187
+ const health = { score: healthResult.healthScore };
10188
+ const symbolCount = {};
10189
+ for (const node of clusterNodes) {
10190
+ symbolCount[node.kind] = (symbolCount[node.kind] ?? 0) + 1;
10191
+ }
10192
+ let purpose;
10193
+ const topNode = sortedByCallers[0];
10194
+ if (topNode?.metadata?.["summary"] && typeof topNode.metadata["summary"] === "string") {
10195
+ purpose = topNode.metadata["summary"];
10196
+ } else {
10197
+ const clusterName = cluster.split("/").pop() ?? cluster;
10198
+ purpose = `Handles ${clusterName.replace(/[-_/]/g, " ")} functionality`;
10199
+ }
10200
+ return {
10201
+ cluster,
10202
+ purpose,
10203
+ keySymbols,
10204
+ dependencies,
10205
+ dependents,
10206
+ health,
10207
+ symbolCount
10208
+ };
10209
+ }
10210
+
10211
+ // src/mcp-server/server.ts
9593
10212
  function createMcpServer(graph, repoName, workspaceRoot) {
9594
10213
  const server = new Server(
9595
10214
  { name: "code-intel", version: "0.1.0" },
@@ -9619,7 +10238,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9619
10238
  type: "object",
9620
10239
  properties: {
9621
10240
  query: { type: "string", description: "Search query (symbol name, keyword, or partial match)" },
9622
- limit: { type: "number", description: "Max results to return (default: 20)" },
10241
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
10242
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
9623
10243
  ..._tokenProp
9624
10244
  },
9625
10245
  required: ["query"]
@@ -9662,6 +10282,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9662
10282
  type: "object",
9663
10283
  properties: {
9664
10284
  file_path: { type: "string", description: 'File path (partial match is supported, e.g. "auth/login.ts")' },
10285
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
10286
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
9665
10287
  ..._tokenProp
9666
10288
  },
9667
10289
  required: ["file_path"]
@@ -9691,7 +10313,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9691
10313
  type: "string",
9692
10314
  description: "Filter by node kind: function | class | interface | method | type_alias | constant | enum (optional)"
9693
10315
  },
9694
- limit: { type: "number", description: "Max results (default: 100)" },
10316
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
10317
+ limit: { type: "number", description: "Max results per page (default: 50, max: 500)" },
9695
10318
  ..._tokenProp
9696
10319
  }
9697
10320
  }
@@ -9708,7 +10331,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9708
10331
  inputSchema: {
9709
10332
  type: "object",
9710
10333
  properties: {
9711
- limit: { type: "number", description: "Max clusters to return (default: 50)" },
10334
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
10335
+ limit: { type: "number", description: "Max clusters per page (default: 50, max: 500)" },
9712
10336
  ..._tokenProp
9713
10337
  }
9714
10338
  }
@@ -9719,7 +10343,8 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9719
10343
  inputSchema: {
9720
10344
  type: "object",
9721
10345
  properties: {
9722
- limit: { type: "number", description: "Max flows to return (default: 50)" },
10346
+ offset: { type: "number", description: "Number of results to skip for pagination (default: 0)" },
10347
+ limit: { type: "number", description: "Max flows per page (default: 50, max: 500)" },
9723
10348
  ..._tokenProp
9724
10349
  }
9725
10350
  }
@@ -9841,6 +10466,91 @@ function createMcpServer(graph, repoName, workspaceRoot) {
9841
10466
  },
9842
10467
  required: ["name"]
9843
10468
  }
10469
+ },
10470
+ // ── Reasoning / analysis tools ────────────────────────────────────────
10471
+ {
10472
+ name: "explain_relationship",
10473
+ 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.",
10474
+ inputSchema: {
10475
+ type: "object",
10476
+ properties: {
10477
+ from: { type: "string", description: "Source symbol name" },
10478
+ to: { type: "string", description: "Target symbol name" },
10479
+ ..._tokenProp
10480
+ },
10481
+ required: ["from", "to"]
10482
+ }
10483
+ },
10484
+ {
10485
+ name: "pr_impact",
10486
+ 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.",
10487
+ inputSchema: {
10488
+ type: "object",
10489
+ properties: {
10490
+ changedFiles: {
10491
+ type: "array",
10492
+ items: { type: "string" },
10493
+ description: "List of changed file paths (relative or absolute)"
10494
+ },
10495
+ diff: {
10496
+ type: "string",
10497
+ description: "Raw unified diff text. Changed files are extracted automatically."
10498
+ },
10499
+ maxHops: {
10500
+ type: "number",
10501
+ description: "Maximum BFS depth for blast radius (default: 5)"
10502
+ },
10503
+ ..._tokenProp
10504
+ }
10505
+ }
10506
+ },
10507
+ {
10508
+ name: "similar_symbols",
10509
+ description: "Find symbols with similar names or structure using Levenshtein distance and kind matching. Useful for finding related functions, classes, or interfaces.",
10510
+ inputSchema: {
10511
+ type: "object",
10512
+ properties: {
10513
+ symbol: { type: "string", description: "Symbol name to find similar symbols for" },
10514
+ limit: { type: "number", description: "Maximum number of results (default: 10, max: 50)" },
10515
+ ..._tokenProp
10516
+ },
10517
+ required: ["symbol"]
10518
+ }
10519
+ },
10520
+ {
10521
+ name: "health_report",
10522
+ description: "Code health signals for a scope: dead code, cycles, god nodes, orphan files, complexity hotspots",
10523
+ inputSchema: {
10524
+ type: "object",
10525
+ properties: {
10526
+ scope: { type: "string", description: "Directory scope, e.g. 'src/api/' or '.' for whole repo" },
10527
+ ..._tokenProp
10528
+ }
10529
+ }
10530
+ },
10531
+ {
10532
+ name: "suggest_tests",
10533
+ description: "Suggest test cases for a symbol: call paths, suggested cases, existing tests, untested callers",
10534
+ inputSchema: {
10535
+ type: "object",
10536
+ properties: {
10537
+ symbol: { type: "string", description: "Symbol name to generate test suggestions for" },
10538
+ ..._tokenProp
10539
+ },
10540
+ required: ["symbol"]
10541
+ }
10542
+ },
10543
+ {
10544
+ name: "cluster_summary",
10545
+ description: "Rich summary of a module/cluster: purpose, key symbols, dependencies, health",
10546
+ inputSchema: {
10547
+ type: "object",
10548
+ properties: {
10549
+ cluster: { type: "string", description: "Cluster path e.g. 'src/auth'" },
10550
+ ..._tokenProp
10551
+ },
10552
+ required: ["cluster"]
10553
+ }
9844
10554
  }
9845
10555
  ]
9846
10556
  }));
@@ -9911,8 +10621,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
9911
10621
  for (const edge of graph.allEdges()) {
9912
10622
  edgeCounts[edge.kind] = (edgeCounts[edge.kind] ?? 0) + 1;
9913
10623
  }
9914
- const { computeHealthReport: computeHealthReport2 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
9915
- const healthReport = computeHealthReport2(graph);
10624
+ const { computeHealthReport: computeHealthReport3 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
10625
+ const healthReport = computeHealthReport3(graph);
9916
10626
  const health = {
9917
10627
  score: Math.round(healthReport.score),
9918
10628
  grade: healthReport.grade,
@@ -9937,10 +10647,37 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
9937
10647
  // ── search ─────────────────────────────────────────────────────────────
9938
10648
  case "search": {
9939
10649
  const query = a.query;
9940
- const limit = a.limit ?? 20;
10650
+ const offset = a.offset ?? 0;
10651
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
9941
10652
  const vdbPath = workspaceRoot ? getVectorDbPath(workspaceRoot) : void 0;
9942
- const { results, searchMode } = await hybridSearch(graph, query, limit, { vectorDbPath: vdbPath });
9943
- return { content: [{ type: "text", text: JSON.stringify({ results, searchMode, suggested_next_tools: ["inspect", "query", "blast_radius"] }, null, 2) }] };
10653
+ const fetchLimit = Math.min(offset + effectiveLimit, 500);
10654
+ const { results: allResults, searchMode } = await hybridSearch(graph, query, fetchLimit, { vectorDbPath: vdbPath });
10655
+ const total = allResults.length;
10656
+ const results = allResults.slice(offset, offset + effectiveLimit);
10657
+ const hasMore = offset + effectiveLimit < total;
10658
+ const suggestNextTools = [];
10659
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
10660
+ if (suggestEnabled && results.length > 0) {
10661
+ const topName = results[0].name;
10662
+ suggestNextTools.push(
10663
+ { tool: "inspect", reason: "Inspect the top result in detail", input: { symbol: topName } },
10664
+ { tool: "similar_symbols", reason: "Find symbols similar to the top result", input: { symbol: topName } }
10665
+ );
10666
+ }
10667
+ return {
10668
+ content: [{
10669
+ type: "text",
10670
+ text: JSON.stringify({
10671
+ results,
10672
+ searchMode,
10673
+ total,
10674
+ offset,
10675
+ limit: effectiveLimit,
10676
+ hasMore,
10677
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
10678
+ }, null, 2)
10679
+ }]
10680
+ };
9944
10681
  }
9945
10682
  // ── inspect ────────────────────────────────────────────────────────────
9946
10683
  case "inspect": {
@@ -9949,6 +10686,26 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
9949
10686
  if (!node) return { content: [{ type: "text", text: `Symbol "${symbolName}" not found. Try search first.` }] };
9950
10687
  const incoming = [...graph.findEdgesTo(node.id)];
9951
10688
  const outgoing = [...graph.findEdgesFrom(node.id)];
10689
+ const callers = incoming.filter((e) => e.kind === "calls").map((e) => ({
10690
+ id: e.source,
10691
+ name: graph.getNode(e.source)?.name,
10692
+ file: graph.getNode(e.source)?.filePath
10693
+ }));
10694
+ const callees = outgoing.filter((e) => e.kind === "calls").map((e) => ({
10695
+ id: e.target,
10696
+ name: graph.getNode(e.target)?.name,
10697
+ file: graph.getNode(e.target)?.filePath
10698
+ }));
10699
+ const cluster = incoming.filter((e) => e.kind === "belongs_to").map((e) => graph.getNode(e.target)?.name)[0];
10700
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
10701
+ const suggestNextTools = [];
10702
+ if (suggestEnabled) {
10703
+ const topCallerName = callers[0]?.name;
10704
+ suggestNextTools.push(
10705
+ ...topCallerName ? [{ tool: "explain_relationship", reason: "Explain connection to a related symbol", input: { from: node.name, to: topCallerName } }] : [],
10706
+ ...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 } }]
10707
+ );
10708
+ }
9952
10709
  return {
9953
10710
  content: [{
9954
10711
  type: "text",
@@ -9962,16 +10719,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
9962
10719
  endLine: node.endLine,
9963
10720
  exported: node.exported
9964
10721
  },
9965
- callers: incoming.filter((e) => e.kind === "calls").map((e) => ({
9966
- id: e.source,
9967
- name: graph.getNode(e.source)?.name,
9968
- file: graph.getNode(e.source)?.filePath
9969
- })),
9970
- callees: outgoing.filter((e) => e.kind === "calls").map((e) => ({
9971
- id: e.target,
9972
- name: graph.getNode(e.target)?.name,
9973
- file: graph.getNode(e.target)?.filePath
9974
- })),
10722
+ callers,
10723
+ callees,
9975
10724
  imports: incoming.filter((e) => e.kind === "imports").map((e) => graph.getNode(e.source)?.name),
9976
10725
  importedBy: outgoing.filter((e) => e.kind === "imports").map((e) => graph.getNode(e.target)?.name),
9977
10726
  extends: outgoing.filter((e) => e.kind === "extends").map((e) => graph.getNode(e.target)?.name),
@@ -9980,8 +10729,9 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
9980
10729
  name: graph.getNode(e.target)?.name,
9981
10730
  kind: graph.getNode(e.target)?.kind
9982
10731
  })),
9983
- cluster: incoming.filter((e) => e.kind === "belongs_to").map((e) => graph.getNode(e.target)?.name)[0],
9984
- content: node.content?.slice(0, 500)
10732
+ cluster,
10733
+ content: node.content?.slice(0, 500),
10734
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
9985
10735
  }, null, 2)
9986
10736
  }]
9987
10737
  };
@@ -10017,6 +10767,16 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10017
10767
  return n ? { id, name: n.name, kind: n.kind, filePath: n.filePath } : { id };
10018
10768
  });
10019
10769
  const risk = affected.size > 10 ? "HIGH" : affected.size > 5 ? "MEDIUM" : "LOW";
10770
+ const suggestEnabled = process.env["CODE_INTEL_SUGGEST_NEXT_TOOLS"] !== "false";
10771
+ const suggestNextTools = [];
10772
+ if (suggestEnabled) {
10773
+ const highestRiskSymbol = node.name;
10774
+ const firstFilePath = affectedDetails[0]?.filePath ?? "";
10775
+ suggestNextTools.push(
10776
+ { tool: "suggest_tests", reason: "Generate tests for the highest-risk symbol", input: { symbol: highestRiskSymbol } },
10777
+ { tool: "pr_impact", reason: "Compute full PR impact for changed files", input: { changedFiles: [firstFilePath] } }
10778
+ );
10779
+ }
10020
10780
  return {
10021
10781
  content: [{
10022
10782
  type: "text",
@@ -10024,7 +10784,8 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10024
10784
  target: node.name,
10025
10785
  affectedCount: affected.size,
10026
10786
  riskLevel: risk,
10027
- affected: affectedDetails
10787
+ affected: affectedDetails,
10788
+ ...suggestEnabled ? { suggested_next_tools: suggestNextTools } : {}
10028
10789
  }, null, 2)
10029
10790
  }]
10030
10791
  };
@@ -10032,17 +10793,27 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10032
10793
  // ── file_symbols ───────────────────────────────────────────────────────
10033
10794
  case "file_symbols": {
10034
10795
  const filePath = a.file_path;
10035
- const matches = [];
10796
+ const offset = a.offset ?? 0;
10797
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
10798
+ const allMatches = [];
10036
10799
  for (const node of graph.allNodes()) {
10037
10800
  if (node.filePath && node.filePath.includes(filePath)) {
10038
- matches.push({ kind: node.kind, name: node.name, startLine: node.startLine, exported: node.exported });
10801
+ allMatches.push({ kind: node.kind, name: node.name, startLine: node.startLine, exported: node.exported });
10039
10802
  }
10040
10803
  }
10041
- if (matches.length === 0) {
10804
+ if (allMatches.length === 0) {
10042
10805
  return { content: [{ type: "text", text: `No symbols found for file path matching "${filePath}".` }] };
10043
10806
  }
10044
- matches.sort((a2, b) => (a2.startLine ?? 0) - (b.startLine ?? 0));
10045
- return { content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] };
10807
+ allMatches.sort((a2, b) => (a2.startLine ?? 0) - (b.startLine ?? 0));
10808
+ const total = allMatches.length;
10809
+ const matches = allMatches.slice(offset, offset + effectiveLimit);
10810
+ const hasMore = offset + effectiveLimit < total;
10811
+ return {
10812
+ content: [{
10813
+ type: "text",
10814
+ text: JSON.stringify({ symbols: matches, total, offset, limit: effectiveLimit, hasMore }, null, 2)
10815
+ }]
10816
+ };
10046
10817
  }
10047
10818
  // ── find_path ──────────────────────────────────────────────────────────
10048
10819
  case "find_path": {
@@ -10088,15 +10859,23 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10088
10859
  // ── list_exports ───────────────────────────────────────────────────────
10089
10860
  case "list_exports": {
10090
10861
  const kindFilter = a.kind;
10091
- const limit = a.limit ?? 100;
10092
- const exports$1 = [];
10862
+ const offset = a.offset ?? 0;
10863
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
10864
+ const allExports = [];
10093
10865
  for (const node of graph.allNodes()) {
10094
10866
  if (!node.exported) continue;
10095
10867
  if (kindFilter && node.kind !== kindFilter) continue;
10096
- exports$1.push({ kind: node.kind, name: node.name, filePath: node.filePath, startLine: node.startLine });
10097
- if (exports$1.length >= limit) break;
10868
+ allExports.push({ kind: node.kind, name: node.name, filePath: node.filePath, startLine: node.startLine });
10098
10869
  }
10099
- return { content: [{ type: "text", text: JSON.stringify({ total: exports$1.length, exports: exports$1 }, null, 2) }] };
10870
+ const total = allExports.length;
10871
+ const exports$1 = allExports.slice(offset, offset + effectiveLimit);
10872
+ const hasMore = offset + effectiveLimit < total;
10873
+ return {
10874
+ content: [{
10875
+ type: "text",
10876
+ text: JSON.stringify({ exports: exports$1, total, offset, limit: effectiveLimit, hasMore }, null, 2)
10877
+ }]
10878
+ };
10100
10879
  }
10101
10880
  // ── routes ─────────────────────────────────────────────────────────────
10102
10881
  case "routes": {
@@ -10110,8 +10889,9 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10110
10889
  }
10111
10890
  // ── clusters ───────────────────────────────────────────────────────────
10112
10891
  case "clusters": {
10113
- const limit = a.limit ?? 50;
10114
- const clusters = [];
10892
+ const offset = a.offset ?? 0;
10893
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
10894
+ const allClusters = [];
10115
10895
  for (const node of graph.allNodes()) {
10116
10896
  if (node.kind === "cluster") {
10117
10897
  const members = [];
@@ -10123,35 +10903,50 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10123
10903
  }
10124
10904
  }
10125
10905
  }
10126
- clusters.push({
10906
+ allClusters.push({
10127
10907
  id: node.id,
10128
10908
  name: node.name,
10129
10909
  memberCount: node.metadata?.memberCount ?? members.length,
10130
10910
  topSymbols: members.slice(0, 10)
10131
10911
  });
10132
- if (clusters.length >= limit) break;
10133
10912
  }
10134
10913
  }
10135
- return { content: [{ type: "text", text: JSON.stringify(clusters, null, 2) }] };
10914
+ const total = allClusters.length;
10915
+ const clusters = allClusters.slice(offset, offset + effectiveLimit);
10916
+ const hasMore = offset + effectiveLimit < total;
10917
+ return {
10918
+ content: [{
10919
+ type: "text",
10920
+ text: JSON.stringify({ clusters, total, offset, limit: effectiveLimit, hasMore }, null, 2)
10921
+ }]
10922
+ };
10136
10923
  }
10137
10924
  // ── flows ──────────────────────────────────────────────────────────────
10138
10925
  case "flows": {
10139
- const limit = a.limit ?? 50;
10140
- const flows = [];
10926
+ const offset = a.offset ?? 0;
10927
+ const effectiveLimit = Math.min(a.limit ?? 50, 500);
10928
+ const allFlows = [];
10141
10929
  for (const node of graph.allNodes()) {
10142
10930
  if (node.kind === "flow") {
10143
10931
  const steps = node.metadata?.steps;
10144
- flows.push({
10932
+ allFlows.push({
10145
10933
  id: node.id,
10146
10934
  name: node.name,
10147
10935
  entryPoint: node.metadata?.entryPoint,
10148
10936
  steps: steps ?? [],
10149
10937
  stepCount: Array.isArray(steps) ? steps.length : 0
10150
10938
  });
10151
- if (flows.length >= limit) break;
10152
10939
  }
10153
10940
  }
10154
- return { content: [{ type: "text", text: JSON.stringify(flows, null, 2) }] };
10941
+ const total = allFlows.length;
10942
+ const flows = allFlows.slice(offset, offset + effectiveLimit);
10943
+ const hasMore = offset + effectiveLimit < total;
10944
+ return {
10945
+ content: [{
10946
+ type: "text",
10947
+ text: JSON.stringify({ flows, total, offset, limit: effectiveLimit, hasMore }, null, 2)
10948
+ }]
10949
+ };
10155
10950
  }
10156
10951
  // ── detect_changes ─────────────────────────────────────────────────────
10157
10952
  case "detect_changes": {
@@ -10401,6 +11196,57 @@ async function dispatchTool(name, a, graph, repoName, workspaceRoot) {
10401
11196
  }]
10402
11197
  };
10403
11198
  }
11199
+ // ── explain_relationship ───────────────────────────────────────────────
11200
+ case "explain_relationship": {
11201
+ const fromName = a.from;
11202
+ const toName = a.to;
11203
+ const result = explainRelationship(graph, fromName, toName);
11204
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11205
+ }
11206
+ // ── pr_impact ──────────────────────────────────────────────────────────
11207
+ case "pr_impact": {
11208
+ const maxHops = a.maxHops ?? 5;
11209
+ let changedFiles = a.changedFiles ?? [];
11210
+ if (a.diff && typeof a.diff === "string") {
11211
+ const diffFiles = parseDiffFiles(a.diff);
11212
+ changedFiles = [.../* @__PURE__ */ new Set([...changedFiles, ...diffFiles])];
11213
+ }
11214
+ if (changedFiles.length === 0) {
11215
+ return {
11216
+ content: [{
11217
+ type: "text",
11218
+ text: JSON.stringify({ error: 'No changed files provided. Supply "changedFiles" or "diff".' })
11219
+ }]
11220
+ };
11221
+ }
11222
+ const result = computePRImpact(graph, changedFiles, maxHops);
11223
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11224
+ }
11225
+ // ── similar_symbols ────────────────────────────────────────────────────
11226
+ case "similar_symbols": {
11227
+ const symbolName = a.symbol;
11228
+ const limit = a.limit ?? 10;
11229
+ const result = findSimilarSymbols(graph, symbolName, limit);
11230
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11231
+ }
11232
+ // ── health_report ──────────────────────────────────────────────────────
11233
+ case "health_report": {
11234
+ const scope = a.scope ?? ".";
11235
+ const result = computeHealthReport(graph, scope);
11236
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11237
+ }
11238
+ // ── suggest_tests ──────────────────────────────────────────────────────
11239
+ case "suggest_tests": {
11240
+ const sym = a.symbol;
11241
+ const result = suggestTests(graph, sym);
11242
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11243
+ }
11244
+ // ── cluster_summary ────────────────────────────────────────────────────
11245
+ case "cluster_summary": {
11246
+ const cluster = a.cluster;
11247
+ const result = summarizeCluster(graph, cluster);
11248
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
11249
+ }
10404
11250
  default:
10405
11251
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
10406
11252
  }
@@ -12990,13 +13836,13 @@ program.command("health").description("Run code health checks: dead code, circul
12990
13836
  console.error(" Run `code-intel analyze` first to build the index.\n");
12991
13837
  process.exit(1);
12992
13838
  }
12993
- const { computeHealthReport: computeHealthReport2 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
13839
+ const { computeHealthReport: computeHealthReport3 } = await Promise.resolve().then(() => (init_health_score(), health_score_exports));
12994
13840
  const graph = createKnowledgeGraph();
12995
13841
  const db = new DbManager(dbPath);
12996
13842
  await db.init();
12997
13843
  await loadGraphFromDB(graph, db);
12998
13844
  db.close();
12999
- const report = computeHealthReport2(graph);
13845
+ const report = computeHealthReport3(graph);
13000
13846
  if (opts.json) {
13001
13847
  console.log(JSON.stringify({
13002
13848
  score: report.score,