Skip to content

Commit 07944fe

Browse files
aksOpsclaude
andcommitted
feat: add Phase 3 — query engine, REST API, MCP server, Hazelcast caching
Build the query/serving layer for the Java rewrite: - QueryService: cached high-level queries wrapping GraphStore (stats, kinds, node detail, shortest path, cycles, impact trace, ego graph, consumers, producers, callers, dependencies, dependents, search, file component lookup) - GraphRepository: 16 Cypher query methods for graph traversal (shortest path, ego graph, impact trace, cycles, relationship queries, pagination) - GraphStore: facade with all traversal/pagination delegations - GraphController: 22 REST endpoints matching Python API paths (/api/stats, /api/kinds, /api/nodes, /api/edges, /api/ego, /api/query/*, /api/triage/*, /api/search, /api/analyze) - McpTools: 20 Spring AI @tool methods matching Python MCP tool names exactly (get_stats, query_nodes, search_graph, trace_impact, read_file, etc.) - HazelcastConfig: dual-profile caching (serving + k8s) with 6 cache maps (graph-stats, kinds-list, kind-nodes, node-detail, search-results, impact-trace) - GraphHealthIndicator: custom actuator health check for graph data presence - All depth/radius params capped at 10 to prevent DoS Tests: 696 passing (99 new), 0 failures. New test classes: GraphControllerTest (26), McpToolsTest (29), HazelcastConfigTest (13), GraphHealthIndicatorTest (3), QueryServiceTest (28) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d73b1eb commit 07944fe

14 files changed

Lines changed: 2474 additions & 19 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package io.github.randomcodespace.iq.api;
2+
3+
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
4+
import io.github.randomcodespace.iq.analyzer.Analyzer;
5+
import io.github.randomcodespace.iq.config.CodeIqConfig;
6+
import io.github.randomcodespace.iq.query.QueryService;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.web.server.ResponseStatusException;
15+
16+
import java.nio.file.Path;
17+
import java.util.LinkedHashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
21+
/**
22+
* REST API controller matching the Python OSSCodeIQ API paths.
23+
*/
24+
@RestController
25+
@RequestMapping("/api")
26+
public class GraphController {
27+
28+
private final QueryService queryService;
29+
private final Analyzer analyzer;
30+
private final CodeIqConfig config;
31+
32+
public GraphController(QueryService queryService, Analyzer analyzer, CodeIqConfig config) {
33+
this.queryService = queryService;
34+
this.analyzer = analyzer;
35+
this.config = config;
36+
}
37+
38+
@GetMapping("/stats")
39+
public Map<String, Object> getStats() {
40+
return queryService.getStats();
41+
}
42+
43+
@GetMapping("/kinds")
44+
public Map<String, Object> listKinds() {
45+
return queryService.listKinds();
46+
}
47+
48+
@GetMapping("/kinds/{kind}")
49+
public Map<String, Object> nodesByKind(
50+
@PathVariable String kind,
51+
@RequestParam(defaultValue = "50") int limit,
52+
@RequestParam(defaultValue = "0") int offset) {
53+
return queryService.nodesByKind(kind, limit, offset);
54+
}
55+
56+
@GetMapping("/nodes")
57+
public Map<String, Object> listNodes(
58+
@RequestParam(required = false) String kind,
59+
@RequestParam(defaultValue = "100") int limit,
60+
@RequestParam(defaultValue = "0") int offset) {
61+
return queryService.listNodes(kind, limit, offset);
62+
}
63+
64+
@GetMapping("/nodes/{nodeId}/detail")
65+
public Map<String, Object> nodeDetail(@PathVariable String nodeId) {
66+
Map<String, Object> result = queryService.nodeDetailWithEdges(nodeId);
67+
if (result == null) {
68+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Node not found: " + nodeId);
69+
}
70+
return result;
71+
}
72+
73+
@GetMapping("/nodes/{nodeId}/neighbors")
74+
public Map<String, Object> neighbors(
75+
@PathVariable String nodeId,
76+
@RequestParam(defaultValue = "both") String direction) {
77+
return queryService.getNeighbors(nodeId, direction);
78+
}
79+
80+
@GetMapping("/edges")
81+
public Map<String, Object> listEdges(
82+
@RequestParam(required = false) String kind,
83+
@RequestParam(defaultValue = "100") int limit,
84+
@RequestParam(defaultValue = "0") int offset) {
85+
return queryService.listEdges(kind, limit, offset);
86+
}
87+
88+
@GetMapping("/ego/{center}")
89+
public Map<String, Object> egoGraph(
90+
@PathVariable String center,
91+
@RequestParam(defaultValue = "2") int radius) {
92+
int cappedRadius = Math.min(radius, config.getMaxRadius());
93+
return queryService.egoGraph(center, cappedRadius);
94+
}
95+
96+
@GetMapping("/query/cycles")
97+
public Map<String, Object> findCycles(@RequestParam(defaultValue = "100") int limit) {
98+
return queryService.findCycles(limit);
99+
}
100+
101+
@GetMapping("/query/shortest-path")
102+
public Map<String, Object> shortestPath(
103+
@RequestParam String source,
104+
@RequestParam String target) {
105+
Map<String, Object> result = queryService.shortestPath(source, target);
106+
if (result == null) {
107+
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
108+
"No path found between " + source + " and " + target);
109+
}
110+
return result;
111+
}
112+
113+
@GetMapping("/query/consumers/{targetId}")
114+
public Map<String, Object> consumersOf(@PathVariable String targetId) {
115+
return queryService.consumersOf(targetId);
116+
}
117+
118+
@GetMapping("/query/producers/{targetId}")
119+
public Map<String, Object> producersOf(@PathVariable String targetId) {
120+
return queryService.producersOf(targetId);
121+
}
122+
123+
@GetMapping("/query/callers/{targetId}")
124+
public Map<String, Object> callersOf(@PathVariable String targetId) {
125+
return queryService.callersOf(targetId);
126+
}
127+
128+
@GetMapping("/query/dependencies/{moduleId}")
129+
public Map<String, Object> dependenciesOf(@PathVariable String moduleId) {
130+
return queryService.dependenciesOf(moduleId);
131+
}
132+
133+
@GetMapping("/query/dependents/{moduleId}")
134+
public Map<String, Object> dependentsOf(@PathVariable String moduleId) {
135+
return queryService.dependentsOf(moduleId);
136+
}
137+
138+
@GetMapping("/triage/component")
139+
public Map<String, Object> findComponent(@RequestParam String file) {
140+
return queryService.findComponentByFile(file);
141+
}
142+
143+
@GetMapping("/triage/impact/{nodeId}")
144+
public Map<String, Object> traceImpact(
145+
@PathVariable String nodeId,
146+
@RequestParam(defaultValue = "3") int depth) {
147+
int cappedDepth = Math.min(depth, config.getMaxDepth());
148+
return queryService.traceImpact(nodeId, cappedDepth);
149+
}
150+
151+
@GetMapping("/search")
152+
public List<Map<String, Object>> searchGraph(
153+
@RequestParam String q,
154+
@RequestParam(defaultValue = "50") int limit) {
155+
return queryService.searchGraph(q, limit);
156+
}
157+
158+
@PostMapping("/analyze")
159+
public Map<String, Object> triggerAnalysis(
160+
@RequestParam(defaultValue = "false") boolean incremental) {
161+
AnalysisResult result = analyzer.run(Path.of(config.getRootPath()), null);
162+
163+
Map<String, Object> response = new LinkedHashMap<>();
164+
response.put("status", "complete");
165+
response.put("total_files", result.totalFiles());
166+
response.put("files_analyzed", result.filesAnalyzed());
167+
response.put("node_count", result.nodeCount());
168+
response.put("edge_count", result.edgeCount());
169+
response.put("elapsed_ms", result.elapsed().toMillis());
170+
return response;
171+
}
172+
}

src/main/java/io/github/randomcodespace/iq/config/HazelcastConfig.java

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@
1212
import org.springframework.context.annotation.Profile;
1313

1414
/**
15-
* Hazelcast cache configuration, active only on the "serving" profile.
15+
* Hazelcast cache configuration with two profiles:
16+
* <ul>
17+
* <li><b>serving</b> (default/local): Standalone Hazelcast instance, no network discovery</li>
18+
* <li><b>k8s</b>: Kubernetes service-based discovery for clustered deployments</li>
19+
* </ul>
1620
*
17-
* Configures near-cache for hot data and optionally enables Kubernetes pod
18-
* discovery when {@code codeiq.hazelcast.k8s-discovery} is set to {@code true}.
21+
* Both modes support the same cache maps: graph-stats, kinds-list, kind-nodes,
22+
* node-detail, search-results, impact-trace.
1923
*/
2024
@Configuration
21-
@Profile("serving")
25+
@Profile({"serving", "k8s"})
2226
public class HazelcastConfig {
2327

2428
@Value("${codeiq.hazelcast.k8s-discovery:false}")
@@ -33,7 +37,14 @@ Config hazelcastConfig() {
3337
config.setInstanceName("code-iq-cache");
3438
config.setClusterName("code-iq");
3539

36-
// Near-cache for hot graph data — reduces latency for repeated reads
40+
// --- Local profile: disable multicast for standalone mode ---
41+
if (!k8sDiscovery) {
42+
var joinConfig = config.getNetworkConfig().getJoin();
43+
joinConfig.getMulticastConfig().setEnabled(false);
44+
joinConfig.getTcpIpConfig().setEnabled(false);
45+
}
46+
47+
// --- Near-cache for hot graph data ---
3748
var nearCacheConfig = new NearCacheConfig()
3849
.setName("graph-nodes")
3950
.setTimeToLiveSeconds(300)
@@ -45,40 +56,64 @@ Config hazelcastConfig() {
4556
.setEvictionPolicy(EvictionPolicy.LRU)
4657
);
4758

48-
// Map config for graph node cache
49-
var graphNodeMapConfig = new MapConfig("graph-nodes")
50-
.setTimeToLiveSeconds(600)
59+
// --- Cache map configs ---
60+
61+
// graph-stats: infrequently updated, long TTL
62+
config.addMapConfig(new MapConfig("graph-stats")
63+
.setTimeToLiveSeconds(600));
64+
65+
// kinds-list: infrequently updated, long TTL
66+
config.addMapConfig(new MapConfig("kinds-list")
67+
.setTimeToLiveSeconds(600));
68+
69+
// kind-nodes: paginated results, medium TTL
70+
config.addMapConfig(new MapConfig("kind-nodes")
71+
.setTimeToLiveSeconds(300)
72+
.setEvictionConfig(
73+
new EvictionConfig()
74+
.setMaxSizePolicy(MaxSizePolicy.ENTRY_COUNT)
75+
.setSize(5_000)
76+
.setEvictionPolicy(EvictionPolicy.LRU)
77+
));
78+
79+
// node-detail: per-node detail with edges, near-cached
80+
config.addMapConfig(new MapConfig("node-detail")
81+
.setTimeToLiveSeconds(300)
5182
.setEvictionConfig(
5283
new EvictionConfig()
5384
.setMaxSizePolicy(MaxSizePolicy.FREE_HEAP_PERCENTAGE)
5485
.setSize(25)
5586
.setEvictionPolicy(EvictionPolicy.LRU)
5687
)
57-
.setNearCacheConfig(nearCacheConfig);
88+
.setNearCacheConfig(nearCacheConfig));
5889

59-
config.addMapConfig(graphNodeMapConfig);
60-
61-
// Map config for search results
62-
var searchMapConfig = new MapConfig("search-results")
90+
// search-results: short TTL, bounded size
91+
config.addMapConfig(new MapConfig("search-results")
6392
.setTimeToLiveSeconds(120)
6493
.setEvictionConfig(
6594
new EvictionConfig()
6695
.setMaxSizePolicy(MaxSizePolicy.ENTRY_COUNT)
6796
.setSize(1_000)
6897
.setEvictionPolicy(EvictionPolicy.LRU)
69-
);
98+
));
7099

71-
config.addMapConfig(searchMapConfig);
100+
// impact-trace: graph traversal results, medium TTL
101+
config.addMapConfig(new MapConfig("impact-trace")
102+
.setTimeToLiveSeconds(300)
103+
.setEvictionConfig(
104+
new EvictionConfig()
105+
.setMaxSizePolicy(MaxSizePolicy.ENTRY_COUNT)
106+
.setSize(2_000)
107+
.setEvictionPolicy(EvictionPolicy.LRU)
108+
));
72109

73-
// K8s pod discovery — when running in Kubernetes, use DNS-based discovery
110+
// --- K8s pod discovery ---
74111
if (k8sDiscovery) {
75112
var networkConfig = config.getNetworkConfig();
76113
var joinConfig = networkConfig.getJoin();
77114
joinConfig.getMulticastConfig().setEnabled(false);
78115
joinConfig.getTcpIpConfig().setEnabled(false);
79116

80-
// Use Hazelcast Kubernetes plugin via DNS lookup
81-
// Requires the hazelcast-kubernetes plugin on the classpath
82117
if (k8sServiceDns != null && !k8sServiceDns.isBlank()) {
83118
joinConfig.getTcpIpConfig().setEnabled(true);
84119
joinConfig.getTcpIpConfig().addMember(k8sServiceDns);

src/main/java/io/github/randomcodespace/iq/graph/GraphRepository.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public interface GraphRepository extends Neo4jRepository<CodeNode, String> {
2222
@Query("MATCH (n:CodeNode) WHERE n.filePath = $filePath RETURN n")
2323
List<CodeNode> findByFilePath(String filePath);
2424

25+
@Query("MATCH (n:CodeNode) WHERE toLower(n.label) CONTAINS toLower($text) OR toLower(n.fqn) CONTAINS toLower($text) RETURN n LIMIT $limit")
26+
List<CodeNode> search(String text, int limit);
27+
2528
@Query("MATCH (n:CodeNode) WHERE n.label CONTAINS $text OR n.fqn CONTAINS $text RETURN n")
2629
List<CodeNode> search(String text);
2730

@@ -33,4 +36,45 @@ public interface GraphRepository extends Neo4jRepository<CodeNode, String> {
3336

3437
@Query("MATCH (n:CodeNode)<-[r]-(m:CodeNode) WHERE n.id = $nodeId RETURN m")
3538
List<CodeNode> findIncomingNeighbors(String nodeId);
39+
40+
// --- Graph traversal queries ---
41+
42+
@Query("MATCH p = shortestPath((a:CodeNode {id: $source})-[*..20]-(b:CodeNode {id: $target})) RETURN [n IN nodes(p) | n.id]")
43+
List<String> findShortestPath(String source, String target);
44+
45+
@Query("MATCH (a:CodeNode {id: $center})-[*1..$radius]-(b:CodeNode) RETURN DISTINCT b")
46+
List<CodeNode> findEgoGraph(String center, int radius);
47+
48+
@Query("MATCH (a:CodeNode {id: $nodeId})-[:CALLS|DEPENDS_ON|IMPORTS*1..$depth]->(b:CodeNode) RETURN DISTINCT b")
49+
List<CodeNode> traceImpact(String nodeId, int depth);
50+
51+
@Query("MATCH p = (a:CodeNode)-[:DEPENDS_ON|CALLS*2..10]->(a) RETURN [n IN nodes(p) | n.id] LIMIT $limit")
52+
List<List<String>> findCycles(int limit);
53+
54+
@Query("MATCH (n:CodeNode)<-[:CONSUMES|LISTENS]-(m:CodeNode) WHERE n.id = $targetId RETURN m")
55+
List<CodeNode> findConsumers(String targetId);
56+
57+
@Query("MATCH (n:CodeNode)<-[:PRODUCES|PUBLISHES]-(m:CodeNode) WHERE n.id = $targetId RETURN m")
58+
List<CodeNode> findProducers(String targetId);
59+
60+
@Query("MATCH (n:CodeNode)<-[:CALLS]-(m:CodeNode) WHERE n.id = $targetId RETURN m")
61+
List<CodeNode> findCallers(String targetId);
62+
63+
@Query("MATCH (n:CodeNode)-[:DEPENDS_ON]->(m:CodeNode) WHERE n.id = $moduleId RETURN m")
64+
List<CodeNode> findDependencies(String moduleId);
65+
66+
@Query("MATCH (n:CodeNode)<-[:DEPENDS_ON]-(m:CodeNode) WHERE n.id = $moduleId RETURN m")
67+
List<CodeNode> findDependents(String moduleId);
68+
69+
@Query("MATCH (n:CodeNode) WHERE n.kind = $kind RETURN n SKIP $offset LIMIT $limit")
70+
List<CodeNode> findByKindPaginated(String kind, int offset, int limit);
71+
72+
@Query("MATCH (n:CodeNode) RETURN n SKIP $offset LIMIT $limit")
73+
List<CodeNode> findAllPaginated(int offset, int limit);
74+
75+
@Query("MATCH (n:CodeNode) WHERE n.kind = $kind RETURN count(n)")
76+
long countByKind(String kind);
77+
78+
@Query("MATCH (n:CodeNode)-[r]->(m:CodeNode) WHERE n.id = $nodeId RETURN type(r) AS kind, m")
79+
List<CodeNode> findOutgoingWithRelType(String nodeId);
3680
}

0 commit comments

Comments
 (0)