+
- {filteredData.nodes.length.toLocaleString()} nodes /{" "}
- {filteredData.edges.length.toLocaleString()} edges
+ {filteredData.nodes.length.toLocaleString()} groups /{" "}
+ {filteredData.edges.length.toLocaleString()} links
+
+
+ click a node to drill in - size = code volume
- {data.nodes.length > filteredData.nodes.length && (
-
- filtered from {data.nodes.length.toLocaleString()}
-
- )}
{highlightedIds && highlightedIds.size > 0 && (
-
- {highlightedIds.size} selected
-
+
{highlightedIds.size} selected
)}
@@ -311,7 +359,7 @@ export function GraphTab({ project }: GraphTabProps) {
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
- fetchOverview(project);
+ fetchGraph(project, crumbs[crumbs.length - 1]?.qn ?? project);
}}
>
Refresh
diff --git a/graph-ui/src/components/LevelList.tsx b/graph-ui/src/components/LevelList.tsx
new file mode 100644
index 000000000..f8f2ba055
--- /dev/null
+++ b/graph-ui/src/components/LevelList.tsx
@@ -0,0 +1,69 @@
+import { useMemo, useState } from "react";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import type { GraphNode } from "../lib/types";
+
+interface LevelListProps {
+ nodes: GraphNode[];
+ onPick: (node: GraphNode) => void;
+ selectedId: number | null;
+}
+
+/* Aim-free navigation for the drill-down explorer: a plain clickable list of the
+ * current level's container nodes. Clicking a row drills in (expandable) or
+ * selects (leaf) - identical to clicking the 3D node, but without having to aim
+ * at a small sphere. Sorted by code volume (descending). */
+export function LevelList({ nodes, onPick, selectedId }: LevelListProps) {
+ const [search, setSearch] = useState("");
+ const rows = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return [...nodes]
+ .filter((n) => !q || n.name.toLowerCase().includes(q))
+ .sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
+ }, [nodes, search]);
+
+ return (
+
+
+ setSearch(e.target.value)}
+ className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all"
+ />
+
+
+
+ {rows.length === 0 ? (
+
No matches
+ ) : (
+ rows.map((n) => (
+
onPick(n)}
+ title={n.expandable ? `Open ${n.name}` : n.name}
+ className={`flex items-center gap-2 w-full text-left px-3 py-[6px] text-[12px] transition-colors ${
+ selectedId === n.id
+ ? "bg-primary/10 text-primary"
+ : "text-foreground/65 hover:text-foreground hover:bg-white/[0.04]"
+ }`}
+ >
+
+ {n.name}
+
+
+ {(n.count ?? 0).toLocaleString()}
+
+ {n.expandable ? ">" : ""}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/graph-ui/src/components/NodeCloud.tsx b/graph-ui/src/components/NodeCloud.tsx
index e5465b8f2..bb428344c 100644
--- a/graph-ui/src/components/NodeCloud.tsx
+++ b/graph-ui/src/components/NodeCloud.tsx
@@ -55,7 +55,9 @@ export function NodeCloud({
const n = nodes[i];
tempObj.position.set(n.x, n.y, n.z);
const isHighlighted = !hasHighlight || highlightedIds.has(n.id);
- const s = n.size * (isHighlighted ? 0.5 : 0.2);
+ /* Larger solid spheres so the clickable geometry roughly matches the
+ * bloom glow - clicking the visible halo previously missed the tiny core. */
+ const s = n.size * (isHighlighted ? 0.9 : 0.35);
tempObj.scale.set(s, s, s);
tempObj.updateMatrix();
mesh.setMatrixAt(i, tempObj.matrix);
diff --git a/graph-ui/src/hooks/useGraphData.ts b/graph-ui/src/hooks/useGraphData.ts
index 36048bcac..96a8890f7 100644
--- a/graph-ui/src/hooks/useGraphData.ts
+++ b/graph-ui/src/hooks/useGraphData.ts
@@ -5,16 +5,19 @@ interface UseGraphDataResult {
data: GraphData | null;
loading: boolean;
error: string | null;
- fetchOverview: (project: string) => void;
- fetchDetail: (project: string, centerNode: string) => void;
+ /* Load one hierarchy level of the drill-down explorer. `parent` is a container
+ * qualified_name; omit (or pass the project) for the repo root. */
+ fetchGraph: (project: string, parent?: string) => void;
}
-async function fetchLayout(
- project: string,
- maxNodes = 50000,
-): Promise
{
- const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
- const res = await fetch(`/api/layout?${params}`);
+/* The drill-down explorer fetches aggregated container nodes + weighted
+ * super-edges from /api/graph. The backend only ever materializes the current
+ * level (a few hundred nodes), so this is memory-safe for any repo size -
+ * unlike /api/layout, which tried to lay out the whole graph and OOM'd. */
+async function fetchLevel(project: string, parent?: string): Promise {
+ const params = new URLSearchParams({ project });
+ if (parent) params.set("parent", parent);
+ const res = await fetch(`/api/graph?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -29,35 +32,18 @@ export function useGraphData(): UseGraphDataResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const fetchOverview = useCallback(async (project: string) => {
+ const fetchGraph = useCallback(async (project: string, parent?: string) => {
setLoading(true);
setError(null);
try {
- const result = await fetchLayout(project, 50000);
+ const result = await fetchLevel(project, parent);
setData(result);
} catch (e) {
- setError(e instanceof Error ? e.message : "Failed to fetch layout");
+ setError(e instanceof Error ? e.message : "Failed to load graph");
} finally {
setLoading(false);
}
}, []);
- const fetchDetail = useCallback(
- async (project: string, _centerNode: string) => {
- setLoading(true);
- setError(null);
- try {
- /* TODO: detail level with center_node filtering */
- const result = await fetchLayout(project, 50000);
- setData(result);
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to fetch layout");
- } finally {
- setLoading(false);
- }
- },
- [],
- );
-
- return { data, loading, error, fetchOverview, fetchDetail };
+ return { data, loading, error, fetchGraph };
}
diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts
index 0286a35b5..39a37b275 100644
--- a/graph-ui/src/lib/types.ts
+++ b/graph-ui/src/lib/types.ts
@@ -10,12 +10,17 @@ export interface GraphNode {
file_path?: string;
size: number;
color: string;
+ /* Drill-down explorer (aggregated container nodes from /api/graph): */
+ count?: number; /* number of code symbols in this subtree */
+ expandable?: boolean; /* can be drilled into */
+ qn?: string; /* full qualified_name; pass as the next `parent` to drill in */
}
export interface GraphEdge {
source: number;
target: number;
type: string;
+ weight?: number; /* aggregated super-edge weight (drill-down explorer) */
}
export interface LinkedProject {
@@ -31,6 +36,7 @@ export interface GraphData {
edges: GraphEdge[];
total_nodes: number;
linked_projects?: LinkedProject[];
+ prefix?: string; /* current container QN (drill-down explorer) */
}
export interface Project {
diff --git a/graph-ui/src/main.tsx b/graph-ui/src/main.tsx
index e95ed1bd2..20a12e310 100644
--- a/graph-ui/src/main.tsx
+++ b/graph-ui/src/main.tsx
@@ -1,6 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
+/* Self-hosted fonts (bundled by vite) - replaces the Google Fonts CDN link so
+ * the UI makes no external requests when viewing a proprietary codebase. */
+import "@fontsource/inter/400.css";
+import "@fontsource/inter/500.css";
+import "@fontsource/inter/600.css";
+import "@fontsource/jetbrains-mono/400.css";
+import "@fontsource/jetbrains-mono/500.css";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo
index e1401cf36..618452f0c 100644
--- a/graph-ui/tsconfig.tsbuildinfo
+++ b/graph-ui/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.tsx","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/ControlTab.tsx","./src/components/EdgeLines.tsx","./src/components/ErrorBoundary.tsx","./src/components/FilterPanel.tsx","./src/components/GraphScene.tsx","./src/components/GraphTab.tsx","./src/components/LevelList.tsx","./src/components/NodeCloud.tsx","./src/components/NodeDetailPanel.tsx","./src/components/NodeLabels.tsx","./src/components/NodeTooltip.tsx","./src/components/ProjectCard.tsx","./src/components/ResizeHandle.tsx","./src/components/Sidebar.tsx","./src/components/StatsTab.tsx","./src/components/TabBar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/useGraphData.ts","./src/hooks/useProjects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/scripts/embed-frontend.sh b/scripts/embed-frontend.sh
index 908c39790..edef97013 100755
--- a/scripts/embed-frontend.sh
+++ b/scripts/embed-frontend.sh
@@ -38,6 +38,8 @@ content_type_for() {
*.ico) echo "image/x-icon" ;;
*.woff2) echo "font/woff2" ;;
*.woff) echo "font/woff" ;;
+ *.ttf) echo "font/ttf" ;;
+ *.otf) echo "font/otf" ;;
*.map) echo "application/json" ;;
*) echo "application/octet-stream" ;;
esac
diff --git a/src/cli/cli.c b/src/cli/cli.c
index f159f5914..8fddcc18b 100644
--- a/src/cli/cli.c
+++ b/src/cli/cli.c
@@ -3898,9 +3898,28 @@ static int download_verify_install(const char *url, const char *ext, const char
want_ui ? "ui-" : "", os, arch, portable, ext);
int crc = verify_download_checksum(tmp_archive, archive_name);
if (crc == CLI_TRUE) {
+ /* Explicit checksum mismatch: the binary is tampered or corrupt. Never install. */
cbm_unlink(tmp_archive);
return CLI_TRUE;
}
+ if (crc != 0) {
+ /* "Could not verify" (missing checksums.txt, name absent, or no sha256 tool).
+ * Fail closed: do not install an unverified binary. An operator on an
+ * isolated/offline host may opt in explicitly via CBM_ALLOW_UNVERIFIED_UPDATE=1. */
+ char allow_buf[CLI_BUF_16];
+ const char *allow =
+ cbm_safe_getenv("CBM_ALLOW_UNVERIFIED_UPDATE", allow_buf, sizeof(allow_buf), NULL);
+ if (!allow || strcmp(allow, "1") != 0) {
+ (void)fprintf(stderr,
+ "error: could not verify the downloaded binary - aborting.\n"
+ " Verification is required by default. To override on an offline host, "
+ "set CBM_ALLOW_UNVERIFIED_UPDATE=1.\n");
+ cbm_unlink(tmp_archive);
+ return CLI_TRUE;
+ }
+ (void)fprintf(stderr, "warning: installing UNVERIFIED binary "
+ "(CBM_ALLOW_UNVERIFIED_UPDATE=1).\n");
+ }
int killed = cbm_kill_other_instances();
if (killed > 0) {
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 7016a0d21..f13fa3544 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -4737,6 +4737,15 @@ static void start_update_check(cbm_mcp_server_t *srv) {
if (srv->update_checked) {
return;
}
+ /* Opt-out: skip the GitHub version check entirely for restricted-egress or
+ * air-gapped environments. Set CBM_NO_UPDATE_CHECK=1 to disable. */
+ char optout_buf[8];
+ const char *optout =
+ cbm_safe_getenv("CBM_NO_UPDATE_CHECK", optout_buf, sizeof(optout_buf), NULL);
+ if (optout && strcmp(optout, "1") == 0) {
+ srv->update_checked = true; /* mark done so we never launch the thread */
+ return;
+ }
srv->update_checked = true; /* prevent double-launch */
if (cbm_thread_create(&srv->update_tid, 0, update_check_thread, srv) == 0) {
srv->update_thread_active = true;
diff --git a/src/store/store.c b/src/store/store.c
index 995f6e85c..0ad9db1db 100644
--- a/src/store/store.c
+++ b/src/store/store.c
@@ -2348,12 +2348,36 @@ static int search_where_basic(const cbm_search_params_t *params, char *where, in
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, params->project);
}
- /* Ignore an empty-string label: it is non-NULL but should behave like an
- * omitted label (no filter), matching the BM25 query path. Without the
- * params->label[0] guard, name_pattern/qn_pattern searches that pass
- * label="" append `n.label = ''`, which matches no node and silently
- * returns zero results (issue #481). */
- if (params->label && params->label[0]) {
+ if (params->include_labels && params->include_labels[0]) {
+ /* n.label IN (?,?,...) - one bind per whitelisted label. Track the write
+ * offset as a clamped size_t so the remaining-size passed to snprintf can
+ * never underflow, even on truncation (snprintf returns the would-be
+ * length, which may exceed the buffer). */
+ char in_buf[CBM_SZ_256];
+ const size_t cap = sizeof(in_buf);
+ int head = snprintf(in_buf, cap, "n.label IN (");
+ size_t off = (head > 0) ? (size_t)head : 0;
+ if (off >= cap)
+ off = cap - 1;
+ int base = *bind_idx;
+ int k = 0;
+ for (; k < ST_SEARCH_MAX_BINDS && params->include_labels[k]; k++) {
+ int w = snprintf(in_buf + off, cap - off, "%s?%d", k ? "," : "", base + SKIP_ONE + k);
+ if (w < 0)
+ break;
+ off += (size_t)w;
+ if (off >= cap) { /* truncated: stop before the size could underflow */
+ off = cap - 1;
+ break;
+ }
+ }
+ snprintf(in_buf + off, cap - off, ")");
+ *wlen = where_append(where, where_sz, *wlen, nparams, in_buf);
+ for (int i = 0; i < k; i++)
+ where_bind_text(binds, bind_idx, params->include_labels[i]);
+ } else if (params->label && params->label[0]) {
+ /* Ignore an empty-string label (issue #481): non-NULL but behaves like an
+ * omitted label, matching the BM25 query path. */
snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, params->label);
@@ -2565,6 +2589,201 @@ void cbm_store_search_free(cbm_search_output_t *out) {
memset(out, 0, sizeof(*out));
}
+/* ── Hierarchy expansion (semantic-zoom drill-down) ─────────────────── */
+
+void cbm_tree_view_free(cbm_tree_view_t *v) {
+ if (!v) {
+ return;
+ }
+ for (int i = 0; i < v->child_count; i++) {
+ free(v->children[i].name);
+ free(v->children[i].full_qn);
+ free(v->children[i].kind);
+ }
+ free(v->children);
+ free(v->edges);
+ memset(v, 0, sizeof(*v));
+}
+
+/* SQL fragment: the QN segment of column `col` immediately after the bound
+ * prefix (param ?2 = length of "prefix."). Reused in the child-grouping and
+ * edge-aggregation queries. SUBSTR(col, ?2 + 1) is the remainder after the
+ * prefix; the first dotted token of that remainder is the child segment. */
+#define CBM_TREE_SEG(col) \
+ "CASE WHEN INSTR(SUBSTR(" col ", ?2 + 1), '.') > 0 " \
+ " THEN SUBSTR(SUBSTR(" col ", ?2 + 1), 1, INSTR(SUBSTR(" col ", ?2 + 1), '.') - 1) " \
+ " ELSE SUBSTR(" col ", ?2 + 1) END"
+
+static int tree_child_index(const cbm_tree_view_t *v, const char *name) {
+ for (int i = 0; i < v->child_count; i++) {
+ if (strcmp(v->children[i].name, name) == 0) {
+ return i;
+ }
+ }
+ return CBM_NOT_FOUND;
+}
+
+int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit,
+ int edge_limit, cbm_tree_view_t *out) {
+ if (!s || !s->db || !project || !prefix || !out) {
+ return CBM_STORE_ERR;
+ }
+ memset(out, 0, sizeof(*out));
+ if (child_limit <= 0) {
+ child_limit = 500;
+ }
+ if (edge_limit <= 0) {
+ edge_limit = 2000;
+ }
+
+ size_t plen = strlen(prefix);
+ /* Range bounds so the (project, qualified_name) index can prefix-scan:
+ * every child QN is "prefix" + "." + ..., and '.' (0x2E) < '/' (0x2F), so
+ * [prefix+".", prefix+"/") is exactly the set of descendants. This replaces a
+ * non-sargable SUBSTR(...) = ? predicate that forced a full table scan. */
+ char *lo = malloc(plen + 2);
+ char *hi = malloc(plen + 2);
+ if (!lo || !hi) {
+ free(lo);
+ free(hi);
+ return CBM_STORE_ERR;
+ }
+ memcpy(lo, prefix, plen);
+ lo[plen] = '.';
+ lo[plen + 1] = '\0';
+ memcpy(hi, prefix, plen);
+ hi[plen] = '/';
+ hi[plen + 1] = '\0';
+ int substr_len = (int)plen + 1; /* offset of the segment after "prefix." */
+
+ /* 1. Children: group nodes one level below `prefix` by their next QN segment. */
+ const char *child_sql = "SELECT " CBM_TREE_SEG(
+ "qualified_name") " AS child, COUNT(*) AS cnt, "
+ "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') > 0 "
+ " THEN 1 ELSE 0 END) AS expandable, "
+ "MAX(CASE WHEN INSTR(SUBSTR(qualified_name, ?2 + 1), '.') = 0 "
+ " THEN label END) AS kind "
+ "FROM nodes WHERE project = ?1 "
+ "AND qualified_name >= ?3 AND qualified_name < ?4 "
+ "GROUP BY child ORDER BY cnt DESC LIMIT ?5";
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(s->db, child_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) {
+ free(lo);
+ free(hi);
+ return CBM_STORE_ERR;
+ }
+ sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_int(st, 2, substr_len);
+ sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_int(st, 5, child_limit);
+
+ int cap = 16, n = 0;
+ cbm_tree_child_t *children = malloc((size_t)cap * sizeof(*children));
+ if (!children) {
+ sqlite3_finalize(st);
+ free(lo);
+ free(hi);
+ return CBM_STORE_ERR;
+ }
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ const char *child = (const char *)sqlite3_column_text(st, 0);
+ if (!child || child[0] == '\0') {
+ continue;
+ }
+ if (n >= cap) {
+ cap *= 2;
+ cbm_tree_child_t *tmp = realloc(children, (size_t)cap * sizeof(*children));
+ if (!tmp) {
+ break;
+ }
+ children = tmp;
+ }
+ const char *kind = (const char *)sqlite3_column_text(st, 3);
+ size_t clen = strlen(child);
+ char *fq = malloc(plen + 1 + clen + 1);
+ if (fq) {
+ memcpy(fq, prefix, plen);
+ fq[plen] = '.';
+ memcpy(fq + plen + 1, child, clen + 1);
+ }
+ children[n].name = heap_strdup(child);
+ children[n].full_qn = fq;
+ children[n].kind = heap_strdup(kind ? kind : "Group");
+ children[n].count = sqlite3_column_int(st, 1);
+ children[n].expandable = sqlite3_column_int(st, 2);
+ n++;
+ }
+ sqlite3_finalize(st);
+ out->children = children;
+ out->child_count = n;
+ if (n == 0) {
+ free(lo);
+ free(hi);
+ return CBM_STORE_OK;
+ }
+
+ /* 2. Edges: aggregate cross-child relationship edges into weighted super-edges.
+ * Drive from the (project, qualified_name) index range on each endpoint so the
+ * planner restricts to the current subtree before the COUNT/GROUP, instead of
+ * scanning every typed edge in the project. */
+ const char *edge_sql = "SELECT " CBM_TREE_SEG("ns.qualified_name") " AS src, " CBM_TREE_SEG(
+ "nt.qualified_name") " AS tgt, COUNT(*) AS w "
+ "FROM edges e JOIN nodes ns ON ns.id = e.source_id JOIN nodes nt ON "
+ "nt.id = e.target_id "
+ "WHERE e.project = ?1 AND e.type IN "
+ "('CALLS','IMPORTS','INHERITS','USAGE','WRITES','CONFIGURES','HTTP_"
+ "CALLS','THROWS') "
+ "AND ns.qualified_name >= ?3 AND ns.qualified_name < ?4 "
+ "AND nt.qualified_name >= ?3 AND nt.qualified_name < ?4 "
+ "AND " CBM_TREE_SEG("ns.qualified_name") " <> " CBM_TREE_SEG(
+ "nt.qualified_name") " "
+ "GROUP BY src, tgt ORDER BY w DESC LIMIT ?5";
+
+ if (sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) {
+ free(lo);
+ free(hi);
+ return CBM_STORE_OK; /* children are still valid */
+ }
+ sqlite3_bind_text(st, 1, project, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_int(st, 2, substr_len);
+ sqlite3_bind_text(st, 3, lo, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_text(st, 4, hi, CBM_NOT_FOUND, SQLITE_STATIC);
+ sqlite3_bind_int(st, 5, edge_limit);
+
+ int ecap = 32, en = 0;
+ cbm_tree_edge_t *edges = malloc((size_t)ecap * sizeof(*edges));
+ while (edges && sqlite3_step(st) == SQLITE_ROW) {
+ const char *sc = (const char *)sqlite3_column_text(st, 0);
+ const char *tc = (const char *)sqlite3_column_text(st, 1);
+ int si = sc ? tree_child_index(out, sc) : CBM_NOT_FOUND;
+ int di = tc ? tree_child_index(out, tc) : CBM_NOT_FOUND;
+ if (si < 0 || di < 0) {
+ continue; /* endpoint truncated by child_limit */
+ }
+ if (en >= ecap) {
+ ecap *= 2;
+ cbm_tree_edge_t *tmp = realloc(edges, (size_t)ecap * sizeof(*edges));
+ if (!tmp) {
+ break;
+ }
+ edges = tmp;
+ }
+ edges[en].src = si;
+ edges[en].dst = di;
+ edges[en].weight = sqlite3_column_int(st, 2);
+ en++;
+ }
+ sqlite3_finalize(st);
+ out->edges = edges;
+ out->edge_count = en;
+
+ free(lo);
+ free(hi);
+ return CBM_STORE_OK;
+}
+
/* ── BFS Traversal ──────────────────────────────────────────────── */
static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited,
diff --git a/src/store/store.h b/src/store/store.h
index 26b09a5c2..c54376ad0 100644
--- a/src/store/store.h
+++ b/src/store/store.h
@@ -104,15 +104,16 @@ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src);
typedef struct {
const char *project;
- const char *label; /* NULL = any label */
- const char *name_pattern; /* regex on name, NULL = any */
- const char *qn_pattern; /* regex on qualified_name, NULL = any */
- const char *file_pattern; /* glob on file_path, NULL = any */
- const char *relationship; /* edge type filter, NULL = any */
- const char *direction; /* "inbound" / "outbound" / "any", NULL = any */
- int min_degree; /* -1 = no filter (default), 0+ = minimum */
- int max_degree; /* -1 = no filter (default), 0+ = maximum */
- int limit; /* 0 = default (10) */
+ const char *label; /* NULL = any label */
+ const char **include_labels; /* NULL-terminated whitelist (label IN (...)); overrides `label` */
+ const char *name_pattern; /* regex on name, NULL = any */
+ const char *qn_pattern; /* regex on qualified_name, NULL = any */
+ const char *file_pattern; /* glob on file_path, NULL = any */
+ const char *relationship; /* edge type filter, NULL = any */
+ const char *direction; /* "inbound" / "outbound" / "any", NULL = any */
+ int min_degree; /* -1 = no filter (default), 0+ = minimum */
+ int max_degree; /* -1 = no filter (default), 0+ = maximum */
+ int limit; /* 0 = default (10) */
int offset;
bool exclude_entry_points;
bool include_connected;
@@ -380,6 +381,41 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
/* Free a search output's allocated memory. */
void cbm_store_search_free(cbm_search_output_t *out);
+/* ── Hierarchy expansion (semantic-zoom drill-down) ─────────────────
+ * Groups nodes one level below a qualified_name prefix by their next QN
+ * segment, and aggregates cross-group relationship edges into weighted
+ * super-edges. Lets the UI represent a 300k-node graph as a navigable tree
+ * of container super-nodes without ever materializing the whole graph. */
+typedef struct {
+ char *name; /* child segment (the next QN token under the prefix) */
+ char *full_qn; /* prefix + "." + name (pass back as the next prefix to drill in) */
+ char *kind; /* label of the container/leaf node for this segment, or "Group" */
+ int count; /* number of nodes in this subtree */
+ int expandable; /* 1 if the subtree has deeper nodes (can drill in) */
+} cbm_tree_child_t;
+
+typedef struct {
+ int src; /* index into children[] */
+ int dst; /* index into children[] */
+ int weight; /* number of underlying relationship edges crossing the boundary */
+} cbm_tree_edge_t;
+
+typedef struct {
+ cbm_tree_child_t *children;
+ int child_count;
+ cbm_tree_edge_t *edges;
+ int edge_count;
+} cbm_tree_view_t;
+
+/* Expand one hierarchy level under `prefix` (a node qualified_name, e.g. the
+ * project name for the root). Caps children at child_limit and edges at
+ * edge_limit. Returns CBM_STORE_OK and fills `out` (free with cbm_tree_view_free). */
+int cbm_store_expand_tree(cbm_store_t *s, const char *project, const char *prefix, int child_limit,
+ int edge_limit, cbm_tree_view_t *out);
+
+/* Free a tree view's allocated memory. */
+void cbm_tree_view_free(cbm_tree_view_t *v);
+
/* ── Traversal ──────────────────────────────────────────────────── */
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index 568b47cc0..b75b04dcb 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -105,6 +105,25 @@ typedef struct {
static index_job_t g_index_jobs[MAX_INDEX_JOBS];
+/* Content-Security-Policy for the UI document. The loopback server and its
+ * embedded assets are entirely self-contained, so every fetch directive is
+ * pinned to 'self'. This makes the browser refuse any external request -
+ * fonts, scripts, XHR/fetch/WebSocket, images - so viewing a proprietary
+ * codebase in the graph UI cannot leak data to any third-party host.
+ * script/style allow 'unsafe-inline'/'unsafe-eval' and worker allows blob:
+ * because the bundled React + three.js/troika stack needs them; none of those
+ * widen network egress, which is governed by connect-src/font-src/img-src. */
+/* Note on blob: in script-src - three.js/troika build Web Workers from blob:
+ * URLs and those workers call importScripts(blob:...), which is governed by
+ * script-src (script-src-elem falls back to it), not worker-src. blob: is a
+ * local, same-origin scheme and does NOT permit any network egress; egress is
+ * still fully pinned by connect-src/font-src/img-src 'self'. */
+#define CBM_UI_CSP \
+ "Content-Security-Policy: default-src 'self'; connect-src 'self'; font-src 'self'; " \
+ "img-src 'self' data: blob:; media-src 'self' data: blob:; " \
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; " \
+ "worker-src 'self' blob:; object-src 'none'; base-uri 'self'\r\n"
+
/* ── Serve embedded asset ─────────────────────────────────────── */
static bool serve_embedded(cbm_http_conn_t *c, const char *path) {
@@ -976,6 +995,14 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
max_nodes = v;
}
+ /* Optional label filter. Empty string ("label=") explicitly means "all labels";
+ * an absent param keeps the default. */
+ char label[64] = {0};
+ const char *label_filter = NULL;
+ if (cbm_http_query_param(req->query, "label", label, (int)sizeof(label))) {
+ label_filter = label[0] != '\0' ? label : NULL;
+ }
+
char db_path[1024];
db_path_for_project(project, db_path, sizeof(db_path));
@@ -991,7 +1018,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
}
cbm_layout_result_t *layout =
- cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
+ cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, label_filter);
/* Find linked projects from CROSS_* edges. Keep `store` open through the
* linked-projects loop below so we can resolve target Route QNs against
@@ -1054,8 +1081,8 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
}
/* Keep lp_store open through cross_edges resolution below. */
- cbm_layout_result_t *lp_layout =
- cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
+ cbm_layout_result_t *lp_layout = cbm_layout_compute(
+ lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes, label_filter);
if (!lp_layout) {
cbm_store_close(lp_store);
@@ -1190,6 +1217,186 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
}
}
+/* Color for an aggregated container super-node, keyed by its dominant node label. */
+static uint32_t tree_kind_color(const char *kind) {
+ if (!kind)
+ return 0x8888aa;
+ if (strcmp(kind, "Folder") == 0)
+ return 0xf2c14e;
+ if (strcmp(kind, "File") == 0)
+ return 0x4ea8de;
+ if (strcmp(kind, "Class") == 0)
+ return 0x9b5de5;
+ if (strcmp(kind, "Interface") == 0)
+ return 0xc77dff;
+ if (strcmp(kind, "Method") == 0)
+ return 0x00bbf9;
+ if (strcmp(kind, "Function") == 0)
+ return 0x00f5d4;
+ if (strcmp(kind, "Module") == 0)
+ return 0xff8fab;
+ if (strcmp(kind, "Variable") == 0)
+ return 0xfee440;
+ if (strcmp(kind, "Route") == 0)
+ return 0xff5d8f;
+ if (strcmp(kind, "Enum") == 0 || strcmp(kind, "Type") == 0)
+ return 0x90be6d;
+ return 0x8888aa; /* Group / unknown */
+}
+
+/* Memo cache for /api/graph. The aggregation over a large subtree scans many
+ * edges (~seconds at the root), but the index is static while the server runs,
+ * so each (project,parent) view is computed once and reused. The HTTP server is
+ * single-threaded per request (see httpd.c), so no locking is needed. */
+#define GRAPH_CACHE_MAX 64
+static struct {
+ char *key;
+ char *json;
+} g_graph_cache[GRAPH_CACHE_MAX];
+static int g_graph_cache_n = 0;
+static int g_graph_cache_next = 0; /* FIFO eviction cursor when full */
+
+static const char *graph_cache_get(const char *key) {
+ for (int i = 0; i < g_graph_cache_n; i++) {
+ if (g_graph_cache[i].key && strcmp(g_graph_cache[i].key, key) == 0) {
+ return g_graph_cache[i].json;
+ }
+ }
+ return NULL;
+}
+
+static void graph_cache_put(const char *key, const char *json) {
+ char *kd = strdup(key);
+ char *jd = strdup(json);
+ if (!kd || !jd) {
+ free(kd);
+ free(jd);
+ return;
+ }
+ int slot;
+ if (g_graph_cache_n < GRAPH_CACHE_MAX) {
+ slot = g_graph_cache_n++;
+ } else {
+ slot = g_graph_cache_next;
+ g_graph_cache_next = (g_graph_cache_next + 1) % GRAPH_CACHE_MAX;
+ free(g_graph_cache[slot].key);
+ free(g_graph_cache[slot].json);
+ }
+ g_graph_cache[slot].key = kd;
+ g_graph_cache[slot].json = jd;
+}
+
+/* GET /api/graph?project=X&parent= — one hierarchy level for the drill-down
+ * explorer: aggregated container children + weighted cross-container super-edges.
+ * `parent` defaults to the project root. Memory-safe for any repo size: only the
+ * (capped) children + super-edges are materialized, never the full graph. */
+static void handle_graph(cbm_http_conn_t *c, const cbm_http_req_t *req) {
+ char project[256] = {0};
+ char parent[1024] = {0};
+ if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
+ project[0] == '\0') {
+ cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}");
+ return;
+ }
+ /* Root = the project node, whose qualified_name equals the project name. */
+ if (!cbm_http_query_param(req->query, "parent", parent, (int)sizeof(parent)) ||
+ parent[0] == '\0') {
+ snprintf(parent, sizeof(parent), "%s", project);
+ }
+
+ /* Cache key = project + '\n' + parent. */
+ char cache_key[1300];
+ snprintf(cache_key, sizeof(cache_key), "%s\n%s", project, parent);
+ const char *cached = graph_cache_get(cache_key);
+ if (cached) {
+ cbm_http_replyf(c, 200, g_cors_json, "%s", cached);
+ return;
+ }
+
+ char db_path[1024];
+ db_path_for_project(project, db_path, sizeof(db_path));
+ if (!cbm_file_exists(db_path)) {
+ cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
+ return;
+ }
+ cbm_store_t *store = cbm_store_open_path(db_path);
+ if (!store) {
+ cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
+ return;
+ }
+
+ cbm_tree_view_t view;
+ if (cbm_store_expand_tree(store, project, parent, 500, 2000, &view) != CBM_STORE_OK) {
+ cbm_store_close(store);
+ cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"expand failed\"}");
+ return;
+ }
+
+ int n = view.child_count;
+ /* Spread nodes wider so they overlap less and present larger click targets. */
+ double radius = 90.0 + sqrt((double)(n > 0 ? n : 1)) * 26.0;
+ double golden = M_PI * (3.0 - sqrt(5.0));
+
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root = yyjson_mut_obj(doc);
+ yyjson_mut_doc_set_root(doc, root);
+
+ yyjson_mut_val *na = yyjson_mut_arr(doc);
+ for (int i = 0; i < n; i++) {
+ cbm_tree_child_t *ch = &view.children[i];
+ yyjson_mut_val *nd = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, nd, "id", i);
+ /* Fibonacci sphere placement so the overview reads as a ball of nodes. */
+ double yy = n > 1 ? 1.0 - (i / (double)(n - 1)) * 2.0 : 0.0;
+ double rr = sqrt(1.0 - yy * yy);
+ double th = golden * i;
+ yyjson_mut_obj_add_real(doc, nd, "x", cos(th) * rr * radius);
+ yyjson_mut_obj_add_real(doc, nd, "y", yy * radius);
+ yyjson_mut_obj_add_real(doc, nd, "z", sin(th) * rr * radius);
+ yyjson_mut_obj_add_str(doc, nd, "label", ch->kind ? ch->kind : "Group");
+ yyjson_mut_obj_add_str(doc, nd, "name", ch->name ? ch->name : "");
+ double sz = 4.0 + log((double)(ch->count > 0 ? ch->count : 1) + 1.0) * 2.6;
+ yyjson_mut_obj_add_real(doc, nd, "size", sz);
+ char hex[16];
+ snprintf(hex, sizeof(hex), "#%06x", tree_kind_color(ch->kind));
+ yyjson_mut_obj_add_strcpy(doc, nd, "color", hex);
+ yyjson_mut_obj_add_int(doc, nd, "count", ch->count);
+ yyjson_mut_obj_add_bool(doc, nd, "expandable", ch->expandable != 0);
+ if (ch->full_qn) {
+ yyjson_mut_obj_add_strcpy(doc, nd, "qn", ch->full_qn);
+ }
+ yyjson_mut_arr_append(na, nd);
+ }
+ yyjson_mut_obj_add_val(doc, root, "nodes", na);
+
+ yyjson_mut_val *ea = yyjson_mut_arr(doc);
+ for (int i = 0; i < view.edge_count; i++) {
+ yyjson_mut_val *ed = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, ed, "source", view.edges[i].src);
+ yyjson_mut_obj_add_int(doc, ed, "target", view.edges[i].dst);
+ yyjson_mut_obj_add_str(doc, ed, "type", "AGG");
+ yyjson_mut_obj_add_int(doc, ed, "weight", view.edges[i].weight);
+ yyjson_mut_arr_append(ea, ed);
+ }
+ yyjson_mut_obj_add_val(doc, root, "edges", ea);
+ yyjson_mut_obj_add_int(doc, root, "total_nodes", n);
+ yyjson_mut_obj_add_strcpy(doc, root, "prefix", parent);
+
+ size_t len = 0;
+ char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, NULL);
+ yyjson_mut_doc_free(doc);
+ cbm_tree_view_free(&view);
+ cbm_store_close(store);
+
+ if (json) {
+ graph_cache_put(cache_key, json);
+ cbm_http_replyf(c, 200, g_cors_json, "%s", json);
+ free(json);
+ } else {
+ cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON write failed\"}");
+ }
+}
+
/* ── Handle JSON-RPC request ──────────────────────────────────── */
static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_server_t *mcp) {
@@ -1240,6 +1447,11 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
+ if (is_get && cbm_http_path_match(req->path, "/api/graph*")) {
+ handle_graph(c, req);
+ return;
+ }
+
/* POST /api/index → start background indexing */
if (is_post && cbm_http_path_match(req->path, "/api/index")) {
handle_index_start(c, req);
@@ -1304,9 +1516,9 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
if (cbm_http_path_match(req->path, "/")) {
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
if (f) {
- char html_hdrs[512];
+ char html_hdrs[1024];
snprintf(html_hdrs, sizeof(html_hdrs),
- "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n", g_cors);
+ "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n" CBM_UI_CSP, g_cors);
cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size);
return;
}
diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c
index 5758a3334..e39f395a0 100644
--- a/src/ui/layout3d.c
+++ b/src/ui/layout3d.c
@@ -384,7 +384,7 @@ static int find_node_index(const node_id_entry_t *map, int count, int64_t id) {
cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
cbm_layout_level_t level, const char *center_node,
- int radius, int max_nodes) {
+ int radius, int max_nodes, const char *label) {
if (!store || !project)
return NULL;
if (max_nodes <= 0)
@@ -400,11 +400,75 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
params.limit = max_nodes;
params.min_degree = -1;
params.max_degree = -1;
+ /* Optional label filter. Accepts a comma-separated whitelist (e.g.
+ * "Class,Method,Variable"); NULL/empty means all labels. */
+ char label_buf[CBM_SZ_256];
+ const char *label_ptrs[16];
+ int label_n = 0;
+ if (label && label[0] != '\0') {
+ snprintf(label_buf, sizeof(label_buf), "%s", label);
+ char *save = NULL;
+ for (char *tok = strtok_r(label_buf, ",", &save);
+ tok && label_n < (int)(sizeof(label_ptrs) / sizeof(label_ptrs[0])) - 1;
+ tok = strtok_r(NULL, ",", &save)) {
+ while (*tok == ' ')
+ tok++;
+ if (*tok != '\0')
+ label_ptrs[label_n++] = tok;
+ }
+ }
+ label_ptrs[label_n] = NULL;
+ params.include_labels = label_n > 0 ? label_ptrs : NULL;
cbm_search_output_t search_out;
memset(&search_out, 0, sizeof(search_out));
- if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK)
+ if (label_n > 1) {
+ /* Multiple labels: give each its own quota so every requested type is
+ * represented. A flat "ORDER BY name LIMIT" lets one label (e.g. Variable)
+ * crowd out the rest. Run one search per label, then merge - transferring
+ * string ownership to the combined output (free only each sub's container
+ * array; cbm_store_search_free(&search_out) later frees the strings once). */
+ int per = max_nodes / label_n;
+ if (per < 1)
+ per = 1;
+ cbm_search_output_t subs[16];
+ int sub_count = 0;
+ int merged_cap = 0, merged_total = 0;
+ for (int li = 0; li < label_n; li++) {
+ const char *one[2] = {label_ptrs[li], NULL};
+ cbm_search_params_t p = params;
+ p.include_labels = one;
+ p.limit = per;
+ memset(&subs[sub_count], 0, sizeof(subs[0]));
+ if (cbm_store_search(store, &p, &subs[sub_count]) == CBM_STORE_OK) {
+ merged_cap += subs[sub_count].count;
+ merged_total += subs[sub_count].total;
+ sub_count++;
+ }
+ }
+ int merged_ok = 0;
+ if (merged_cap > 0) {
+ search_out.results = malloc((size_t)merged_cap * sizeof(cbm_search_result_t));
+ if (search_out.results) {
+ int idx = 0;
+ for (int s = 0; s < sub_count; s++)
+ for (int i = 0; i < subs[s].count; i++)
+ search_out.results[idx++] = subs[s].results[i];
+ search_out.count = merged_cap;
+ search_out.total = merged_total;
+ for (int s = 0; s < sub_count; s++)
+ free(subs[s].results); /* container only; strings moved to search_out */
+ merged_ok = 1;
+ }
+ }
+ if (!merged_ok) {
+ for (int s = 0; s < sub_count; s++)
+ cbm_store_search_free(&subs[s]);
+ return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t));
+ }
+ } else if (cbm_store_search(store, ¶ms, &search_out) != CBM_STORE_OK) {
return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t));
+ }
int n = search_out.count, total_count = search_out.total;
if (n == 0) {
diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h
index a939d6996..03fc95e5b 100644
--- a/src/ui/layout3d.h
+++ b/src/ui/layout3d.h
@@ -54,10 +54,11 @@ typedef enum {
/* Compute layout for a project.
* center_node: QN of center (for detail level), NULL for overview
* radius: hop distance from center (for detail level)
- * max_nodes: cap on returned nodes */
+ * max_nodes: cap on returned nodes
+ * label: restrict to a single node label (e.g. "Class"), or NULL for all labels */
cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
cbm_layout_level_t level, const char *center_node,
- int radius, int max_nodes);
+ int radius, int max_nodes, const char *label);
/* Free a layout result. */
void cbm_layout_free(cbm_layout_result_t *result);
diff --git a/tests/test_ui.c b/tests/test_ui.c
index 002e8d39a..daa85eea6 100644
--- a/tests/test_ui.c
+++ b/tests/test_ui.c
@@ -203,7 +203,7 @@ TEST(layout_empty_graph) {
/* No nodes in store → empty result */
cbm_layout_result_t *r =
- cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_compute(store, "test-project", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NOT_NULL(r);
ASSERT_EQ(r->node_count, 0);
ASSERT_EQ(r->edge_count, 0);
@@ -230,7 +230,8 @@ TEST(layout_single_node) {
int64_t id = cbm_store_upsert_node(store, &node);
ASSERT_GT(id, 0);
- cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_result_t *r =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NOT_NULL(r);
ASSERT_EQ(r->node_count, 1);
ASSERT_STR_EQ(r->nodes[0].name, "main");
@@ -267,7 +268,8 @@ TEST(layout_two_connected) {
cbm_edge_t edge = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"};
cbm_store_insert_edge(store, &edge);
- cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_result_t *r =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NOT_NULL(r);
ASSERT_EQ(r->node_count, 2);
@@ -307,7 +309,8 @@ TEST(layout_respects_max_nodes) {
}
/* max_nodes=5 should return at most 5 */
- cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5);
+ cbm_layout_result_t *r =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 5, NULL);
ASSERT_NOT_NULL(r);
ASSERT_LTE(r->node_count, 5);
ASSERT_EQ(r->total_nodes, 20);
@@ -341,8 +344,10 @@ TEST(layout_deterministic) {
cbm_store_upsert_node(store, &n2);
/* Run twice, check positions match */
- cbm_layout_result_t *r1 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
- cbm_layout_result_t *r2 = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_result_t *r1 =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
+ cbm_layout_result_t *r2 =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NOT_NULL(r1);
ASSERT_NOT_NULL(r2);
ASSERT_EQ(r1->node_count, r2->node_count);
@@ -374,7 +379,8 @@ TEST(layout_to_json) {
.end_line = 5};
cbm_store_upsert_node(store, &n);
- cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_result_t *r =
+ cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NOT_NULL(r);
char *json = cbm_layout_to_json(r);
@@ -395,12 +401,13 @@ TEST(layout_to_json) {
TEST(layout_null_inputs) {
/* NULL store → NULL result */
- cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ cbm_layout_result_t *r =
+ cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NULL(r);
/* NULL project → NULL result */
cbm_store_t *store = cbm_store_open_memory();
- r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100);
+ r = cbm_layout_compute(store, NULL, CBM_LAYOUT_OVERVIEW, NULL, 0, 100, NULL);
ASSERT_NULL(r);
/* cbm_layout_free(NULL) should not crash */
@@ -414,6 +421,72 @@ TEST(layout_null_inputs) {
PASS();
}
+/* ── Hierarchy expansion (drill-down explorer) ────────────────── */
+
+TEST(expand_tree_groups_and_aggregates) {
+ cbm_store_t *store = cbm_store_open_memory();
+ ASSERT_NOT_NULL(store);
+ cbm_store_upsert_project(store, "test", "/tmp/test");
+
+ /* Hierarchy under "test": two folders with children + a leaf module.
+ * QNs use '.' separators, matching the real indexer's scheme. */
+ cbm_node_t nodes[] = {
+ {.project = "test", .label = "Folder", .name = "alpha", .qualified_name = "test.alpha"},
+ {.project = "test", .label = "Method", .name = "x", .qualified_name = "test.alpha.x"},
+ {.project = "test", .label = "Method", .name = "y", .qualified_name = "test.alpha.y"},
+ {.project = "test", .label = "Folder", .name = "beta", .qualified_name = "test.beta"},
+ {.project = "test", .label = "Method", .name = "z", .qualified_name = "test.beta.z"},
+ {.project = "test", .label = "Module", .name = "gamma", .qualified_name = "test.gamma"},
+ };
+ int64_t ids[6];
+ for (int i = 0; i < 6; i++) {
+ ids[i] = cbm_store_upsert_node(store, &nodes[i]);
+ }
+ /* Cross-folder call alpha.x -> beta.z should aggregate to a super-edge. */
+ cbm_edge_t e = {.project = "test", .source_id = ids[1], .target_id = ids[4], .type = "CALLS"};
+ cbm_store_insert_edge(store, &e);
+
+ cbm_tree_view_t v;
+ ASSERT_EQ(cbm_store_expand_tree(store, "test", "test", 500, 2000, &v), CBM_STORE_OK);
+
+ /* Three top-level groups: alpha (3 nodes), beta (2), gamma (1, leaf). */
+ ASSERT_EQ(v.child_count, 3);
+ int ai = -1, bi = -1, gi = -1;
+ for (int i = 0; i < v.child_count; i++) {
+ if (strcmp(v.children[i].name, "alpha") == 0)
+ ai = i;
+ else if (strcmp(v.children[i].name, "beta") == 0)
+ bi = i;
+ else if (strcmp(v.children[i].name, "gamma") == 0)
+ gi = i;
+ }
+ ASSERT_GTE(ai, 0);
+ ASSERT_GTE(bi, 0);
+ ASSERT_GTE(gi, 0);
+ ASSERT_EQ(v.children[ai].count, 3);
+ ASSERT_TRUE(v.children[ai].expandable);
+ ASSERT_EQ(v.children[bi].count, 2);
+ ASSERT_EQ(v.children[gi].count, 1);
+ ASSERT_FALSE(v.children[gi].expandable); /* single leaf, nothing to drill into */
+
+ /* Exactly one aggregated super-edge: alpha -> beta, weight 1. */
+ ASSERT_EQ(v.edge_count, 1);
+ ASSERT_EQ(v.edges[0].src, ai);
+ ASSERT_EQ(v.edges[0].dst, bi);
+ ASSERT_EQ(v.edges[0].weight, 1);
+
+ cbm_tree_view_free(&v);
+ cbm_store_close(store);
+ PASS();
+}
+
+TEST(expand_tree_null_inputs) {
+ cbm_tree_view_t v;
+ ASSERT_EQ(cbm_store_expand_tree(NULL, "test", "test", 0, 0, &v), CBM_STORE_ERR);
+ cbm_tree_view_free(NULL); /* must not crash */
+ PASS();
+}
+
/* ── Suite ────────────────────────────────────────────────────── */
SUITE(ui) {
@@ -436,4 +509,8 @@ SUITE(ui) {
RUN_TEST(layout_deterministic);
RUN_TEST(layout_to_json);
RUN_TEST(layout_null_inputs);
+
+ /* Hierarchy expansion */
+ RUN_TEST(expand_tree_groups_and_aggregates);
+ RUN_TEST(expand_tree_null_inputs);
}