From abf7f924d2a8079f88943c1ba4da60fa69633257 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 31 Mar 2026 17:28:45 +0000 Subject: [PATCH 1/3] checkpoint: pre-yolo 20260331-172845 --- .claude/settings.local.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..369fab44 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,4 @@ +{ + "prefersReducedMotion": true, + "spinnerTipsEnabled": false +} From f17eeba29a035645568eafcaa7b9c2b88df15be2 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 31 Mar 2026 19:09:58 +0000 Subject: [PATCH 2/3] perf: replace in-memory stats with Cypher aggregations, add cache invalidation - P1: QueryService.getStats() and getDetailedStats() now use GraphStore.computeAggregateStats() which runs Cypher COUNT/GROUP BY queries instead of loading all nodes into heap via findAll(). This prevents OOM on large codebases (100K+ nodes). - P2: Added POST /api/cache/invalidate endpoint that clears all Spring caches. Allows cache refresh after re-enrichment without server restart. - P4: Wrapped Analyzer.runBatchedIndex() executor in try-with-resources to prevent thread leak on exception between creation (line 522) and close (line 639). - Code cleanup: removed dead matchesAnyExclude() method, removed dead branches in ProjectConfigLoader.applyOverrides(), fixed AbstractJavaParserDetector ThreadLocal misuse (PARSER.remove() defeated pooling), removed unused ServeCommand.COMMAND_NAME, removed unused QueryService.statsService field and useNeo4j() method. All 1431 tests pass. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 (1M context) --- .../randomcodespace/iq/analyzer/Analyzer.java | 12 +- .../iq/api/GraphController.java | 29 +- .../randomcodespace/iq/cli/ServeCommand.java | 2 - .../iq/config/ProjectConfigLoader.java | 17 +- .../java/AbstractJavaParserDetector.java | 2 - .../randomcodespace/iq/graph/GraphStore.java | 256 ++++++++++++++++++ .../iq/query/QueryService.java | 27 +- .../iq/api/GraphControllerTest.java | 12 +- .../iq/api/TopologyEndpointTest.java | 4 +- .../iq/cli/ServeCommandTest.java | 5 - .../iq/query/QueryServiceTest.java | 50 ++-- 11 files changed, 327 insertions(+), 89 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 3903e3fa..0e73f9e5 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -522,6 +522,7 @@ private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int b var batchExecutorService = parallelism != null && parallelism > 0 ? Executors.newFixedThreadPool(parallelism) : Executors.newVirtualThreadPerTaskExecutor(); + try (batchExecutorService) { List batch = new ArrayList<>(batchSize); for (int fileIdx = 0; fileIdx < files.size(); fileIdx++) { batch.add(files.get(fileIdx)); @@ -636,7 +637,7 @@ private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int b batch.clear(); } } - batchExecutorService.close(); + } // close batchExecutorService if (cacheHits > 0) { report.accept("Cache hits: " + cacheHits + " / " + totalFiles + " files"); @@ -1290,15 +1291,6 @@ private static List compileExcludePatterns(List .toList(); } - /** - * Check whether a file path matches any of the given pre-compiled exclude patterns. - */ - private static boolean matchesAnyExclude(String filePath, List excludePatterns) { - if (excludePatterns == null) return false; - List compiled = compileExcludePatterns(excludePatterns); - return matchesAnyCompiledExclude(filePath, compiled); - } - /** * Check whether a file path matches any of the given pre-compiled patterns. */ diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 57b3ebdc..1d6609ca 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -2,12 +2,14 @@ import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.query.QueryService; +import org.springframework.cache.CacheManager; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.context.annotation.Profile; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,11 +32,14 @@ public class GraphController { private final QueryService queryService; private final CodeIqConfig config; + private final CacheManager cacheManager; public GraphController(@org.springframework.beans.factory.annotation.Autowired(required = false) QueryService queryService, - CodeIqConfig config) { + CodeIqConfig config, + @org.springframework.beans.factory.annotation.Autowired(required = false) CacheManager cacheManager) { this.queryService = queryService; this.config = config; + this.cacheManager = cacheManager; } @GetMapping("/stats") @@ -196,13 +201,6 @@ public List> searchGraph( return queryService.searchGraph(q, Math.min(limit, 1000)); } - /** - * Check whether Neo4j (via QueryService) is available for queries. - */ - private boolean useNeo4j() { - return queryService != null; - } - private void requireQueryService() { if (queryService == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, @@ -250,6 +248,21 @@ public ResponseEntity readFile( } } + @PostMapping("/cache/invalidate") + public Map invalidateCache() { + int cleared = 0; + if (cacheManager != null) { + for (String name : cacheManager.getCacheNames()) { + var cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + cleared++; + } + } + } + return Map.of("status", "ok", "caches_cleared", cleared); + } + // POST /api/analyze removed — API/MCP server is read-only. // Analysis is done locally via CLI: code-iq analyze / code-iq index // Data is loaded into Neo4j on serve startup (auto-enrich). diff --git a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java index 89347020..1e783dfd 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java @@ -30,8 +30,6 @@ public class ServeCommand implements Callable { private static final Logger log = LoggerFactory.getLogger(ServeCommand.class); - public static final String COMMAND_NAME = "serve"; - @Parameters(index = "0", defaultValue = ".", description = "Path to analyzed codebase") private Path path; diff --git a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java index 26a36750..bb9bf376 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java @@ -162,21 +162,8 @@ private static void applyOverrides(Map data, CodeIqConfig config if (data.containsKey("max_radius")) { config.setMaxRadius(toInt(data.get("max_radius"), config.getMaxRadius())); } - // Nested analysis section (matches Python config structure) - if (data.get("analysis") instanceof Map analysis) { - if (analysis.containsKey("parallelism")) { - // Stored for CLI to pick up; not directly in CodeIqConfig - } - if (analysis.containsKey("incremental")) { - // Available for future use - } - } - // Nested output section - if (data.get("output") instanceof Map output) { - if (output.containsKey("max_nodes")) { - // Available for future use - } - } + // Nested analysis/output sections are recognized but not yet mapped to CodeIqConfig. + // They are loaded and accessible via data map for CLI commands that need them. } private static int toInt(Object value, int defaultValue) { diff --git a/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java index bd960833..f2dfeb5e 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java @@ -29,8 +29,6 @@ protected Optional parse(DetectorContext ctx) { // JavaParser may throw AssertionError for unrecognized token kinds // (e.g. newer Java syntax). Fall back to regex in those cases. return Optional.empty(); - } finally { - PARSER.remove(); } } diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index 51e720c4..3b571f3d 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -21,6 +21,8 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; /** * Facade service over the Neo4j graph backend. @@ -647,6 +649,260 @@ public Map getTopology() { return topology; } + // --- Stats aggregation queries (Cypher-only, no node hydration) --- + + /** + * Compute all categorized stats via Cypher aggregation. + * Never loads full nodes into heap — safe for 100K+ node graphs. + */ + public Map computeAggregateStats() { + Map result = new LinkedHashMap<>(); + result.put("graph", computeGraphStats()); + result.put("languages", computeLanguageStats()); + result.put("frameworks", computeFrameworkStats()); + result.put("infra", computeInfraStats()); + result.put("connections", computeConnectionStats()); + result.put("auth", computeAuthStats()); + result.put("architecture", computeArchitectureStats()); + return result; + } + + /** + * Compute stats for a single category via Cypher aggregation. + */ + public Map computeAggregateCategoryStats(String category) { + return switch (category.toLowerCase()) { + case "graph" -> computeGraphStats(); + case "languages" -> computeLanguageStats(); + case "frameworks" -> computeFrameworkStats(); + case "infra" -> computeInfraStats(); + case "connections" -> computeConnectionStats(); + case "auth" -> computeAuthStats(); + case "architecture" -> computeArchitectureStats(); + default -> null; + }; + } + + private Map computeGraphStats() { + Map graph = new LinkedHashMap<>(); + graph.put("nodes", count()); + graph.put("edges", countEdges()); + graph.put("files", countDistinctFiles()); + return graph; + } + + private Map computeLanguageStats() { + Map langCounts = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.prop_language IS NOT NULL " + + "RETURN toLower(n.prop_language) AS lang, count(n) AS cnt"); + while (result.hasNext()) { + var row = result.next(); + String lang = ((String) row.get("lang")).trim(); + if (!lang.isBlank()) { + langCounts.merge(lang, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + } + if (langCounts.isEmpty()) { + for (Map row : countByFileExtension()) { + String ext = String.valueOf(row.get("ext")).trim().toLowerCase(); + String lang = extensionToLanguage(ext); + if (lang != null) { + langCounts.merge(lang, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + } + return new LinkedHashMap<>(sortByValueDesc(langCounts)); + } + + private Map computeFrameworkStats() { + Map fwCounts = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.prop_framework IS NOT NULL " + + "RETURN n.prop_framework AS fw, count(n) AS cnt"); + while (result.hasNext()) { + var row = result.next(); + String fw = ((String) row.get("fw")).trim(); + if (!fw.isBlank()) { + fwCounts.merge(fw, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + } + return new LinkedHashMap<>(sortByValueDesc(fwCounts)); + } + + private Map computeInfraStats() { + Map infra = new LinkedHashMap<>(); + + Map databases = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind = 'database_connection' AND n.prop_db_type IS NOT NULL " + + "RETURN n.prop_db_type AS dbType, count(n) AS cnt"); + while (result.hasNext()) { + var row = result.next(); + String dbType = normalizeDbType(String.valueOf(row.get("dbType"))); + if (dbType != null) { + databases.merge(dbType, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + } + infra.put("databases", sortByValueDesc(databases)); + + Map messaging = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind IN ['topic', 'queue', 'message_queue'] " + + "RETURN coalesce(n.prop_protocol, n.label, 'unknown') AS protocol, count(n) AS cnt"); + while (result.hasNext()) { + var row = result.next(); + messaging.merge((String) row.get("protocol"), ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + infra.put("messaging", sortByValueDesc(messaging)); + + Map cloud = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind IN ['azure_resource', 'infra_resource'] " + + "RETURN coalesce(n.prop_resource_type, n.label, 'unknown') AS resType, count(n) AS cnt"); + while (result.hasNext()) { + var row = result.next(); + cloud.merge((String) row.get("resType"), ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + infra.put("cloud", sortByValueDesc(cloud)); + + return infra; + } + + private Map computeConnectionStats() { + Map connections = new LinkedHashMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var restResult = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind = 'endpoint' " + + "AND (n.prop_protocol IS NULL OR n.prop_protocol <> 'grpc') " + + "RETURN coalesce(toUpper(n.prop_http_method), 'UNKNOWN') AS method, count(n) AS cnt"); + Map restByMethod = new TreeMap<>(); + while (restResult.hasNext()) { + var row = restResult.next(); + restByMethod.put((String) row.get("method"), ((Number) row.get("cnt")).longValue()); + } + long restTotal = restByMethod.values().stream().mapToLong(Long::longValue).sum(); + Map rest = new LinkedHashMap<>(); + rest.put("total", restTotal); + rest.put("by_method", sortByValueDesc(restByMethod)); + connections.put("rest", rest); + + var grpcResult = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind = 'endpoint' AND n.prop_protocol = 'grpc' RETURN count(n) AS cnt"); + connections.put("grpc", grpcResult.hasNext() ? ((Number) grpcResult.next().get("cnt")).longValue() : 0L); + + var wsResult = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind = 'websocket_endpoint' RETURN count(n) AS cnt"); + connections.put("websocket", wsResult.hasNext() ? ((Number) wsResult.next().get("cnt")).longValue() : 0L); + + var prodResult = tx.execute( + "MATCH ()-[r:RELATES_TO]->() WHERE r.kind IN ['produces', 'publishes'] RETURN count(r) AS cnt"); + connections.put("producers", prodResult.hasNext() ? ((Number) prodResult.next().get("cnt")).longValue() : 0L); + + var consResult = tx.execute( + "MATCH ()-[r:RELATES_TO]->() WHERE r.kind IN ['consumes', 'listens'] RETURN count(r) AS cnt"); + connections.put("consumers", consResult.hasNext() ? ((Number) consResult.next().get("cnt")).longValue() : 0L); + } + return connections; + } + + private Map computeAuthStats() { + Map authCounts = new TreeMap<>(); + try (Transaction tx = graphDb.beginTx()) { + var guardResult = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind = 'guard' AND n.prop_auth_type IS NOT NULL " + + "RETURN n.prop_auth_type AS authType, count(n) AS cnt"); + while (guardResult.hasNext()) { + var row = guardResult.next(); + String authType = ((String) row.get("authType")).trim(); + if (!authType.isBlank()) { + authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + var fwResult = tx.execute( + "MATCH (n:CodeNode) WHERE n.prop_framework STARTS WITH 'auth:' " + + "RETURN n.prop_framework AS fw, count(n) AS cnt"); + while (fwResult.hasNext()) { + var row = fwResult.next(); + String fw = ((String) row.get("fw")).trim(); + String authType = fw.substring("auth:".length()).trim(); + if (!authType.isEmpty()) { + authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); + } + } + } + return new LinkedHashMap<>(sortByValueDesc(authCounts)); + } + + private Map computeArchitectureStats() { + Map arch = new LinkedHashMap<>(); + Map kindToLabel = Map.of( + "class", "classes", "interface", "interfaces", + "abstract_class", "abstract_classes", "enum", "enums", + "annotation_type", "annotation_types", "module", "modules", + "method", "methods"); + List archKinds = List.of("class", "interface", "abstract_class", "enum", + "annotation_type", "module", "method"); + try (Transaction tx = graphDb.beginTx()) { + var result = tx.execute( + "MATCH (n:CodeNode) WHERE n.kind IN $kinds RETURN n.kind AS kind, count(n) AS cnt", + Map.of("kinds", archKinds)); + while (result.hasNext()) { + var row = result.next(); + String kind = (String) row.get("kind"); + long cnt = ((Number) row.get("cnt")).longValue(); + if (cnt > 0) { + arch.put(kindToLabel.getOrDefault(kind, kind), cnt); + } + } + } + return arch; + } + + private static final Map STATS_DB_TYPE_NORMALIZE = Map.ofEntries( + Map.entry("mysql", "MySQL"), Map.entry("postgresql", "PostgreSQL"), + Map.entry("postgres", "PostgreSQL"), Map.entry("sqlserver", "SQL Server"), + Map.entry("mssql", "SQL Server"), Map.entry("oracle", "Oracle"), + Map.entry("h2", "H2"), Map.entry("sqlite", "SQLite"), + Map.entry("mariadb", "MariaDB"), Map.entry("mongo", "MongoDB"), + Map.entry("mongodb", "MongoDB"), Map.entry("redis", "Redis"), + Map.entry("neo4j", "Neo4j")); + + private static String normalizeDbType(String raw) { + String lower = raw.trim().toLowerCase(); + if (lower.contains("@")) lower = lower.substring(0, lower.indexOf('@')); + return STATS_DB_TYPE_NORMALIZE.getOrDefault(lower, raw.trim()); + } + + private static String extensionToLanguage(String ext) { + return switch (ext) { + case "java" -> "java"; case "kt", "kts" -> "kotlin"; + case "py" -> "python"; case "js", "mjs", "cjs" -> "javascript"; + case "ts", "tsx" -> "typescript"; case "go" -> "go"; + case "rs" -> "rust"; case "cs" -> "csharp"; + case "scala" -> "scala"; case "cpp", "cc", "cxx" -> "cpp"; + case "proto" -> "protobuf"; default -> null; + }; + } + + private static Map sortByValueDesc(Map map) { + return map.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .collect(Collectors.toMap( + Map.Entry::getKey, Map.Entry::getValue, + (a, b) -> a, LinkedHashMap::new)); + } + // --- Internal helpers --- /** diff --git a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java index aab558cf..3960e2e1 100644 --- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java +++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java @@ -25,23 +25,16 @@ public class QueryService { private final GraphStore graphStore; private final CodeIqConfig config; - private final StatsService statsService; - public QueryService(GraphStore graphStore, CodeIqConfig config, StatsService statsService) { + public QueryService(GraphStore graphStore, CodeIqConfig config) { this.graphStore = graphStore; this.config = config; - this.statsService = statsService; } @Cacheable("graph-stats") public Map getStats() { - // Load full graph data and compute rich categorized stats - List nodes = graphStore.findAll(); - List edges = nodes.stream() - .flatMap(n -> n.getEdges().stream()) - .toList(); - - Map result = statsService.computeStats(nodes, edges); + // Use Cypher aggregation — never loads full nodes into heap + Map result = graphStore.computeAggregateStats(); // Also include raw counts and breakdowns for backward compat Map nodesByKind = new LinkedHashMap<>(); @@ -53,8 +46,8 @@ public Map getStats() { nodesByLayer.put((String) row.get("layer"), ((Number) row.get("cnt")).longValue()); } - result.put("node_count", nodes.size()); - result.put("edge_count", edges.size()); + result.put("node_count", graphStore.count()); + result.put("edge_count", graphStore.countEdges()); result.put("nodes_by_kind", nodesByKind); result.put("nodes_by_layer", nodesByLayer); return result; @@ -66,15 +59,11 @@ public Map getStats() { */ @Cacheable(value = "detailed-stats", key = "#category") public Map getDetailedStats(String category) { - List nodes = graphStore.findAll(); - List edges = nodes.stream() - .flatMap(n -> n.getEdges().stream()) - .toList(); - + // Use Cypher aggregation — never loads full nodes into heap if (category == null || "all".equalsIgnoreCase(category)) { - return statsService.computeStats(nodes, edges); + return graphStore.computeAggregateStats(); } - Map catResult = statsService.computeCategory(nodes, edges, category); + Map catResult = graphStore.computeAggregateCategoryStats(category); if (catResult == null) { Map error = new LinkedHashMap<>(); error.put("error", "Unknown category: " + category diff --git a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java index e8540e66..1c436de5 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java @@ -45,7 +45,7 @@ void setUp() { config.setMaxDepth(10); config.setMaxRadius(10); config.setRootPath("."); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -416,7 +416,7 @@ void searchGraphShouldReturnResults() throws Exception { void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("hello.txt"), "Hello World", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "hello.txt")) @@ -427,7 +427,7 @@ void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception { @Test void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception { config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "nonexistent.txt")) @@ -437,7 +437,7 @@ void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception { @Test void readFileShouldBlockPathTraversal(@TempDir Path tempDir) throws Exception { config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "../../../etc/passwd")) @@ -450,7 +450,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("multi.txt"), "line1\nline2\nline3\nline4\nline5", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file") @@ -465,7 +465,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception { void readFileShouldReturnFullContentWithoutLineParams(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("full.txt"), "aaa\nbbb\nccc", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config); + var controller = new GraphController(queryService, config, null); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "full.txt")) diff --git a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java index 03462599..14f51a97 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java @@ -184,7 +184,7 @@ void getTopologyReturnsEmptyListsWhenNoData() throws Exception { @Test void queryServiceGetTopologyDelegatesToGraphStore() { CodeIqConfig cfg = new CodeIqConfig(); - QueryService service = new QueryService(graphStore, cfg, new io.github.randomcodespace.iq.query.StatsService()); + QueryService service = new QueryService(graphStore, cfg); when(graphStore.getTopology()).thenReturn(buildTopologyResponse()); Map result = service.getTopology(); @@ -199,7 +199,7 @@ void queryServiceGetTopologyDelegatesToGraphStore() { @Test void queryServiceGetTopologyReturnsServicesInfraConnections() { CodeIqConfig cfg = new CodeIqConfig(); - QueryService service = new QueryService(graphStore, cfg, new io.github.randomcodespace.iq.query.StatsService()); + QueryService service = new QueryService(graphStore, cfg); Map topology = buildTopologyResponse(); when(graphStore.getTopology()).thenReturn(topology); diff --git a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java index 5fcdfdc2..fc730f7f 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java @@ -15,11 +15,6 @@ void commandNameIsServe() { assertEquals("serve", cmdLine.getCommandName()); } - @Test - void commandNameConstantMatchesAnnotation() { - assertEquals("serve", ServeCommand.COMMAND_NAME); - } - @Test void defaultPortIs8080() { var cmd = new ServeCommand(); diff --git a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java index 0c8f9464..de94f40e 100644 --- a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java +++ b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java @@ -36,8 +36,7 @@ void setUp() { config = new CodeIqConfig(); config.setMaxDepth(10); config.setMaxRadius(10); - statsService = new StatsService(); - service = new QueryService(graphStore, config, statsService); + service = new QueryService(graphStore, config); } private CodeNode makeNode(String id, NodeKind kind, String label) { @@ -61,14 +60,28 @@ private CodeNode makeNodeWithEdge(String id, NodeKind kind, String label, @Test void getStatsShouldReturnNodeAndEdgeCounts() { - var endpoint = makeNodeWithEdge("ep:1", NodeKind.ENDPOINT, "GET /users", - "svc:1", EdgeKind.CALLS); - endpoint.getProperties().put("http_method", "GET"); - endpoint.setFilePath("src/Main.java"); - var cls = makeNode("cls:1", NodeKind.CLASS, "UserService"); - cls.setFilePath("src/UserService.java"); - cls.setEdges(new ArrayList<>()); - when(graphStore.findAll()).thenReturn(List.of(endpoint, cls)); + // Mock Cypher aggregation from GraphStore + Map aggregateStats = new java.util.LinkedHashMap<>(); + aggregateStats.put("graph", Map.of("nodes", 2L, "edges", 1L, "files", 2L)); + aggregateStats.put("languages", Map.of("java", 2L)); + aggregateStats.put("frameworks", Map.of()); + aggregateStats.put("infra", Map.of("databases", Map.of(), "messaging", Map.of(), "cloud", Map.of())); + Map rest = new java.util.LinkedHashMap<>(); + rest.put("total", 1L); + rest.put("by_method", Map.of("GET", 1L)); + Map connections = new java.util.LinkedHashMap<>(); + connections.put("rest", rest); + connections.put("grpc", 0L); + connections.put("websocket", 0L); + connections.put("producers", 0L); + connections.put("consumers", 0L); + aggregateStats.put("connections", connections); + aggregateStats.put("auth", Map.of()); + aggregateStats.put("architecture", Map.of("classes", 1L)); + + when(graphStore.computeAggregateStats()).thenReturn(aggregateStats); + when(graphStore.count()).thenReturn(2L); + when(graphStore.countEdges()).thenReturn(1L); when(graphStore.countNodesByKind()).thenReturn(List.of( Map.of("kind", "endpoint", "cnt", 1L), Map.of("kind", "class", "cnt", 1L))); @@ -77,11 +90,10 @@ void getStatsShouldReturnNodeAndEdgeCounts() { Map stats = service.getStats(); - // ComputedStatsResponse format — graph section from StatsService @SuppressWarnings("unchecked") Map graph = (Map) stats.get("graph"); - assertEquals(2, graph.get("nodes")); - assertEquals(1, graph.get("edges")); + assertEquals(2L, graph.get("nodes")); + assertEquals(1L, graph.get("edges")); assertEquals(2L, graph.get("files")); assertNotNull(stats.get("languages")); assertNotNull(stats.get("frameworks")); @@ -89,15 +101,13 @@ void getStatsShouldReturnNodeAndEdgeCounts() { assertNotNull(stats.get("connections")); assertNotNull(stats.get("auth")); assertNotNull(stats.get("architecture")); - // REST endpoint detection @SuppressWarnings("unchecked") - Map connections = (Map) stats.get("connections"); + Map resultConnections = (Map) stats.get("connections"); @SuppressWarnings("unchecked") - Map rest = (Map) connections.get("rest"); - assertEquals(1L, rest.get("total")); - // Backward compat - assertEquals(2, stats.get("node_count")); - assertEquals(1, stats.get("edge_count")); + Map resultRest = (Map) resultConnections.get("rest"); + assertEquals(1L, resultRest.get("total")); + assertEquals(2L, stats.get("node_count")); + assertEquals(1L, stats.get("edge_count")); assertNotNull(stats.get("nodes_by_kind")); assertNotNull(stats.get("nodes_by_layer")); } From 336a40a986b320daa53184b870115de0febec2ae Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 31 Mar 2026 19:58:54 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20blockers=20?= =?UTF-8?q?=E2=80=94=20remove=20cache=20endpoint,=20fix=20casts,=20revert?= =?UTF-8?q?=20ThreadLocal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for PR #3: BLOCKER 1: Remove POST /api/cache/invalidate endpoint — violates read-only serving architecture and has no auth (DoS vector via cache stampede). Cache invalidation now only happens on server restart. BLOCKER 2: Add blank-string filter to countDistinctFiles() Cypher query (AND n.filePath <> '') to match the Java-side behavior it replaced. MUST FIX: Revert PARSER.remove() deletion in AbstractJavaParserDetector — the finally block is the correct ThreadLocal cleanup pattern to prevent memory leaks with pooled threads. MEDIUM: Replace unchecked (String) casts with String.valueOf() in computeLanguageStats, computeFrameworkStats, computeAuthStats, computeConnectionStats, and computeInfraStats for robustness. LOW: Read node_count/edge_count from already-computed graph sub-map in QueryService.getStats() instead of re-querying graphStore.count() and countEdges(). All 1395 tests pass (0 failures, 0 errors). Co-Authored-By: Paperclip --- .../iq/api/GraphController.java | 21 +------------------ .../java/AbstractJavaParserDetector.java | 2 ++ .../randomcodespace/iq/graph/GraphStore.java | 16 +++++++------- .../iq/query/QueryService.java | 12 +++++++++-- .../iq/api/GraphControllerTest.java | 12 +++++------ .../iq/query/QueryServiceTest.java | 2 -- 6 files changed, 27 insertions(+), 38 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 1d6609ca..60210ead 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -2,7 +2,6 @@ import io.github.randomcodespace.iq.config.CodeIqConfig; import io.github.randomcodespace.iq.query.QueryService; -import org.springframework.cache.CacheManager; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -32,14 +31,11 @@ public class GraphController { private final QueryService queryService; private final CodeIqConfig config; - private final CacheManager cacheManager; public GraphController(@org.springframework.beans.factory.annotation.Autowired(required = false) QueryService queryService, - CodeIqConfig config, - @org.springframework.beans.factory.annotation.Autowired(required = false) CacheManager cacheManager) { + CodeIqConfig config) { this.queryService = queryService; this.config = config; - this.cacheManager = cacheManager; } @GetMapping("/stats") @@ -248,21 +244,6 @@ public ResponseEntity readFile( } } - @PostMapping("/cache/invalidate") - public Map invalidateCache() { - int cleared = 0; - if (cacheManager != null) { - for (String name : cacheManager.getCacheNames()) { - var cache = cacheManager.getCache(name); - if (cache != null) { - cache.clear(); - cleared++; - } - } - } - return Map.of("status", "ok", "caches_cleared", cleared); - } - // POST /api/analyze removed — API/MCP server is read-only. // Analysis is done locally via CLI: code-iq analyze / code-iq index // Data is loaded into Neo4j on serve startup (auto-enrich). diff --git a/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java index f2dfeb5e..bd960833 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/java/AbstractJavaParserDetector.java @@ -29,6 +29,8 @@ protected Optional parse(DetectorContext ctx) { // JavaParser may throw AssertionError for unrecognized token kinds // (e.g. newer Java syntax). Fall back to regex in those cases. return Optional.empty(); + } finally { + PARSER.remove(); } } diff --git a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java index 3b571f3d..bee550c4 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -402,7 +402,7 @@ public long countEdges() { public long countDistinctFiles() { try (Transaction tx = graphDb.beginTx()) { var result = tx.execute( - "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL " + "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL AND n.filePath <> '' " + "RETURN count(DISTINCT n.filePath) AS cnt"); if (result.hasNext()) { return ((Number) result.next().get("cnt")).longValue(); @@ -699,7 +699,7 @@ private Map computeLanguageStats() { + "RETURN toLower(n.prop_language) AS lang, count(n) AS cnt"); while (result.hasNext()) { var row = result.next(); - String lang = ((String) row.get("lang")).trim(); + String lang = String.valueOf(row.get("lang")).trim(); if (!lang.isBlank()) { langCounts.merge(lang, ((Number) row.get("cnt")).longValue(), Long::sum); } @@ -725,7 +725,7 @@ private Map computeFrameworkStats() { + "RETURN n.prop_framework AS fw, count(n) AS cnt"); while (result.hasNext()) { var row = result.next(); - String fw = ((String) row.get("fw")).trim(); + String fw = String.valueOf(row.get("fw")).trim(); if (!fw.isBlank()) { fwCounts.merge(fw, ((Number) row.get("cnt")).longValue(), Long::sum); } @@ -759,7 +759,7 @@ private Map computeInfraStats() { + "RETURN coalesce(n.prop_protocol, n.label, 'unknown') AS protocol, count(n) AS cnt"); while (result.hasNext()) { var row = result.next(); - messaging.merge((String) row.get("protocol"), ((Number) row.get("cnt")).longValue(), Long::sum); + messaging.merge(String.valueOf(row.get("protocol")), ((Number) row.get("cnt")).longValue(), Long::sum); } } infra.put("messaging", sortByValueDesc(messaging)); @@ -771,7 +771,7 @@ private Map computeInfraStats() { + "RETURN coalesce(n.prop_resource_type, n.label, 'unknown') AS resType, count(n) AS cnt"); while (result.hasNext()) { var row = result.next(); - cloud.merge((String) row.get("resType"), ((Number) row.get("cnt")).longValue(), Long::sum); + cloud.merge(String.valueOf(row.get("resType")), ((Number) row.get("cnt")).longValue(), Long::sum); } } infra.put("cloud", sortByValueDesc(cloud)); @@ -789,7 +789,7 @@ private Map computeConnectionStats() { Map restByMethod = new TreeMap<>(); while (restResult.hasNext()) { var row = restResult.next(); - restByMethod.put((String) row.get("method"), ((Number) row.get("cnt")).longValue()); + restByMethod.put(String.valueOf(row.get("method")), ((Number) row.get("cnt")).longValue()); } long restTotal = restByMethod.values().stream().mapToLong(Long::longValue).sum(); Map rest = new LinkedHashMap<>(); @@ -824,7 +824,7 @@ private Map computeAuthStats() { + "RETURN n.prop_auth_type AS authType, count(n) AS cnt"); while (guardResult.hasNext()) { var row = guardResult.next(); - String authType = ((String) row.get("authType")).trim(); + String authType = String.valueOf(row.get("authType")).trim(); if (!authType.isBlank()) { authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); } @@ -834,7 +834,7 @@ private Map computeAuthStats() { + "RETURN n.prop_framework AS fw, count(n) AS cnt"); while (fwResult.hasNext()) { var row = fwResult.next(); - String fw = ((String) row.get("fw")).trim(); + String fw = String.valueOf(row.get("fw")).trim(); String authType = fw.substring("auth:".length()).trim(); if (!authType.isEmpty()) { authCounts.merge(authType, ((Number) row.get("cnt")).longValue(), Long::sum); diff --git a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java index 3960e2e1..26555192 100644 --- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java +++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java @@ -46,8 +46,16 @@ public Map getStats() { nodesByLayer.put((String) row.get("layer"), ((Number) row.get("cnt")).longValue()); } - result.put("node_count", graphStore.count()); - result.put("edge_count", graphStore.countEdges()); + // Read from already-computed graph sub-map instead of re-querying + @SuppressWarnings("unchecked") + Map graphStats = (Map) result.get("graph"); + if (graphStats != null) { + result.put("node_count", graphStats.get("nodes")); + result.put("edge_count", graphStats.get("edges")); + } else { + result.put("node_count", graphStore.count()); + result.put("edge_count", graphStore.countEdges()); + } result.put("nodes_by_kind", nodesByKind); result.put("nodes_by_layer", nodesByLayer); return result; diff --git a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java index 1c436de5..e8540e66 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java @@ -45,7 +45,7 @@ void setUp() { config.setMaxDepth(10); config.setMaxRadius(10); config.setRootPath("."); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -416,7 +416,7 @@ void searchGraphShouldReturnResults() throws Exception { void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("hello.txt"), "Hello World", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "hello.txt")) @@ -427,7 +427,7 @@ void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception { @Test void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception { config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "nonexistent.txt")) @@ -437,7 +437,7 @@ void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception { @Test void readFileShouldBlockPathTraversal(@TempDir Path tempDir) throws Exception { config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "../../../etc/passwd")) @@ -450,7 +450,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("multi.txt"), "line1\nline2\nline3\nline4\nline5", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file") @@ -465,7 +465,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception { void readFileShouldReturnFullContentWithoutLineParams(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("full.txt"), "aaa\nbbb\nccc", StandardCharsets.UTF_8); config.setRootPath(tempDir.toAbsolutePath().toString()); - var controller = new GraphController(queryService, config, null); + var controller = new GraphController(queryService, config); var fileMvc = MockMvcBuilders.standaloneSetup(controller).build(); fileMvc.perform(get("/api/file").param("path", "full.txt")) diff --git a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java index de94f40e..da4750a6 100644 --- a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java +++ b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java @@ -80,8 +80,6 @@ void getStatsShouldReturnNodeAndEdgeCounts() { aggregateStats.put("architecture", Map.of("classes", 1L)); when(graphStore.computeAggregateStats()).thenReturn(aggregateStats); - when(graphStore.count()).thenReturn(2L); - when(graphStore.countEdges()).thenReturn(1L); when(graphStore.countNodesByKind()).thenReturn(List.of( Map.of("kind", "endpoint", "cnt", 1L), Map.of("kind", "class", "cnt", 1L)));