diff --git a/src/main/frontend/src/lib/api.ts b/src/main/frontend/src/lib/api.ts index 7070bf11..2362fd33 100644 --- a/src/main/frontend/src/lib/api.ts +++ b/src/main/frontend/src/lib/api.ts @@ -95,9 +95,12 @@ export const api = { traceImpact: (id: string, depth = 3) => fetchJson>(`${BASE}/triage/impact/${encodeURIComponent(id)}?depth=${depth}`), - getFileTree: (depth?: number) => { - const params = depth !== undefined ? `?depth=${depth}` : ''; - return fetchJson(`${BASE}/file-tree${params}`); + getFileTree: (depth?: number, path?: string) => { + const qs = new URLSearchParams(); + if (depth !== undefined) qs.set('depth', String(depth)); + if (path !== undefined && path !== '') qs.set('path', path); + const suffix = qs.toString() ? `?${qs}` : ''; + return fetchJson(`${BASE}/file-tree${suffix}`); }, getTopology: () => diff --git a/src/main/frontend/src/pages/Dashboard.tsx b/src/main/frontend/src/pages/Dashboard.tsx index e7b1deae..3f80f3a6 100644 --- a/src/main/frontend/src/pages/Dashboard.tsx +++ b/src/main/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useEffect, Fragment } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef, Fragment } from 'react'; import { Card, Spin, Alert, Modal, Drawer, Stat, Table, ScrollDiv, Space, } from '@ossrandom/design-system'; @@ -132,6 +132,7 @@ function buildTreemapTree( nodes: FileTreeNode[], parentPath: string, pathMap: WeakMap, + truncatedDirMap: WeakMap, ): TreemapNode[] { // Sort children by name so the treemap layout is stable across page // loads regardless of API result ordering. d3-hierarchy's squarified @@ -143,7 +144,7 @@ function buildTreemapTree( const fullPath = parentPath ? `${parentPath}/${n.name}` : n.name; if (n.nodeCount <= 0 && (!n.children || n.children.length === 0)) continue; if (n.type === 'directory' && n.children && n.children.length > 0) { - const children = buildTreemapTree(n.children, fullPath, pathMap); + const children = buildTreemapTree(n.children, fullPath, pathMap, truncatedDirMap); if (children.length === 0) continue; const node: TreemapNode = { name: n.name, @@ -153,18 +154,60 @@ function buildTreemapTree( pathMap.set(node, fullPath); out.push(node); } else { + // A directory with no children but nodeCount > 0 is the backend's depth-cap + // marker — descendants exist but weren't fetched. Render as a leaf and + // flag for lazy expansion on click (Phase 2/3 path-rooted refetch). + const isTruncatedDir = n.type === 'directory' && n.nodeCount > 0; const node: TreemapNode = { name: n.name, value: Math.max(n.nodeCount, 1), - color: LANG_COLORS[inferLang(n.name)] ?? '#666', + color: LANG_COLORS[isTruncatedDir ? 'other' : inferLang(n.name)] ?? '#666', }; pathMap.set(node, fullPath); + if (isTruncatedDir) truncatedDirMap.set(node, true); out.push(node); } } return out; } +/** + * Walk down to leaves and sum their values. Used when flattening the visible + * level for label rendering: the design-system Treemap canvas only paints + * names on leaf cells (its `isLeaf` check guards label drawing), so + * directories at the focused level have to be rendered as leaves with a + * pre-aggregated value to keep the cell-sizing accurate. + */ +function aggregateLeafValue(node: TreemapNode): number { + if (!node.children || node.children.length === 0) return node.value ?? 1; + let sum = 0; + for (const c of node.children) sum += aggregateLeafValue(c); + return sum; +} + +/** + * Splice a freshly-fetched subtree into the right slot of the existing tree. + * Matches by absolute filesystem path (which the backend emits unchanged in + * `path` for both root and path-rooted responses), then replaces the + * directory's children. Returns a new tree array — the caller should treat + * the result as immutable. + */ +function mergeSubtree( + tree: FileTreeNode[], + fsPath: string, + subtree: FileTreeNode[], +): FileTreeNode[] { + return tree.map(n => { + if (n.path === fsPath && n.type === 'directory') { + return { ...n, children: subtree }; + } + if (n.children && n.children.length > 0 && fsPath.startsWith(n.path + '/')) { + return { ...n, children: mergeSubtree(n.children, fsPath, subtree) }; + } + return n; + }); +} + function useViewportHeight(offset: number): number { const [h, setH] = useState(() => (typeof window === 'undefined' ? 600 : window.innerHeight - offset)); useEffect(() => { @@ -208,18 +251,51 @@ export default function Dashboard() { // breadcrumb(38) + gaps(24) const treemapHeight = useViewportHeight(56 + 32 + 110 + 38 + 24); - // Treemap. Cap initial fetch at depth 8 — enough for a fully-qualified Java + // Treemap. Initial fetch caps at depth 8 — enough for a fully-qualified Java // path (src/main/java/io/github////File.java = 8 segments) - // and most other languages, but spares the 200 K-node case from shipping - // the full tree for paths the user will never drill into. Past depth 8 - // the directory renders as a leaf with its aggregate node count; on-demand - // subtree fetching is a follow-up (Phase 2). - const { data: treeData, loading: treeLoading } = useApi(() => api.getFileTree(8), []); - const { treemapRoot, pathMap } = useMemo(() => { - const map = new WeakMap(); - const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', map); + // and the typical TS/Python/Go layouts. Directories beyond depth 8 come + // back as truncation markers (type=directory, children=[], nodeCount>0); + // when the user clicks one, we fetch its subtree on demand via the + // path-rooted /api/file-tree?path=… endpoint and splice it into place. + const [treeData, setTreeData] = useState(null); + const [treeLoading, setTreeLoading] = useState(true); + const [subtreeLoading, setSubtreeLoading] = useState(false); + const loadedPathsRef = useRef>(new Set()); + + useEffect(() => { + let cancelled = false; + setTreeLoading(true); + api.getFileTree(8) + .then(r => { if (!cancelled) setTreeData(r); }) + .catch(() => { /* surface via empty-tree state */ }) + .finally(() => { if (!cancelled) setTreeLoading(false); }); + return () => { cancelled = true; }; + }, []); + + const ensureSubtreeLoaded = useCallback(async (fsPath: string) => { + if (loadedPathsRef.current.has(fsPath)) return; + // Mark eagerly to dedupe concurrent clicks; revert on failure so the + // user can retry by clicking again. + loadedPathsRef.current.add(fsPath); + setSubtreeLoading(true); + try { + const sub = await api.getFileTree(8, fsPath); + setTreeData(prev => prev + ? { ...prev, tree: mergeSubtree(prev.tree, fsPath, sub.tree) } + : prev); + } catch { + loadedPathsRef.current.delete(fsPath); + } finally { + setSubtreeLoading(false); + } + }, []); + + const { treemapRoot, pathMap, truncatedDirMap } = useMemo(() => { + const pMap = new WeakMap(); + const tMap = new WeakMap(); + const children = buildTreemapTree(collapseTree(treeData?.tree ?? []), '', pMap, tMap); const root: TreemapNode = { name: 'root', children }; - return { treemapRoot: root, pathMap: map }; + return { treemapRoot: root, pathMap: pMap, truncatedDirMap: tMap }; }, [treeData]); // Drill state — names of the directories we've drilled into, in order. @@ -227,8 +303,11 @@ export default function Dashboard() { // breadcrumb segment slices back to that depth. const [focusPath, setFocusPath] = useState([]); - // Reset focus when the underlying tree changes (e.g., re-fetch after enrich). - useEffect(() => { setFocusPath([]); }, [treemapRoot]); + // Reset focus only on a fresh full-tree load (total_files indicates the + // initial fetch landed). Subtree-merge updates also bump treeData but keep + // total_files unchanged, so the user's drill position survives lazy loads. + const totalFiles = treeData?.total_files; + useEffect(() => { setFocusPath([]); }, [totalFiles]); // Walk treemapRoot along focusPath. Falls back to root if any segment is // missing (defensive — shouldn't happen since focusPath only ever holds @@ -243,15 +322,56 @@ export default function Dashboard() { return cur; }, [treemapRoot, focusPath]); + // Render-only flat copy of the focused level. The design-system Treemap + // only paints names on leaf cells (its canvas path checks `!n.children?.length` + // before drawing the label), so directories at the visible level — which + // legitimately have children for drill-down — appear as unlabelled + // rectangles. Strip children for the render pass to satisfy the leaf + // check; the original TreemapNode (with intact children) stays in + // `focusedRoot` and is recovered via `renderToOriginal` in the click + // handler so drill-down still works. + const { focusedRootForRender, renderToOriginal } = useMemo(() => { + const map = new WeakMap(); + const flat = (focusedRoot.children ?? []).map(c => { + if (c.children && c.children.length > 0) { + const rendered: TreemapNode = { + name: c.name, + value: aggregateLeafValue(c), + color: c.color, + }; + map.set(rendered, c); + return rendered; + } + // Files and truncated-directory markers are already leaves; preserve + // identity so existing pathMap / truncatedDirMap lookups keep working. + return c; + }); + return { + focusedRootForRender: { name: focusedRoot.name, children: flat }, + renderToOriginal: map, + }; + }, [focusedRoot]); + // File viewer const [fileDrawer, setFileDrawer] = useState<{ path: string; content: string } | null>(null); const [fileLoading, setFileLoading] = useState(false); - const onTreemapNodeClick = useCallback(async (node: TreemapNode) => { - // Directory — drill down one level. + const onTreemapNodeClick = useCallback(async (clicked: TreemapNode) => { + // Resolve the original TreemapNode behind the rendered (children-stripped) + // cell so pathMap / truncatedDirMap / drill-down keep working. + const node = renderToOriginal.get(clicked) ?? clicked; + // Directory with children — drill down one level. if (node.children && node.children.length > 0) { setFocusPath(prev => [...prev, node.name]); return; } + // Truncated directory (depth-cap marker) — fetch its subtree, then drill in. + if (truncatedDirMap.get(node)) { + const fsPath = pathMap.get(node); + if (!fsPath) return; + await ensureSubtreeLoaded(fsPath); + setFocusPath(prev => [...prev, node.name]); + return; + } // Leaf — open file in drawer. const filePath = pathMap.get(node); if (!filePath) return; @@ -260,7 +380,7 @@ export default function Dashboard() { try { setFileDrawer({ path: filePath, content: await api.readFile(filePath) }); } catch { setFileDrawer({ path: filePath, content: '// Could not load file' }); } finally { setFileLoading(false); } - }, [pathMap]); + }, [pathMap, truncatedDirMap, renderToOriginal, ensureSubtreeLoaded]); if (statsLoading || treeLoading) { return
; @@ -307,6 +427,11 @@ export default function Dashboard() { ))} + {subtreeLoading && ( + + loading subtree… + + )}
@@ -316,7 +441,7 @@ export default function Dashboard() { // design-system Treemap caches layout on `data` identity, and a // remount is the simplest way to ensure a clean redraw. key={focusPath.join('/') || 'root'} - data={focusedRoot} + data={focusedRootForRender} height={treemapHeight} engine="canvas" // One level at a time — each cell maps 1:1 to a direct child of 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 3ad070cc..a2b75fc6 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -211,13 +211,43 @@ public List> searchGraph( public Map getFileTree( @RequestParam(required = false) Integer depth, @RequestParam(required = false) Integer maxFiles, + @RequestParam(required = false) String path, @RequestParam(defaultValue = "true") boolean excludeTests) { requireQueryService(); // depth=null means unlimited (full tree for treemap). Otherwise cap at maxDepth. Integer cappedDepth = (depth != null) ? Math.min(depth, config.getMaxDepth()) : null; // Default unlimited for treemap int limit = (maxFiles != null) ? maxFiles : Integer.MAX_VALUE; - return queryService.getFileTree(cappedDepth, limit, excludeTests); + String normalizedPath = normalizeFileTreePath(path); + return queryService.getFileTree(cappedDepth, limit, excludeTests, normalizedPath); + } + + /** + * Validate + normalize a user-supplied filesystem path before it reaches the query + * layer. Rejects {@code ..} traversal, leading slashes, and overlong inputs; + * trims trailing slashes. {@code null} and blank inputs collapse to {@code null} + * which means "root tree" downstream. + */ + private static String normalizeFileTreePath(String raw) { + if (raw == null || raw.isBlank()) return null; + String p = raw.trim(); + while (p.endsWith("/")) p = p.substring(0, p.length() - 1); + while (p.startsWith("/")) p = p.substring(1); + if (p.length() > 1024) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.BAD_REQUEST, + "path too long"); + } + // Reject path-traversal segments. Splitting first prevents false positives on + // legitimate filenames that happen to contain ".." as a substring. + for (String seg : p.split("/")) { + if ("..".equals(seg)) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.BAD_REQUEST, + "path must not contain '..' segments"); + } + } + return p.isEmpty() ? null : p; } @GetMapping("/capabilities") 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 8b871e4a..051416ee 100644 --- a/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java +++ b/src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java @@ -678,20 +678,34 @@ public record FilePathResult(List> rows, boolean truncated) * exist the {@link FilePathResult#truncated()} flag is set to {@code true}. */ public FilePathResult getFilePathsWithCounts(int maxFiles) { + return getFilePathsWithCounts(maxFiles, null); + } + + /** + * Path-rooted variant: when {@code pathPrefix} is non-null/non-blank, limits results + * to files whose {@code filePath} is a descendant of the prefix (matches + * {@code prefix + "/*"}). Used by the file-tree REST endpoint to fetch subtrees on + * demand instead of shipping the full tree on every page load. + */ + public FilePathResult getFilePathsWithCounts(int maxFiles, String pathPrefix) { List> rows = new ArrayList<>(); try (Transaction tx = graphDb.beginTx()) { + StringBuilder query = new StringBuilder( + "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL"); + Map params = new HashMap<>(); + if (pathPrefix != null && !pathPrefix.isBlank()) { + // Append "/" so "src/main" matches "src/main/Foo.java" but not "src/main2/...". + query.append(" AND n.filePath STARTS WITH $prefix"); + params.put("prefix", pathPrefix + "/"); + } + query.append(" RETURN n.filePath AS filePath, count(n) AS nodeCount ORDER BY n.filePath"); // When maxFiles is very large (e.g., Integer.MAX_VALUE for unlimited treemap), - // skip the LIMIT clause entirely to avoid integer overflow - String query = "MATCH (n:CodeNode) WHERE n.filePath IS NOT NULL " - + "RETURN n.filePath AS filePath, count(n) AS nodeCount " - + "ORDER BY n.filePath"; - Result result; + // skip the LIMIT clause entirely to avoid integer overflow. if (maxFiles < 1_000_000) { - result = tx.execute(query + " LIMIT $limit", - Map.of(PROP_LIMIT, (long) (maxFiles + 1))); - } else { - result = tx.execute(query); + query.append(" LIMIT $limit"); + params.put(PROP_LIMIT, (long) (maxFiles + 1)); } + Result result = tx.execute(query.toString(), params); while (result.hasNext()) { var row = result.next(); Map m = new LinkedHashMap<>(); 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 db508f1f..4cd1b41c 100644 --- a/src/main/java/io/github/randomcodespace/iq/query/QueryService.java +++ b/src/main/java/io/github/randomcodespace/iq/query/QueryService.java @@ -376,18 +376,36 @@ private static boolean isTestPath(String filePath) { return TEST_FILE_PATTERN.matcher(fileName).find(); } - @Cacheable(value = "file-tree", key = "#maxDepth + '-' + #maxFiles + '-' + #excludeTests") public Map getFileTree(Integer maxDepth, int maxFiles, boolean excludeTests) { - GraphStore.FilePathResult filePathResult = graphStore.getFilePathsWithCounts(maxFiles); + return getFileTree(maxDepth, maxFiles, excludeTests, null); + } + + /** + * Path-rooted variant of file-tree. When {@code path} is non-null, the returned tree + * is rooted at that filesystem path (its descendants only). Output {@code path} + * fields remain absolute so the frontend can stitch the subtree back into the + * full tree at the matching node without bookkeeping. + */ + @Cacheable( + value = "file-tree", + key = "#maxDepth + '-' + #maxFiles + '-' + #excludeTests + '-' + (#path == null ? '_root' : #path)") + public Map getFileTree(Integer maxDepth, int maxFiles, boolean excludeTests, String path) { + GraphStore.FilePathResult filePathResult = graphStore.getFilePathsWithCounts(maxFiles, path); List> rows = excludeTests ? filePathResult.rows().stream().filter(r -> !isTestPath((String) r.get("filePath"))).toList() : filePathResult.rows(); + // When path-rooted, strip the prefix before walking so the local TreeNode root + // represents the focused directory rather than the absolute filesystem root. + String prefix = (path != null && !path.isBlank()) ? path + "/" : ""; TreeNode root = new TreeNode("", PROP_DIRECTORY); for (Map row : rows) { String filePath = (String) row.get("filePath"); long count = ((Number) row.get(PROP_NODECOUNT)).longValue(); - String[] parts = filePath.split("/", -1); + String walkPath = prefix.isEmpty() + ? filePath + : (filePath.startsWith(prefix) ? filePath.substring(prefix.length()) : filePath); + String[] parts = walkPath.split("/", -1); TreeNode current = root; for (int i = 0; i < parts.length; i++) { String part = parts[i]; @@ -408,7 +426,10 @@ public Map getFileTree(Integer maxDepth, int maxFiles, boolean e } } - List> tree = buildTreeOutput(root, maxDepth, 1, ""); + // Pass the focused path as the parentPath so emitted "path" fields are absolute + // — frontend can match these directly against the node it's expanding. + String outputParentPath = (path != null && !path.isBlank()) ? path : ""; + List> tree = buildTreeOutput(root, maxDepth, 1, outputParentPath); Map result = new LinkedHashMap<>(); result.put("tree", tree); result.put("total_files", (long) rows.size()); 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 8d1746d4..4f7d259b 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java @@ -660,7 +660,7 @@ void getFileTreeShouldReturnHierarchicalTree() throws Exception { Map.of("name", "src", "type", "directory", "nodeCount", 10L, "children", List.of()), Map.of("name", "pom.xml", "type", "file", "nodeCount", 1L, "children", List.of()))); treeResult.put("total_files", 3L); - when(queryService.getFileTree(any(), anyInt(), anyBoolean())).thenReturn(treeResult); + when(queryService.getFileTree(any(), anyInt(), anyBoolean(), any())).thenReturn(treeResult); mockMvc.perform(get("/api/file-tree")) .andExpect(status().isOk()) @@ -678,7 +678,7 @@ void getFileTreeShouldPassDepthParam() throws Exception { Map treeResult = new LinkedHashMap<>(); treeResult.put("tree", List.of()); treeResult.put("total_files", 0L); - when(queryService.getFileTree(eq(2), anyInt(), anyBoolean())).thenReturn(treeResult); + when(queryService.getFileTree(eq(2), anyInt(), anyBoolean(), any())).thenReturn(treeResult); mockMvc.perform(get("/api/file-tree").param("depth", "2")) .andExpect(status().isOk()) @@ -690,12 +690,12 @@ void getFileTreeShouldCapDepthAtMaxDepth() throws Exception { Map treeResult = new LinkedHashMap<>(); treeResult.put("tree", List.of()); treeResult.put("total_files", 0L); - when(queryService.getFileTree(eq(10), anyInt(), anyBoolean())).thenReturn(treeResult); + when(queryService.getFileTree(eq(10), anyInt(), anyBoolean(), any())).thenReturn(treeResult); mockMvc.perform(get("/api/file-tree").param("depth", "999")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total_files").value(0)); - verify(queryService).getFileTree(eq(10), anyInt(), anyBoolean()); + verify(queryService).getFileTree(eq(10), anyInt(), anyBoolean(), any()); } } 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 cd5c6f8f..c3dfcdee 100644 --- a/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java +++ b/src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java @@ -637,7 +637,7 @@ void nodeToMapShouldOmitNullFields() { @Test @SuppressWarnings("unchecked") void getFileTreeShouldBuildHierarchicalTree() { - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of( + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of( Map.of("filePath", "src/main/Foo.java", "nodeCount", 3L), Map.of("filePath", "src/main/Bar.java", "nodeCount", 1L), Map.of("filePath", "src/test/FooTest.java", "nodeCount", 2L), @@ -665,7 +665,7 @@ void getFileTreeShouldBuildHierarchicalTree() { @Test @SuppressWarnings("unchecked") void getFileTreeShouldIncludePathFieldAtEveryLevel() { - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of( + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of( Map.of("filePath", "src/main/java/Foo.java", "nodeCount", 2L), Map.of("filePath", "pom.xml", "nodeCount", 1L)), false)); @@ -700,7 +700,7 @@ void getFileTreeShouldIncludePathFieldAtEveryLevel() { @Test @SuppressWarnings("unchecked") void getFileTreeShouldSortDirectoriesBeforeFiles() { - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of( + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of( Map.of("filePath", "README.md", "nodeCount", 1L), Map.of("filePath", "src/Foo.java", "nodeCount", 2L)), false)); @@ -714,7 +714,7 @@ void getFileTreeShouldSortDirectoriesBeforeFiles() { @Test @SuppressWarnings("unchecked") void getFileTreeShouldRespectDepthLimit() { - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of( + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of( Map.of("filePath", "src/main/java/Foo.java", "nodeCount", 5L)), false)); Map result = service.getFileTree(2); @@ -731,7 +731,7 @@ void getFileTreeShouldRespectDepthLimit() { @Test @SuppressWarnings("unchecked") void getFileTreeShouldReturnEmptyTreeForNoNodes() { - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of(), false)); + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of(), false)); Map result = service.getFileTree(null); @@ -748,10 +748,10 @@ void getFileTreeShouldBeDeterministic() { Map.of("filePath", "src/A.java", "nodeCount", 1L), Map.of("filePath", "lib/C.java", "nodeCount", 3L)); - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(paths, false)); + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(paths, false)); Map first = service.getFileTree(null); - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(paths, false)); + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(paths, false)); Map second = service.getFileTree(null); assertEquals(first.toString(), second.toString()); @@ -762,7 +762,7 @@ void getFileTreeShouldBeDeterministic() { void getFileTreeShouldUpgradeFileToDirectoryWhenUsedAsIntermediate() { // Simulates a monorepo where SERVICE nodes have directory-like filePaths // e.g., "packages/api" (SERVICE) AND "packages/api/src/index.ts" (source file) - when(graphStore.getFilePathsWithCounts(anyInt())).thenReturn(new GraphStore.FilePathResult(List.of( + when(graphStore.getFilePathsWithCounts(anyInt(), any())).thenReturn(new GraphStore.FilePathResult(List.of( Map.of("filePath", "packages/api", "nodeCount", 1L), Map.of("filePath", "packages/api/src/index.ts", "nodeCount", 5L), Map.of("filePath", "packages/api/src/users.ts", "nodeCount", 3L),