Skip to content

Commit 3900087

Browse files
aksOpsclaude
andcommitted
Implement Phase C: Service Topology — SERVICE nodes, TopologyService, REST + MCP + CLI
Add NodeKind.SERVICE and ServiceDetector that detects module boundaries from build files (pom.xml, package.json, go.mod, build.gradle, Cargo.toml, *.csproj). Creates SERVICE nodes during enrich phase, sets service property on child nodes. TopologyService provides 10 query methods: getTopology, serviceDetail, serviceDependencies, serviceDependents, blastRadius, findPath, findBottlenecks, findCircularDeps, findDeadServices, findNode. All work on in-memory node/edge lists using only runtime edges (CALLS, PRODUCES, CONSUMES, QUERIES, CONNECTS_TO). TopologyController exposes REST endpoints at /api/topology/*. McpTools adds 10 new tools. TopologyCommand adds CLI `topology` subcommand with pretty/json output. All 2,454 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1030f7b commit 3900087

16 files changed

Lines changed: 1832 additions & 11 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package io.github.randomcodespace.iq.analyzer;
2+
3+
import io.github.randomcodespace.iq.model.CodeEdge;
4+
import io.github.randomcodespace.iq.model.CodeNode;
5+
import io.github.randomcodespace.iq.model.EdgeKind;
6+
import io.github.randomcodespace.iq.model.NodeKind;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
10+
import java.nio.file.Path;
11+
import java.util.ArrayList;
12+
import java.util.LinkedHashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.TreeMap;
16+
import java.util.UUID;
17+
18+
/**
19+
* Detects service boundaries by scanning the graph for build file nodes
20+
* that indicate module boundaries. Runs AFTER all detectors + linkers
21+
* during the enrich phase.
22+
* <p>
23+
* Creates SERVICE nodes and sets the {@code service} property on all
24+
* child nodes (nodes whose filePath starts with the module directory).
25+
*/
26+
public class ServiceDetector {
27+
28+
private static final Logger log = LoggerFactory.getLogger(ServiceDetector.class);
29+
30+
/**
31+
* Build file patterns that indicate module boundaries.
32+
* Maps filename to build tool name.
33+
*/
34+
private static final Map<String, String> BUILD_FILES = Map.of(
35+
"pom.xml", "maven",
36+
"package.json", "npm",
37+
"go.mod", "go",
38+
"build.gradle", "gradle",
39+
"build.gradle.kts", "gradle",
40+
"Cargo.toml", "cargo"
41+
);
42+
43+
/** File extension for .csproj files (matched by suffix). */
44+
private static final String CSPROJ_EXTENSION = ".csproj";
45+
46+
/**
47+
* Detect service boundaries from the graph's nodes and create SERVICE nodes.
48+
*
49+
* @param nodes all current nodes in the graph
50+
* @param edges all current edges in the graph
51+
* @param projectDir the project root directory name (used as fallback service name)
52+
* @return result containing new SERVICE nodes, CONTAINS edges, and
53+
* the service property assignments for existing nodes
54+
*/
55+
public ServiceDetectionResult detect(List<CodeNode> nodes, List<CodeEdge> edges, String projectDir) {
56+
// 1. Find module boundaries by scanning node file paths for build files
57+
// Use TreeMap for deterministic ordering (sorted by directory path)
58+
Map<String, ModuleInfo> modules = new TreeMap<>();
59+
60+
for (CodeNode node : nodes) {
61+
String filePath = node.getFilePath();
62+
if (filePath == null) continue;
63+
64+
String fileName = Path.of(filePath).getFileName().toString();
65+
String dirPath = parentDir(filePath);
66+
67+
// Check known build files
68+
String buildTool = BUILD_FILES.get(fileName);
69+
if (buildTool != null) {
70+
modules.putIfAbsent(dirPath, new ModuleInfo(dirPath, buildTool, fileName));
71+
}
72+
// Check .csproj files
73+
if (fileName.endsWith(CSPROJ_EXTENSION)) {
74+
modules.putIfAbsent(dirPath, new ModuleInfo(dirPath, "dotnet", fileName));
75+
}
76+
}
77+
78+
// 2. If no modules detected, create one service for the whole project
79+
if (modules.isEmpty()) {
80+
modules.put("", new ModuleInfo("", "unknown", ""));
81+
}
82+
83+
// 3. Create SERVICE nodes and assign child nodes
84+
List<CodeNode> serviceNodes = new ArrayList<>();
85+
List<CodeEdge> serviceEdges = new ArrayList<>();
86+
87+
// Sort module dirs by length descending so deeper paths match first
88+
List<String> sortedDirs = new ArrayList<>(modules.keySet());
89+
sortedDirs.sort((a, b) -> Integer.compare(b.length(), a.length()));
90+
91+
// Map from module dir -> service node for child assignment
92+
Map<String, CodeNode> serviceByDir = new LinkedHashMap<>();
93+
94+
for (var entry : modules.entrySet()) {
95+
String dir = entry.getKey();
96+
ModuleInfo info = entry.getValue();
97+
98+
String serviceName = deriveServiceName(dir, projectDir);
99+
100+
CodeNode service = new CodeNode();
101+
service.setId("service:" + serviceName);
102+
service.setKind(NodeKind.SERVICE);
103+
service.setLabel(serviceName);
104+
service.setFilePath(dir.isEmpty() ? "." : dir);
105+
service.setLayer("backend"); // default, can be refined
106+
107+
Map<String, Object> props = new LinkedHashMap<>();
108+
props.put("build_tool", info.buildTool());
109+
props.put("detected_from", info.buildFile());
110+
// Counts filled below
111+
props.put("endpoint_count", 0);
112+
props.put("entity_count", 0);
113+
service.setProperties(props);
114+
115+
serviceNodes.add(service);
116+
serviceByDir.put(dir, service);
117+
}
118+
119+
// 4. Assign service property to all child nodes + count endpoints/entities
120+
Map<String, Integer> endpointCounts = new LinkedHashMap<>();
121+
Map<String, Integer> entityCounts = new LinkedHashMap<>();
122+
123+
for (CodeNode node : nodes) {
124+
String filePath = node.getFilePath();
125+
if (filePath == null) filePath = "";
126+
127+
// Find the best matching service (deepest directory match)
128+
String matchedDir = null;
129+
for (String dir : sortedDirs) {
130+
if (dir.isEmpty() || filePath.startsWith(dir + "/") || filePath.equals(dir)) {
131+
matchedDir = dir;
132+
break;
133+
}
134+
}
135+
// Fallback to root module if present
136+
if (matchedDir == null && modules.containsKey("")) {
137+
matchedDir = "";
138+
}
139+
140+
if (matchedDir != null) {
141+
CodeNode serviceNode = serviceByDir.get(matchedDir);
142+
if (serviceNode != null) {
143+
String serviceName = serviceNode.getLabel();
144+
node.getProperties().put("service", serviceName);
145+
146+
// Create CONTAINS edge
147+
CodeEdge containsEdge = new CodeEdge(
148+
"edge:service:" + serviceName + ":contains:" + node.getId(),
149+
EdgeKind.CONTAINS,
150+
serviceNode.getId(),
151+
node
152+
);
153+
serviceEdges.add(containsEdge);
154+
155+
// Count endpoints and entities
156+
if (node.getKind() == NodeKind.ENDPOINT) {
157+
endpointCounts.merge(serviceName, 1, Integer::sum);
158+
} else if (node.getKind() == NodeKind.ENTITY) {
159+
entityCounts.merge(serviceName, 1, Integer::sum);
160+
}
161+
}
162+
}
163+
}
164+
165+
// 5. Update counts on service nodes
166+
for (CodeNode service : serviceNodes) {
167+
String name = service.getLabel();
168+
service.getProperties().put("endpoint_count",
169+
endpointCounts.getOrDefault(name, 0));
170+
service.getProperties().put("entity_count",
171+
entityCounts.getOrDefault(name, 0));
172+
}
173+
174+
log.info("Detected {} service(s): {}", serviceNodes.size(),
175+
serviceNodes.stream().map(CodeNode::getLabel).toList());
176+
177+
return new ServiceDetectionResult(serviceNodes, serviceEdges);
178+
}
179+
180+
/**
181+
* Derive a human-readable service name from a directory path.
182+
*/
183+
private String deriveServiceName(String dir, String projectDir) {
184+
if (dir.isEmpty()) {
185+
return projectDir != null && !projectDir.isEmpty() ? projectDir : "root";
186+
}
187+
// Use the last path component
188+
String[] parts = dir.replace('\\', '/').split("/");
189+
return parts[parts.length - 1];
190+
}
191+
192+
/**
193+
* Get the parent directory of a file path.
194+
*/
195+
private static String parentDir(String filePath) {
196+
if (filePath == null) return "";
197+
String normalized = filePath.replace('\\', '/');
198+
int lastSlash = normalized.lastIndexOf('/');
199+
if (lastSlash <= 0) return "";
200+
return normalized.substring(0, lastSlash);
201+
}
202+
203+
/**
204+
* Internal record for module metadata.
205+
*/
206+
private record ModuleInfo(String directory, String buildTool, String buildFile) {}
207+
208+
/**
209+
* Result of service detection.
210+
*/
211+
public record ServiceDetectionResult(
212+
List<CodeNode> serviceNodes,
213+
List<CodeEdge> serviceEdges
214+
) {}
215+
}

src/main/java/io/github/randomcodespace/iq/api/GraphController.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io.github.randomcodespace.iq.model.CodeNode;
99
import io.github.randomcodespace.iq.query.QueryService;
1010
import io.github.randomcodespace.iq.query.StatsService;
11+
import io.github.randomcodespace.iq.query.TopologyService;
1112
import org.springframework.http.HttpStatus;
1213
import org.springframework.http.MediaType;
1314
import org.springframework.http.ResponseEntity;
@@ -38,13 +39,16 @@ public class GraphController {
3839
private final Analyzer analyzer;
3940
private final CodeIqConfig config;
4041
private final StatsService statsService;
42+
private final TopologyService topologyService;
4143

4244
public GraphController(QueryService queryService, Analyzer analyzer,
43-
CodeIqConfig config, StatsService statsService) {
45+
CodeIqConfig config, StatsService statsService,
46+
TopologyService topologyService) {
4447
this.queryService = queryService;
4548
this.analyzer = analyzer;
4649
this.config = config;
4750
this.statsService = statsService;
51+
this.topologyService = topologyService;
4852
}
4953

5054
@GetMapping("/stats")
@@ -106,6 +110,22 @@ public Map<String, Object> listNodes(
106110
return queryService.listNodes(kind, limit, offset);
107111
}
108112

113+
@GetMapping("/nodes/find")
114+
public List<Map<String, Object>> findNode(@RequestParam String q) {
115+
Path root = Path.of(config.getRootPath()).toAbsolutePath().normalize();
116+
Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db");
117+
Path h2File = root.resolve(config.getCacheDir()).resolve("analysis-cache.mv.db");
118+
119+
if (!Files.exists(h2File)) {
120+
return List.of();
121+
}
122+
123+
try (AnalysisCache cache = new AnalysisCache(cachePath)) {
124+
List<CodeNode> nodes = cache.loadAllNodes();
125+
return topologyService.findNode(q, nodes);
126+
}
127+
}
128+
109129
@GetMapping("/nodes/{nodeId}/detail")
110130
public Map<String, Object> nodeDetail(@PathVariable String nodeId) {
111131
Map<String, Object> result = queryService.nodeDetailWithEdges(nodeId);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.github.randomcodespace.iq.api;
2+
3+
import io.github.randomcodespace.iq.cache.AnalysisCache;
4+
import io.github.randomcodespace.iq.config.CodeIqConfig;
5+
import io.github.randomcodespace.iq.model.CodeEdge;
6+
import io.github.randomcodespace.iq.model.CodeNode;
7+
import io.github.randomcodespace.iq.query.TopologyService;
8+
import org.springframework.context.annotation.Profile;
9+
import org.springframework.http.HttpStatus;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RequestParam;
14+
import org.springframework.web.bind.annotation.RestController;
15+
import org.springframework.web.server.ResponseStatusException;
16+
17+
import java.nio.file.Files;
18+
import java.nio.file.Path;
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
/**
23+
* REST API controller for service topology queries.
24+
*/
25+
@RestController
26+
@RequestMapping("/api/topology")
27+
@Profile("serving")
28+
public class TopologyController {
29+
30+
private final TopologyService topologyService;
31+
private final CodeIqConfig config;
32+
33+
public TopologyController(TopologyService topologyService, CodeIqConfig config) {
34+
this.topologyService = topologyService;
35+
this.config = config;
36+
}
37+
38+
@GetMapping
39+
public Map<String, Object> getTopology() {
40+
var data = loadData();
41+
return topologyService.getTopology(data.nodes(), data.edges());
42+
}
43+
44+
@GetMapping("/services/{name}")
45+
public Map<String, Object> serviceDetail(@PathVariable String name) {
46+
var data = loadData();
47+
return topologyService.serviceDetail(name, data.nodes(), data.edges());
48+
}
49+
50+
@GetMapping("/services/{name}/deps")
51+
public Map<String, Object> serviceDependencies(@PathVariable String name) {
52+
var data = loadData();
53+
return topologyService.serviceDependencies(name, data.nodes(), data.edges());
54+
}
55+
56+
@GetMapping("/services/{name}/dependents")
57+
public Map<String, Object> serviceDependents(@PathVariable String name) {
58+
var data = loadData();
59+
return topologyService.serviceDependents(name, data.nodes(), data.edges());
60+
}
61+
62+
@GetMapping("/blast-radius/{nodeId}")
63+
public Map<String, Object> blastRadius(@PathVariable String nodeId) {
64+
var data = loadData();
65+
return topologyService.blastRadius(nodeId, data.nodes(), data.edges());
66+
}
67+
68+
@GetMapping("/path")
69+
public List<Map<String, Object>> findPath(
70+
@RequestParam("from") String source,
71+
@RequestParam("to") String target) {
72+
var data = loadData();
73+
return topologyService.findPath(source, target, data.nodes(), data.edges());
74+
}
75+
76+
@GetMapping("/bottlenecks")
77+
public List<Map<String, Object>> findBottlenecks() {
78+
var data = loadData();
79+
return topologyService.findBottlenecks(data.nodes(), data.edges());
80+
}
81+
82+
@GetMapping("/circular")
83+
public List<List<String>> findCircularDeps() {
84+
var data = loadData();
85+
return topologyService.findCircularDeps(data.nodes(), data.edges());
86+
}
87+
88+
@GetMapping("/dead")
89+
public List<Map<String, Object>> findDeadServices() {
90+
var data = loadData();
91+
return topologyService.findDeadServices(data.nodes(), data.edges());
92+
}
93+
94+
/**
95+
* Load nodes and edges from the analysis cache.
96+
*/
97+
private GraphData loadData() {
98+
Path root = Path.of(config.getRootPath()).toAbsolutePath().normalize();
99+
Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db");
100+
Path h2File = root.resolve(config.getCacheDir()).resolve("analysis-cache.mv.db");
101+
102+
if (!Files.exists(h2File)) {
103+
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
104+
"No analysis cache found. Run analyze first.");
105+
}
106+
107+
try (AnalysisCache cache = new AnalysisCache(cachePath)) {
108+
List<CodeNode> nodes = cache.loadAllNodes();
109+
List<CodeEdge> edges = cache.loadAllEdges();
110+
return new GraphData(nodes, edges);
111+
}
112+
}
113+
114+
private record GraphData(List<CodeNode> nodes, List<CodeEdge> edges) {}
115+
}

src/main/java/io/github/randomcodespace/iq/cli/CodeIqCli.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
BundleCommand.class,
2828
CacheCommand.class,
2929
StatsCommand.class,
30+
TopologyCommand.class,
3031
PluginsCommand.class,
3132
VersionCommand.class
3233
}

0 commit comments

Comments
 (0)