From dab11a7d31737522e623ce8930d6191c3a3b6a7b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 23 Aug 2026 21:55:07 +0200 Subject: [PATCH 01/52] feat(ui): open the /rpc allowlist to the read-only query surface The graph UI could call only list_projects and get_code_snippet through /rpc; every other tool answered 403 'UI RPC method is not allowed'. That starved the browser of schema, search, tracing, architecture and impact data the server already computes, and broke the Projects tab, which needs get_graph_schema (#1663). Allow the read-only tools (get_graph_schema, search_graph, search_code, trace_path/trace_call_path, get_architecture, query_graph, detect_changes, check_index_coverage, index_status) and manage_adr in its read modes only (default get, sections). A duplicated or non-string mode refuses, so a write cannot ride in behind a read. Mutating tools keep their 403; the loopback-only boundary is unchanged. Fixes #1663. Signed-off-by: Martin Vogel --- src/ui/http_server.c | 54 +++++++++++++++++++++++++++++++++-- tests/test_httpd.c | 68 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 18589d8d8..540a97cb3 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1709,6 +1709,50 @@ static yyjson_val *json_unique_member(yyjson_val *object, const char *name) { return found; } +/* Tools the browser may call through /rpc: the read-only query surface and + * nothing that mutates the index. Writes (index_repository, delete_project, + * ingest_traces, manage_adr updates) stay on the dedicated /api routes or the + * MCP transport, where the daemon's mutation guard applies. CBM Atlas is the + * human's window on the graph; this list is what that window may see. */ +static const char *const UI_RPC_READ_TOOLS[] = { + "list_projects", "get_code_snippet", "get_graph_schema", "search_graph", + "search_code", "trace_path", "trace_call_path", "get_architecture", + "query_graph", "detect_changes", "check_index_coverage", "index_status", +}; + +static bool rpc_tool_is_read_only(const char *name) { + for (size_t i = 0; i < sizeof(UI_RPC_READ_TOOLS) / sizeof(UI_RPC_READ_TOOLS[0]); i++) { + if (strcmp(name, UI_RPC_READ_TOOLS[i]) == 0) + return true; + } + return false; +} + +/* manage_adr is read-only only in its "get" (the default) and "sections" + * modes; anything else writes the document and is refused here. A duplicated + * or non-string "mode" is ambiguous and refused the same way. */ +static bool rpc_manage_adr_is_read(yyjson_val *params) { + yyjson_val *arguments = json_unique_member(params, "arguments"); + if (!arguments) + return true; /* no arguments → default mode "get" */ + if (!yyjson_is_obj(arguments)) + return false; + bool mode_seen = false; + size_t index, maximum; + yyjson_val *key, *value; + yyjson_obj_foreach(arguments, index, maximum, key, value) { + if (strcmp(yyjson_get_str(key), "mode") != 0) + continue; + if (mode_seen || !yyjson_is_str(value)) + return false; + mode_seen = true; + const char *mode_text = yyjson_get_str(value); + if (strcmp(mode_text, "get") != 0 && strcmp(mode_text, "sections") != 0) + return false; + } + return true; +} + static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) { yyjson_doc *document = yyjson_read(body, body_len, 0); if (!document) @@ -1719,9 +1763,13 @@ static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) { yyjson_val *name = json_unique_member(params, "name"); const char *method_text = yyjson_is_str(method) ? yyjson_get_str(method) : NULL; const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL; - bool allowed = - method_text && strcmp(method_text, "tools/call") == 0 && name_text && - (strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_code_snippet") == 0); + bool allowed = false; + if (method_text && strcmp(method_text, "tools/call") == 0 && name_text) { + if (rpc_tool_is_read_only(name_text)) + allowed = true; + else if (strcmp(name_text, "manage_adr") == 0) + allowed = rpc_manage_adr_is_read(params); + } yyjson_doc_free(document); return allowed; } diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 0d2f82402..18a1a5b00 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1086,22 +1086,36 @@ TEST(ui_server_mutations_require_json_content_type) { TEST(ui_server_rpc_allows_only_ui_read_tools) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); - const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"; + /* CBM Atlas reads the graph through /rpc: every read-only query tool must + * pass the allowlist (a 403 on get_graph_schema is #1663). HTTP 200 means + * "allowed and dispatched" — tool-level argument errors still ride inside + * a 200 JSON-RPC envelope. */ + static const char *allowed_tools[] = { + "list_projects", "get_code_snippet", "get_graph_schema", "search_graph", + "search_code", "trace_path", "trace_call_path", "get_architecture", + "query_graph", "detect_changes", "check_index_coverage", "index_status", + }; char req[1024]; - snprintf(req, sizeof(req), - "POST /rpc HTTP/1.1\r\n" - "Content-Type: application/json\r\n" - "Content-Length: %d\r\n\r\n%s", - (int)strlen(body), body); char resp[8192]; - int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); - ASSERT_GT(n, 0); - ASSERT_EQ(th_status(resp), 200); - ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); + int n; + for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) { + char body[512]; + snprintf(body, sizeof(body), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"%s\",\"arguments\":{}}}", + allowed_tools[i]); + snprintf(req, sizeof(req), + "POST /rpc HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n\r\n%s", + (int)strlen(body), body); + n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); + } - static const char *blocked_tools[] = {"delete_project", "manage_adr", "ingest_traces", - "index_repository"}; + static const char *blocked_tools[] = {"delete_project", "ingest_traces", "index_repository"}; for (size_t i = 0; i < sizeof(blocked_tools) / sizeof(blocked_tools[0]); i++) { char blocked_body[512]; snprintf(blocked_body, sizeof(blocked_body), @@ -1117,6 +1131,34 @@ TEST(ui_server_rpc_allows_only_ui_read_tools) { ASSERT_EQ(th_status(resp), 403); } + /* manage_adr: the read modes pass, everything that writes is refused. */ + static const struct { + const char *arguments; + int expected_status; + } adr_cases[] = { + {"{}", 200}, /* no mode → default "get" */ + {"{\"mode\":\"get\"}", 200}, /* explicit read */ + {"{\"mode\":\"sections\"}", 200}, /* header listing */ + {"{\"mode\":\"update\"}", 403}, /* write */ + {"{\"mode\":\"store\"}", 403}, /* legacy write alias */ + {"{\"mode\":1}", 403}, /* non-string mode */ + {"{\"mode\":\"get\",\"mode\":\"update\"}", 403}, /* smuggled duplicate */ + }; + for (size_t i = 0; i < sizeof(adr_cases) / sizeof(adr_cases[0]); i++) { + char adr_body[512]; + snprintf(adr_body, sizeof(adr_body), + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":%s}}", + adr_cases[i].arguments); + snprintf(req, sizeof(req), + "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\n\r\n%s", + strlen(adr_body), adr_body); + n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), adr_cases[i].expected_status); + } + const char *initialize = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\"," "\"params\":{}}"; snprintf(req, sizeof(req), From 499eeba51b316a0e6b5d3fa42078a85d04c52f3b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 23 Aug 2026 22:14:28 +0200 Subject: [PATCH 02/52] feat(ui): region level of detail for the galaxy (level=regions, scope=region:N) The galaxy loaded 'the first max_nodes nodes' of a project, which above ~10k nodes meant freezing the tab (#498 family) or truncating the graph. Give it a real coarsest level instead: one body per REGION. Regions are sets of files. Files are assigned by majority vote of their callables' Leiden communities over the CALLS graph (seeded, deterministic); files no kept community explains fall back to folder groups (first two path components), and a final 'misc' bucket catches the rest. Every node inherits its file's region, all edge types aggregate into region-to-region weights, and each region carries its name's provenance ('call community: 202 files, 38% under tests/db_functions'), cohesion, member/file counts and its top callables by degree. GET /api/layout?project=X&level=regions -> region bodies + weighted arcs GET /api/layout?project=X&scope=region:N -> full detail, that region only Positions come from a golden-angle sphere seed refined by the shared anchored optimizer (extracted to layout_internal.h alongside the new cbm_layout_from_nodes core, so the region scope reuses the exact layout path). Results are cached per (project, indexed_at): on a 48k-node index the region scene computes in ~1.6s cold, <1ms cached; a 4.8k-node region scope lays out in ~0.5s. Signed-off-by: Martin Vogel --- Makefile.cbm | 1 + src/ui/http_server.c | 51 ++ src/ui/layout3d.c | 75 ++- src/ui/layout3d.h | 17 + src/ui/layout_internal.h | 42 ++ src/ui/layout_regions.c | 1142 ++++++++++++++++++++++++++++++++++++++ tests/test_ui.c | 156 ++++++ 7 files changed, 1455 insertions(+), 29 deletions(-) create mode 100644 src/ui/layout_internal.h create mode 100644 src/ui/layout_regions.c diff --git a/Makefile.cbm b/Makefile.cbm index 28eb21e27..0ea461d06 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -387,6 +387,7 @@ UI_SRCS = \ src/ui/config.c \ src/ui/http_server.c \ src/ui/layout3d.c \ + src/ui/layout_regions.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 540a97cb3..c7c3ac744 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1485,6 +1485,57 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } + /* level=regions (CBM Atlas): the coarsest level of detail — one body per + * region instead of one per node. Regions do not mix with the missed + * skeleton or cross-repo satellites; the client asks for those at finer + * levels. */ + char level_str[32] = {0}; + if (cbm_http_query_param(req->query, "level", level_str, (int)sizeof(level_str)) && + strcmp(level_str, "regions") == 0 && !missed_graph) { + char *regions_json = cbm_layout_regions_json(store, scoped_project); + cbm_store_close(store); + if (!regions_json) { + cbm_http_replyf(c, 500, g_cors_json, + "{\"error\":\"region computation failed\"}"); + return; + } + cbm_http_replyf(c, 200, g_cors_json, "%s", regions_json); + free(regions_json); + return; + } + + /* scope=region: — full-detail layout of one region's files only. */ + char scope_str[64] = {0}; + int scope_region = -1; + if (cbm_http_query_param(req->query, "scope", scope_str, (int)sizeof(scope_str)) && + strncmp(scope_str, "region:", 7) == 0) { + scope_region = atoi(scope_str + 7); + if (scope_region < 0) { + cbm_store_close(store); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid scope\"}"); + return; + } + } + if (scope_region >= 0 && !missed_graph) { + cbm_layout_result_t *scoped = + cbm_layout_compute_region(store, scoped_project, scope_region, max_nodes); + cbm_store_close(store); + if (!scoped) { + cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"unknown region\"}"); + return; + } + char *scoped_json = cbm_layout_to_json(scoped); + cbm_layout_free(scoped); + if (!scoped_json) { + cbm_http_replyf(c, 500, g_cors_json, + "{\"error\":\"JSON serialization failed\"}"); + return; + } + cbm_http_replyf(c, 200, g_cors_json, "%s", scoped_json); + free(scoped_json); + return; + } + cbm_layout_result_t *layout = cbm_layout_compute(store, scoped_project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes); diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 3176da2ff..f7802d3f5 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -12,6 +12,7 @@ */ #include "foundation/constants.h" #include "ui/layout3d.h" +#include "ui/layout_internal.h" #include "foundation/log.h" #include @@ -157,7 +158,7 @@ static float size_for_label(const char *label) { return 4.0f; } -static uint32_t fnv1a(const char *s) { +uint32_t cbm_layout_fnv1a(const char *s) { uint32_t h = 2166136261u; if (!s) return h; @@ -168,7 +169,7 @@ static uint32_t fnv1a(const char *s) { return h; } -static float rand_float(uint32_t *seed) { +float cbm_layout_rand_float(uint32_t *seed) { *seed = (*seed) * 1103515245u + 12345u; return (float)((*seed >> 16) & 0x7FFF) / 32768.0f - 0.5f; } @@ -314,18 +315,11 @@ static void octree_repulse(octree_node_t *n, float px, float py, float pz, float octree_repulse(n->children[i], px, py, pz, mm, si, kr, fx, fy, fz); } -/* ── Body with anchor ─────────────────────────────────────────── */ - -typedef struct { - float x, y, z; - float ax, ay, az; /* anchor position (from ring layout) */ - float fx, fy, fz; - float mass; -} body_t; +/* ── Body with anchor — cbm_layout_cbm_layout_body_t in layout_internal.h ── */ /* ── Local optimization (gentle, anchor-preserving) ───────────── */ -static void local_optimize(body_t *b, int n, const int *es, const int *ed, int ne) { +void cbm_layout_local_optimize(cbm_layout_body_t *b, int n, const int *es, const int *ed, int ne) { /* Scale iteration effort down for very large graphs: each iteration is * O(n log n) octree work, and past ~100k bodies the anchor layout already * dominates the visible structure — fewer refinement passes keep huge @@ -514,7 +508,10 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, (void)radius; (void)level; - /* 1. Query nodes */ + /* 1. Query nodes, then hand the structs to the fetch-agnostic core (the + * region scope feeds cbm_layout_from_nodes its own node set). Strings in + * the copied structs stay borrowed from search_out until the core has + * duplicated them into the result. */ cbm_search_params_t params; memset(¶ms, 0, sizeof(params)); params.project = project; @@ -528,8 +525,31 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, return calloc(CBM_ALLOC_ONE, sizeof(cbm_layout_result_t)); int n = search_out.count, total_count = search_out.total; - if (n == 0) { - cbm_store_search_free(&search_out); + cbm_node_t *node_arr = NULL; + if (n > 0) { + node_arr = malloc((size_t)n * sizeof(cbm_node_t)); + if (!node_arr) { + cbm_store_search_free(&search_out); + cbm_layout_result_t *r = calloc(CBM_ALLOC_ONE, sizeof(*r)); + if (r) + r->total_nodes = total_count; + return r; + } + for (int i = 0; i < n; i++) + node_arr[i] = search_out.results[i].node; + } + cbm_layout_result_t *result = cbm_layout_from_nodes(store, project, node_arr, n, total_count); + free(node_arr); + cbm_store_search_free(&search_out); + return result; +} + +cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *project, + const cbm_node_t *nodes, int n, int total_nodes) { + int total_count = total_nodes; + if (!store || !project || n < 0) + return NULL; + if (n == 0 || !nodes) { cbm_layout_result_t *r = calloc(CBM_ALLOC_ONE, sizeof(*r)); if (r) r->total_nodes = total_count; @@ -539,7 +559,6 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* 2. Build sorted node-ID → index map for O(log n) edge filtering */ node_id_entry_t *id_map = malloc((size_t)n * sizeof(node_id_entry_t)); if (!id_map) { - cbm_store_search_free(&search_out); cbm_layout_result_t *r = calloc(CBM_ALLOC_ONE, sizeof(*r)); if (r) { r->total_nodes = total_count; @@ -547,7 +566,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, return r; } for (int i = 0; i < n; i++) { - id_map[i].id = search_out.results[i].node.id; + id_map[i].id = nodes[i].id; id_map[i].idx = i; } qsort(id_map, (size_t)n, sizeof(node_id_entry_t), cmp_node_id_entry); @@ -618,14 +637,14 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, const char **lbls = malloc((size_t)n * sizeof(char *)); if (lbls) { for (int i = 0; i < n; i++) - lbls[i] = search_out.results[i].node.label; + lbls[i] = nodes[i].label; if (cdepth) compute_call_depth(n, es, ed, mapped, lbls, cdepth); free(lbls); } /* 5. Seed positions: ring by directory cluster key + z from call depth */ - body_t *bodies = calloc((size_t)n, sizeof(body_t)); + cbm_layout_body_t *bodies = calloc((size_t)n, sizeof(cbm_layout_body_t)); cbm_layout_result_t *result = calloc(CBM_ALLOC_ONE, sizeof(*result)); if (!result || !bodies) { free(bodies); @@ -635,7 +654,6 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, free(cdepth); cbm_layout_free(result); free_edge_array(all_edges, mapped); - cbm_store_search_free(&search_out); return NULL; } result->nodes = calloc((size_t)n, sizeof(cbm_layout_node_t)); @@ -655,7 +673,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, bool dead_degrees_valid = node_ids && in_calls && in_usage && in_call_reference && deg_dummy; if (dead_degrees_valid) { for (int i = 0; i < n; i++) - node_ids[i] = search_out.results[i].node.id; + node_ids[i] = nodes[i].id; for (int off = 0; off < n; off += DEAD_DEGREE_CHUNK) { int cnt = (n - off < DEAD_DEGREE_CHUNK) ? (n - off) : DEAD_DEGREE_CHUNK; if (cbm_store_batch_count_degrees(store, node_ids + off, cnt, "CALLS", in_calls + off, @@ -672,7 +690,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, } for (int i = 0; i < n; i++) { - const cbm_node_t *sn = &search_out.results[i].node; + const cbm_node_t *sn = &nodes[i]; const char *fp = sn->file_path ? sn->file_path : ""; /* Cluster key = first 3 dir components */ @@ -690,14 +708,14 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, } } - uint32_t h = fnv1a(ck); + uint32_t h = cbm_layout_fnv1a(ck); float angle = ((float)(h & 0xFFFF) / 65535.0f) * 6.2832f; float r = 500.0f + ((float)((h >> 16) & 0xFF) / 255.0f) * 250.0f; - uint32_t seed = fnv1a(sn->qualified_name); + uint32_t seed = cbm_layout_fnv1a(sn->qualified_name); float jitter = 40.0f; - float px = r * cosf(angle) + rand_float(&seed) * jitter; - float py = r * sinf(angle) + rand_float(&seed) * jitter; + float px = r * cosf(angle) + cbm_layout_rand_float(&seed) * jitter; + float py = r * sinf(angle) + cbm_layout_rand_float(&seed) * jitter; float pz = cdepth ? -(float)cdepth[i] * Z_DEPTH_SPACING : 0; bodies[i].x = px; @@ -750,7 +768,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, } /* 6. Gentle local optimization (anchor-preserving) */ - local_optimize(bodies, n, es, ed, mapped); + cbm_layout_local_optimize(bodies, n, es, ed, mapped); /* 7. Copy positions */ for (int i = 0; i < n; i++) { @@ -764,8 +782,8 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, result->edges = calloc((size_t)mapped, sizeof(cbm_layout_edge_t)); result->edge_count = mapped; for (int e = 0; e < mapped && result->edges; e++) { - result->edges[e].source = search_out.results[es[e]].node.id; - result->edges[e].target = search_out.results[ed[e]].node.id; + result->edges[e].source = nodes[es[e]].id; + result->edges[e].target = nodes[ed[e]].id; result->edges[e].type = all_edges[e].type ? strdup(all_edges[e].type) : NULL; } } @@ -781,7 +799,6 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, free(in_call_reference); free(deg_dummy); free_edge_array(all_edges, mapped); - cbm_store_search_free(&search_out); return result; } diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 20303bf64..fc1e9ebaa 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -71,4 +71,21 @@ void cbm_layout_free(cbm_layout_result_t *result); /* Serialize layout result to JSON string. Caller must free(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); +/* ── CBM Atlas region level ───────────────────────────────────── */ + +/* level=regions payload: one body per region (Leiden communities voted per + * file, folder groups as fallback), aggregated region→region edge weights, + * deterministic positions. Cached per (project, indexed_at). Returns a + * heap-allocated JSON document; caller frees. NULL on error. */ +char *cbm_layout_regions_json(cbm_store_t *store, const char *project); + +/* scope=region: — full-detail layout restricted to one region's files. + * total_nodes in the result is the REGION's node count. NULL on error or an + * unknown region id. */ +cbm_layout_result_t *cbm_layout_compute_region(cbm_store_t *store, const char *project, + int region_id, int max_nodes); + +/* Drop the region cache (tests; safe at any time). */ +void cbm_layout_regions_cache_clear(void); + #endif /* CBM_UI_LAYOUT3D_H */ diff --git a/src/ui/layout_internal.h b/src/ui/layout_internal.h new file mode 100644 index 000000000..ddd36d127 --- /dev/null +++ b/src/ui/layout_internal.h @@ -0,0 +1,42 @@ +/* + * layout_internal.h — shared internals of the server-side layout engine. + * + * layout3d.c owns the anchored ForceAtlas2 optimizer and its helpers; + * layout_regions.c (the CBM Atlas region level) reuses them on a much + * smaller body set. Nothing here is public API — src/ui only. + */ +#ifndef CBM_UI_LAYOUT_INTERNAL_H +#define CBM_UI_LAYOUT_INTERNAL_H + +#include "store/store.h" +#include "ui/layout3d.h" + +#include + +/* ── Body with anchor ─────────────────────────────────────────── */ + +typedef struct { + float x, y, z; + float ax, ay, az; /* anchor position (from the seeding pass) */ + float fx, fy, fz; + float mass; +} cbm_layout_body_t; + +/* Gentle anchor-preserving optimization (Barnes-Hut repulsion + edge + * attraction + anchor springs). `es`/`ed` are edge endpoint indices into the + * body array; `ne` is the edge count. */ +void cbm_layout_local_optimize(cbm_layout_body_t *b, int n, const int *es, const int *ed, int ne); + +/* FNV-1a over a NUL-terminated string (NULL-safe). */ +uint32_t cbm_layout_fnv1a(const char *s); + +/* Deterministic [-0.5, 0.5) jitter stream. */ +float cbm_layout_rand_float(uint32_t *seed); + +/* Layout core: seed + optimize + classify an explicit node array. Borrowed + * `nodes` structs (strings are copied into the result). `total_nodes` is the + * project-wide count the caller knows (may exceed n). */ +cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *project, + const cbm_node_t *nodes, int n, int total_nodes); + +#endif /* CBM_UI_LAYOUT_INTERNAL_H */ diff --git a/src/ui/layout_regions.c b/src/ui/layout_regions.c new file mode 100644 index 000000000..caac04520 --- /dev/null +++ b/src/ui/layout_regions.c @@ -0,0 +1,1142 @@ +/* + * layout_regions.c — CBM Atlas region level (level=regions). + * + * The galaxy's coarsest level of detail: one body per REGION instead of one + * per node, so a project of any size opens instantly (#498). A region is a + * set of FILES — every node inherits its file's region — assigned in order + * of preference: + * + * 1. Leiden communities over the CALLS graph (the de-facto architecture), + * voted per file, top communities kept; + * 2. folder groups (first two path components) for files the communities + * do not explain — and for very large graphs where Leiden is skipped; + * 3. a final "misc" bucket so every file belongs somewhere. + * + * Everything here is deterministic (Leiden is seeded, ranking ties break by + * id) and explained: each region carries the reason it got its name. Results + * are cached per (project, indexed_at) so the region scene and the region + * scopes reuse one computation. + */ +#include "foundation/constants.h" +#include "ui/layout_internal.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" + +#include +#include + +#include +#include +#include +#include +#include + +/* ── Tunables ─────────────────────────────────────────────────── */ + +enum { + REGION_LEIDEN_NODE_CAP = 200000, /* above this, folder regions only */ + REGION_MAX_LEIDEN = 24, /* Leiden regions kept (by file count) */ + REGION_MAX_TOTAL = 40, /* hard cap on regions overall */ + REGION_MIN_FILES = 2, /* a Leiden region must span ≥2 files */ + REGION_FOLDER_MIN_FILES = 3, /* a folder group must have ≥3 files */ + REGION_TOP_NODES = 5, /* representative names per region */ + REGION_EDGE_PULL_CAP = 6, /* max layout-edge replication per pair */ + REGION_SEED_RADIUS = 600, +}; + +/* ── Per-file record (interned by file_path) ──────────────────── */ + +typedef struct { + int idx; /* dense file index */ + int region; /* final region id, -1 until assigned */ + int community; /* winning Leiden community, -1 if none */ + int votes; /* votes for the winning community so far */ +} file_rec_t; + +typedef struct { + char *name; /* display name (dominant folder key, or the key itself) */ + char *hub; /* highest-degree member callable (may be NULL) */ + char *why; /* one-line provenance for the name/grouping */ + int files; + long long members; + long long internal_edges; + long long boundary_edges; + char *top_nodes[REGION_TOP_NODES]; + int top_count; +} region_t; + +/* ── Cache: one entry, guarded — the daemon serves one UI ─────── */ + +typedef struct { + char key[1152]; + char *json; /* serialized regions payload */ + int region_count; + char ***region_files; /* per region: owned file_path strings */ + int *region_file_count; + long long total_nodes; +} region_cache_t; + +static region_cache_t g_region_cache; +static pthread_mutex_t g_region_mu = PTHREAD_MUTEX_INITIALIZER; + +static void region_cache_clear_locked(void) { + free(g_region_cache.json); + for (int r = 0; r < g_region_cache.region_count; r++) { + for (int f = 0; f < g_region_cache.region_file_count[r]; f++) + free(g_region_cache.region_files[r][f]); + free(g_region_cache.region_files[r]); + } + free(g_region_cache.region_files); + free(g_region_cache.region_file_count); + memset(&g_region_cache, 0, sizeof(g_region_cache)); +} + +void cbm_layout_regions_cache_clear(void) { + pthread_mutex_lock(&g_region_mu); + region_cache_clear_locked(); + pthread_mutex_unlock(&g_region_mu); +} + +/* Freshness key: the project's indexed_at stamp (updated on every index + * write). A missing projects row hashes as the empty string. */ +static void region_cache_key(cbm_store_t *store, const char *project, char *out, size_t cap) { + char stamp[128] = {0}; + struct sqlite3 *db = cbm_store_get_db(store); + sqlite3_stmt *st = NULL; + if (db && sqlite3_prepare_v2(db, "SELECT indexed_at FROM projects WHERE name=?1", -1, &st, + NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *t = (const char *)sqlite3_column_text(st, 0); + if (t) + snprintf(stamp, sizeof(stamp), "%s", t); + } + sqlite3_finalize(st); + } + snprintf(out, cap, "%s|%s", project, stamp); +} + +/* ── Small helpers ────────────────────────────────────────────── */ + +static char *rg_strdup(const char *s) { + if (!s) + return NULL; + size_t len = strlen(s) + 1; + char *copy = malloc(len); + if (copy) + memcpy(copy, s, len); + return copy; +} + +/* Folder key: the first two path components ("src/pipeline"), one for a + * top-level directory, "." for repo-root files. */ +static void rg_folder_key(const char *path, char *out, size_t cap) { + if (!path || !path[0]) { + snprintf(out, cap, "."); + return; + } + const char *first = strchr(path, '/'); + if (!first) { + snprintf(out, cap, "."); + return; + } + const char *second = strchr(first + 1, '/'); + size_t len = second ? (size_t)(second - path) : (size_t)(first - path); + if (len >= cap) + len = cap - 1; + memcpy(out, path, len); + out[len] = '\0'; +} + +typedef struct { + int64_t id; + int32_t region; +} rg_id_region_t; + +static int rg_id_region_cmp(const void *a, const void *b) { + int64_t ia = ((const rg_id_region_t *)a)->id; + int64_t ib = ((const rg_id_region_t *)b)->id; + return (ia > ib) - (ia < ib); +} + +static int32_t rg_region_of(const rg_id_region_t *map, long long n, int64_t id) { + long long lo = 0, hi = n - 1; + while (lo <= hi) { + long long mid = lo + (hi - lo) / 2; + if (map[mid].id == id) + return map[mid].region; + if (map[mid].id < id) + lo = mid + 1; + else + hi = mid - 1; + } + return -1; +} + +/* HSV(h, 0.62, 0.95) → 0xRRGGBB, hue in degrees. */ +static uint32_t rg_wheel_color(int index, int count) { + float h = count > 0 ? (360.0f * (float)index / (float)count) : 0.0f; + float s = 0.62f, v = 0.95f; + float c = v * s; + float x = c * (1.0f - fabsf(fmodf(h / 60.0f, 2.0f) - 1.0f)); + float m = v - c; + float rf = 0, gf = 0, bf = 0; + int seg = (int)(h / 60.0f) % 6; + switch (seg) { + case 0: rf = c; gf = x; break; + case 1: rf = x; gf = c; break; + case 2: gf = c; bf = x; break; + case 3: gf = x; bf = c; break; + case 4: rf = x; bf = c; break; + default: rf = c; bf = x; break; + } + uint32_t r = (uint32_t)((rf + m) * 255.0f); + uint32_t g = (uint32_t)((gf + m) * 255.0f); + uint32_t b = (uint32_t)((bf + m) * 255.0f); + return (r << 16) | (g << 8) | b; +} + +/* ── Leiden vote: (file_idx, community) pairs → per-file winner ── */ + +typedef struct { + int file_idx; + int community; +} rg_vote_t; + +static int rg_vote_cmp(const void *a, const void *b) { + const rg_vote_t *va = a, *vb = b; + if (va->file_idx != vb->file_idx) + return va->file_idx - vb->file_idx; + return va->community - vb->community; +} + +/* ── Region build ─────────────────────────────────────────────── */ + +typedef struct { + region_t *regions; + int region_count; + long long *edge_w; /* dense region_count × region_count weights */ + rg_id_region_t *node_map; + long long node_map_count; + long long total_nodes; + long long unmapped_nodes; + const char *method; /* "leiden+folders" or "folders" */ + /* per-file bookkeeping for the cache */ + char **file_paths; + file_rec_t **file_recs; + int file_count; +} rg_build_t; + +static void rg_build_free(rg_build_t *b) { + if (!b) + return; + for (int r = 0; r < b->region_count; r++) { + free(b->regions[r].name); + free(b->regions[r].hub); + free(b->regions[r].why); + for (int t = 0; t < b->regions[r].top_count; t++) + free(b->regions[r].top_nodes[t]); + } + free(b->regions); + free(b->edge_w); + free(b->node_map); + for (int f = 0; f < b->file_count; f++) { + free(b->file_paths[f]); + free(b->file_recs[f]); + } + free(b->file_paths); + free(b->file_recs); + memset(b, 0, sizeof(*b)); +} + +/* Intern a file path; returns its record (creating one on first sight). */ +static file_rec_t *rg_file_intern(CBMHashTable *ht, rg_build_t *b, int *cap, const char *path) { + file_rec_t *rec = cbm_ht_get(ht, path); + if (rec) + return rec; + if (b->file_count >= *cap) { + int nc = *cap ? *cap * 2 : 1024; + char **np = realloc(b->file_paths, (size_t)nc * sizeof(char *)); + file_rec_t **nr = realloc(b->file_recs, (size_t)nc * sizeof(file_rec_t *)); + if (!np || !nr) { + free(np ? np : b->file_paths); + b->file_paths = np ? np : NULL; + if (nr) + b->file_recs = nr; + return NULL; + } + b->file_paths = np; + b->file_recs = nr; + *cap = nc; + } + rec = calloc(1, sizeof(*rec)); + char *owned = rg_strdup(path); + if (!rec || !owned) { + free(rec); + free(owned); + return NULL; + } + rec->idx = b->file_count; + rec->region = -1; + rec->community = -1; + b->file_paths[b->file_count] = owned; + b->file_recs[b->file_count] = rec; + b->file_count++; + cbm_ht_set(ht, owned, rec); + return rec; +} + +/* Rank helper: (key index, file count) sorted by count desc, index asc. */ +typedef struct { + int key; + int files; +} rg_rank_t; + +static int rg_rank_cmp(const void *a, const void *b) { + const rg_rank_t *ra = a, *rb = b; + if (rb->files != ra->files) + return rb->files - ra->files; + return ra->key - rb->key; +} + +/* Assign regions to files. Returns 0 on success. Fills b->regions (names, + * files counts, why) and every file's rec->region. */ +static int rg_assign_regions(cbm_store_t *store, const char *project, CBMHashTable *file_ht, + rg_build_t *b) { + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return -1; + + /* 1. Callable nodes → files + Leiden input. */ + int cap = 0; + int64_t *cids = NULL; + int *cfile = NULL; /* file idx per callable */ + char **cnames = NULL; + int cn = 0, ccap = 0; + sqlite3_stmt *st = NULL; + const char *nsql = "SELECT id, name, file_path FROM nodes WHERE project=?1 AND label IN (" + CBM_SQL_CALLABLE_OR_TYPE_LABELS ") AND file_path IS NOT NULL ORDER BY id LIMIT ?2"; + if (sqlite3_prepare_v2(db, nsql, -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 2, REGION_LEIDEN_NODE_CAP + 1); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *path = (const char *)sqlite3_column_text(st, 2); + if (!path || !path[0]) + continue; + file_rec_t *rec = rg_file_intern(file_ht, b, &cap, path); + if (!rec) + break; + if (cn >= ccap) { + int nc = ccap ? ccap * 2 : 4096; + int64_t *ni = realloc(cids, (size_t)nc * sizeof(int64_t)); + int *nf = realloc(cfile, (size_t)nc * sizeof(int)); + char **nn = realloc(cnames, (size_t)nc * sizeof(char *)); + if (!ni || !nf || !nn) { + free(ni ? ni : cids); + free(nf ? nf : cfile); + free(nn ? nn : cnames); + cids = NULL; + cfile = NULL; + cnames = NULL; + cn = 0; + break; + } + cids = ni; + cfile = nf; + cnames = nn; + ccap = nc; + } + cids[cn] = sqlite3_column_int64(st, 0); + cfile[cn] = rec->idx; + cnames[cn] = rg_strdup((const char *)sqlite3_column_text(st, 1)); + cn++; + } + sqlite3_finalize(st); + + bool use_leiden = cn > 0 && cn <= REGION_LEIDEN_NODE_CAP; + int *ccomm = NULL; /* community per callable */ + int community_count = 0; + int *cdeg = calloc(cn > 0 ? (size_t)cn : 1, sizeof(int)); + + if (use_leiden && cdeg) { + /* CALLS edges among the callable set. */ + rg_id_region_t *idmap = malloc((size_t)cn * sizeof(rg_id_region_t)); + cbm_louvain_edge_t *ledges = NULL; + int le = 0, lcap = 0; + if (idmap) { + for (int i = 0; i < cn; i++) { + idmap[i].id = cids[i]; + idmap[i].region = i; + } + qsort(idmap, (size_t)cn, sizeof(rg_id_region_t), rg_id_region_cmp); + sqlite3_stmt *est = NULL; + if (sqlite3_prepare_v2(db, + "SELECT source_id, target_id FROM edges WHERE project=?1 AND " + "type='CALLS'", + -1, &est, NULL) == SQLITE_OK) { + sqlite3_bind_text(est, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(est) == SQLITE_ROW) { + int si = rg_region_of(idmap, cn, sqlite3_column_int64(est, 0)); + int ti = rg_region_of(idmap, cn, sqlite3_column_int64(est, 1)); + if (si < 0 || ti < 0 || si == ti) + continue; + if (le >= lcap) { + int nc = lcap ? lcap * 2 : 4096; + cbm_louvain_edge_t *ne = realloc(ledges, (size_t)nc * sizeof(*ledges)); + if (!ne) + break; + ledges = ne; + lcap = nc; + } + ledges[le].src = cids[si]; + ledges[le].dst = cids[ti]; + le++; + cdeg[si]++; + cdeg[ti]++; + } + sqlite3_finalize(est); + } + } + cbm_louvain_result_t *res = NULL; + int rn = 0; + if (idmap && + cbm_leiden(cids, cn, ledges, le, 1.0, &res, &rn) == CBM_STORE_OK && res && rn == cn) { + ccomm = malloc((size_t)cn * sizeof(int)); + if (ccomm) { + for (int i = 0; i < cn; i++) { + ccomm[i] = res[i].community; + if (ccomm[i] + 1 > community_count) + community_count = ccomm[i] + 1; + } + } + } + free(res); + free(ledges); + free(idmap); + } + + /* 2. Per-file community vote (majority; ties break on lower community). */ + if (ccomm && community_count > 0 && b->file_count > 0) { + rg_vote_t *votes = malloc((size_t)cn * sizeof(rg_vote_t)); + if (votes) { + for (int i = 0; i < cn; i++) { + votes[i].file_idx = cfile[i]; + votes[i].community = ccomm[i]; + } + qsort(votes, (size_t)cn, sizeof(rg_vote_t), rg_vote_cmp); + int i = 0; + while (i < cn) { + int f = votes[i].file_idx; + int best_comm = -1, best_votes = 0; + int j = i; + while (j < cn && votes[j].file_idx == f) { + int comm = votes[j].community; + int k = j; + while (k < cn && votes[k].file_idx == f && votes[k].community == comm) + k++; + if (k - j > best_votes) { + best_votes = k - j; + best_comm = comm; + } + j = k; + } + b->file_recs[f]->community = best_comm; + b->file_recs[f]->votes = best_votes; + i = j; + } + free(votes); + } + } + + /* 3. Community → file counts; keep the biggest as Leiden regions. */ + int *comm_files = community_count > 0 ? calloc((size_t)community_count, sizeof(int)) : NULL; + if (comm_files) { + for (int f = 0; f < b->file_count; f++) + if (b->file_recs[f]->community >= 0) + comm_files[b->file_recs[f]->community]++; + } + int *comm_region = community_count > 0 ? malloc((size_t)community_count * sizeof(int)) : NULL; + if (comm_region) + for (int c = 0; c < community_count; c++) + comm_region[c] = -1; + + region_t *regions = calloc(REGION_MAX_TOTAL, sizeof(region_t)); + int region_count = 0; + if (!regions) { + free(comm_files); + free(comm_region); + for (int i = 0; i < cn; i++) + free(cnames[i]); + free(cnames); + free(cids); + free(cfile); + free(cdeg); + free(ccomm); + return -1; + } + + if (comm_files && comm_region) { + rg_rank_t *rank = malloc((size_t)community_count * sizeof(rg_rank_t)); + if (rank) { + for (int c = 0; c < community_count; c++) + rank[c] = (rg_rank_t){c, comm_files[c]}; + qsort(rank, (size_t)community_count, sizeof(rg_rank_t), rg_rank_cmp); + for (int r = 0; r < community_count && region_count < REGION_MAX_LEIDEN; r++) { + if (rank[r].files < REGION_MIN_FILES) + break; + comm_region[rank[r].key] = region_count; + regions[region_count].files = rank[r].files; + region_count++; + } + free(rank); + } + } + + /* Assign files covered by kept communities. */ + for (int f = 0; f < b->file_count; f++) { + int comm = b->file_recs[f]->community; + if (comm >= 0 && comm_region && comm_region[comm] >= 0) + b->file_recs[f]->region = comm_region[comm]; + } + int leiden_regions = region_count; + + /* 4. Folder groups for everything still unassigned. Files seen so far are + * only those with callables; other files join in the full node pass by + * inheriting their folder group (interned there). Here we pre-create + * folder regions from the already-known unassigned files; the "misc" + * region catches the rest. */ + CBMHashTable *folder_ht = cbm_ht_create(256); /* key → 1-based folder slot */ + char **folder_keys = NULL; + int *folder_files = NULL; + int folder_count = 0, folder_cap = 0; + if (folder_ht) { + for (int f = 0; f < b->file_count; f++) { + if (b->file_recs[f]->region >= 0) + continue; + char key[CBM_SZ_256]; + rg_folder_key(b->file_paths[f], key, sizeof(key)); + void *slot = cbm_ht_get(folder_ht, key); + int fi; + if (!slot) { + if (folder_count >= folder_cap) { + int nc = folder_cap ? folder_cap * 2 : 64; + char **nk = realloc(folder_keys, (size_t)nc * sizeof(char *)); + int *nf = realloc(folder_files, (size_t)nc * sizeof(int)); + if (!nk || !nf) { + free(nk ? nk : folder_keys); + free(nf ? nf : folder_files); + folder_keys = NULL; + folder_files = NULL; + folder_count = 0; + break; + } + folder_keys = nk; + folder_files = nf; + folder_cap = nc; + } + folder_keys[folder_count] = rg_strdup(key); + folder_files[folder_count] = 0; + cbm_ht_set(folder_ht, folder_keys[folder_count], + (void *)(intptr_t)(folder_count + 1)); + fi = folder_count; + folder_count++; + } else { + fi = (int)(intptr_t)slot - 1; + } + folder_files[fi]++; + } + } + if (folder_count > 0) { + rg_rank_t *rank = malloc((size_t)folder_count * sizeof(rg_rank_t)); + if (rank) { + for (int i = 0; i < folder_count; i++) + rank[i] = (rg_rank_t){i, folder_files[i]}; + qsort(rank, (size_t)folder_count, sizeof(rg_rank_t), rg_rank_cmp); + /* folder slot → region id, only for kept groups */ + int *folder_region = malloc((size_t)folder_count * sizeof(int)); + if (folder_region) { + for (int i = 0; i < folder_count; i++) + folder_region[i] = -1; + for (int i = 0; i < folder_count && region_count < REGION_MAX_TOTAL - 1; i++) { + if (rank[i].files < REGION_FOLDER_MIN_FILES) + break; + folder_region[rank[i].key] = region_count; + regions[region_count].files = rank[i].files; + regions[region_count].name = rg_strdup(folder_keys[rank[i].key]); + regions[region_count].why = rg_strdup("folder group (not explained by a kept " + "call community)"); + region_count++; + } + for (int f = 0; f < b->file_count; f++) { + if (b->file_recs[f]->region >= 0) + continue; + char key[CBM_SZ_256]; + rg_folder_key(b->file_paths[f], key, sizeof(key)); + void *slot = cbm_ht_get(folder_ht, key); + if (slot) { + int fr = folder_region[(int)(intptr_t)slot - 1]; + if (fr >= 0) + b->file_recs[f]->region = fr; + } + } + free(folder_region); + } + free(rank); + } + } + for (int i = 0; i < folder_count; i++) + free(folder_keys[i]); + free(folder_keys); + free(folder_files); + cbm_ht_free(folder_ht); + + /* 5. Misc region for whatever is left (created lazily in the node pass + * for files first seen there, too). */ + int misc_region = -1; + for (int f = 0; f < b->file_count; f++) { + if (b->file_recs[f]->region >= 0) + continue; + if (misc_region < 0 && region_count < REGION_MAX_TOTAL) { + misc_region = region_count; + regions[misc_region].name = rg_strdup("misc"); + regions[misc_region].why = rg_strdup("files outside every kept community and folder " + "group"); + region_count++; + } + if (misc_region >= 0) { + b->file_recs[f]->region = misc_region; + regions[misc_region].files++; + } + } + + /* 6. Names for the Leiden regions: dominant folder key (majority of + * member files), with the key strings owned by a local list. */ + for (int r = 0; r < leiden_regions; r++) { + CBMHashTable *dir_ht = cbm_ht_create(64); /* key → count (borrowed keys) */ + char **dir_keys = NULL; + int dir_count = 0, dir_cap = 0; + char *best_key = NULL; + int best = 0; + if (dir_ht) { + for (int f = 0; f < b->file_count; f++) { + if (b->file_recs[f]->region != r) + continue; + char key[CBM_SZ_256]; + rg_folder_key(b->file_paths[f], key, sizeof(key)); + const char *canon = cbm_ht_get_key(dir_ht, key); + if (!canon) { + if (dir_count >= dir_cap) { + int nc = dir_cap ? dir_cap * 2 : 32; + char **nk = realloc(dir_keys, (size_t)nc * sizeof(char *)); + if (!nk) + break; + dir_keys = nk; + dir_cap = nc; + } + dir_keys[dir_count] = rg_strdup(key); + if (!dir_keys[dir_count]) + break; + canon = dir_keys[dir_count]; + dir_count++; + } + int cnt = (int)(intptr_t)cbm_ht_get(dir_ht, canon) + 1; + cbm_ht_set(dir_ht, canon, (void *)(intptr_t)cnt); + if (cnt > best) { + best = cnt; + free(best_key); + best_key = rg_strdup(canon); + } + } + } + regions[r].name = best_key ? best_key : rg_strdup("(cross-cutting)"); + char why[CBM_SZ_512]; + snprintf(why, sizeof(why), "call community: %d files, %d%% under %s", regions[r].files, + regions[r].files > 0 ? best * 100 / regions[r].files : 0, + regions[r].name ? regions[r].name : "?"); + regions[r].why = rg_strdup(why); + for (int d = 0; d < dir_count; d++) + free(dir_keys[d]); + free(dir_keys); + cbm_ht_free(dir_ht); + } + + /* Top-k per region: the k highest-degree callables (name + hub). */ + { + int k = REGION_TOP_NODES; + int *top_deg = malloc((size_t)region_count * (size_t)k * sizeof(int)); + int *top_idx = malloc((size_t)region_count * (size_t)k * sizeof(int)); + if (top_deg && top_idx) { + for (int i = 0; i < region_count * k; i++) { + top_deg[i] = -1; + top_idx[i] = -1; + } + for (int i = 0; i < cn; i++) { + int r = b->file_recs[cfile[i]]->region; + if (r < 0 || r >= region_count) + continue; + int d = cdeg ? cdeg[i] : 0; + int base = r * k; + for (int t = 0; t < k; t++) { + if (d > top_deg[base + t]) { + for (int u = k - 1; u > t; u--) { + top_deg[base + u] = top_deg[base + u - 1]; + top_idx[base + u] = top_idx[base + u - 1]; + } + top_deg[base + t] = d; + top_idx[base + t] = i; + break; + } + } + } + for (int r = 0; r < region_count; r++) { + for (int t = 0; t < k; t++) { + int i = top_idx[r * k + t]; + if (i < 0 || !cnames[i]) + continue; + regions[r].top_nodes[regions[r].top_count++] = rg_strdup(cnames[i]); + } + if (regions[r].top_count > 0 && !regions[r].hub) + regions[r].hub = rg_strdup(regions[r].top_nodes[0]); + } + } + free(top_deg); + free(top_idx); + } + + for (int i = 0; i < cn; i++) + free(cnames[i]); + free(cnames); + free(cids); + free(cfile); + free(cdeg); + free(ccomm); + free(comm_files); + free(comm_region); + + b->regions = regions; + b->region_count = region_count; + b->method = use_leiden ? "leiden+folders" : "folders"; + return 0; +} + +/* Full node pass: map every node to a region via its file; count members. + * Files not seen in the callable pass join folder/misc regions here. */ +static int rg_map_all_nodes(cbm_store_t *store, const char *project, CBMHashTable *file_ht, + rg_build_t *b) { + struct sqlite3 *db = cbm_store_get_db(store); + sqlite3_stmt *st = NULL; + if (!db || sqlite3_prepare_v2(db, "SELECT id, file_path FROM nodes WHERE project=?1", -1, &st, + NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + + /* folder key → region id for late files (folder regions + misc). */ + CBMHashTable *late_ht = cbm_ht_create(128); + for (int r = 0; r < b->region_count; r++) { + /* Folder regions are addressable by their name (the key). Leiden + * region names may collide with folder keys; folder regions were + * created later, so set them last — but only map names that ARE + * folder keys (why starts with "folder group"). */ + if (b->regions[r].why && strncmp(b->regions[r].why, "folder group", 12) == 0) + cbm_ht_set(late_ht, b->regions[r].name, (void *)(intptr_t)(r + 1)); + } + int misc_region = -1; + for (int r = 0; r < b->region_count; r++) + if (b->regions[r].name && strcmp(b->regions[r].name, "misc") == 0) + misc_region = r; + + long long cap = 0, n = 0; + rg_id_region_t *map = NULL; + int file_cap_local = b->file_count; /* interning may grow the arrays */ + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t id = sqlite3_column_int64(st, 0); + const char *path = (const char *)sqlite3_column_text(st, 1); + int region = -1; + if (path && path[0]) { + file_rec_t *rec = cbm_ht_get(file_ht, path); + if (!rec) { + rec = rg_file_intern(file_ht, b, &file_cap_local, path); + if (rec) { + char key[CBM_SZ_256]; + rg_folder_key(path, key, sizeof(key)); + void *slot = cbm_ht_get(late_ht, key); + if (slot) + rec->region = (int)(intptr_t)slot - 1; + else if (misc_region >= 0) + rec->region = misc_region; + if (rec->region >= 0) + b->regions[rec->region].files++; + } + } + if (rec) + region = rec->region; + } + if (n >= cap) { + long long nc = cap ? cap * 2 : 65536; + rg_id_region_t *nm = realloc(map, (size_t)nc * sizeof(rg_id_region_t)); + if (!nm) + break; + map = nm; + cap = nc; + } + map[n].id = id; + map[n].region = region; + n++; + if (region >= 0) + b->regions[region].members++; + else + b->unmapped_nodes++; + } + sqlite3_finalize(st); + cbm_ht_free(late_ht); + if (map) + qsort(map, (size_t)n, sizeof(rg_id_region_t), rg_id_region_cmp); + b->node_map = map; + b->node_map_count = n; + b->total_nodes = n; + return map || n == 0 ? 0 : -1; +} + +/* Edge pass: aggregate weights between regions across ALL edge types. */ +static int rg_aggregate_edges(cbm_store_t *store, const char *project, rg_build_t *b) { + int rc2 = b->region_count * b->region_count; + b->edge_w = calloc(rc2 > 0 ? (size_t)rc2 : 1, sizeof(long long)); + if (!b->edge_w) + return -1; + struct sqlite3 *db = cbm_store_get_db(store); + sqlite3_stmt *st = NULL; + if (!db || sqlite3_prepare_v2(db, "SELECT source_id, target_id FROM edges WHERE project=?1", + -1, &st, NULL) != SQLITE_OK) + return -1; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + int rs = rg_region_of(b->node_map, b->node_map_count, sqlite3_column_int64(st, 0)); + int rt = rg_region_of(b->node_map, b->node_map_count, sqlite3_column_int64(st, 1)); + if (rs < 0 || rt < 0) + continue; + if (rs == rt) { + b->regions[rs].internal_edges++; + } else { + b->regions[rs].boundary_edges++; + b->regions[rt].boundary_edges++; + b->edge_w[rs * b->region_count + rt]++; + } + } + sqlite3_finalize(st); + return 0; +} + +/* Layout: golden-angle sphere seed + the shared optimizer, edges replicated + * by log2(weight) so heavy couplings pull harder. Deterministic. */ +static void rg_layout(rg_build_t *b, float *out_xyz, float *out_size) { + int rc = b->region_count; + cbm_layout_body_t *bodies = calloc(rc > 0 ? (size_t)rc : 1, sizeof(cbm_layout_body_t)); + if (!bodies) + return; + long long max_members = 1; + for (int r = 0; r < rc; r++) + if (b->regions[r].members > max_members) + max_members = b->regions[r].members; + const float golden = 2.39996323f; /* golden angle in radians */ + for (int r = 0; r < rc; r++) { + float t = rc > 1 ? (float)r / (float)(rc - 1) : 0.5f; + float inclination = acosf(1.0f - 2.0f * t); + float azimuth = golden * (float)r; + float radius = (float)REGION_SEED_RADIUS; + bodies[r].x = radius * sinf(inclination) * cosf(azimuth); + bodies[r].y = radius * sinf(inclination) * sinf(azimuth); + bodies[r].z = radius * cosf(inclination); + bodies[r].ax = bodies[r].x; + bodies[r].ay = bodies[r].y; + bodies[r].az = bodies[r].z; + bodies[r].mass = (float)(1.0 + sqrt((double)b->regions[r].members)); + } + /* Replicated edge list. */ + int ecap = 0, ne = 0; + int *es = NULL, *ed = NULL; + for (int s = 0; s < rc; s++) { + for (int t2 = 0; t2 < rc; t2++) { + long long w = b->edge_w[s * rc + t2]; + if (w <= 0) + continue; + int rep = 1; + while ((1LL << rep) < w && rep < REGION_EDGE_PULL_CAP) + rep++; + for (int k = 0; k < rep; k++) { + if (ne >= ecap) { + int nc = ecap ? ecap * 2 : 256; + int *ns = realloc(es, (size_t)nc * sizeof(int)); + int *nd = realloc(ed, (size_t)nc * sizeof(int)); + if (!ns || !nd) { + free(ns ? ns : es); + free(nd ? nd : ed); + es = NULL; + ed = NULL; + ne = 0; + goto layout; + } + es = ns; + ed = nd; + ecap = nc; + } + es[ne] = s; + ed[ne] = t2; + ne++; + } + } + } +layout: + if (es && ed) + cbm_layout_local_optimize(bodies, rc, es, ed, ne); + for (int r = 0; r < rc; r++) { + out_xyz[r * 3 + 0] = bodies[r].x; + out_xyz[r * 3 + 1] = bodies[r].y; + out_xyz[r * 3 + 2] = bodies[r].z; + double frac = max_members > 0 ? (double)b->regions[r].members / (double)max_members : 0.0; + out_size[r] = 14.0f + (float)(sqrt(frac) * 46.0); + } + free(es); + free(ed); + free(bodies); +} + +/* Serialize the build to the level=regions payload. */ +static char *rg_to_json(const rg_build_t *b, const float *xyz, const float *size) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) + return NULL; + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "level", "regions"); + yyjson_mut_obj_add_str(doc, root, "method", b->method); + yyjson_mut_obj_add_int(doc, root, "total_nodes", b->total_nodes); + yyjson_mut_obj_add_int(doc, root, "unmapped_nodes", b->unmapped_nodes); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int r = 0; r < b->region_count; r++) { + const region_t *rg = &b->regions[r]; + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, o, "id", r); + yyjson_mut_obj_add_strcpy(doc, o, "name", rg->name ? rg->name : "?"); + if (rg->hub) + yyjson_mut_obj_add_strcpy(doc, o, "hub", rg->hub); + if (rg->why) + yyjson_mut_obj_add_strcpy(doc, o, "why", rg->why); + yyjson_mut_obj_add_int(doc, o, "files", rg->files); + yyjson_mut_obj_add_int(doc, o, "members", rg->members); + double denom = (double)rg->internal_edges + (double)rg->boundary_edges; + yyjson_mut_obj_add_real(doc, o, "cohesion", + denom > 0 ? (double)rg->internal_edges / denom : 0.0); + yyjson_mut_val *tops = yyjson_mut_arr(doc); + for (int t = 0; t < rg->top_count; t++) + yyjson_mut_arr_add_strcpy(doc, tops, rg->top_nodes[t]); + yyjson_mut_obj_add_val(doc, o, "top_nodes", tops); + yyjson_mut_obj_add_real(doc, o, "x", xyz[r * 3 + 0]); + yyjson_mut_obj_add_real(doc, o, "y", xyz[r * 3 + 1]); + yyjson_mut_obj_add_real(doc, o, "z", xyz[r * 3 + 2]); + yyjson_mut_obj_add_real(doc, o, "size", size[r]); + char color[8]; + snprintf(color, sizeof(color), "#%06x", rg_wheel_color(r, b->region_count)); + yyjson_mut_obj_add_strcpy(doc, o, "color", color); + yyjson_mut_arr_append(arr, o); + } + yyjson_mut_obj_add_val(doc, root, "regions", arr); + yyjson_mut_val *earr = yyjson_mut_arr(doc); + for (int s = 0; s < b->region_count; s++) { + for (int t = 0; t < b->region_count; t++) { + long long w = b->edge_w[s * b->region_count + t]; + if (w <= 0) + continue; + yyjson_mut_val *e = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, e, "source", s); + yyjson_mut_obj_add_int(doc, e, "target", t); + yyjson_mut_obj_add_int(doc, e, "weight", w); + yyjson_mut_arr_append(earr, e); + } + } + yyjson_mut_obj_add_val(doc, root, "edges", earr); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +/* Build everything and refresh the cache. Caller holds g_region_mu. */ +static int rg_rebuild_locked(cbm_store_t *store, const char *project, const char *key) { + rg_build_t b; + memset(&b, 0, sizeof(b)); + CBMHashTable *file_ht = cbm_ht_create(4096); + if (!file_ht) + return -1; + int rc = rg_assign_regions(store, project, file_ht, &b); + if (rc == 0) + rc = rg_map_all_nodes(store, project, file_ht, &b); + if (rc == 0) + rc = rg_aggregate_edges(store, project, &b); + char *json = NULL; + if (rc == 0) { + float *xyz = calloc(b.region_count > 0 ? (size_t)b.region_count * 3 : 1, sizeof(float)); + float *size = calloc(b.region_count > 0 ? (size_t)b.region_count : 1, sizeof(float)); + if (xyz && size) { + rg_layout(&b, xyz, size); + json = rg_to_json(&b, xyz, size); + } + free(xyz); + free(size); + } + if (!json) { + rg_build_free(&b); + cbm_ht_free(file_ht); + return -1; + } + + /* Refresh the cache: JSON + per-region file lists for scope queries. */ + region_cache_clear_locked(); + snprintf(g_region_cache.key, sizeof(g_region_cache.key), "%s", key); + g_region_cache.json = json; + g_region_cache.region_count = b.region_count; + g_region_cache.total_nodes = b.total_nodes; + g_region_cache.region_files = calloc(b.region_count > 0 ? (size_t)b.region_count : 1, + sizeof(char **)); + g_region_cache.region_file_count = calloc(b.region_count > 0 ? (size_t)b.region_count : 1, + sizeof(int)); + if (g_region_cache.region_files && g_region_cache.region_file_count) { + for (int f = 0; f < b.file_count; f++) { + int r = b.file_recs[f]->region; + if (r < 0 || r >= b.region_count) + continue; + int idx = g_region_cache.region_file_count[r]; + char **grown = realloc(g_region_cache.region_files[r], + ((size_t)idx + 1) * sizeof(char *)); + if (!grown) + continue; + g_region_cache.region_files[r] = grown; + g_region_cache.region_files[r][idx] = rg_strdup(b.file_paths[f]); + if (g_region_cache.region_files[r][idx]) + g_region_cache.region_file_count[r] = idx + 1; + } + } + rg_build_free(&b); + cbm_ht_free(file_ht); + return 0; +} + +static bool rg_cache_fresh_locked(const char *key) { + return g_region_cache.json && strcmp(g_region_cache.key, key) == 0; +} + +char *cbm_layout_regions_json(cbm_store_t *store, const char *project) { + if (!store || !project) + return NULL; + char key[1152]; + region_cache_key(store, project, key, sizeof(key)); + pthread_mutex_lock(&g_region_mu); + if (!rg_cache_fresh_locked(key)) { + if (rg_rebuild_locked(store, project, key) != 0) { + pthread_mutex_unlock(&g_region_mu); + return NULL; + } + } + char *copy = rg_strdup(g_region_cache.json); + pthread_mutex_unlock(&g_region_mu); + return copy; +} + +/* ── Region scope: full-detail layout of one region's nodes ───── */ + +enum { RG_SCOPE_IN_CHUNK = 200 }; + +cbm_layout_result_t *cbm_layout_compute_region(cbm_store_t *store, const char *project, + int region_id, int max_nodes) { + if (!store || !project || region_id < 0) + return NULL; + char key[1152]; + region_cache_key(store, project, key, sizeof(key)); + pthread_mutex_lock(&g_region_mu); + if (!rg_cache_fresh_locked(key) && rg_rebuild_locked(store, project, key) != 0) { + pthread_mutex_unlock(&g_region_mu); + return NULL; + } + if (region_id >= g_region_cache.region_count) { + pthread_mutex_unlock(&g_region_mu); + return NULL; + } + /* Copy the file list out so the store queries run unlocked. */ + int file_count = g_region_cache.region_file_count[region_id]; + char **files = calloc(file_count > 0 ? (size_t)file_count : 1, sizeof(char *)); + if (!files) { + pthread_mutex_unlock(&g_region_mu); + return NULL; + } + for (int f = 0; f < file_count; f++) + files[f] = rg_strdup(g_region_cache.region_files[region_id][f]); + pthread_mutex_unlock(&g_region_mu); + + if (max_nodes <= 0) + max_nodes = 5000; + + /* Fetch nodes file-chunk by file-chunk. */ + struct sqlite3 *db = cbm_store_get_db(store); + cbm_node_t *nodes = NULL; + int n = 0, cap = 0; + long long region_total = 0; + if (db) { + for (int off = 0; off < file_count; off += RG_SCOPE_IN_CHUNK) { + int cnt = file_count - off < RG_SCOPE_IN_CHUNK ? file_count - off : RG_SCOPE_IN_CHUNK; + char sql[8192]; + int pos = snprintf(sql, sizeof(sql), + "SELECT id, label, name, qualified_name, file_path, start_line, " + "end_line, properties FROM nodes WHERE project=?1 AND file_path " + "IN ("); + for (int i = 0; i < cnt && pos < (int)sizeof(sql) - 8; i++) + pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", + i + 2); + snprintf(sql + pos, sizeof(sql) - (size_t)pos, ") ORDER BY id"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) + break; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + for (int i = 0; i < cnt; i++) + sqlite3_bind_text(st, i + 2, files[off + i], -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + region_total++; + if (n >= max_nodes) + continue; /* keep counting the region total */ + if (n >= cap) { + int nc = cap ? cap * 2 : 1024; + cbm_node_t *nn = realloc(nodes, (size_t)nc * sizeof(cbm_node_t)); + if (!nn) + break; + nodes = nn; + cap = nc; + } + memset(&nodes[n], 0, sizeof(cbm_node_t)); + nodes[n].id = sqlite3_column_int64(st, 0); + nodes[n].project = project; + nodes[n].label = rg_strdup((const char *)sqlite3_column_text(st, 1)); + nodes[n].name = rg_strdup((const char *)sqlite3_column_text(st, 2)); + nodes[n].qualified_name = rg_strdup((const char *)sqlite3_column_text(st, 3)); + nodes[n].file_path = rg_strdup((const char *)sqlite3_column_text(st, 4)); + nodes[n].start_line = sqlite3_column_int(st, 5); + nodes[n].end_line = sqlite3_column_int(st, 6); + nodes[n].properties_json = rg_strdup((const char *)sqlite3_column_text(st, 7)); + n++; + } + sqlite3_finalize(st); + } + } + for (int f = 0; f < file_count; f++) + free(files[f]); + free(files); + + cbm_layout_result_t *result = + cbm_layout_from_nodes(store, project, nodes, n, (int)region_total); + for (int i = 0; i < n; i++) { + free((void *)nodes[i].label); + free((void *)nodes[i].name); + free((void *)nodes[i].qualified_name); + free((void *)nodes[i].file_path); + free((void *)nodes[i].properties_json); + } + free(nodes); + return result; +} diff --git a/tests/test_ui.c b/tests/test_ui.c index a403dbbbe..6967cecd0 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -11,6 +11,8 @@ #include "ui/embedded_assets.h" #include "ui/layout3d.h" #include "store/store.h" + +#include #ifdef _WIN32 #include "foundation/win_utf8.h" #endif @@ -859,6 +861,156 @@ TEST(layout_coincident_nodes_bounded) { #endif } + +/* ── CBM Atlas region level ───────────────────────────────────── */ + +/* Two call communities in two folders, one cross call. The region level must + * find both, name them by their dominant folder, aggregate the cross edge, + * and lay them out deterministically. */ +static cbm_store_t *regions_fixture(void) { + cbm_store_t *store = cbm_store_open_memory(); + if (!store) + return NULL; + cbm_store_upsert_project(store, "regions-test", "/tmp/regions-test"); + static const struct { + const char *name; + const char *file; + } defs[] = { + {"alpha_one", "src/alpha/a1.c"}, {"alpha_two", "src/alpha/a1.c"}, + {"alpha_three", "src/alpha/a2.c"}, {"beta_one", "src/beta/b1.c"}, + {"beta_two", "src/beta/b1.c"}, {"beta_three", "src/beta/b2.c"}, + }; + int64_t ids[6]; + for (int i = 0; i < 6; i++) { + cbm_node_t node; + memset(&node, 0, sizeof(node)); + node.project = "regions-test"; + node.label = "Function"; + node.name = defs[i].name; + char qn[128]; + snprintf(qn, sizeof(qn), "regions-test::%s", defs[i].name); + node.qualified_name = qn; + node.file_path = defs[i].file; + node.start_line = 1; + node.end_line = 5; + ids[i] = cbm_store_upsert_node(store, &node); + } + /* Dense intra-community calls, one cross call alpha_one → beta_one. */ + static const int pairs[][2] = {{0, 1}, {1, 2}, {2, 0}, {0, 2}, + {3, 4}, {4, 5}, {5, 3}, {3, 5}, {0, 3}}; + for (size_t e = 0; e < sizeof(pairs) / sizeof(pairs[0]); e++) { + cbm_edge_t edge; + memset(&edge, 0, sizeof(edge)); + edge.project = "regions-test"; + edge.source_id = ids[pairs[e][0]]; + edge.target_id = ids[pairs[e][1]]; + edge.type = "CALLS"; + cbm_store_insert_edge(store, &edge); + } + return store; +} + +TEST(layout_regions_two_communities) { + cbm_layout_regions_cache_clear(); + cbm_store_t *store = regions_fixture(); + ASSERT_NOT_NULL(store); + + char *json = cbm_layout_regions_json(store, "regions-test"); + ASSERT_NOT_NULL(json); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "level")), "regions"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "method")), "leiden+folders"); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(root, "total_nodes")), 6); + + yyjson_val *regions = yyjson_obj_get(root, "regions"); + ASSERT_EQ((int)yyjson_arr_size(regions), 2); + bool saw_alpha = false, saw_beta = false; + size_t idx, max; + yyjson_val *rv; + yyjson_arr_foreach(regions, idx, max, rv) { + const char *name = yyjson_get_str(yyjson_obj_get(rv, "name")); + ASSERT_NOT_NULL(name); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(rv, "files")), 2); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(rv, "members")), 3); + ASSERT_NOT_NULL(yyjson_obj_get(rv, "why")); + ASSERT_NOT_NULL(yyjson_obj_get(rv, "x")); + yyjson_val *tops = yyjson_obj_get(rv, "top_nodes"); + ASSERT_GT((int)yyjson_arr_size(tops), 0); + if (strcmp(name, "src/alpha") == 0) + saw_alpha = true; + if (strcmp(name, "src/beta") == 0) + saw_beta = true; + } + ASSERT_TRUE(saw_alpha); + ASSERT_TRUE(saw_beta); + + /* Exactly one aggregated cross edge with weight 1. */ + yyjson_val *edges = yyjson_obj_get(root, "edges"); + ASSERT_EQ((int)yyjson_arr_size(edges), 1); + yyjson_val *edge = yyjson_arr_get(edges, 0); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(edge, "weight")), 1); + + /* Deterministic: a second computation (fresh cache) is byte-identical. */ + cbm_layout_regions_cache_clear(); + char *json2 = cbm_layout_regions_json(store, "regions-test"); + ASSERT_NOT_NULL(json2); + ASSERT_STR_EQ(json, json2); + + free(json2); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + cbm_layout_regions_cache_clear(); + PASS(); +} + +TEST(layout_regions_scope_restricts_nodes) { + cbm_layout_regions_cache_clear(); + cbm_store_t *store = regions_fixture(); + ASSERT_NOT_NULL(store); + + /* Find the region id whose name is src/alpha. */ + char *json = cbm_layout_regions_json(store, "regions-test"); + ASSERT_NOT_NULL(json); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + int alpha_id = -1; + yyjson_val *regions = yyjson_obj_get(yyjson_doc_get_root(doc), "regions"); + size_t idx, max; + yyjson_val *rv; + yyjson_arr_foreach(regions, idx, max, rv) { + const char *name = yyjson_get_str(yyjson_obj_get(rv, "name")); + if (name && strcmp(name, "src/alpha") == 0) + alpha_id = (int)yyjson_get_int(yyjson_obj_get(rv, "id")); + } + yyjson_doc_free(doc); + free(json); + ASSERT_GT(alpha_id, -1); + + cbm_layout_result_t *r = cbm_layout_compute_region(store, "regions-test", alpha_id, 100); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->node_count, 3); + ASSERT_EQ(r->total_nodes, 3); + for (int i = 0; i < r->node_count; i++) + ASSERT_NOT_NULL(strstr(r->nodes[i].file_path, "src/alpha/")); + /* Only intra-region edges: the cross call to beta is not present. */ + for (int e = 0; e < r->edge_count; e++) + for (int i = 0; i < r->node_count; i++) + if (r->edges[e].target == r->nodes[i].id) + ASSERT_NOT_NULL(strstr(r->nodes[i].file_path, "src/alpha/")); + cbm_layout_free(r); + + /* Unknown region id → NULL, not a crash. */ + ASSERT_NULL(cbm_layout_compute_region(store, "regions-test", 99, 100)); + + cbm_store_close(store); + cbm_layout_regions_cache_clear(); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -886,4 +1038,8 @@ SUITE(ui) { RUN_TEST(layout_null_inputs); RUN_TEST(layout_dead_code_classification); RUN_TEST(layout_coincident_nodes_bounded); + + /* CBM Atlas region level */ + RUN_TEST(layout_regions_two_communities); + RUN_TEST(layout_regions_scope_restricts_nodes); } From bbbc0153041daeadf487f17ce7ed7689476c9a91 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 23 Aug 2026 22:22:56 +0200 Subject: [PATCH 03/52] feat(ui): CBM Atlas region scene, deep links, sidebar selection, uncapped connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph tab becomes CBM Atlas and gains the region level as its default for large projects: - Projects above 5,000 nodes open on the REGION SCENE: one body per Leiden call community (folder groups as fallback), weighted arcs between them, a region list on the left and a region panel (provenance, cohesion, top callables) on the right. Double-click or 'Open region' loads that region's full-detail layout (scope=region:N) with a '< Regions' breadcrumb; 'Load full galaxy' keeps the classic whole-graph view one click away. Small projects skip the region hop entirely. - Deep links (#564): ?node= and ?region= join ?tab=&project= — a selected node or opened region survives reload and back/forward, and can be shared. - #1197: clicking a symbol row in the sidebar (tree or search) selects the node itself — scene highlight AND detail panel; selecting a directory clears a stale panel instead of leaving the previous node in it. - #1198: connection lists page with 'Show N more' instead of a silent cap of 25. - Rename: the UI announces itself as CBM Atlas (header + title). Signed-off-by: Martin Vogel --- graph-ui/index.html | 2 +- graph-ui/src/App.tsx | 61 +++- .../src/components/GraphTab.deadcode.test.tsx | 14 + .../src/components/GraphTab.filters.test.tsx | 14 + .../src/components/GraphTab.regions.test.tsx | 148 +++++++++ graph-ui/src/components/GraphTab.tsx | 293 +++++++++++++++++- .../NodeDetailPanel.showmore.test.tsx | 45 +++ graph-ui/src/components/NodeDetailPanel.tsx | 64 ++-- graph-ui/src/components/RegionPanel.tsx | 94 ++++++ graph-ui/src/components/Sidebar.tsx | 16 +- graph-ui/src/hooks/useGraphData.ts | 25 +- graph-ui/src/lib/regions.test.ts | 68 ++++ graph-ui/src/lib/regions.ts | 59 ++++ graph-ui/src/lib/types.ts | 33 ++ graph-ui/tsconfig.tsbuildinfo | 2 +- 15 files changed, 884 insertions(+), 54 deletions(-) create mode 100644 graph-ui/src/components/GraphTab.regions.test.tsx create mode 100644 graph-ui/src/components/NodeDetailPanel.showmore.test.tsx create mode 100644 graph-ui/src/components/RegionPanel.tsx create mode 100644 graph-ui/src/lib/regions.test.ts create mode 100644 graph-ui/src/lib/regions.ts diff --git a/graph-ui/index.html b/graph-ui/index.html index e20a6503b..a1cf9a24c 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -3,7 +3,7 @@ - Codebase Memory — Graph + CBM Atlas
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 18392667c..2cd1b72ee 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -10,6 +10,10 @@ const TAB_IDS: TabId[] = ["graph", "stats", "control"]; interface RouteState { tab: TabId; project: string | null; + /* Deep links: a selected node and/or an opened region survive reloads and + * can be shared (#564). Values are the server-assigned ids. */ + node: string | null; + region: string | null; } /* Read the active tab + selected project from the URL query string so the @@ -19,14 +23,28 @@ function readRoute(): RouteState { const rawTab = params.get("tab"); const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats"; const project = params.get("project"); - return { tab, project: project ? project : null }; + const node = params.get("node"); + const region = params.get("region"); + return { + tab, + project: project ? project : null, + node: node ? node : null, + region: region ? region : null, + }; } /* Build the canonical URL for a route, preserving the path and hash. */ -function routeUrl(tab: TabId, project: string | null): string { +function routeUrl( + tab: TabId, + project: string | null, + node?: string | null, + region?: string | null, +): string { const params = new URLSearchParams(); params.set("tab", tab); if (project) params.set("project", project); + if (region) params.set("region", region); + if (node) params.set("node", node); return `${window.location.pathname}?${params.toString()}${window.location.hash}`; } @@ -38,7 +56,11 @@ export function App() { /* Normalize the URL on first load so it always carries the current route. */ useEffect(() => { const initial = readRoute(); - window.history.replaceState(null, "", routeUrl(initial.tab, initial.project)); + window.history.replaceState( + null, + "", + routeUrl(initial.tab, initial.project, initial.node, initial.region), + ); }, []); /* Sync state when the user navigates with the browser back/forward buttons. */ @@ -49,13 +71,21 @@ export function App() { }, []); /* Change the route and push a history entry (skips no-op navigations). */ - const navigate = useCallback((tab: TabId, project: string | null) => { - const url = routeUrl(tab, project); - const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (url === current) return; - window.history.pushState(null, "", url); - setRoute({ tab, project }); - }, []); + const navigate = useCallback( + ( + tab: TabId, + project: string | null, + node: string | null = null, + region: string | null = null, + ) => { + const url = routeUrl(tab, project, node, region); + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (url === current) return; + window.history.pushState(null, "", url); + setRoute({ tab, project, node, region }); + }, + [], + ); const tabs: { id: TabId; label: string }[] = [ { id: "graph", label: t.tabs.graph }, @@ -71,7 +101,7 @@ export function App() {
- Codebase Memory + CBM Atlas
@@ -121,7 +151,14 @@ export function App() { {/* Content */}
{activeTab === "graph" ? ( - + + navigate("graph", selectedProject, node, region) + } + /> ) : activeTab === "control" ? ( ) : ( diff --git a/graph-ui/src/components/GraphTab.deadcode.test.tsx b/graph-ui/src/components/GraphTab.deadcode.test.tsx index 43f880f30..285600188 100644 --- a/graph-ui/src/components/GraphTab.deadcode.test.tsx +++ b/graph-ui/src/components/GraphTab.deadcode.test.tsx @@ -30,6 +30,20 @@ function mockLayoutFetch(data: GraphData) { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/layout")) { + /* The region probe answers first: a small project — no region scene. */ + if (url.includes("level=regions")) { + return new Response( + JSON.stringify({ + level: "regions", + method: "folders", + total_nodes: data.total_nodes, + unmapped_nodes: 0, + regions: [], + edges: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/graph-ui/src/components/GraphTab.filters.test.tsx b/graph-ui/src/components/GraphTab.filters.test.tsx index 9ee22f210..bba467595 100644 --- a/graph-ui/src/components/GraphTab.filters.test.tsx +++ b/graph-ui/src/components/GraphTab.filters.test.tsx @@ -24,6 +24,20 @@ function mockLayoutFetch(data: GraphData) { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/layout")) { + /* The region probe answers first: a small project — no region scene. */ + if (url.includes("level=regions")) { + return new Response( + JSON.stringify({ + level: "regions", + method: "folders", + total_nodes: data.total_nodes, + unmapped_nodes: 0, + regions: [], + edges: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/graph-ui/src/components/GraphTab.regions.test.tsx b/graph-ui/src/components/GraphTab.regions.test.tsx new file mode 100644 index 000000000..ae21bb108 --- /dev/null +++ b/graph-ui/src/components/GraphTab.regions.test.tsx @@ -0,0 +1,148 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphTab } from "./GraphTab"; +import type { GraphData, RegionsPayload } from "../lib/types"; + +vi.mock("./GraphScene", () => ({ + GraphScene: () => null, + computeCameraTarget: () => null, +})); + +const REGIONS: RegionsPayload = { + level: "regions", + method: "leiden+folders", + total_nodes: 48000, + unmapped_nodes: 12, + regions: [ + { + id: 0, + name: "src/alpha", + hub: "alpha_one", + why: "call community: 2 files, 100% under src/alpha", + files: 2, + members: 24000, + cohesion: 0.8, + top_nodes: ["alpha_one", "alpha_two"], + x: 0, + y: 0, + z: 0, + size: 60, + color: "#22aa88", + }, + { + id: 1, + name: "src/beta", + hub: "beta_one", + why: "call community: 2 files, 100% under src/beta", + files: 2, + members: 24000, + cohesion: 0.7, + top_nodes: ["beta_one"], + x: 100, + y: 0, + z: 0, + size: 60, + color: "#8822aa", + }, + ], + edges: [{ source: 0, target: 1, weight: 4 }], +}; + +const SCOPED: GraphData = { + nodes: [ + { + id: 7, x: 0, y: 0, z: 0, label: "Function", name: "alpha_one", + file_path: "src/alpha/a1.c", size: 1, color: "#fff", + }, + ], + edges: [], + total_nodes: 1, +}; + +function mockAtlasFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/layout")) { + if (url.includes("level=regions")) { + return new Response(JSON.stringify(REGIONS), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify(SCOPED), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("GraphTab region level", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens large projects on the region scene, not the full galaxy", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + /* The region list is the left panel; both regions are listed. */ + expect(await screen.findByText("src/alpha")).toBeInTheDocument(); + expect(screen.getByText("src/beta")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Load full galaxy" })).toBeInTheDocument(); + + /* No full layout was fetched — only the region probe. */ + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("level=regions"))).toBe(true); + expect(urls.some((u) => u.includes("max_nodes"))).toBe(false); + }); + + it("selects a region into its panel and opens it as a scoped layout", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + fireEvent.click(await screen.findByText("src/alpha")); + /* The region panel shows provenance and the door in. */ + expect( + screen.getByText("call community: 2 files, 100% under src/alpha"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open region →" })); + + /* The scoped node arrives and the breadcrumb back to regions exists. */ + expect(await screen.findByText("region: src/alpha")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "‹ Regions" })).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("scope=region%3A0"))).toBe(true); + }); + + it("restores an opened region from the URL (?region=)", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + expect(await screen.findByText("region: src/beta")).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("scope=region%3A1"))).toBe(true); + }); + + it("reports selected nodes to the route for deep links", async () => { + mockAtlasFetch(); + const onRouteChange = vi.fn(); + render( + , + ); + expect(await screen.findByText("region: src/alpha")).toBeInTheDocument(); + + /* Select the only node via the sidebar symbol row (#1197). */ + fireEvent.click(screen.getByText("src/alpha")); /* expand the tree */ + fireEvent.click(await screen.findByText("alpha_one")); + expect(onRouteChange).toHaveBeenCalledWith("7", "0"); + /* The detail panel now shows the node, not a stale or empty panel. */ + expect(screen.getByRole("heading", { name: "alpha_one" })).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index bc05d5c14..ea6e96694 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -1,12 +1,15 @@ -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { useGraphData, + fetchRegions, clampNodeBudget, GRAPH_RENDER_NODE_LIMIT, GRAPH_NODE_BUDGET_STEP, GRAPH_NODE_BUDGET_MAX, } from "../hooks/useGraphData"; +import { regionsToGraphData, regionsViewWorthwhile, disambiguateRegionNames } from "../lib/regions"; +import { RegionPanel } from "./RegionPanel"; import { GraphLoader } from "./GraphLoader"; import { DisplaySettingsMenu } from "./DisplaySettingsMenu"; import { @@ -25,7 +28,7 @@ import { NodeDetailPanel } from "./NodeDetailPanel"; import { MissedCallout } from "./MissedCallout"; import { ResizeHandle } from "./ResizeHandle"; import { ErrorBoundary } from "./ErrorBoundary"; -import type { GraphNode, GraphData, RepoInfo } from "../lib/types"; +import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types"; import { colorForStatus } from "../lib/colors"; /* Persist panel widths */ @@ -57,15 +60,38 @@ function saveNodeBudget(project: string, value: number) { interface GraphTabProps { project: string | null; + /* Deep-link state from the URL (?node=®ion=) and the reporter back. */ + routeNode?: string | null; + routeRegion?: string | null; + onRouteChange?: (node: string | null, region: string | null) => void; } +/* The galaxy's level-of-detail state: the region scene (coarsest), one + * opened region (full detail, scoped), or the classic full galaxy. */ +type AtlasView = + | { kind: "deciding" } + | { kind: "regions" } + | { kind: "region"; region: Region } + | { kind: "full" }; + export function formatGraphLimitNotice(data: GraphData | null): string | null { if (!data || data.total_nodes <= data.nodes.length) return null; return `Showing ${data.nodes.length.toLocaleString("en-US")} of ${data.total_nodes.toLocaleString("en-US")} nodes (${data.edges.length.toLocaleString("en-US")} edges). Raise the node budget or use filters.`; } -export function GraphTab({ project }: GraphTabProps) { +export function GraphTab({ + project, + routeNode = null, + routeRegion = null, + onRouteChange, +}: GraphTabProps) { const { data, loading, error, progress, fetchOverview } = useGraphData(); + const [view, setView] = useState({ kind: "deciding" }); + const viewRef = useRef(view); + viewRef.current = view; + const [regionsPayload, setRegionsPayload] = useState(null); + const [regionsError, setRegionsError] = useState(null); + const [selectedRegion, setSelectedRegion] = useState(null); const [highlightedIds, setHighlightedIds] = useState | null>(null); const [selectedPath, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); @@ -188,15 +214,66 @@ export function GraphTab({ project }: GraphTabProps) { } }, [project]); - /* …and fetch only once budget and project agree (one fetch per change). */ + /* …and decide the level of detail once budget and project agree. The + * region scene is the default above REGIONS_MIN_TOTAL_NODES; small + * projects load the full galaxy directly. An open region survives budget + * changes (the ref carries the current view across this effect). */ useEffect(() => { - if (project && budget.project === project) { + if (!project || budget.project !== project) return; + let cancelled = false; + setHighlightedIds(null); + setSelectedPath(null); + const current = viewRef.current; + if (current.kind === "region") { + fetchOverview(project, budget.value, "code", `region:${current.region.id}`); + return; + } + if (current.kind === "full") { fetchOverview(project, budget.value); - setHighlightedIds(null); - setSelectedPath(null); + return; } + setView({ kind: "deciding" }); + setRegionsError(null); + (async () => { + try { + const payload = await fetchRegions(project); + if (cancelled) return; + setRegionsPayload(payload); + const restored = routeRegion + ? payload.regions.find((r) => String(r.id) === routeRegion) + : null; + if (restored) { + setView({ kind: "region", region: restored }); + fetchOverview(project, budget.value, "code", `region:${restored.id}`); + } else if (regionsViewWorthwhile(payload)) { + setView({ kind: "regions" }); + } else { + setView({ kind: "full" }); + fetchOverview(project, budget.value); + } + } catch (e) { + if (cancelled) return; + /* Older servers have no region level — fall back to the galaxy. */ + setRegionsError(e instanceof Error ? e.message : "regions unavailable"); + setView({ kind: "full" }); + fetchOverview(project, budget.value); + } + })(); + return () => { + cancelled = true; + }; + /* routeRegion is a first-load restore input, not a live dependency. */ + // eslint-disable-next-line react-hooks/exhaustive-deps }, [project, budget, fetchOverview]); + /* Deep link: once data is loaded, restore the node the URL names. */ + useEffect(() => { + if (!routeNode || !data || selectedNode) return; + const target = data.nodes.find((n) => String(n.id) === routeNode); + if (target) handleNodeClickRef.current?.(target); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routeNode, data]); + /* Missed skeleton: offset into place and paint white — a ghost of the * files the graph could not fully cover, sitting beside the galaxy. */ const missedSkeleton = useMemo(() => { @@ -267,6 +344,7 @@ export function GraphTab({ project }: GraphTabProps) { } setSelectedPath(path); setHighlightedIds(nodeIds); + setSelectedNode(null); /* #1197: never leave a stale node in the panel */ setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds)); }, [filteredData], @@ -299,9 +377,55 @@ export function GraphTab({ project }: GraphTabProps) { setHighlightedIds(connectedIds); setSelectedPath(node.file_path ?? null); setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds)); + onRouteChange?.( + String(node.id), + viewRef.current.kind === "region" ? String(viewRef.current.region.id) : null, + ); }, - [filteredData, missedSkeleton], + [filteredData, missedSkeleton, onRouteChange], ); + const handleNodeClickRef = useRef(null); + handleNodeClickRef.current = handleNodeClick; + + /* Region scene interactions. */ + const regionGraph = useMemo( + () => (regionsPayload ? regionsToGraphData(regionsPayload) : null), + [regionsPayload], + ); + const displayRegions = useMemo( + () => (regionsPayload ? disambiguateRegionNames(regionsPayload.regions) : []), + [regionsPayload], + ); + const openRegion = useCallback( + (region: Region) => { + if (!project) return; + setSelectedRegion(null); + setSelectedNode(null); + setHighlightedIds(null); + setSelectedPath(null); + setCameraTarget(null); + setView({ kind: "region", region }); + fetchOverview(project, budget.value, "code", `region:${region.id}`); + onRouteChange?.(null, String(region.id)); + }, + [project, budget.value, fetchOverview, onRouteChange], + ); + const backToRegions = useCallback(() => { + setSelectedRegion(null); + setSelectedNode(null); + setHighlightedIds(null); + setSelectedPath(null); + setCameraTarget(null); + setView({ kind: "regions" }); + onRouteChange?.(null, null); + }, [onRouteChange]); + const loadFullGalaxy = useCallback(() => { + if (!project) return; + setSelectedRegion(null); + setView({ kind: "full" }); + fetchOverview(project, budget.value); + onRouteChange?.(null, null); + }, [project, budget.value, fetchOverview, onRouteChange]); const handleNavigateToNode = useCallback( (node: GraphNode) => { @@ -356,6 +480,136 @@ export function GraphTab({ project }: GraphTabProps) { ); } + if (view.kind === "deciding") { + return ( +
+

Mapping regions…

+
+ ); + } + + /* ── Region scene: the coarsest level of detail ──────────────── */ + if (view.kind === "regions" && regionGraph) { + const selected = selectedRegion; + return ( +
+
+
+ + Regions + +

+ {regionsPayload?.method === "leiden+folders" + ? "call communities + folder groups" + : "folder groups"} +

+
+
+ {displayRegions.map((region) => ( + + ))} +
+
+ { + setLeftWidth((w) => { + const nw = Math.max(150, Math.min(500, w + d)); + saveWidth("cbm-left-w", nw); + return nw; + }); + }} + /> + +
+ + { + const region = displayRegions.find((r) => r.id === node.id); + if (region) { + setSelectedRegion(region); + setCameraTarget( + computeCameraTarget(regionGraph.nodes, new Set([node.id])), + ); + } + }} + onBackgroundClick={() => setSelectedRegion(null)} + /> + + +
+

+ {displayRegions.length} regions /{" "} + {regionsPayload?.total_nodes.toLocaleString("en-US")} nodes +

+

+ double-click a region (or use its panel) to open it +

+
+ +
+ + +
+
+ + {selected && ( + <> + { + setRightWidth((w) => { + const nw = Math.max(200, Math.min(500, w + d)); + saveWidth("cbm-right-w", nw); + return nw; + }); + }} + /> +
+ setSelectedRegion(null)} + /> +
+ + )} +
+ ); + } + if (loading) { return (
@@ -420,6 +674,7 @@ export function GraphTab({ project }: GraphTabProps) {
@@ -462,6 +717,12 @@ export function GraphTab({ project }: GraphTabProps) { {/* HUD */}
+ {regionsError && ( +

regions unavailable: {regionsError}

+ )} + {view.kind === "region" && ( +

region: {view.region.name}

+ )}

{filteredData.nodes.length.toLocaleString()} nodes /{" "} {filteredData.edges.length.toLocaleString()} edges @@ -482,6 +743,11 @@ export function GraphTab({ project }: GraphTabProps) {

+ {view.kind === "region" && ( + + )} {highlightedIds && ( + ))} + {remaining > 0 && ( + + )} +
+
+ ); +} + function ConnectionSection({ title, count, icon, groups, onNavigate }: { title: string; count: number; icon: string; groups: [string, Connection[]][]; @@ -217,28 +258,7 @@ function ConnectionSection({ title, count, icon, groups, onNavigate }: { {title} ({count})

{groups.map(([type, conns]) => ( -
-

- {type.replace(/_/g, " ").toLowerCase()} -

-
- {conns.slice(0, 25).map((c, i) => ( - - ))} - {conns.length > 25 && ( -

+{conns.length - 25} more

- )} -
-
+ ))} ); diff --git a/graph-ui/src/components/RegionPanel.tsx b/graph-ui/src/components/RegionPanel.tsx new file mode 100644 index 000000000..69bb0a886 --- /dev/null +++ b/graph-ui/src/components/RegionPanel.tsx @@ -0,0 +1,94 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { Region } from "../lib/types"; + +interface RegionPanelProps { + region: Region; + onOpen: (region: Region) => void; + onClose: () => void; +} + +/* Detail panel for a selected region body: what it is, why it got its name, + * its representative symbols, and the door into its full-detail layout. */ +export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { + return ( +
+
+
+
+
+ +

+ {region.name} +

+
+ + Region + +
+ +
+ + {region.why && ( +

+ {region.why} +

+ )} + + + +
+ {[ + { label: "Symbols", value: region.members.toLocaleString("en-US") }, + { label: "Files", value: region.files.toLocaleString("en-US") }, + { label: "Cohesion", value: region.cohesion.toFixed(2) }, + ].map((stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ ))} +
+
+ + +
+

+ Most connected +

+
+ {region.top_nodes.map((name) => ( +

+ {name} +

+ ))} + {region.top_nodes.length === 0 && ( +

+ No callable members +

+ )} +
+
+
+
+ ); +} diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 0343940a4..52a76a8ae 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -6,6 +6,9 @@ import { useUiMessages } from "../lib/i18n"; interface SidebarProps { nodes: GraphNode[]; onSelectPath: (path: string, nodeIds: Set) => void; + /* #1197: clicking a symbol row selects the node itself — highlight in the + * scene AND the detail panel, exactly like clicking its 3D point. */ + onSelectNode: (node: GraphNode) => void; selectedPath: string | null; } @@ -59,9 +62,10 @@ function flattenSingleChild(dir: DirNode): DirNode { return { ...dir, children }; } -function TreeItem({ dir, depth, onSelect, selectedPath }: { +function TreeItem({ dir, depth, onSelect, onSelectNode, selectedPath }: { dir: DirNode; depth: number; onSelect: (path: string, ids: Set) => void; + onSelectNode: (node: GraphNode) => void; selectedPath: string | null; }) { const [expanded, setExpanded] = useState(false); @@ -86,11 +90,11 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { {expanded && ( <> - {sorted.map((c) => )} + {sorted.map((c) => )} {sortedNodes.map((gn) => ( + ); + })} + - {/* Tabs inline in header */} - - - - {selectedProject && ( -
- - {t.graph.selectedLabel} - - - {selectedProject} - +
+ {selectedProject && ( +
+ + {selectedProject} + + +
+ )}
- )} - - - {/* Content */} -
- {activeTab === "graph" ? ( - - navigate("graph", selectedProject, node, region) - } - /> - ) : activeTab === "control" ? ( - - ) : ( - navigate("graph", p)} - /> - )} -
-
+ + + {/* Content */} +
+ {activeTab === "overview" && selectedProject ? ( + openSymbol({ qn })} + onOpenModules={() => navigate({ tab: "modules" })} + onOpenFlows={() => navigate({ tab: "flows" })} + /> + ) : activeTab === "modules" && selectedProject ? ( + navigate({ tab: "modules", path })} + onOpenSymbol={openSymbol} + onOpenRegion={openRegion} + /> + ) : activeTab === "graph" ? ( + + navigate({ tab: "graph", node, region }) + } + /> + ) : activeTab === "flows" && selectedProject ? ( + + navigate({ tab: "flows", flow: id !== null ? String(id) : null }) + } + onOpenSymbol={openSymbol} + /> + ) : activeTab === "changes" && selectedProject ? ( + + ) : activeTab === "symbol" && selectedProject && symbolRef ? ( + + ) : activeTab === "control" ? ( + + ) : ( + navigate({ tab: "overview", project })} + /> + )} +
+ + setCommandOpen(false)} + onOpenSymbol={openSymbol} + /> + + + ); } diff --git a/graph-ui/src/components/ChangesTab.tsx b/graph-ui/src/components/ChangesTab.tsx new file mode 100644 index 000000000..18c14ecb5 --- /dev/null +++ b/graph-ui/src/components/ChangesTab.tsx @@ -0,0 +1,173 @@ +/* Changes — what am I about to break: detect_changes on the working tree, + * grouped by risk, every affected symbol one click from its page and the + * whole blast radius one click from the prompt composer. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { fetchChanges, archRows, type ArchitectureJson } from "../lib/atlas"; +import { usePromptBasket } from "./PromptBasket"; + +interface ChangesTabProps { + project: string; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +} + +interface AffectedRow { + qn?: string; + symbol?: string; + file?: string; + risk?: string; + hops?: number; + [key: string]: string | number | null | undefined; +} + +const RISK_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]; +const RISK_TONE: Record = { + CRITICAL: "text-red-300/90 border-red-300/40 bg-red-400/10", + HIGH: "text-amber-300/90 border-amber-300/40 bg-amber-400/10", + MEDIUM: "text-sky-300/80 border-sky-300/30 bg-sky-400/5", + LOW: "text-foreground/50 border-border/40 bg-white/[0.02]", +}; + +export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const basket = usePromptBasket(); + + const run = () => { + setLoading(true); + setError(null); + fetchChanges(project) + .then(setResult) + .catch((err) => setError(err instanceof Error ? err.message : "failed")) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + setResult(null); + setError(null); + }, [project]); + + const sections = useMemo(() => { + if (!result) return []; + /* detect_changes(format:"json") emits {cols, rows} sections; collect + * whichever carry symbol-shaped rows, keep scalars as banner text. */ + return Object.entries(result) + .filter(([, value]) => typeof value === "object" && value !== null && "cols" in value) + .map(([name]) => ({ name, rows: archRows(result, name) })) + .filter((section) => section.rows.length > 0); + }, [result]); + + const scalars = useMemo(() => { + if (!result) return []; + return Object.entries(result).filter( + ([, value]) => typeof value === "string" || typeof value === "number", + ) as [string, string | number][]; + }, [result]); + + const riskOf = (row: AffectedRow): string => String(row.risk ?? "LOW").toUpperCase(); + const qnOf = (row: AffectedRow): string | undefined => + (row.qn as string) ?? (row.symbol as string) ?? undefined; + + const affected = useMemo(() => { + const rows = sections.flatMap((section) => section.rows).filter((row) => qnOf(row)); + return [...rows].sort( + (a, b) => RISK_ORDER.indexOf(riskOf(a)) - RISK_ORDER.indexOf(riskOf(b)), + ); + }, [sections]); + + return ( + +
+
+

+ Working-tree impact +

+ + {affected.length > 0 && ( + + )} +
+ + {error &&

{error}

} + {!result && !loading && !error && ( +

+ Runs detect_changes against the + project's git working tree: changed symbols, their transitive callers with risk + classification, and the tests that cover them — the map to read before asking an + agent to finish a refactor. +

+ )} + + {scalars.length > 0 && ( +
+ {scalars.map(([key, value]) => ( +
+

+ {key.replace(/_/g, " ")} +

+

{String(value)}

+
+ ))} +
+ )} + + {result && affected.length === 0 && ( +

+ No affected symbols — the working tree is clean or the changes touch nothing indexed. +

+ )} + + {affected.length > 0 && ( +
+ {affected.map((row, index) => { + const risk = riskOf(row); + const qn = qnOf(row)!; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/CommandK.tsx b/graph-ui/src/components/CommandK.tsx new file mode 100644 index 000000000..bace9ef09 --- /dev/null +++ b/graph-ui/src/components/CommandK.tsx @@ -0,0 +1,197 @@ +/* ⌘K — one box, two engines: symbol names (search_graph) and code text + * (search_code), grouped, keyboard-first. Enter opens, Esc closes. */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { callTool } from "../api/rpc"; +import { searchGraph, type SearchRow } from "../lib/atlas"; + +interface CommandKProps { + project: string | null; + open: boolean; + onClose: () => void; + onOpenSymbol: (ref: { qn: string }) => void; +} + +interface TextHit { + file: string; + line: number; + text: string; +} + +/* search_code(format:"json") shape is tool-versioned; decode defensively. */ +function decodeTextHits(payload: unknown): TextHit[] { + const out: TextHit[] = []; + const root = payload as { + matches?: { file?: string; line?: number; text?: string }[]; + groups?: { file?: string; rows?: (string | number | null)[][] }[]; + }; + if (Array.isArray(root.matches)) { + for (const match of root.matches) + if (match.file) + out.push({ file: match.file, line: match.line ?? 0, text: match.text ?? "" }); + } else if (Array.isArray(root.groups)) { + for (const group of root.groups) + for (const row of group.rows ?? []) + out.push({ + file: group.file ?? "?", + line: Number(row[0] ?? 0), + text: String(row[1] ?? ""), + }); + } + return out.slice(0, 12); +} + +export function CommandK({ project, open, onClose, onOpenSymbol }: CommandKProps) { + const [query, setQuery] = useState(""); + const [symbols, setSymbols] = useState([]); + const [textHits, setTextHits] = useState([]); + const [active, setActive] = useState(0); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + const seqRef = useRef(0); + + useEffect(() => { + if (open) { + setQuery(""); + setSymbols([]); + setTextHits([]); + setActive(0); + setTimeout(() => inputRef.current?.focus(), 30); + } + }, [open]); + + useEffect(() => { + if (!open || !project || query.trim().length < 2) { + setSymbols([]); + setTextHits([]); + return; + } + const seq = ++seqRef.current; + const timer = setTimeout(async () => { + setBusy(true); + const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const [nameResult, textResult] = await Promise.allSettled([ + searchGraph(project, { name_pattern: escaped, limit: 12 }), + callTool("search_code", { project, query: query.trim(), limit: 12, format: "json" }), + ]); + if (seq !== seqRef.current) return; + setSymbols(nameResult.status === "fulfilled" ? nameResult.value.rows : []); + setTextHits(textResult.status === "fulfilled" ? decodeTextHits(textResult.value) : []); + setActive(0); + setBusy(false); + }, 220); + return () => clearTimeout(timer); + }, [open, project, query]); + + const entries = useMemo( + () => + [ + ...symbols.map((row) => ({ kind: "symbol" as const, row })), + ...textHits.map((hit) => ({ kind: "text" as const, hit })), + ], + [symbols, textHits], + ); + + const activate = useCallback( + (index: number) => { + const entry = entries[index]; + if (!entry) return; + if (entry.kind === "symbol") { + onOpenSymbol({ qn: entry.row.qualified_name }); + onClose(); + } + /* Text hits have no symbol identity; they stay informational. */ + }, + [entries, onOpenSymbol, onClose], + ); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowDown") { + event.preventDefault(); + setActive((value) => Math.min(value + 1, entries.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((value) => Math.max(value - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + activate(active); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, entries.length, active, activate, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > + setQuery(event.target.value)} + className="w-full bg-transparent px-4 py-3 text-[14px] text-foreground placeholder-foreground/25 outline-none border-b border-border/40" + /> +
+ {busy && entries.length === 0 && ( +

Searching…

+ )} + {!busy && query.trim().length >= 2 && entries.length === 0 && ( +

No matches.

+ )} + {symbols.length > 0 && ( +

+ Symbols +

+ )} + {symbols.map((row, index) => ( + + ))} + {textHits.length > 0 && ( +

+ In code +

+ )} + {textHits.map((hit, index) => ( +
+

+ {hit.file}:{hit.line} +

+

{hit.text}

+
+ ))} +
+
+ ↑↓ navigate · Enter opens the symbol · Esc closes +
+
+
+ ); +} diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx new file mode 100644 index 000000000..829c41ac3 --- /dev/null +++ b/graph-ui/src/components/FlowsTab.tsx @@ -0,0 +1,225 @@ +/* Flows — how the codebase runs: ranked entry→terminal call journeys, each + * opening as an indented call tree with every step one click from its + * symbol page. Truncation is stated, never silent. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + fetchFlow, + fetchFlows, + type FlowDetail, + type FlowsPayload, +} from "../lib/atlas"; +import { AddToPromptButton } from "./PromptBasket"; + +interface FlowsTabProps { + project: string; + flowId: number | null; + onOpenFlow: (id: number | null) => void; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +} + +function flowToMermaid(detail: FlowDetail): string { + const lines = ["graph TD"]; + for (let index = 1; index < detail.steps.length; index++) { + const step = detail.steps[index]; + const parent = detail.steps[step.parent]; + if (!parent) continue; + const safe = (name: string) => name.replace(/[^A-Za-z0-9_]/g, "_"); + lines.push(` ${safe(parent.name)}${step.parent} --> ${safe(step.name)}${index}`); + } + return lines.join("\n"); +} + +export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTabProps) { + const [payload, setPayload] = useState(null); + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let cancelled = false; + setPayload(null); + setError(null); + fetchFlows(project) + .then((flows) => { + if (!cancelled) setPayload(flows); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project]); + + useEffect(() => { + let cancelled = false; + setDetail(null); + if (flowId === null) return; + fetchFlow(project, flowId) + .then((flow) => { + if (!cancelled) setDetail(flow); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project, flowId]); + + const grouped = useMemo(() => { + const flows = payload?.flows ?? []; + return { + cross: flows.filter((flow) => flow.cross_region), + intra: flows.filter((flow) => !flow.cross_region), + }; + }, [payload]); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!payload) { + return ( +
+

Tracing the journeys…

+
+ ); + } + + return ( +
+ {/* Flow list */} +
+
+

+ Flows — entry → terminal +

+

+ {payload.flows.length} journeys from{" "} + {payload.callable_total.toLocaleString("en-US")} callables + {payload.candidates_dropped > 0 && + ` · ${payload.candidates_dropped.toLocaleString("en-US")} candidates not walked`} +

+
+ +
+ {(["cross", "intra"] as const).map((groupKey) => { + const flows = grouped[groupKey]; + if (flows.length === 0) return null; + return ( +
+

+ {groupKey === "cross" ? "across regions" : "within one region"} +

+ {flows.map((flow) => ( + + ))} +
+ ); + })} + {payload.flows.length === 0 && ( +

+ No flows detected — the project may have no clear entry points. +

+ )} +
+
+
+ + {/* Flow detail */} +
+ {flowId === null || !detail ? ( +
+

+ {flowId === null ? "Pick a journey on the left" : "Loading…"} +

+
+ ) : ( + +
+
+

+ {detail.entry.name} + → + {detail.terminal.name} +

+ + +
+

+ {detail.steps.length} steps + {detail.sink_terminated ? " · ends at a sink" : " · stopped at the depth cap"} + {detail.cross_region ? " · crosses regions" : ""} + {detail.steps_capped + ? ` · ${detail.steps_capped} branches beyond the cap not shown` + : ""} +

+ +
+ {detail.steps.map((step, index) => ( + + ))} +
+
+
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/ModulesTab.tsx b/graph-ui/src/components/ModulesTab.tsx new file mode 100644 index 000000000..dc25c6b07 --- /dev/null +++ b/graph-ui/src/components/ModulesTab.tsx @@ -0,0 +1,319 @@ +/* Modules — the territory: a squarified treemap of one folder's children, + * sized by symbols, colored by dominant region, hatched when the indexer + * missed files inside. Click a folder to drill, a file to list and open its + * symbols. */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { fetchTree, type TreeChild, type TreePayload } from "../lib/atlas"; +import { fetchRegions } from "../hooks/useGraphData"; +import { squarify } from "../lib/treemap"; +import { disambiguateRegionNames } from "../lib/regions"; +import { searchGraph, type SearchRow } from "../lib/atlas"; +import type { RegionsPayload } from "../lib/types"; + +interface ModulesTabProps { + project: string; + path: string; + onNavigatePath: (path: string) => void; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; + onOpenRegion: (regionId: number) => void; +} + +const NEUTRAL = "#39505f"; + +/* search_graph rows for one file, via the read-only /rpc surface. */ +async function fetchFileSymbols(project: string, file: string): Promise { + const { rows } = await searchGraph(project, { file_pattern: file, limit: 200 }); + return rows; +} + +export function ModulesTab({ + project, + path, + onNavigatePath, + onOpenSymbol, + onOpenRegion, +}: ModulesTabProps) { + const [tree, setTree] = useState(null); + const [regions, setRegions] = useState(null); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [fileSymbols, setFileSymbols] = useState(null); + const [size, setSize] = useState({ w: 900, h: 560 }); + + useEffect(() => { + let cancelled = false; + setTree(null); + setError(null); + setSelectedFile(null); + setFileSymbols(null); + fetchTree(project, path) + .then((payload) => { + if (!cancelled) setTree(payload); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project, path]); + + useEffect(() => { + let cancelled = false; + fetchRegions(project) + .then((payload) => { + if (!cancelled) setRegions(payload); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + + const regionMeta = useMemo(() => { + const map = new Map(); + if (regions) + for (const region of disambiguateRegionNames(regions.regions)) + map.set(region.id, { name: region.name, color: region.color }); + return map; + }, [regions]); + + const rects = useMemo(() => { + if (!tree) return []; + const items = tree.children.map((child) => ({ + id: `${child.kind}:${child.path}`, + value: Math.max(1, child.symbols), + })); + return squarify(items, 0, 0, size.w, size.h); + }, [tree, size]); + + const childByKey = useMemo(() => { + const map = new Map(); + if (tree) + for (const child of tree.children) map.set(`${child.kind}:${child.path}`, child); + return map; + }, [tree]); + + const openFile = useCallback( + (child: TreeChild) => { + setSelectedFile(child); + setFileSymbols(null); + fetchFileSymbols(project, child.path) + .then(setFileSymbols) + .catch(() => setFileSymbols([])); + }, + [project], + ); + + const crumbs = useMemo(() => { + const parts = path ? path.split("/") : []; + return [{ label: "root", path: "" }].concat( + parts.map((part, index) => ({ + label: part, + path: parts.slice(0, index + 1).join("/"), + })), + ); + }, [path]); + + const measureRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const rect = entry.contentRect; + if (rect.width > 100 && rect.height > 100) + setSize({ w: rect.width, h: rect.height }); + } + }); + observer.observe(element); + }, []); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+ {/* Breadcrumbs */} +
+ {crumbs.map((crumb, index) => ( + + {index > 0 && /} + + + ))} + {tree && ( + + {tree.files.toLocaleString("en-US")} files ·{" "} + {tree.symbols.toLocaleString("en-US")} symbols + {tree.children_dropped ? ` · ${tree.children_dropped} children not shown` : ""} + + )} +
+ +
+ {/* Treemap */} +
+ {!tree ? ( +
+

Measuring the territory…

+
+ ) : ( + + + + + + + + {rects.map((rect) => { + const child = childByKey.get(rect.id); + if (!child) return null; + const meta = child.region !== undefined ? regionMeta.get(child.region) : undefined; + const fill = meta?.color ?? NEUTRAL; + const showLabel = rect.w > 60 && rect.h > 24; + return ( + + child.kind === "dir" ? onNavigatePath(child.path) : openFile(child) + } + className="cursor-pointer" + > + + + {`${child.path} — ${child.symbols.toLocaleString("en-US")} symbols` + + (child.kind === "dir" ? `, ${child.files} files` : "") + + (meta ? `\nregion: ${meta.name}` : "") + + (child.missed ? `\n${child.missed} file(s) not fully indexed` : "")} + + + {child.missed ? ( + + ) : null} + {showLabel && ( + + {child.kind === "dir" ? `${child.name}/` : child.name} + + )} + {showLabel && rect.h > 40 && ( + + {child.symbols.toLocaleString("en-US")} + + )} + + ); + })} + + )} +
+ + {/* File panel */} + {selectedFile && ( +
+
+
+

+ {selectedFile.path} +

+ +
+

+ {selectedFile.symbols.toLocaleString("en-US")} symbols + {selectedFile.missed ? " · not fully indexed" : ""} +

+
+
+ {fileSymbols === null ? ( +

Loading…

+ ) : fileSymbols.length === 0 ? ( +

No symbols found.

+ ) : ( + fileSymbols.map((symbol) => ( + + )) + )} +
+ {selectedFile.region !== undefined && regionMeta.get(selectedFile.region) && ( + + )} +
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/OverviewTab.tsx b/graph-ui/src/components/OverviewTab.tsx new file mode 100644 index 000000000..ee50ffd13 --- /dev/null +++ b/graph-ui/src/components/OverviewTab.tsx @@ -0,0 +1,314 @@ +/* Overview — the first read of a project: freshness, the map of regions, + * hubs, entry points, boundaries, surprising couplings and suggested + * questions. Everything deterministic, everything with a why, everything + * one click from detail or from the prompt composer. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { fetchArchitecture, archRows, type ArchitectureJson } from "../lib/atlas"; +import { fetchRegions } from "../hooks/useGraphData"; +import { disambiguateRegionNames } from "../lib/regions"; +import { surprisingCouplings, suggestedQuestions } from "../lib/firstread"; +import { AddToPromptButton } from "./PromptBasket"; +import type { RegionsPayload } from "../lib/types"; + +interface OverviewTabProps { + project: string; + onOpenRegion: (regionId: number) => void; + onOpenSymbol: (qn: string) => void; + onOpenModules: () => void; + onOpenFlows: () => void; +} + +interface HotspotRow { + qn: string; + fan_in: number; +} +interface EntryRow { + qn: string; + file: string; +} +interface BoundaryRow { + from: string; + to: string; + calls: number; +} +interface LanguageRow { + language: string; + files: number; +} + +function shortQn(qn: string): string { + const parts = qn.split("."); + return parts.slice(-2).join("."); +} + +function Card({ title, children, wide }: { title: string; children: React.ReactNode; wide?: boolean }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function OverviewTab({ + project, + onOpenRegion, + onOpenSymbol, + onOpenModules, + onOpenFlows, +}: OverviewTabProps) { + const [arch, setArch] = useState(null); + const [regions, setRegions] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setArch(null); + setRegions(null); + setError(null); + Promise.allSettled([fetchArchitecture(project), fetchRegions(project)]).then( + ([archResult, regionsResult]) => { + if (cancelled) return; + if (archResult.status === "fulfilled") setArch(archResult.value); + if (regionsResult.status === "fulfilled") setRegions(regionsResult.value); + if (archResult.status === "rejected" && regionsResult.status === "rejected") + setError(String(archResult.reason?.message ?? "failed to load")); + }, + ); + return () => { + cancelled = true; + }; + }, [project]); + + const displayRegions = useMemo( + () => (regions ? disambiguateRegionNames(regions.regions) : []), + [regions], + ); + const couplings = useMemo(() => (regions ? surprisingCouplings(regions, 4) : []), [regions]); + const questions = useMemo(() => (regions ? suggestedQuestions(regions, {}, 4) : []), [regions]); + + const hotspots = useMemo(() => archRows(arch, "hotspots"), [arch]); + const entries = useMemo(() => archRows(arch, "entry_points"), [arch]); + const boundaries = useMemo(() => archRows(arch, "boundaries"), [arch]); + const languages = useMemo(() => archRows(arch, "languages"), [arch]); + const totalNodes = typeof arch?.total_nodes === "number" ? arch.total_nodes : regions?.total_nodes; + const totalEdges = typeof arch?.total_edges === "number" ? arch.total_edges : undefined; + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!arch && !regions) { + return ( +
+

Reading the map…

+
+ ); + } + + return ( + +
+ {/* Header numbers */} +
+ {[ + { label: "Symbols", value: totalNodes?.toLocaleString("en-US") ?? "…" }, + { label: "Edges", value: totalEdges?.toLocaleString("en-US") ?? "…" }, + { label: "Regions", value: displayRegions.length || "…" }, + { + label: "Languages", + value: + languages + .slice(0, 3) + .map((lang) => lang.language) + .join(" · ") || "…", + }, + ].map((stat) => ( +
+

{stat.label}

+

+ {stat.value} +

+
+ ))} + {regions && regions.unmapped_nodes > 0 && ( +
+

Unmapped

+

+ {regions.unmapped_nodes.toLocaleString("en-US")} +

+
+ )} +
+ +
+ {/* Regions — the map */} + +
+ {displayRegions.slice(0, 12).map((region) => ( + + ))} +
+ {displayRegions.length > 12 && ( + + )} +
+ + {/* Hubs */} + +
+ {hotspots.slice(0, 8).map((hotspot) => ( +
+ + + {Number(hotspot.fan_in).toLocaleString("en-US")} + +
+ ))} + {hotspots.length === 0 && ( +

No hotspot data.

+ )} +
+
+ + {/* Start here */} + +
+ {entries.slice(0, 8).map((entry) => ( + + ))} + {entries.length === 0 && ( +

No entry points detected.

+ )} +
+ +
+ + {/* Surprising couplings */} + +
+ {couplings.map((coupling) => ( +
+

+ {coupling.source.name} + ⇄ + {coupling.target.name} +

+

{coupling.reasons.join(" · ")}

+
+ ))} + {couplings.length === 0 && ( +

+ No cross-region couplings stand out. +

+ )} +
+
+ + {/* Suggested questions */} + +
+ {questions.map((question) => ( +
+
+

{question.question}

+

{question.why}

+
+ +
+ ))} + {questions.length === 0 && ( +

Nothing stands out structurally.

+ )} +
+
+ + {/* Boundaries */} + +
+ + + + + + + + + + {boundaries.slice(0, 8).map((boundary, index) => ( + + + + + + ))} + +
fromtocalls
{boundary.from}{boundary.to} + {Number(boundary.calls).toLocaleString("en-US")} +
+ {boundaries.length === 0 && ( +

No cross-package calls recorded.

+ )} +
+
+
+
+
+ ); +} diff --git a/graph-ui/src/components/PromptBasket.tsx b/graph-ui/src/components/PromptBasket.tsx new file mode 100644 index 000000000..1e672fa1d --- /dev/null +++ b/graph-ui/src/components/PromptBasket.tsx @@ -0,0 +1,208 @@ +/* The prompt composer: a basket of cited entities (symbols, regions, flows, + * questions) that becomes a precise prompt for the user's coding agent — + * the bridge from what the human learned to what the agent is told. */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { composePrompt, basketKey, type BasketItem } from "../lib/composer"; + +interface BasketState { + items: BasketItem[]; + add: (item: BasketItem) => void; + remove: (key: string) => void; + clear: () => void; + open: boolean; + setOpen: (open: boolean) => void; +} + +const BasketContext = createContext(null); + +const STORAGE_KEY = "cbm-atlas-basket"; + +export function PromptBasketProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState(() => { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as BasketItem[]) : []; + } catch { + return []; + } + }); + const [open, setOpen] = useState(false); + + useEffect(() => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch { + /* session storage unavailable — the basket still works in memory */ + } + }, [items]); + + const add = useCallback((item: BasketItem) => { + setItems((prev) => + prev.some((existing) => basketKey(existing) === basketKey(item)) + ? prev + : [...prev, item], + ); + setOpen(true); + }, []); + const remove = useCallback((key: string) => { + setItems((prev) => prev.filter((item) => basketKey(item) !== key)); + }, []); + const clear = useCallback(() => setItems([]), []); + + const value = useMemo( + () => ({ items, add, remove, clear, open, setOpen }), + [items, add, remove, clear, open], + ); + return {children}; +} + +export function usePromptBasket(): BasketState { + const ctx = useContext(BasketContext); + if (!ctx) throw new Error("usePromptBasket outside PromptBasketProvider"); + return ctx; +} + +/* The standard "cite this" affordance. */ +export function AddToPromptButton({ item, small }: { item: BasketItem; small?: boolean }) { + const basket = usePromptBasket(); + const inBasket = basket.items.some((existing) => basketKey(existing) === basketKey(item)); + return ( + + ); +} + +function itemLabel(item: BasketItem): string { + switch (item.kind) { + case "symbol": + return item.qualified_name ?? item.name; + case "region": + return `region ${item.name}`; + case "flow": + return `flow ${item.label}`; + case "question": + return item.question; + } +} + +/* The drawer: goal text + cited items + the composed prompt with copy. */ +export function PromptBasketDrawer({ project }: { project: string | null }) { + const basket = usePromptBasket(); + const [goal, setGoal] = useState(""); + const [copied, setCopied] = useState(false); + const prompt = useMemo( + () => composePrompt(project ?? "?", goal, basket.items), + [project, goal, basket.items], + ); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + if (!basket.open) { + return ( + + ); + } + + return ( +
+
+ + Prompt composer ({basket.items.length} cited) + +
+ {basket.items.length > 0 && ( + + )} + +
+
+ +
+ setGoal(event.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 transition-all" + /> +
+ +
+ {basket.items.length === 0 ? ( +

+ Cite symbols, regions, flows or questions with “+ prompt” anywhere in Atlas. +

+ ) : ( + basket.items.map((item) => ( +
+ + {item.kind} + + + {itemLabel(item)} + + +
+ )) + )} +
+ +
+        {prompt}
+      
+ +
+ +
+
+ ); +} diff --git a/graph-ui/src/components/SymbolTab.tsx b/graph-ui/src/components/SymbolTab.tsx new file mode 100644 index 000000000..43728635a --- /dev/null +++ b/graph-ui/src/components/SymbolTab.tsx @@ -0,0 +1,349 @@ +/* Symbol — one page per node, addressable by id or qualified name: what it + * is (docstring, flags), where it lives (region, file, editor links), who + * calls it and whom it calls (true totals, paged, confidence-ordered), + * what tests it, what changes with it, and what nearly duplicates it. */ +import { useEffect, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { callTool } from "../api/rpc"; +import { + fetchSymbol, + type ConnectionPage, + type SymbolBundle, + type SymbolRef, +} from "../lib/atlas"; +import { AddToPromptButton } from "./PromptBasket"; +import type { RepoInfo } from "../lib/types"; + +interface SymbolTabProps { + project: string; + symbolRef: { id?: number; qn?: string }; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; + onOpenRegion: (regionId: number) => void; +} + +const CONFIDENCE: Record = { + CALLS: { label: "resolved", tone: "text-emerald-300/80 border-emerald-300/30" }, + CALL_REFERENCE: { label: "reference", tone: "text-sky-300/80 border-sky-300/30" }, + USAGE: { label: "unproven", tone: "text-amber-300/80 border-amber-300/30" }, + HTTP_CALLS: { label: "http", tone: "text-purple-300/80 border-purple-300/30" }, + ASYNC_CALLS: { label: "async", tone: "text-purple-300/80 border-purple-300/30" }, +}; + +function ConnectionList({ + title, + page, + onLoadMore, + onOpenSymbol, +}: { + title: string; + page: ConnectionPage; + onLoadMore: () => void; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +}) { + const shown = page.offset + page.items.length; + return ( +
+
+

{title}

+ + {page.total.toLocaleString("en-US")} + + + {Object.entries(page.by_type) + .map(([type, count]) => `${CONFIDENCE[type]?.label ?? type.toLowerCase()} ${count}`) + .join(" · ")} + +
+
+ {page.items.map((item, index) => ( + + ))} + {page.items.length === 0 && ( +

None.

+ )} +
+ {shown < page.total && ( + + )} +
+ ); +} + +export function SymbolTab({ project, symbolRef, onOpenSymbol, onOpenRegion }: SymbolTabProps) { + const [bundle, setBundle] = useState(null); + const [error, setError] = useState(null); + const [limit, setLimit] = useState(50); + const [code, setCode] = useState(null); + const [codeLoading, setCodeLoading] = useState(false); + const [repoInfo, setRepoInfo] = useState(null); + + useEffect(() => { + let cancelled = false; + setBundle(null); + setError(null); + setCode(null); + fetchSymbol(project, symbolRef, limit, 0) + .then((payload) => { + if (!cancelled) setBundle(payload); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project, symbolRef.id, symbolRef.qn, limit]); + + useEffect(() => { + let cancelled = false; + fetch(`/api/repo-info?project=${encodeURIComponent(project)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data && !data.error) setRepoInfo(data as RepoInfo); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!bundle) { + return ( +
+

Loading symbol…

+
+ ); + } + + const { node } = bundle; + const lineSuffix = node.start_line + ? `#L${node.start_line}${node.end_line && node.end_line !== node.start_line ? `-L${node.end_line}` : ""}` + : ""; + const ghUrl = + repoInfo?.blob_base && node.file_path + ? `${repoInfo.blob_base}/${node.file_path.split("/").map(encodeURIComponent).join("/")}${lineSuffix}` + : null; + const editorUrl = + repoInfo?.root_path && node.file_path + ? `vscode://file/${repoInfo.root_path}/${node.file_path}${node.start_line ? `:${node.start_line}` : ""}` + : null; + + const loadCode = async () => { + if (!node.qualified_name) return; + setCodeLoading(true); + try { + const result = await callTool<{ source?: string }>("get_code_snippet", { + qualified_name: node.qualified_name, + project, + }); + setCode(result.source ?? "(source not available)"); + } catch (err) { + setCode(err instanceof Error ? err.message : "failed to load code"); + } finally { + setCodeLoading(false); + } + }; + + return ( + +
+ {/* Header */} +
+
+

+ {node.name} +

+ + {node.label} + + {node.is_entry && ( + + entry point + + )} + {node.is_test && ( + + test + + )} + {node.is_exported && ( + + exported + + )} +
+ +
+
+ {node.qualified_name && ( +

+ {node.qualified_name} +

+ )} +
+ {node.file_path && ( +

+ {node.file_path} + {node.start_line ? `:${node.start_line}` : ""} +

+ )} + {bundle.region && ( + + )} + {editorUrl && ( + + Open in editor ↗ + + )} + {ghUrl && ( + + GitHub ↗ + + )} +
+ {node.docstring && ( +

+ {node.docstring} +

+ )} +
+ + {code && ( +
+                {code}
+              
+ )} +
+
+ + {/* Connections */} +
+ setLimit((value) => value + 100)} + onOpenSymbol={onOpenSymbol} + /> + setLimit((value) => value + 100)} + onOpenSymbol={onOpenSymbol} + /> +
+ +
+
+

+ Tested by +

+ {(bundle.tests ?? []).map((test: SymbolRef) => ( + + ))} + {(bundle.tests ?? []).length === 0 && ( +

No TESTS edges reach this symbol.

+ )} +
+ +
+

+ Changes together with +

+ {(bundle.co_change ?? []).map((partner) => ( +

+ {partner.file_path} + {partner.score !== undefined && ( + · {partner.score.toFixed(2)} + )} +

+ ))} + {(bundle.co_change ?? []).length === 0 && ( +

No co-change history.

+ )} +
+ +
+

+ Near-clones +

+ {(bundle.similar ?? []).map((clone) => ( + + ))} + {(bundle.similar ?? []).length === 0 && ( +

No near-clones.

+ )} +
+
+
+
+ ); +} diff --git a/graph-ui/src/lib/atlas.test.ts b/graph-ui/src/lib/atlas.test.ts new file mode 100644 index 000000000..2aa33f18c --- /dev/null +++ b/graph-ui/src/lib/atlas.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { archRows } from "./atlas"; +import { surprisingCouplings, suggestedQuestions } from "./firstread"; +import { squarify } from "./treemap"; +import { composePrompt } from "./composer"; +import type { Region, RegionsPayload } from "./types"; + +function region(partial: Partial & { id: number; name: string }): Region { + return { + files: 4, + members: 100, + cohesion: 0.5, + top_nodes: [], + x: 0, + y: 0, + z: 0, + size: 20, + color: "#123456", + ...partial, + }; +} + +describe("archRows", () => { + it("decodes {cols, rows} sections into row objects", () => { + const arch = { + hotspots: { cols: ["qn", "fan_in"], rows: [["a.b.c", 42], ["d.e.f", 7]] }, + project: "demo", + }; + expect(archRows(arch, "hotspots")).toEqual([ + { qn: "a.b.c", fan_in: 42 }, + { qn: "d.e.f", fan_in: 7 }, + ]); + expect(archRows(arch, "absent")).toEqual([]); + expect(archRows(null, "hotspots")).toEqual([]); + }); +}); + +describe("first read", () => { + const payload: RegionsPayload = { + level: "regions", + method: "leiden+folders", + total_nodes: 20000, + unmapped_nodes: 0, + regions: [ + region({ id: 0, name: "src/pipeline", cohesion: 0.8, members: 900 }), + region({ id: 1, name: "tests/e2e", cohesion: 0.7, members: 400 }), + region({ id: 2, name: "src/store", cohesion: 0.15, members: 600 }), + region({ id: 3, name: "misc", members: 50 }), + ], + edges: [ + { source: 0, target: 1, weight: 120 }, + { source: 0, target: 2, weight: 40 }, + { source: 3, target: 0, weight: 999 }, + ], + }; + + it("ranks cross-area couplings with reasons and ignores misc", () => { + const out = surprisingCouplings(payload, 5); + expect(out).toHaveLength(2); + /* src→tests crosses top-level areas and is heavier. */ + expect(out[0].source.name).toBe("src/pipeline"); + expect(out[0].target.name).toBe("tests/e2e"); + expect(out[0].reasons.some((reason) => reason.includes("different top-level"))).toBe(true); + /* src/store's looseness is called out on the second coupling. */ + expect(out[1].reasons.some((reason) => reason.includes("cohesion 0.15"))).toBe(true); + }); + + it("asks split/dead/certainty questions with a why", () => { + const questions = suggestedQuestions(payload, { deadCount: 87, unresolvedShare: 0.4 }); + const texts = questions.map((question) => question.question); + expect(texts.some((text) => text.includes("Should src/store be split"))).toBe(true); + expect(texts.some((text) => text.includes("87 functions with no callers"))).toBe(true); + for (const question of questions) expect(question.why.length).toBeGreaterThan(0); + }); +}); + +describe("squarify", () => { + it("tiles the full rectangle with area-proportional cells", () => { + const rects = squarify( + [ + { id: "a", value: 6 }, + { id: "b", value: 3 }, + { id: "c", value: 1 }, + ], + 0, + 0, + 100, + 50, + ); + expect(rects).toHaveLength(3); + const area = (id: string) => { + const rect = rects.find((r) => r.id === id)!; + return rect.w * rect.h; + }; + expect(area("a")).toBeCloseTo(3000, 0); + expect(area("b")).toBeCloseTo(1500, 0); + expect(area("c")).toBeCloseTo(500, 0); + const total = rects.reduce((a, rect) => a + rect.w * rect.h, 0); + expect(total).toBeCloseTo(5000, 0); + for (const rect of rects) { + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.y).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.w).toBeLessThanOrEqual(100.001); + expect(rect.y + rect.h).toBeLessThanOrEqual(50.001); + } + }); + + it("handles empty and zero-value input", () => { + expect(squarify([], 0, 0, 10, 10)).toEqual([]); + expect(squarify([{ id: "a", value: 0 }], 0, 0, 10, 10)).toEqual([]); + }); +}); + +describe("composePrompt", () => { + it("cites exact identifiers and the MCP calls that reproduce context", () => { + const prompt = composePrompt("demo", "Refactor the store locking.", [ + { + kind: "symbol", + id: 7, + name: "cbm_store_close", + qualified_name: "demo.src.store.cbm_store_close", + file_path: "src/store/store.c", + start_line: 100, + end_line: 140, + }, + { kind: "region", id: 0, name: "src/store", members: 600, why: "call community" }, + { kind: "question", question: "Should src/store be split?", why: "cohesion 0.15" }, + ]); + expect(prompt).toContain("Refactor the store locking."); + expect(prompt).toContain("demo.src.store.cbm_store_close — src/store/store.c:100-140"); + expect(prompt).toContain('get_code_snippet(qualified_name: "demo.src.store.cbm_store_close"'); + expect(prompt).toContain('trace_path(qualified_name: "demo.src.store.cbm_store_close"'); + expect(prompt).toContain("Should src/store be split?"); + expect(prompt).toContain("USAGE edges mean the graph could not prove"); + }); +}); diff --git a/graph-ui/src/lib/atlas.ts b/graph-ui/src/lib/atlas.ts new file mode 100644 index 000000000..1754e4c4a --- /dev/null +++ b/graph-ui/src/lib/atlas.ts @@ -0,0 +1,237 @@ +/* CBM Atlas data layer: the /api services and the read-only /rpc tools the + * Atlas screens consume. */ +import { callTool } from "../api/rpc"; + +/* ── /api/tree ──────────────────────────────────────────────── */ + +export interface TreeChild { + name: string; + path: string; + kind: "dir" | "file"; + symbols: number; + files: number; + missed?: number; + region?: number; +} + +export interface TreePayload { + path: string; + files: number; + symbols: number; + children_dropped?: number; + children: TreeChild[]; +} + +async function getJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return res.json(); +} + +export function fetchTree(project: string, path: string): Promise { + const params = new URLSearchParams({ project, path }); + return getJson(`/api/tree?${params}`); +} + +/* ── /api/symbol ────────────────────────────────────────────── */ + +export interface SymbolRef { + id: number; + name: string; + label?: string; + file_path?: string; + start_line?: number; + qualified_name?: string; + type?: string; +} + +export interface ConnectionPage { + total: number; + by_type: Record; + limit: number; + offset: number; + items: SymbolRef[]; +} + +export interface SymbolBundle { + node: { + id: number; + label: string; + name: string; + qualified_name?: string; + file_path?: string; + start_line?: number; + end_line?: number; + docstring?: string; + is_entry?: boolean; + is_test?: boolean; + is_exported?: boolean; + }; + region?: { id: number; name?: string }; + callers: ConnectionPage; + callees: ConnectionPage; + tests?: SymbolRef[]; + co_change?: { file_path: string; score?: number }[]; + similar?: { id: number; name: string; file_path?: string; score?: number }[]; +} + +export function fetchSymbol( + project: string, + ref: { id?: number; qn?: string }, + limit = 50, + offset = 0, +): Promise { + const params = new URLSearchParams({ project, limit: String(limit), offset: String(offset) }); + if (ref.id !== undefined) params.set("id", String(ref.id)); + else if (ref.qn) params.set("qn", ref.qn); + return getJson(`/api/symbol?${params}`); +} + +/* ── /api/flows ─────────────────────────────────────────────── */ + +export interface FlowSummary { + id: number; + label: string; + entry: SymbolRef; + terminal: SymbolRef; + steps: number; + sink_terminated: boolean; + cross_region: boolean; + steps_capped?: number; +} + +export interface FlowsPayload { + callable_total: number; + candidates_dropped: number; + flows: FlowSummary[]; +} + +export interface FlowStep { + id: number; + name: string; + file_path?: string; + depth: number; + parent: number; +} + +export interface FlowDetail { + id: number; + entry: SymbolRef; + terminal: SymbolRef; + sink_terminated: boolean; + cross_region: boolean; + steps_capped?: number; + steps: FlowStep[]; +} + +export function fetchFlows(project: string): Promise { + return getJson(`/api/flows?${new URLSearchParams({ project })}`); +} + +export function fetchFlow(project: string, id: number): Promise { + return getJson(`/api/flow?${new URLSearchParams({ project, id: String(id) })}`); +} + +/* ── get_architecture (format:"json" → {cols, rows} sections) ── */ + +export interface ArchSection { + cols: string[]; + rows: (string | number | null)[][]; +} + +export type ArchitectureJson = Record; + +/* Decode one {cols, rows} section into row objects. Absent → []. */ +export function archRows>( + arch: ArchitectureJson | null, + section: string, +): T[] { + const s = arch?.[section]; + if (!s || typeof s !== "object" || !("cols" in s)) return []; + const { cols, rows } = s as ArchSection; + return rows.map((row) => { + const obj: Record = {}; + cols.forEach((col, i) => { + obj[col] = row[i] ?? null; + }); + return obj as T; + }); +} + +export function fetchArchitecture(project: string): Promise { + return callTool("get_architecture", { + project, + format: "json", + aspects: ["overview"], + }); +} + +/* ── detect_changes (format:"json") ─────────────────────────── */ + +export function fetchChanges(project: string): Promise { + return callTool("detect_changes", { + project, + format: "json", + risk_labels: true, + }); +} + +/* ── search_graph (format:"json") ─────────────────────────────── + * Shape: {total, count, cols, groups:[{qn_prefix, file, rows}], has_more}. + * The first row column is the qualified-name SUFFIX under qn_prefix. */ + +export interface SearchRow { + qualified_name: string; + name: string; + label: string; + lines?: string; + fan_in: number; + fan_out: number; + file?: string; +} + +interface SearchGroupsJson { + total?: number; + count?: number; + groups?: { + qn_prefix?: string; + file?: string; + rows: (string | number | null)[][]; + }[]; + has_more?: boolean; +} + +export function decodeSearchGroups(payload: SearchGroupsJson): SearchRow[] { + const out: SearchRow[] = []; + for (const group of payload.groups ?? []) { + for (const row of group.rows ?? []) { + const suffix = String(row[0] ?? ""); + const qualified = group.qn_prefix ? `${group.qn_prefix}.${suffix}` : suffix; + out.push({ + qualified_name: qualified, + name: suffix.split(".").pop() ?? suffix, + label: String(row[1] ?? ""), + lines: row[2] ? String(row[2]) : undefined, + fan_in: Number(row[3] ?? 0), + fan_out: Number(row[4] ?? 0), + file: group.file, + }); + } + } + return out; +} + +export async function searchGraph( + project: string, + args: Record, +): Promise<{ rows: SearchRow[]; total: number }> { + const payload = await callTool("search_graph", { + project, + format: "json", + ...args, + }); + return { rows: decodeSearchGroups(payload), total: payload.total ?? 0 }; +} diff --git a/graph-ui/src/lib/composer.ts b/graph-ui/src/lib/composer.ts new file mode 100644 index 000000000..d41b91564 --- /dev/null +++ b/graph-ui/src/lib/composer.ts @@ -0,0 +1,90 @@ +/* The prompt composer: the bridge from what the human learned to what the + * agent is told. A basket of cited entities becomes a prompt that names + * exact identifiers and the MCP calls that reproduce the context. */ + +export type BasketItem = + | { kind: "symbol"; id: number; name: string; qualified_name?: string; file_path?: string; start_line?: number; end_line?: number } + | { kind: "region"; id: number; name: string; members: number; why?: string } + | { kind: "flow"; id: number; label: string; steps: number } + | { kind: "question"; question: string; why: string }; + +export function basketKey(item: BasketItem): string { + switch (item.kind) { + case "symbol": + return `symbol:${item.id}`; + case "region": + return `region:${item.id}`; + case "flow": + return `flow:${item.id}`; + case "question": + return `question:${item.question}`; + } +} + +/* Render the basket + the human's goal into a prompt for a coding agent. + * Structure: goal → exact citations → how to reproduce the context via MCP + * → honesty notes. Deterministic; no timestamps. */ +export function composePrompt(project: string, goal: string, items: BasketItem[]): string { + const lines: string[] = []; + lines.push(goal.trim() || "Help me with the code cited below."); + lines.push(""); + lines.push(`Project (codebase-memory-mcp index): ${project}`); + + const symbols = items.filter((item) => item.kind === "symbol"); + const regions = items.filter((item) => item.kind === "region"); + const flows = items.filter((item) => item.kind === "flow"); + const questions = items.filter((item) => item.kind === "question"); + + if (symbols.length > 0) { + lines.push(""); + lines.push("Symbols involved (exact identifiers):"); + for (const s of symbols) { + const where = + s.file_path && s.start_line + ? ` — ${s.file_path}:${s.start_line}${s.end_line && s.end_line !== s.start_line ? `-${s.end_line}` : ""}` + : s.file_path + ? ` — ${s.file_path}` + : ""; + lines.push(`- ${s.qualified_name ?? s.name}${where}`); + } + } + if (regions.length > 0) { + lines.push(""); + lines.push("Modules (call-community regions from the graph):"); + for (const r of regions) { + lines.push( + `- ${r.name} (${r.members.toLocaleString("en-US")} symbols${r.why ? `; ${r.why}` : ""})`, + ); + } + } + if (flows.length > 0) { + lines.push(""); + lines.push("Flows (entry → terminal call journeys):"); + for (const f of flows) lines.push(`- ${f.label} (${f.steps} steps)`); + } + if (questions.length > 0) { + lines.push(""); + lines.push("Open questions:"); + for (const q of questions) lines.push(`- ${q.question} (${q.why})`); + } + + lines.push(""); + lines.push("To reproduce this context with the codebase-memory-mcp tools:"); + for (const s of symbols.slice(0, 5)) { + if (s.qualified_name) { + lines.push( + `- get_code_snippet(qualified_name: "${s.qualified_name}", project: "${project}")`, + ); + lines.push( + `- trace_path(qualified_name: "${s.qualified_name}", project: "${project}", direction: "inbound")`, + ); + } + } + lines.push(`- get_architecture(project: "${project}") for the module map`); + lines.push(""); + lines.push( + "Note: CALLS edges are resolved calls; USAGE edges mean the graph could not " + + "prove a single target — verify before relying on them.", + ); + return lines.join("\n"); +} diff --git a/graph-ui/src/lib/firstread.ts b/graph-ui/src/lib/firstread.ts new file mode 100644 index 000000000..40973825e --- /dev/null +++ b/graph-ui/src/lib/firstread.ts @@ -0,0 +1,115 @@ +/* The "first read": deterministic teaching artifacts computed from data the + * graph already holds — every insight carries the reason it is interesting. + * No model, no scores without provenance. */ +import type { Region, RegionsPayload } from "./types"; +import { disambiguateRegionNames } from "./regions"; + +export interface SurprisingCoupling { + source: Region; + target: Region; + weight: number; + reasons: string[]; +} + +/* Cross-region couplings ranked by an explained score: heavier links, links + * that cross top-level directories, and links touching a low-cohesion region + * rank higher. `misc` never surprises anyone. */ +export function surprisingCouplings( + payload: RegionsPayload, + limit = 5, +): SurprisingCoupling[] { + const regions = disambiguateRegionNames(payload.regions); + const byId = new Map(regions.map((region) => [region.id, region])); + const top = (name: string) => name.split("/")[0] ?? name; + const scored: (SurprisingCoupling & { score: number })[] = []; + for (const edge of payload.edges) { + const source = byId.get(edge.source); + const target = byId.get(edge.target); + if (!source || !target) continue; + if (source.name === "misc" || target.name === "misc") continue; + const reasons: string[] = []; + let score = Math.log2(1 + edge.weight); + reasons.push(`${edge.weight.toLocaleString("en-US")} edges cross the boundary`); + if (top(source.name) !== top(target.name)) { + score += 2; + reasons.push( + `links ${top(source.name)}/ to ${top(target.name)}/ — different top-level areas`, + ); + } + if (source.cohesion < 0.3 || target.cohesion < 0.3) { + score += 1; + const loose = source.cohesion < target.cohesion ? source : target; + reasons.push( + `${loose.name} holds together loosely (cohesion ${loose.cohesion.toFixed(2)})`, + ); + } + scored.push({ source, target, weight: edge.weight, reasons, score }); + } + scored.sort( + (a, b) => b.score - a.score || b.weight - a.weight || a.source.id - b.source.id, + ); + return scored.slice(0, limit).map(({ score: _score, ...rest }) => rest); +} + +export interface SuggestedQuestion { + question: string; + why: string; + /* Seed for the prompt composer: the entities the question is about. */ + about: string[]; +} + +export interface FirstReadStats { + deadCount?: number; + unresolvedShare?: number; /* USAGE / (CALLS+CALL_REFERENCE+USAGE), 0..1 */ +} + +/* Question templates keyed to structural signals — each one is a prompt + * starter the human can hand to their agent. */ +export function suggestedQuestions( + payload: RegionsPayload, + stats: FirstReadStats = {}, + limit = 5, +): SuggestedQuestion[] { + const regions = disambiguateRegionNames(payload.regions).filter( + (region) => region.name !== "misc", + ); + const questions: SuggestedQuestion[] = []; + + const couplings = surprisingCouplings(payload, 2); + for (const coupling of couplings) { + questions.push({ + question: `Why does ${coupling.source.name} depend on ${coupling.target.name}?`, + why: coupling.reasons.join("; "), + about: [coupling.source.name, coupling.target.name], + }); + } + + const loose = [...regions] + .filter((region) => region.members >= 50 && region.cohesion > 0 && region.cohesion < 0.25) + .sort((a, b) => a.cohesion - b.cohesion)[0]; + if (loose) { + questions.push({ + question: `Should ${loose.name} be split into smaller modules?`, + why: `only ${(loose.cohesion * 100).toFixed(0)}% of its edges stay inside the region (${loose.members.toLocaleString("en-US")} symbols)`, + about: [loose.name], + }); + } + + if (stats.deadCount && stats.deadCount > 0) { + questions.push({ + question: `Are the ${stats.deadCount.toLocaleString("en-US")} functions with no callers safe to delete?`, + why: "zero CALLS and zero USAGE reach them, excluding entry points and tests", + about: ["dead code"], + }); + } + + if (stats.unresolvedShare !== undefined && stats.unresolvedShare > 0.3) { + questions.push({ + question: "Which call sites does the graph fail to resolve, and why?", + why: `${(stats.unresolvedShare * 100).toFixed(0)}% of call-ish edges are USAGE (no proven single target)`, + about: ["resolution certainty"], + }); + } + + return questions.slice(0, limit); +} diff --git a/graph-ui/src/lib/treemap.ts b/graph-ui/src/lib/treemap.ts new file mode 100644 index 000000000..a3a7bb282 --- /dev/null +++ b/graph-ui/src/lib/treemap.ts @@ -0,0 +1,92 @@ +/* Squarified treemap layout (Bruls, Huizing & van Wijk 2000) — in-house so + * the Modules screen needs no new dependency. Deterministic: input order is + * preserved inside rows; items should arrive value-sorted descending. */ + +export interface TreemapItem { + id: string; + value: number; +} + +export interface TreemapRect { + id: string; + x: number; + y: number; + w: number; + h: number; +} + +/* Worst aspect ratio a row would have if `row` (+lengths) shared `side`. */ +function worst(row: number[], side: number, total: number): number { + if (row.length === 0 || total <= 0) return Infinity; + const sum = row.reduce((a, b) => a + b, 0); + const max = Math.max(...row); + const min = Math.min(...row); + const s2 = sum * sum; + const sideSq = side * side; + return Math.max((sideSq * max) / s2, s2 / (sideSq * min)); +} + +export function squarify( + items: TreemapItem[], + x: number, + y: number, + w: number, + h: number, +): TreemapRect[] { + const positive = items.filter((item) => item.value > 0); + const total = positive.reduce((a, item) => a + item.value, 0); + if (positive.length === 0 || total <= 0 || w <= 0 || h <= 0) return []; + /* Scale values to areas within the rect. */ + const scale = (w * h) / total; + let queue = positive.map((item) => ({ id: item.id, area: item.value * scale })); + const out: TreemapRect[] = []; + let cx = x; + let cy = y; + let cw = w; + let ch = h; + + while (queue.length > 0) { + const side = Math.min(cw, ch); + const row: typeof queue = []; + let rowAreas: number[] = []; + /* Grow the row while the worst aspect ratio improves. */ + while (queue.length > 0) { + const candidate = [...rowAreas, queue[0].area]; + if ( + rowAreas.length === 0 || + worst(candidate, side, 1) <= worst(rowAreas, side, 1) + ) { + row.push(queue[0]); + rowAreas = candidate; + queue = queue.slice(1); + } else { + break; + } + } + const rowArea = rowAreas.reduce((a, b) => a + b, 0); + if (cw >= ch) { + /* Vertical strip on the left. */ + const stripW = rowArea / ch; + let oy = cy; + for (const item of row) { + const cellH = item.area / stripW; + out.push({ id: item.id, x: cx, y: oy, w: stripW, h: cellH }); + oy += cellH; + } + cx += stripW; + cw -= stripW; + } else { + /* Horizontal strip on top. */ + const stripH = rowArea / cw; + let ox = cx; + for (const item of row) { + const cellW = item.area / stripH; + out.push({ id: item.id, x: ox, y: cy, w: cellW, h: stripH }); + ox += cellW; + } + cy += stripH; + ch -= stripH; + } + } + return out; +} diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index 7cd37fe11..cf4317d7f 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -113,7 +113,15 @@ export interface SchemaInfo { total_edges: number; } -export type TabId = "graph" | "stats" | "control"; +export type TabId = + | "overview" + | "modules" + | "graph" + | "flows" + | "changes" + | "symbol" + | "stats" + | "control"; export interface ProcessInfo { pid: number; diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index 1207dc1dc..d1716ee91 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/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/regionpanel.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.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.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/regions.test.ts","./src/lib/regions.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/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.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.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/firstread.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 1b473ab4a..428d73dff 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -2138,7 +2138,7 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, } /* GET /api/flow → one CBM Atlas flow */ - if (is_get && cbm_http_path_match(req->path, "/api/flow?*")) { + if (is_get && cbm_http_path_match(req->path, "/api/flow")) { handle_atlas_flow(c, req); return; } diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 18a1a5b00..f1df83347 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1184,6 +1184,26 @@ TEST(ui_server_rpc_allows_only_ui_read_tools) { PASS(); } +TEST(ui_server_atlas_routes_are_wired) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + /* A wired route with a missing ?project answers 400; an unwired path + * falls through to the static handler's 404. This pins the /api/flow + * (singular) route, which once used a "?*" pattern that never matched + * because req->path carries no query string. */ + static const char *const routes[] = {"/api/tree", "/api/symbol", "/api/flows", "/api/flow"}; + char req[256]; + char resp[4096]; + for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { + snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n\r\n", routes[i]); + int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 400); + } + th_server_stop(&ts); + PASS(); +} + TEST(ui_server_oversized_body_rejected) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); @@ -2340,6 +2360,7 @@ SUITE(httpd) { RUN_TEST(ui_server_rejects_foreign_and_null_origins); RUN_TEST(ui_server_mutations_require_json_content_type); RUN_TEST(ui_server_rpc_allows_only_ui_read_tools); + RUN_TEST(ui_server_atlas_routes_are_wired); RUN_TEST(ui_server_oversized_body_rejected); RUN_TEST(ui_server_encoded_slash_not_routed); RUN_TEST(ui_server_nul_in_target_rejected); From ae5988a102a07e8bdc1252fb2eb3262d52be4a0e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 00:54:50 +0200 Subject: [PATCH 07/52] docs: describe CBM Atlas in the README The UI section now names the two interfaces (MCP for the agent, Atlas for the human) and documents the screens: Overview, Modules, region-LOD Galaxy, Flows, Changes, Symbol pages, search and the prompt composer, plus deep-linking. Signed-off-by: Martin Vogel --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ca39ef418..01c4d8c44 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **158 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. - **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles. - **43 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries. -- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself. +- **CBM Atlas** — the built-in human interface at `localhost:9749`: overview, modules treemap, region-level 3D galaxy, call flows, change impact, symbol pages and a prompt composer, served from the binary itself. - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources. - **15 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. @@ -131,9 +131,9 @@ Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent priva The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details. -### Graph Visualization UI +### CBM Atlas — the human interface -The graph UI is built into the binary — every install on every channel has it. Then run it: +The MCP tools are the agent's interface to the code graph; **CBM Atlas** is the human's. It is built into the binary — every install on every channel has it: ```bash codebase-memory-mcp --ui=true --port=9749 @@ -141,6 +141,18 @@ codebase-memory-mcp --ui=true --port=9749 Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers. +Atlas is a code-discovery instrument, not a chart: every insight is deterministic, carries the reason it is shown, and links onward. + +- **Overview** — the first read of a project: regions (Leiden call communities voted per file, named with provenance and cohesion), hubs by fan-in, entry points, surprising cross-region couplings with reasons, suggested questions, cross-package boundaries. +- **Modules** — a treemap of any folder, sized by symbols, colored by region, hatched where indexing was incomplete; drill to files and symbols. +- **Galaxy** — the 3D view, opening at region level of detail (one body per region, weighted arcs) so any project size renders; open a region for full detail, or load the whole galaxy explicitly. +- **Flows** — named entry→terminal call journeys as clickable call trees, with truncation stated, exportable as mermaid. +- **Changes** — `detect_changes` on the working tree: affected symbols grouped by risk before you ask an agent to finish the job. +- **Symbol pages** — docstring, region, callers/callees with true totals and per-edge confidence (resolved / reference / unproven), tests, co-change partners, near-clones, editor and GitHub links. +- **⌘K** search across symbol names and code text; a **prompt composer** turns anything you cite into a precise, identifier-exact prompt for your coding agent. + +Every screen deep-links (`?tab=…&project=…&sym=…®ion=…&flow=…`), so a place in the codebase is a URL you can share. + ### Auto-Index Enable automatic indexing on MCP session start: From 16b90077dbf15d2665f030e33e2961929afe9cb1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 01:09:25 +0200 Subject: [PATCH 08/52] =?UTF-8?q?feat(ui):=20the=20Atlas=20Dashboard=20?= =?UTF-8?q?=E2=80=94=20graph-derived=20metrics=20with=20drill-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numbers view of a project, with the code behind every number one click away and no composite health score — the cards are the score: - /api/metrics (cached per project+indexed_at): complexity and function- length histograms and top lists straight from pass_complexity's stored cyclomatic/cognitive/lines numbers, edge certainty (CALLS vs CALL_REFERENCE vs USAGE), dead-code candidates (same exclusions as the galaxy classifier), TESTS linkage, SIMILAR_TO duplication, missed files, documented-exports coverage. - Per-file churn from one bounded git log pass (1 year, 10k commits, the pass_githistory bounds) computed at metrics time — ZERO cost added to indexing — plus churn × complexity, the classic where-the-bugs-live list. - A per-index history sidecar (JSONL in the cache dir, one snapshot per indexed_at) feeds trend sparklines across reindexes. - Dashboard tab: stat cards with warn tones, histograms, top lists linking to symbol pages; churn honestly marked unavailable when the project has no readable git history. The wired-routes test covers /api/metrics; the ui suite pins totals, certainty, histogram completeness and history presence on a fixture. Signed-off-by: Martin Vogel --- Makefile.cbm | 1 + graph-ui/src/App.tsx | 10 +- graph-ui/src/components/DashboardTab.tsx | 309 +++++++++++++ graph-ui/src/lib/types.ts | 1 + graph-ui/tsconfig.tsbuildinfo | 2 +- src/ui/atlas.h | 9 + src/ui/atlas_metrics.c | 539 +++++++++++++++++++++++ src/ui/http_server.c | 17 + tests/test_httpd.c | 3 +- tests/test_ui.c | 46 ++ 10 files changed, 934 insertions(+), 3 deletions(-) create mode 100644 graph-ui/src/components/DashboardTab.tsx create mode 100644 src/ui/atlas_metrics.c diff --git a/Makefile.cbm b/Makefile.cbm index d8f43fe6c..431872f95 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -390,6 +390,7 @@ UI_SRCS = \ src/ui/layout_regions.c \ src/ui/atlas_api.c \ src/ui/atlas_flows.c \ + src/ui/atlas_metrics.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 6d21ad144..ad18efb81 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -7,6 +7,7 @@ import { ModulesTab } from "./components/ModulesTab"; import { SymbolTab } from "./components/SymbolTab"; import { FlowsTab } from "./components/FlowsTab"; import { ChangesTab } from "./components/ChangesTab"; +import { DashboardTab } from "./components/DashboardTab"; import { CommandK } from "./components/CommandK"; import { PromptBasketProvider, @@ -21,13 +22,14 @@ const TAB_IDS: TabId[] = [ "graph", "flows", "changes", + "dashboard", "symbol", "stats", "control", ]; /* Tabs that need a selected project. "symbol" is reachable only via links. */ -const PROJECT_TABS: TabId[] = ["overview", "modules", "graph", "flows", "changes"]; +const PROJECT_TABS: TabId[] = ["overview", "modules", "graph", "flows", "changes", "dashboard"]; interface RouteState { tab: TabId; @@ -137,6 +139,7 @@ export function App() { { id: "graph", label: "Galaxy" }, { id: "flows", label: "Flows" }, { id: "changes", label: "Changes" }, + { id: "dashboard", label: "Dashboard" }, { id: "stats", label: t.tabs.projects }, { id: "control", label: t.tabs.control }, ]; @@ -251,6 +254,11 @@ export function App() { /> ) : activeTab === "changes" && selectedProject ? ( + ) : activeTab === "dashboard" && selectedProject ? ( + openSymbol(ref)} + /> ) : activeTab === "symbol" && selectedProject && symbolRef ? ( void; +} + +const CPLX_LABELS = ["1", "2–5", "6–10", "11–20", "21–50", ">50"]; +const LINE_LABELS = ["≤10", "≤30", "≤60", "≤120", ">120"]; + +function Histogram({ values, labels }: { values: number[]; labels: string[] }) { + const max = Math.max(1, ...values); + return ( +
+ {values.map((value, index) => ( +
+ + {value.toLocaleString("en-US")} + +
+ {labels[index]} +
+ ))} +
+ ); +} + +function Sparkline({ points }: { points: number[] }) { + if (points.length < 2) return null; + const max = Math.max(...points); + const min = Math.min(...points); + const span = max - min || 1; + const coords = points + .map( + (point, index) => + `${(index / (points.length - 1)) * 100},${28 - ((point - min) / span) * 24}`, + ) + .join(" "); + return ( + + + + ); +} + +function StatCard({ + label, + value, + sub, + tone, + trend, +}: { + label: string; + value: string; + sub?: string; + tone?: "warn" | "crit"; + trend?: number[]; +}) { + return ( +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} + {trend && trend.length > 1 && } +
+ ); +} + +function TopList({ + title, + entries, + unit, + onOpenSymbol, +}: { + title: string; + entries: MetricEntry[]; + unit: string; + onOpenSymbol?: (ref: { qn: string }) => void; +}) { + return ( +
+

{title}

+
+ {entries.map((entry, index) => { + const label = entry.name ?? entry.file ?? "?"; + const clickable = entry.qn && onOpenSymbol; + const Row = clickable ? "button" : "div"; + return ( + onOpenSymbol!({ qn: entry.qn! }) : undefined} + className={`flex items-center gap-2 w-full text-left py-[3px] ${ + clickable ? "group cursor-pointer" : "" + }`} + > + + {label} + + + {entry.value.toLocaleString("en-US")} {unit} + + + ); + })} + {entries.length === 0 &&

None.

} +
+
+ ); +} + +export function DashboardTab({ project, onOpenSymbol }: DashboardTabProps) { + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setMetrics(null); + setError(null); + fetch(`/api/metrics?${new URLSearchParams({ project })}`) + .then(async (res) => { + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + return res.json(); + }) + .then((payload) => { + if (!cancelled) setMetrics(payload); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!metrics) { + return ( +
+

Measuring…

+
+ ); + } + + const { totals, certainty } = metrics; + const callish = certainty.calls + certainty.call_reference + certainty.usage; + const resolvedShare = callish > 0 ? (certainty.calls + certainty.call_reference) / callish : 0; + const docShare = totals.exported > 0 ? totals.documented_exported / totals.exported : 0; + const testedShare = totals.callables > 0 ? totals.tested_symbols / totals.callables : 0; + const history = metrics.history ?? []; + + return ( + +
+

+ computed from index {metrics.generated_from} + {history.length > 1 && ` · trends across ${history.length} index runs`} +

+ + {/* Cards */} +
+ entry.callables)} + /> + 1 - entry.usage_share)} + /> + 0 ? "warn" : undefined} + trend={history.map((entry) => entry.dead)} + /> + entry.tested)} + /> + + + 0 ? "warn" : undefined} + /> +
+

+ Complexity distribution +

+ +
+
+ +
+
+

+ Function length (lines) +

+ +
+ +
+ +
+ + + + +
+
+
+ ); +} diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index cf4317d7f..76df89c33 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -119,6 +119,7 @@ export type TabId = | "graph" | "flows" | "changes" + | "dashboard" | "symbol" | "stats" | "control"; diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index d1716ee91..255a8b2a1 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/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.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.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/firstread.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.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/changestab.tsx","./src/components/commandk.tsx","./src/components/controltab.tsx","./src/components/dashboardtab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/flowstab.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.regions.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/modulestab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.showmore.test.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/overviewtab.tsx","./src/components/projectcard.tsx","./src/components/promptbasket.tsx","./src/components/regionpanel.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/symboltab.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.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/atlas.test.ts","./src/lib/atlas.ts","./src/lib/colors.ts","./src/lib/composer.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/firstread.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/regions.test.ts","./src/lib/regions.ts","./src/lib/treemap.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/ui/atlas.h b/src/ui/atlas.h index 5774deb51..48670614d 100644 --- a/src/ui/atlas.h +++ b/src/ui/atlas.h @@ -36,6 +36,15 @@ char *cbm_atlas_flow_json(cbm_store_t *store, const char *project, int flow_id); /* Drop the flows cache (tests). */ void cbm_atlas_flows_cache_clear(void); +/* ── Dashboard metrics (GET /api/metrics) ───────────────────────── + * Graph-derived metrics with drill-down lists: complexity/length + * histograms and tops (from pass_complexity's stored numbers), edge + * certainty, dead code, tests/duplication/missed counts, per-file churn + * from a bounded `git log` (zero indexing cost), churn×complexity, and a + * per-index history sidecar for trends. Cached per (project, indexed_at). */ +char *cbm_atlas_metrics_json(cbm_store_t *store, const char *project); +void cbm_atlas_metrics_cache_clear(void); + /* ── Region lookups (layout_regions.c) ──────────────────────────── * Cache-backed; computing on first use. name_out (optional) receives a * heap copy the caller frees. Returns region id or -1. */ diff --git a/src/ui/atlas_metrics.c b/src/ui/atlas_metrics.c new file mode 100644 index 000000000..bc4387ab4 --- /dev/null +++ b/src/ui/atlas_metrics.c @@ -0,0 +1,539 @@ +/* + * atlas_metrics.c — the CBM Atlas Dashboard: graph-derived metrics with + * drill-down, computed from data the index already stores plus one bounded + * `git log` pass — ZERO cost added to indexing time. + * + * Tier 0 (stored): cyclomatic + cognitive complexity, lines and flags per + * callable (pass_complexity), edge certainty (CALLS vs CALL_REFERENCE vs + * USAGE), dead code, TESTS linkage, SIMILAR_TO duplication, missed files. + * Tier 1 (computed here, cached): per-file churn from `git log` (same + * bounds as pass_githistory: 1 year, ≤10k commits), churn × complexity. + * History: one snapshot per indexed_at appended to a sidecar JSONL in the + * cache dir, so the dashboard can show trends across reindexes. + */ +#include "foundation/constants.h" +#include "ui/atlas.h" +#include "foundation/compat_fs.h" +#include "foundation/hash_table.h" +#include "foundation/str_util.h" +#include "foundation/workspace.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +enum { + MX_TOP_N = 10, /* entries per drill-down list */ + MX_HISTORY_MAX = 60, /* snapshots served to the dashboard */ + MX_CHURN_MIN_CPLX = 10, /* churn×complexity: only genuinely complex files */ +}; + +/* Complexity histogram bucket upper bounds (inclusive); last is open. */ +static const int MX_CPLX_BUCKETS[] = {1, 5, 10, 20, 50}; +static const int MX_LINE_BUCKETS[] = {10, 30, 60, 120}; + +typedef struct { + char *qn; + char *name; + char *file; + long long value; +} mx_entry_t; + +typedef struct { + mx_entry_t items[MX_TOP_N]; + int count; +} mx_top_t; + +/* Keep the MX_TOP_N largest values (stable for ties: first seen wins). */ +static void mx_top_offer(mx_top_t *top, const char *qn, const char *name, const char *file, + long long value) { + int pos = top->count < MX_TOP_N ? top->count : MX_TOP_N; + while (pos > 0 && top->items[pos - 1].value < value) + pos--; + if (pos >= MX_TOP_N) + return; + mx_entry_t incoming; + incoming.qn = qn ? strdup(qn) : NULL; + incoming.name = name ? strdup(name) : NULL; + incoming.file = file ? strdup(file) : NULL; + incoming.value = value; + mx_entry_t last = {0}; + if (top->count == MX_TOP_N) { + last = top->items[MX_TOP_N - 1]; + } else { + top->count++; + } + for (int i = top->count - 1; i > pos; i--) + top->items[i] = top->items[i - 1]; + top->items[pos] = incoming; + free(last.qn); + free(last.name); + free(last.file); +} + +static void mx_top_free(mx_top_t *top) { + for (int i = 0; i < top->count; i++) { + free(top->items[i].qn); + free(top->items[i].name); + free(top->items[i].file); + } + top->count = 0; +} + +static void mx_top_json(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *field, + const mx_top_t *top) { + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int i = 0; i < top->count; i++) { + yyjson_mut_val *obj = yyjson_mut_obj(doc); + if (top->items[i].qn) + yyjson_mut_obj_add_strcpy(doc, obj, "qn", top->items[i].qn); + if (top->items[i].name) + yyjson_mut_obj_add_strcpy(doc, obj, "name", top->items[i].name); + if (top->items[i].file) + yyjson_mut_obj_add_strcpy(doc, obj, "file", top->items[i].file); + yyjson_mut_obj_add_int(doc, obj, "value", top->items[i].value); + yyjson_mut_arr_append(arr, obj); + } + yyjson_mut_obj_add_val(doc, root, field, arr); +} + +/* ── Cache ────────────────────────────────────────────────────── */ + +typedef struct { + char key[1152]; + char *json; +} mx_cache_t; + +static mx_cache_t g_mx_cache; +static pthread_mutex_t g_mx_mu = PTHREAD_MUTEX_INITIALIZER; + +void cbm_atlas_metrics_cache_clear(void) { + pthread_mutex_lock(&g_mx_mu); + free(g_mx_cache.json); + memset(&g_mx_cache, 0, sizeof(g_mx_cache)); + pthread_mutex_unlock(&g_mx_mu); +} + +static void mx_project_stamp(cbm_store_t *store, const char *project, char *stamp, size_t cap, + char *root_path, size_t root_cap) { + stamp[0] = '\0'; + if (root_path) + root_path[0] = '\0'; + struct sqlite3 *db = cbm_store_get_db(store); + sqlite3_stmt *st = NULL; + if (db && sqlite3_prepare_v2(db, "SELECT indexed_at, root_path FROM projects WHERE name=?1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *t = (const char *)sqlite3_column_text(st, 0); + const char *r = (const char *)sqlite3_column_text(st, 1); + if (t) + snprintf(stamp, cap, "%s", t); + if (r && root_path) + snprintf(root_path, root_cap, "%s", r); + } + sqlite3_finalize(st); + } +} + +/* ── Churn: one bounded `git log` pass, cached with everything else ── */ + +typedef struct { + CBMHashTable *counts; /* file_path → (intptr_t)count; keys owned by list */ + char **keys; + int key_count; + int key_cap; +} mx_churn_t; + +static void mx_churn_scan(const char *root_path, mx_churn_t *churn) { + if (!root_path || !root_path[0] || !cbm_validate_shell_path_arg(root_path)) + return; + churn->counts = cbm_ht_create(1024); + if (!churn->counts) + return; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + char cmd[CBM_SZ_2K]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" log --name-only --pretty=format: --since=\"1 year ago\" " + "--max-count=10000 2>%s", + root_path, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) + return; + char line[CBM_SZ_1K]; + while (fgets(line, sizeof(line), fp)) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) + line[--len] = '\0'; + if (len == 0) + continue; + const char *canon = cbm_ht_get_key(churn->counts, line); + if (!canon) { + if (churn->key_count >= churn->key_cap) { + int nc = churn->key_cap ? churn->key_cap * 2 : 1024; + char **grown = realloc(churn->keys, (size_t)nc * sizeof(char *)); + if (!grown) + break; + churn->keys = grown; + churn->key_cap = nc; + } + churn->keys[churn->key_count] = strdup(line); + if (!churn->keys[churn->key_count]) + break; + canon = churn->keys[churn->key_count]; + churn->key_count++; + } + intptr_t count = (intptr_t)cbm_ht_get(churn->counts, canon); + cbm_ht_set(churn->counts, canon, (void *)(count + 1)); + } + cbm_pclose(fp); +} + +static void mx_churn_free(mx_churn_t *churn) { + cbm_ht_free(churn->counts); + for (int i = 0; i < churn->key_count; i++) + free(churn->keys[i]); + free(churn->keys); + memset(churn, 0, sizeof(*churn)); +} + +/* ── History sidecar (JSONL, one snapshot per indexed_at) ─────── */ + +static void mx_history_path(const char *project, char *out, size_t cap) { + const char *cache = cbm_workspace_cache_dir(); + snprintf(out, cap, "%s/atlas-metrics-%s.jsonl", cache ? cache : ".", project); +} + +static void mx_history_append_and_emit(yyjson_mut_doc *doc, yyjson_mut_val *root, + const char *project, const char *stamp, + long long callables, long long dead, double avg_cplx, + long long tested, long long usage_edges, + long long callish_edges) { + if (!cbm_validate_project_name(project)) + return; + char path[CBM_SZ_1K]; + mx_history_path(project, path, sizeof(path)); + + /* Read existing lines (bounded). */ + char *lines[MX_HISTORY_MAX]; + int line_count = 0; + bool stamp_present = false; + FILE *fp = cbm_fopen(path, "r"); + if (fp) { + char buf[CBM_SZ_2K]; + while (fgets(buf, sizeof(buf), fp)) { + if (strstr(buf, stamp)) + stamp_present = true; + if (line_count == MX_HISTORY_MAX) { + free(lines[0]); + memmove(lines, lines + 1, (size_t)(MX_HISTORY_MAX - 1) * sizeof(char *)); + line_count--; + } + lines[line_count] = strdup(buf); + if (!lines[line_count]) + break; + line_count++; + } + fclose(fp); + } + + /* Append this index generation once. */ + if (!stamp_present && stamp[0]) { + FILE *out = cbm_fopen(path, "a"); + if (out) { + fprintf(out, + "{\"indexed_at\":\"%s\",\"callables\":%lld,\"dead\":%lld," + "\"avg_complexity\":%.2f,\"tested\":%lld,\"usage_share\":%.4f}\n", + stamp, callables, dead, avg_cplx, tested, + callish_edges > 0 ? (double)usage_edges / (double)callish_edges : 0.0); + fclose(out); + } + } + + yyjson_mut_val *history = yyjson_mut_arr(doc); + for (int i = 0; i < line_count; i++) { + yyjson_doc *entry = yyjson_read(lines[i], strlen(lines[i]), 0); + if (entry) { + yyjson_mut_val *copy = yyjson_val_mut_copy(doc, yyjson_doc_get_root(entry)); + if (copy) + yyjson_mut_arr_append(history, copy); + yyjson_doc_free(entry); + } + free(lines[i]); + } + yyjson_mut_obj_add_val(doc, root, "history", history); +} + +/* ── Build ────────────────────────────────────────────────────── */ + +static char *mx_build_json(cbm_store_t *store, const char *project, const char *stamp, + const char *root_path) { + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return NULL; + + 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_obj_add_strcpy(doc, root, "generated_from", stamp); + + /* A. Callable scan: histograms, docstring coverage, top lists, per-file + * max complexity for the churn join. */ + long long callables = 0, cplx_sum = 0, documented_exported = 0, exported = 0; + long long cplx_hist[6] = {0}; + long long line_hist[5] = {0}; + mx_top_t top_complex = {0}, top_cognitive = {0}, top_long = {0}; + CBMHashTable *file_cplx = cbm_ht_create(1024); /* file → max complexity */ + char **file_keys = NULL; + int file_key_count = 0, file_key_cap = 0; + + sqlite3_stmt *st = NULL; + const char *scan_sql = + "SELECT name, qualified_name, file_path, " + "COALESCE(json_extract(properties,'$.complexity'),0), " + "COALESCE(json_extract(properties,'$.cognitive'),0), " + "COALESCE(json_extract(properties,'$.lines'),0), " + "CASE WHEN json_extract(properties,'$.docstring') IS NOT NULL THEN 1 ELSE 0 END, " + "COALESCE(json_extract(properties,'$.is_exported'),0) " + "FROM nodes WHERE project=?1 AND label IN (" CBM_SQL_CALLABLE_LABELS ")"; + if (sqlite3_prepare_v2(db, scan_sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(st, 0); + const char *qn = (const char *)sqlite3_column_text(st, 1); + const char *file = (const char *)sqlite3_column_text(st, 2); + long long cplx = sqlite3_column_int64(st, 3); + long long cognitive = sqlite3_column_int64(st, 4); + long long lines = sqlite3_column_int64(st, 5); + bool documented = sqlite3_column_int(st, 6) != 0; + bool is_exported = sqlite3_column_int(st, 7) != 0; + callables++; + cplx_sum += cplx; + int bucket = 0; + while (bucket < 5 && cplx > MX_CPLX_BUCKETS[bucket]) + bucket++; + cplx_hist[bucket]++; + bucket = 0; + while (bucket < 4 && lines > MX_LINE_BUCKETS[bucket]) + bucket++; + line_hist[bucket]++; + if (is_exported) { + exported++; + if (documented) + documented_exported++; + } + if (cplx > 1) + mx_top_offer(&top_complex, qn, name, file, cplx); + if (cognitive > 1) + mx_top_offer(&top_cognitive, qn, name, file, cognitive); + if (lines > 1) + mx_top_offer(&top_long, qn, name, file, lines); + if (file && file[0] && file_cplx) { + const char *canon = cbm_ht_get_key(file_cplx, file); + if (!canon) { + if (file_key_count >= file_key_cap) { + int nc = file_key_cap ? file_key_cap * 2 : 1024; + char **grown = realloc(file_keys, (size_t)nc * sizeof(char *)); + if (grown) { + file_keys = grown; + file_key_cap = nc; + } + } + if (file_key_count < file_key_cap) { + file_keys[file_key_count] = strdup(file); + if (file_keys[file_key_count]) { + canon = file_keys[file_key_count]; + file_key_count++; + } + } + } + if (canon) { + intptr_t prev = (intptr_t)cbm_ht_get(file_cplx, canon); + if (cplx > prev) + cbm_ht_set(file_cplx, canon, (void *)(intptr_t)cplx); + } + } + } + sqlite3_finalize(st); + } + + /* B. Edge certainty. */ + long long calls_edges = 0, ref_edges = 0, usage_edges = 0; + if (sqlite3_prepare_v2(db, + "SELECT type, COUNT(*) FROM edges WHERE project=?1 AND type IN " + "('CALLS','CALL_REFERENCE','USAGE') GROUP BY type", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *type = (const char *)sqlite3_column_text(st, 0); + long long count = sqlite3_column_int64(st, 1); + if (type && strcmp(type, "CALLS") == 0) + calls_edges = count; + else if (type && strcmp(type, "CALL_REFERENCE") == 0) + ref_edges = count; + else if (type) + usage_edges = count; + } + sqlite3_finalize(st); + } + + /* C. Dead code (candidates only; entry/test/exported excluded, matching + * the galaxy's classifier). */ + long long dead = 0; + if (sqlite3_prepare_v2( + db, + "SELECT COUNT(*) FROM nodes n WHERE n.project=?1 AND n.label IN " + "(" CBM_SQL_CALLABLE_LABELS ") AND " + "COALESCE(json_extract(n.properties,'$.is_entry_point'),0)=0 AND " + "COALESCE(json_extract(n.properties,'$.is_test'),0)=0 AND " + "COALESCE(json_extract(n.properties,'$.is_exported'),0)=0 AND " + "NOT EXISTS (SELECT 1 FROM edges e WHERE e.project=n.project AND " + "e.target_id=n.id AND e.type IN ('CALLS','CALL_REFERENCE','USAGE'))", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) + dead = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + + /* D. Tests + duplication. */ + long long tested = 0, similar_edges = 0; + if (sqlite3_prepare_v2(db, + "SELECT COUNT(DISTINCT target_id) FROM edges WHERE project=?1 AND " + "type='TESTS'", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) + tested = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + if (sqlite3_prepare_v2(db, + "SELECT COUNT(*) FROM edges WHERE project=?1 AND type='SIMILAR_TO'", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) + similar_edges = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + + /* E. Missed files (coverage shadow project, #963). */ + long long missed_files = 0; + { + char shadow[CBM_SZ_512]; + cbm_store_coverage_shadow_project(shadow, sizeof(shadow), project); + if (sqlite3_prepare_v2(db, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND label='File'", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, shadow, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) + missed_files = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + } + + /* F. Churn (git, bounded) + churn × complexity. */ + mx_churn_t churn = {0}; + mx_churn_scan(root_path, &churn); + mx_top_t top_churn = {0}, top_risky = {0}; + for (int i = 0; i < churn.key_count; i++) { + const char *file = churn.keys[i]; + long long commits = (intptr_t)cbm_ht_get(churn.counts, file); + mx_top_offer(&top_churn, NULL, NULL, file, commits); + intptr_t max_cplx = file_cplx ? (intptr_t)cbm_ht_get(file_cplx, file) : 0; + if (max_cplx >= MX_CHURN_MIN_CPLX) + mx_top_offer(&top_risky, NULL, NULL, file, commits * (long long)max_cplx); + } + + /* ── Serialize ───────────────────────────────────────────── */ + yyjson_mut_val *totals = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, totals, "callables", callables); + yyjson_mut_obj_add_int(doc, totals, "dead", dead); + yyjson_mut_obj_add_int(doc, totals, "tested_symbols", tested); + yyjson_mut_obj_add_int(doc, totals, "similar_edges", similar_edges); + yyjson_mut_obj_add_int(doc, totals, "missed_files", missed_files); + yyjson_mut_obj_add_int(doc, totals, "exported", exported); + yyjson_mut_obj_add_int(doc, totals, "documented_exported", documented_exported); + yyjson_mut_obj_add_real(doc, totals, "avg_complexity", + callables > 0 ? (double)cplx_sum / (double)callables : 0.0); + yyjson_mut_obj_add_val(doc, root, "totals", totals); + + yyjson_mut_val *certainty = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, certainty, "calls", calls_edges); + yyjson_mut_obj_add_int(doc, certainty, "call_reference", ref_edges); + yyjson_mut_obj_add_int(doc, certainty, "usage", usage_edges); + yyjson_mut_obj_add_val(doc, root, "certainty", certainty); + + yyjson_mut_val *cplx_arr = yyjson_mut_arr(doc); + for (int i = 0; i < 6; i++) + yyjson_mut_arr_add_int(doc, cplx_arr, cplx_hist[i]); + yyjson_mut_obj_add_val(doc, root, "complexity_hist", cplx_arr); + yyjson_mut_val *line_arr = yyjson_mut_arr(doc); + for (int i = 0; i < 5; i++) + yyjson_mut_arr_add_int(doc, line_arr, line_hist[i]); + yyjson_mut_obj_add_val(doc, root, "lines_hist", line_arr); + + mx_top_json(doc, root, "top_complex", &top_complex); + mx_top_json(doc, root, "top_cognitive", &top_cognitive); + mx_top_json(doc, root, "top_long", &top_long); + mx_top_json(doc, root, "top_churn", &top_churn); + mx_top_json(doc, root, "top_churn_complex", &top_risky); + yyjson_mut_obj_add_bool(doc, root, "churn_available", churn.counts != NULL); + + long long callish = calls_edges + ref_edges + usage_edges; + mx_history_append_and_emit(doc, root, project, stamp, callables, dead, + callables > 0 ? (double)cplx_sum / (double)callables : 0.0, + tested, usage_edges, callish); + + mx_top_free(&top_complex); + mx_top_free(&top_cognitive); + mx_top_free(&top_long); + mx_top_free(&top_churn); + mx_top_free(&top_risky); + mx_churn_free(&churn); + cbm_ht_free(file_cplx); + for (int i = 0; i < file_key_count; i++) + free(file_keys[i]); + free(file_keys); + + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +char *cbm_atlas_metrics_json(cbm_store_t *store, const char *project) { + if (!store || !project) + return NULL; + char stamp[128]; + char root_path[CBM_SZ_1K]; + mx_project_stamp(store, project, stamp, sizeof(stamp), root_path, sizeof(root_path)); + char key[1280]; + snprintf(key, sizeof(key), "%s|%s", project, stamp); + pthread_mutex_lock(&g_mx_mu); + if (g_mx_cache.json && strcmp(g_mx_cache.key, key) == 0) { + char *copy = strdup(g_mx_cache.json); + pthread_mutex_unlock(&g_mx_mu); + return copy; + } + pthread_mutex_unlock(&g_mx_mu); + + char *json = mx_build_json(store, project, stamp, root_path); + if (!json) + return NULL; + pthread_mutex_lock(&g_mx_mu); + free(g_mx_cache.json); + g_mx_cache.json = strdup(json); + snprintf(g_mx_cache.key, sizeof(g_mx_cache.key), "%s", key); + pthread_mutex_unlock(&g_mx_mu); + return json; +} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 428d73dff..ed02d649d 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1818,6 +1818,17 @@ static void handle_atlas_symbol(cbm_http_conn_t *c, const cbm_http_req_t *req) { atlas_reply_json(c, json, 404, "{\"error\":\"symbol not found\"}"); } +/* GET /api/metrics?project=X — the Dashboard payload. */ +static void handle_atlas_metrics(cbm_http_conn_t *c, const cbm_http_req_t *req) { + char project[256] = {0}; + cbm_store_t *store = atlas_open_project(c, req, project, sizeof(project)); + if (!store) + return; + char *json = cbm_atlas_metrics_json(store, project); + cbm_store_close(store); + atlas_reply_json(c, json, 500, "{\"error\":\"metrics computation failed\"}"); +} + /* GET /api/flows?project=X — ranked entry→terminal flows. */ static void handle_atlas_flows(cbm_http_conn_t *c, const cbm_http_req_t *req) { char project[256] = {0}; @@ -2131,6 +2142,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + /* GET /api/metrics → CBM Atlas dashboard payload */ + if (is_get && cbm_http_path_match(req->path, "/api/metrics*")) { + handle_atlas_metrics(c, req); + return; + } + /* GET /api/flows → CBM Atlas flow list */ if (is_get && cbm_http_path_match(req->path, "/api/flows*")) { handle_atlas_flows(c, req); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index f1df83347..8064fea72 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1191,7 +1191,8 @@ TEST(ui_server_atlas_routes_are_wired) { * falls through to the static handler's 404. This pins the /api/flow * (singular) route, which once used a "?*" pattern that never matched * because req->path carries no query string. */ - static const char *const routes[] = {"/api/tree", "/api/symbol", "/api/flows", "/api/flow"}; + static const char *const routes[] = {"/api/tree", "/api/symbol", "/api/flows", "/api/flow", + "/api/metrics"}; char req[256]; char resp[4096]; for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { diff --git a/tests/test_ui.c b/tests/test_ui.c index 2d60424c5..0f402294f 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -1300,6 +1300,51 @@ TEST(atlas_flows_walk_and_rank) { PASS(); } +TEST(atlas_metrics_totals_and_certainty) { + cbm_layout_regions_cache_clear(); + cbm_atlas_metrics_cache_clear(); + cbm_store_t *store = atlas_fixture(NULL); + ASSERT_NOT_NULL(store); + + char *json = cbm_atlas_metrics_json(store, "regions-test"); + ASSERT_NOT_NULL(json); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + + yyjson_val *totals = yyjson_obj_get(root, "totals"); + /* 6 fixture functions + test_alpha_one. */ + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(totals, "callables")), 7); + /* test_alpha_one has no callers but is in a test path — flags absent, so + * it counts as dead alongside beta callables the cycle keeps alive; + * alpha_one is entry-flagged and beta_* all have inbound CALLS: only the + * test function has zero inbound call-ish edges. */ + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(totals, "dead")), 1); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(totals, "tested_symbols")), 1); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(totals, "similar_edges")), 1); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(totals, "missed_files")), 1); + + yyjson_val *certainty = yyjson_obj_get(root, "certainty"); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(certainty, "calls")), 9); + + /* Histograms cover every callable exactly once. */ + yyjson_val *hist = yyjson_obj_get(root, "complexity_hist"); + long long sum = 0; + size_t idx, max; + yyjson_val *bucket; + yyjson_arr_foreach(hist, idx, max, bucket) sum += yyjson_get_int(bucket); + ASSERT_EQ((int)sum, 7); + + ASSERT_NOT_NULL(yyjson_obj_get(root, "history")); + + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + cbm_atlas_metrics_cache_clear(); + cbm_layout_regions_cache_clear(); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -1336,4 +1381,5 @@ SUITE(ui) { RUN_TEST(atlas_tree_aggregates_children); RUN_TEST(atlas_symbol_bundle_totals_and_sections); RUN_TEST(atlas_flows_walk_and_rank); + RUN_TEST(atlas_metrics_totals_and_certainty); } From 71a80ed1516527a6225e9d74786542bcfacb36a1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 09:08:34 +0200 Subject: [PATCH 09/52] fix(ui): render the Projects page immediately; fetch schemas lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the /rpc allowlist revealed a pre-existing cost: the Projects tab awaited get_graph_schema for EVERY project before rendering anything, and a schema scan walks every node and edge — ~30 s on a 13 GB kernel index. (Before #1663's fix those calls failed instantly, which is why the page ever felt fast.) list_projects already reports per-project node/edge totals, so the page now renders from those immediately; per-label chips arrive lazily, one schema at a time, patching each card as it lands — and projects above a million nodes skip the schema scan entirely, keeping a card with totals and no chips rather than burning half a minute of server time per load. Signed-off-by: Martin Vogel --- graph-ui/src/components/StatsTab.tsx | 12 +++++--- graph-ui/src/hooks/useProjects.ts | 43 +++++++++++++++++----------- graph-ui/src/lib/types.ts | 4 +++ 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 644805812..fd32d39eb 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -506,8 +506,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) { const aggregate = useMemo(() => { let totalNodes = 0, totalEdges = 0; for (const p of projects) { - totalNodes += p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0; - totalEdges += p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; + totalNodes += + p.project.nodes ?? p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0; + totalEdges += + p.project.edges ?? p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; } return { projects: projects.length, nodes: totalNodes, edges: totalEdges }; }, [projects]); @@ -556,8 +558,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{projects.map((p) => { - const totalNodes = p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0; - const totalEdges = p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; + const totalNodes = + p.project.nodes ?? p.schema?.node_labels?.reduce((s, l) => s + l.count, 0) ?? 0; + const totalEdges = + p.project.edges ?? p.schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; return (
diff --git a/graph-ui/src/hooks/useProjects.ts b/graph-ui/src/hooks/useProjects.ts index 5deff5215..12767a036 100644 --- a/graph-ui/src/hooks/useProjects.ts +++ b/graph-ui/src/hooks/useProjects.ts @@ -19,6 +19,11 @@ export function useProjects(): UseProjectsResult { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + /* Schemas above this size are not fetched: get_graph_schema scans every + * node and edge, which on a multi-million-node index takes ~30 s. The card + * still shows totals from list_projects; only the label chips are absent. */ + const SCHEMA_LAZY_MAX_NODES = 1_000_000; + const fetchProjects = useCallback(async () => { setLoading(true); setError(null); @@ -26,24 +31,30 @@ export function useProjects(): UseProjectsResult { const result = await callTool<{ projects: Project[] }>("list_projects"); const list = result.projects ?? []; - /* Fetch schema for each project */ - const infos: ProjectInfo[] = await Promise.all( - list.map(async (p) => { - try { - const schema = await callTool("get_graph_schema", { - project: p.name, - }); - return { project: p, schema }; - } catch { - return { project: p, schema: null }; - } - }), - ); - - setProjects(infos); + /* Render immediately from list_projects' own totals… */ + setProjects(list.map((p) => ({ project: p, schema: null }))); + setLoading(false); + + /* …then enrich with per-label schemas one at a time, patching each + * card as its schema lands. Sequential on purpose: N parallel schema + * scans would serialize behind the store lock anyway. */ + for (const p of list) { + if ((p.nodes ?? 0) > SCHEMA_LAZY_MAX_NODES) continue; + try { + const schema = await callTool("get_graph_schema", { + project: p.name, + }); + setProjects((prev) => + prev.map((info) => + info.project.name === p.name ? { ...info, schema } : info, + ), + ); + } catch { + /* card keeps its totals; chips stay absent */ + } + } } catch (e) { setError(e instanceof Error ? e.message : "Failed to fetch projects"); - } finally { setLoading(false); } }, []); diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index 76df89c33..c3c74743b 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -104,6 +104,10 @@ export interface Project { name: string; root_path: string; indexed_at: string; + /* Totals reported by list_projects — render these immediately; the + * per-label schema arrives lazily (a 13 GB index takes ~30 s to scan). */ + nodes?: number; + edges?: number; } export interface SchemaInfo { From b516eb20eb0a94977a30e8a45a20bb655f825238 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 09:16:34 +0200 Subject: [PATCH 10/52] =?UTF-8?q?fix(ui):=20Projects=20enrichment=20?= =?UTF-8?q?=E2=80=94=20pass=20include=5Fdetails,=20cancel=20on=20unmount,?= =?UTF-8?q?=20cache=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-schema fix was ineffective: the UI never sent include_details, so list_projects returned names only, every card lacked totals, and the >1M-node skip compared against undefined — each Projects visit still ran a ~30 s kernel schema scan, and revisits stacked uncancelled sweeps that kept the store busy (the felt slowness). Now: list_projects(include_details:true) carries totals and indexed_at; the enrichment loop is generation-guarded (unmount or refresh abandons it), schemas are cached for the session, auto-fetch is capped at 20 projects of ≤200k nodes, and larger stores keep a card with totals and no chips. After a Projects visit the daemon log shows only sub-second requests. Signed-off-by: Martin Vogel --- graph-ui/src/hooks/useProjects.ts | 58 ++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/graph-ui/src/hooks/useProjects.ts b/graph-ui/src/hooks/useProjects.ts index 12767a036..c4df265a9 100644 --- a/graph-ui/src/hooks/useProjects.ts +++ b/graph-ui/src/hooks/useProjects.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { callTool } from "../api/rpc"; import type { Project, SchemaInfo } from "../lib/types"; @@ -14,36 +14,63 @@ interface UseProjectsResult { refresh: () => void; } +/* Schemas above this size are never auto-fetched: get_graph_schema scans + * every node and edge, which on a multi-million-node index takes ~30 s of + * server time. The card still shows totals from list_projects; only the + * per-label chips are absent. */ +const SCHEMA_LAZY_MAX_NODES = 200_000; +/* And never enrich more than this many cards per visit. */ +const SCHEMA_LAZY_MAX_PROJECTS = 20; + +/* Session-scoped schema cache: revisiting the Projects tab must not rescan + * stores the server already walked for us once. */ +const schemaCache = new Map(); + export function useProjects(): UseProjectsResult { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - - /* Schemas above this size are not fetched: get_graph_schema scans every - * node and edge, which on a multi-million-node index takes ~30 s. The card - * still shows totals from list_projects; only the label chips are absent. */ - const SCHEMA_LAZY_MAX_NODES = 1_000_000; + /* Bumped on unmount/refresh so a stale enrichment loop stops issuing + * requests and stops patching state. */ + const generationRef = useRef(0); const fetchProjects = useCallback(async () => { + const generation = ++generationRef.current; setLoading(true); setError(null); try { - const result = await callTool<{ projects: Project[] }>("list_projects"); + /* include_details carries node/edge totals and indexed_at — without it + * the tool returns names only and every count would need a schema scan. */ + const result = await callTool<{ projects: Project[] }>("list_projects", { + include_details: true, + limit: 100, + }); + if (generation !== generationRef.current) return; const list = result.projects ?? []; /* Render immediately from list_projects' own totals… */ - setProjects(list.map((p) => ({ project: p, schema: null }))); + setProjects( + list.map((p) => ({ project: p, schema: schemaCache.get(p.name) ?? null })), + ); setLoading(false); - /* …then enrich with per-label schemas one at a time, patching each - * card as its schema lands. Sequential on purpose: N parallel schema - * scans would serialize behind the store lock anyway. */ - for (const p of list) { - if ((p.nodes ?? 0) > SCHEMA_LAZY_MAX_NODES) continue; + /* …then enrich small projects with per-label schemas, one at a time, + * patching each card as its schema lands. Sequential on purpose — N + * parallel scans would serialize behind the store lock anyway — and + * abandoned as soon as the tab unmounts or refreshes. */ + const candidates = list + .filter( + (p) => !schemaCache.has(p.name) && (p.nodes ?? Infinity) <= SCHEMA_LAZY_MAX_NODES, + ) + .slice(0, SCHEMA_LAZY_MAX_PROJECTS); + for (const p of candidates) { + if (generation !== generationRef.current) return; try { const schema = await callTool("get_graph_schema", { project: p.name, }); + schemaCache.set(p.name, schema); + if (generation !== generationRef.current) return; setProjects((prev) => prev.map((info) => info.project.name === p.name ? { ...info, schema } : info, @@ -54,6 +81,7 @@ export function useProjects(): UseProjectsResult { } } } catch (e) { + if (generation !== generationRef.current) return; setError(e instanceof Error ? e.message : "Failed to fetch projects"); setLoading(false); } @@ -61,6 +89,10 @@ export function useProjects(): UseProjectsResult { useEffect(() => { fetchProjects(); + return () => { + /* Stop any in-flight enrichment loop when the tab goes away. */ + generationRef.current++; + }; }, [fetchProjects]); return { projects, loading, error, refresh: fetchProjects }; From 3bc9d973e6b08ba8c8a12c3c579ae465bcbd905e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 09:50:25 +0200 Subject: [PATCH 11/52] feat(ui): the Atlas graphite design system + trust repairs (Field Guide D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual language, rebuilt on the researched rules (Primer/Carbon/Material/ Radix/Linear; details in the Atlas Field Guide): - Graphite surfaces with fixed step jobs (#0D0F12 canvas → #293038 selected) replace the saturated navy; elevation is a lighter surface step, not a shadow; hover/selection are surface washes. - One accent (sky #4FA8E0) reserved for interactivity; semantic good/warn/crit separate; the type floor is 12px (labels), body 13-14px, system font stacks (sans UI, mono identifiers only — zero vendored bytes), tabular numerals, radius 6px. - Region colors move to the Okabe-Ito color-blind-safe palette, assigned server-side to the ten largest regions with a neutral tail — the code graph is the only colorful thing on screen. Trust repairs, because every wrong-looking item costs belief in every right one: - builtins filtered out of hub lists (a 'len' hub reads as a bug); - a product-code lens hides test regions by default everywhere regions appear, one honest toggle away ('18 test regions hidden — show'); - flow labels carry file context when the bare name is ambiguous ('datastructures.__init__ → …' instead of '__init__ → …'). Signed-off-by: Martin Vogel --- graph-ui/index.html | 2 +- graph-ui/src/App.tsx | 14 +-- graph-ui/src/components/ChangesTab.tsx | 12 +- graph-ui/src/components/CommandK.tsx | 20 ++-- graph-ui/src/components/ControlTab.tsx | 38 +++---- graph-ui/src/components/DashboardTab.tsx | 32 +++--- .../src/components/DisplaySettingsMenu.tsx | 14 +-- graph-ui/src/components/ErrorBoundary.tsx | 2 +- graph-ui/src/components/FilterPanel.tsx | 48 ++++---- graph-ui/src/components/FlowsTab.tsx | 34 +++--- graph-ui/src/components/GraphLoader.tsx | 2 +- graph-ui/src/components/GraphScene.tsx | 4 +- graph-ui/src/components/GraphTab.tsx | 38 ++++--- graph-ui/src/components/MissedCallout.tsx | 10 +- graph-ui/src/components/ModulesTab.tsx | 24 ++-- graph-ui/src/components/NodeDetailPanel.tsx | 34 +++--- graph-ui/src/components/NodeTooltip.tsx | 14 +-- graph-ui/src/components/OverviewTab.tsx | 83 ++++++++------ graph-ui/src/components/ProjectCard.tsx | 10 +- graph-ui/src/components/PromptBasket.tsx | 24 ++-- graph-ui/src/components/RegionPanel.tsx | 18 +-- graph-ui/src/components/Sidebar.tsx | 22 ++-- graph-ui/src/components/StatsTab.tsx | 104 +++++++++--------- graph-ui/src/components/SymbolTab.tsx | 72 ++++++------ graph-ui/src/lib/regions.ts | 28 +++++ graph-ui/src/styles/globals.css | 62 ++++++----- src/ui/atlas_flows.c | 32 +++++- src/ui/layout_regions.c | 41 +++---- 28 files changed, 460 insertions(+), 378 deletions(-) diff --git a/graph-ui/index.html b/graph-ui/index.html index a1cf9a24c..8ce5f20a9 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -5,7 +5,7 @@ CBM Atlas - +
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index ad18efb81..e51dadacf 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -154,7 +154,7 @@ export function App() {
{/* Header */} -
+
@@ -179,10 +179,10 @@ export function App() { title={disabled ? "Select a project first" : undefined} className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all whitespace-nowrap ${ disabled - ? "text-muted-foreground/30 cursor-not-allowed" + ? "text-muted-foreground/50 cursor-not-allowed" : activeTab === tab.id ? "bg-primary/15 text-primary" - : "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]" + : "text-muted-foreground hover:text-foreground hover:bg-surface-3" }`} > {tab.label} @@ -195,19 +195,19 @@ export function App() {
{selectedProject && ( -
- +
+ {selectedProject} diff --git a/graph-ui/src/components/ChangesTab.tsx b/graph-ui/src/components/ChangesTab.tsx index 18c14ecb5..84e5dd036 100644 --- a/graph-ui/src/components/ChangesTab.tsx +++ b/graph-ui/src/components/ChangesTab.tsx @@ -25,7 +25,7 @@ const RISK_TONE: Record = { CRITICAL: "text-red-300/90 border-red-300/40 bg-red-400/10", HIGH: "text-amber-300/90 border-amber-300/40 bg-amber-400/10", MEDIUM: "text-sky-300/80 border-sky-300/30 bg-sky-400/5", - LOW: "text-foreground/50 border-border/40 bg-white/[0.02]", + LOW: "text-foreground/50 border-border/40 bg-card", }; export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { @@ -104,7 +104,7 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { }); } }} - className="px-3 py-1.5 rounded-md bg-white/[0.05] text-foreground/60 text-[12px] font-medium hover:bg-white/[0.09] transition-colors" + className="px-3 py-1.5 rounded-md bg-surface-3 text-foreground/60 text-[12px] font-medium hover:bg-surface-4 transition-colors" > Cite blast radius in prompt @@ -125,7 +125,7 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) {
{scalars.map(([key, value]) => (
-

+

{key.replace(/_/g, " ")}

{String(value)}

@@ -149,17 +149,17 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { diff --git a/graph-ui/src/components/CommandK.tsx b/graph-ui/src/components/CommandK.tsx index bace9ef09..6ffa9606f 100644 --- a/graph-ui/src/components/CommandK.tsx +++ b/graph-ui/src/components/CommandK.tsx @@ -131,7 +131,7 @@ export function CommandK({ project, open, onClose, onOpenSymbol }: CommandKProps onClick={onClose} >
event.stopPropagation()} >
{busy && entries.length === 0 && ( -

Searching…

+

Searching…

)} {!busy && query.trim().length >= 2 && entries.length === 0 && ( -

No matches.

+

No matches.

)} {symbols.length > 0 && ( -

+

Symbols

)} @@ -165,14 +165,14 @@ export function CommandK({ project, open, onClose, onOpenSymbol }: CommandKProps }`} > {row.name} - {row.label} - + {row.label} + {row.file} ))} {textHits.length > 0 && ( -

+

In code

)} @@ -181,14 +181,14 @@ export function CommandK({ project, open, onClose, onOpenSymbol }: CommandKProps key={`${hit.file}:${hit.line}:${index}`} className={`px-4 py-[5px] ${active === symbols.length + index ? "bg-primary/10" : ""}`} > -

+

{hit.file}:{hit.line}

-

{hit.text}

+

{hit.text}

))}
-
+
↑↓ navigate · Enter opens the symbol · Esc closes
diff --git a/graph-ui/src/components/ControlTab.tsx b/graph-ui/src/components/ControlTab.tsx index 6ea05bc29..32156f736 100644 --- a/graph-ui/src/components/ControlTab.tsx +++ b/graph-ui/src/components/ControlTab.tsx @@ -10,12 +10,12 @@ function Gauge({ label, value, max, unit, color }: { }) { const pct = Math.min(100, (value / max) * 100); return ( -
-

{label}

+
+

{label}

- {value.toFixed(1)}{unit} + {value.toFixed(1)}{unit}

-
+
80 ? "#e05252" : pct > 50 ? "#eab308" : "#1DA27E" }} @@ -35,10 +35,10 @@ function ProcessCard({ proc, selected, onSelect }: { return (
-

CPU

+

CPU

{proc.cpu.toFixed(1)}%

-

RAM

+

RAM

{proc.rss_mb.toFixed(0)} MB

-

{t.control.uptime}

+

{t.control.uptime}

{proc.elapsed}

-

{proc.command}

+

{proc.command}

); } @@ -93,15 +93,15 @@ function LogViewer() { }, []); return ( -
+
- {t.control.processLogs} - {lines.length} lines + {t.control.processLogs} + {lines.length} lines
-
+
{lines.length === 0 ? ( -

{t.control.noLogs}

+

{t.control.noLogs}

) : ( lines.map((line, i) => { const isErr = line.includes("level=error"); @@ -110,7 +110,7 @@ function LogViewer() {
{line} @@ -176,14 +176,14 @@ export function ControlTab() {
{processes.length === 0 ? ( -

{t.control.noProcesses}

+

{t.control.noProcesses}

) : (
{processes.map((p) => ( diff --git a/graph-ui/src/components/DashboardTab.tsx b/graph-ui/src/components/DashboardTab.tsx index ce332869b..a87ded4e4 100644 --- a/graph-ui/src/components/DashboardTab.tsx +++ b/graph-ui/src/components/DashboardTab.tsx @@ -57,14 +57,14 @@ function Histogram({ values, labels }: { values: number[]; labels: string[] }) {
{values.map((value, index) => (
- + {value.toLocaleString("en-US")}
- {labels[index]} + {labels[index]}
))}
@@ -104,8 +104,8 @@ function StatCard({ trend?: number[]; }) { return ( -
-

{label}

+
+

{label}

{value}

- {sub &&

{sub}

} + {sub &&

{sub}

} {trend && trend.length > 1 && }
); @@ -135,8 +135,8 @@ function TopList({ onOpenSymbol?: (ref: { qn: string }) => void; }) { return ( -
-

{title}

+
+

{title}

{entries.map((entry, index) => { const label = entry.name ?? entry.file ?? "?"; @@ -151,7 +151,7 @@ function TopList({ }`} > {label} - + {entry.value.toLocaleString("en-US")} {unit} ); })} - {entries.length === 0 &&

None.

} + {entries.length === 0 &&

None.

}
); @@ -206,7 +206,7 @@ export function DashboardTab({ project, onOpenSymbol }: DashboardTabProps) { if (!metrics) { return (
-

Measuring…

+

Measuring…

); } @@ -221,7 +221,7 @@ export function DashboardTab({ project, onOpenSymbol }: DashboardTabProps) { return (
-

+

computed from index {metrics.generated_from} {history.length > 1 && ` · trends across ${history.length} index runs`}

@@ -271,8 +271,8 @@ export function DashboardTab({ project, onOpenSymbol }: DashboardTabProps) { sub="the missed skeleton in the galaxy" tone={totals.missed_files > 0 ? "warn" : undefined} /> -
-

+

+

Complexity distribution

@@ -280,8 +280,8 @@ export function DashboardTab({ project, onOpenSymbol }: DashboardTabProps) {
-
-

+

+

Function length (lines)

diff --git a/graph-ui/src/components/DisplaySettingsMenu.tsx b/graph-ui/src/components/DisplaySettingsMenu.tsx index 3b78f814c..7b817da06 100644 --- a/graph-ui/src/components/DisplaySettingsMenu.tsx +++ b/graph-ui/src/components/DisplaySettingsMenu.tsx @@ -24,8 +24,8 @@ function SliderRow({ label, hint, value, min, max, onChange }: SliderRowProps) { return ( ); } @@ -98,15 +98,15 @@ export function DisplaySettingsMenu({
- + Contrast ); @@ -105,13 +105,13 @@ export function FilterPanel({
{/* Header row — always visible */}
- + Filters
- - | - + + | +
@@ -121,7 +121,7 @@ export function FilterPanel({ {/* Node types */} {labelCounts.length > 0 && (
-

Node types

+

Node types

{labelCounts.map(([label, count]) => { const on = enabledLabels.has(label); @@ -130,13 +130,13 @@ export function FilterPanel({ ); })} @@ -147,7 +147,7 @@ export function FilterPanel({ {/* Relationships */} {edgeTypeCounts.length > 0 && (
-

Relationships

+

Relationships

{edgeTypeCounts.map(([type, count]) => { const on = enabledEdgeTypes.has(type); @@ -155,12 +155,12 @@ export function FilterPanel({ ); })} @@ -175,11 +175,11 @@ export function FilterPanel({ focus; click the code galaxy to come back. */}
- + Missed files {missedCount > 0 && ( - + {missedCount.toLocaleString()} files )} @@ -189,7 +189,7 @@ export function FilterPanel({ onToggle={onToggleMissedView} label="Show missed skeleton" /> -

+

{missedCount > 0 ? "White satellite = files not fully indexed (best-effort). Click it to focus, click the galaxy to return." : "No known misses (best-effort — not a completeness guarantee)."} @@ -199,10 +199,10 @@ export function FilterPanel({ {/* Dead-code view */}

- + Dead code - + {deadCount.toLocaleString()} dead
@@ -230,7 +230,7 @@ export function FilterPanel({ {STATUS_LEGEND.map((s) => ( diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx index 829c41ac3..4fa36a59f 100644 --- a/graph-ui/src/components/FlowsTab.tsx +++ b/graph-ui/src/components/FlowsTab.tsx @@ -90,7 +90,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab if (!payload) { return (
-

Tracing the journeys…

+

Tracing the journeys…

); } @@ -98,12 +98,12 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab return (
{/* Flow list */} -
+
-

+

Flows — entry → terminal

-

+

{payload.flows.length} journeys from{" "} {payload.callable_total.toLocaleString("en-US")} callables {payload.candidates_dropped > 0 && @@ -117,7 +117,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab if (flows.length === 0) return null; return (

-

+

{groupKey === "cross" ? "across regions" : "within one region"}

{flows.map((flow) => ( @@ -127,15 +127,15 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab className={`flex items-center gap-2 w-full text-left px-4 py-[6px] transition-colors ${ flowId === flow.id ? "bg-primary/10 text-primary" - : "text-foreground/60 hover:text-foreground/85 hover:bg-white/[0.03]" + : "text-foreground/60 hover:text-foreground/85 hover:bg-surface-3" }`} > - {flow.label} - + {flow.label} + {flow.steps} {!flow.sink_terminated && ( - + … )} @@ -145,7 +145,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab ); })} {payload.flows.length === 0 && ( -

+

No flows detected — the project may have no clear entry points.

)} @@ -157,7 +157,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
{flowId === null || !detail ? (
-

+

{flowId === null ? "Pick a journey on the left" : "Loading…"}

@@ -167,7 +167,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab

{detail.entry.name} - → + → {detail.terminal.name}

{ navigator.clipboard?.writeText(flowToMermaid(detail)).then(() => setCopied(true)); }} - className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] transition-colors" + className="px-2.5 py-1 rounded-md bg-surface-3 text-foreground/60 text-[13px] font-medium hover:bg-surface-4 transition-colors" > {copied ? "Copied ✓" : "Copy as mermaid"}
-

+

{detail.steps.length} steps {detail.sink_terminated ? " · ends at a sink" : " · stopped at the depth cap"} {detail.cross_region ? " · crosses regions" : ""} @@ -201,16 +201,16 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab diff --git a/graph-ui/src/components/GraphLoader.tsx b/graph-ui/src/components/GraphLoader.tsx index 0d9154b17..46e6854ce 100644 --- a/graph-ui/src/components/GraphLoader.tsx +++ b/graph-ui/src/components/GraphLoader.tsx @@ -68,7 +68,7 @@ export function GraphLoader({ nodeBudget, progress }: GraphLoaderProps) { /> ))} -

+

{receiving ? "Receiving graph" : "Computing layout"} — up to{" "} {nodeBudget.toLocaleString("en-US")} nodes

diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index cad93d926..9a1ce2f6b 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -153,7 +153,7 @@ export function GraphScene({ return ( - + -

+

Select a project from the Projects tab

@@ -483,7 +483,7 @@ export function GraphTab({ if (view.kind === "deciding") { return (
-

Mapping regions…

+

Mapping regions…

); } @@ -494,21 +494,23 @@ export function GraphTab({ return (
- + Regions -

+

{regionsPayload?.method === "leiden+folders" ? "call communities + folder groups" : "folder groups"}

- {displayRegions.map((region) => ( + {[...displayRegions] + .sort((a, b) => Number(isTestRegion(a.name)) - Number(isTestRegion(b.name))) + .map((region) => ( @@ -564,12 +566,12 @@ export function GraphTab({ /> -
+

{displayRegions.length} regions /{" "} {regionsPayload?.total_nodes.toLocaleString("en-US")} nodes

-

+

double-click a region (or use its panel) to open it

@@ -637,7 +639,7 @@ export function GraphTab({ if (!data || !filteredData || data.nodes.length === 0) { return (
-

No nodes in this project

+

No nodes in this project

); } @@ -646,7 +648,7 @@ export function GraphTab({
{/* Left sidebar — resizable */}
-

All nodes filtered out

+

All nodes filtered out

@@ -716,7 +718,7 @@ export function GraphTab({ {/* HUD */} -
+
{regionsError && (

regions unavailable: {regionsError}

)} @@ -728,7 +730,7 @@ export function GraphTab({ {filteredData.edges.length.toLocaleString()} edges

{data.nodes.length > filteredData.nodes.length && ( -

+

filtered from {data.nodes.length.toLocaleString()}

)} @@ -761,10 +763,10 @@ export function GraphTab({ Clear selection )} -
+
diff --git a/graph-ui/src/components/MissedCallout.tsx b/graph-ui/src/components/MissedCallout.tsx index 1156d19b9..ec15600f5 100644 --- a/graph-ui/src/components/MissedCallout.tsx +++ b/graph-ui/src/components/MissedCallout.tsx @@ -96,11 +96,11 @@ export function MissedCallout({ node, project, onClose }: MissedCalloutProps) {
-

+

Not fully indexed

{path}

-

{node.label}

+

{node.label}

-

+

The prefilled issue contains only the file path and project name — add code snippets only if they are shareable.

diff --git a/graph-ui/src/components/ModulesTab.tsx b/graph-ui/src/components/ModulesTab.tsx index dc25c6b07..f7f3421ff 100644 --- a/graph-ui/src/components/ModulesTab.tsx +++ b/graph-ui/src/components/ModulesTab.tsx @@ -141,7 +141,7 @@ export function ModulesTab({
{crumbs.map((crumb, index) => ( - {index > 0 && /} + {index > 0 && /}
-

+

{selectedFile.symbols.toLocaleString("en-US")} symbols {selectedFile.missed ? " · not fully indexed" : ""}

{fileSymbols === null ? ( -

Loading…

+

Loading…

) : fileSymbols.length === 0 ? ( -

No symbols found.

+

No symbols found.

) : ( fileSymbols.map((symbol) => ( @@ -306,7 +306,7 @@ export function ModulesTab({ {selectedFile.region !== undefined && regionMeta.get(selectedFile.region) && ( diff --git a/graph-ui/src/components/NodeDetailPanel.tsx b/graph-ui/src/components/NodeDetailPanel.tsx index cbad8229b..90351b8f6 100644 --- a/graph-ui/src/components/NodeDetailPanel.tsx +++ b/graph-ui/src/components/NodeDetailPanel.tsx @@ -115,7 +115,7 @@ export function NodeDetailPanel({ }; return ( -
+
{/* Header */}
@@ -125,17 +125,17 @@ export function NodeDetailPanel({

{node.name}

{node.label}
- +
{node.file_path && ( -

+

{node.file_path} {node.start_line ? ( @@ -152,7 +152,7 @@ export function NodeDetailPanel({ @@ -162,16 +162,16 @@ export function NodeDetailPanel({ href={ghUrl} target="_blank" rel="noopener noreferrer" - className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] hover:text-foreground/90 transition-colors" + className="px-2.5 py-1 rounded-md bg-surface-3 text-foreground/60 text-[13px] font-medium hover:bg-surface-4 hover:text-foreground/90 transition-colors" > Open on GitHub ↗ )}

- {codeError &&

{codeError}

} + {codeError &&

{codeError}

} {code && ( -
+          
             {code}
           
)} @@ -184,7 +184,7 @@ export function NodeDetailPanel({ { label: "Total", value: connections.length, color: "text-foreground" }, ].map((s) => (
-

{s.label}

+

{s.label}

{s.value}

))} @@ -201,7 +201,7 @@ export function NodeDetailPanel({ )} {connections.length === 0 && ( -

No connections

+

No connections

)}
@@ -218,7 +218,7 @@ function ConnectionGroup({ type, conns, icon, onNavigate }: { const remaining = conns.length - visible; return (
-

+

{type.replace(/_/g, " ").toLowerCase()}

@@ -226,18 +226,18 @@ function ConnectionGroup({ type, conns, icon, onNavigate }: { ))} {remaining > 0 && ( @@ -254,8 +254,8 @@ function ConnectionSection({ title, count, icon, groups, onNavigate }: { }) { return (
-

- {title} ({count}) +

+ {title} ({count})

{groups.map(([type, conns]) => ( diff --git a/graph-ui/src/components/NodeTooltip.tsx b/graph-ui/src/components/NodeTooltip.tsx index ea7c16bce..a9bdb45db 100644 --- a/graph-ui/src/components/NodeTooltip.tsx +++ b/graph-ui/src/components/NodeTooltip.tsx @@ -20,19 +20,19 @@ export function NodeTooltip({ node }: NodeTooltipProps) { center style={{ pointerEvents: "none" }} > -
+
{node.name} - {node.label} + {node.label}
{node.file_path && ( -

+

{node.file_path} - {lineRange(node) && · {lineRange(node)}} + {lineRange(node) && · {lineRange(node)}}

)} {node.status && node.status !== "structural" && ( @@ -41,15 +41,15 @@ export function NodeTooltip({ node }: NodeTooltipProps) { className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorForStatus(node.status) }} /> - {node.status} + {node.status} {node.in_calls !== undefined && ( - + · {node.in_calls} caller{node.in_calls === 1 ? "" : "s"} )}
)} -

click for code →

+

click for code →

); diff --git a/graph-ui/src/components/OverviewTab.tsx b/graph-ui/src/components/OverviewTab.tsx index ee50ffd13..afe2c9bb5 100644 --- a/graph-ui/src/components/OverviewTab.tsx +++ b/graph-ui/src/components/OverviewTab.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { fetchArchitecture, archRows, type ArchitectureJson } from "../lib/atlas"; import { fetchRegions } from "../hooks/useGraphData"; -import { disambiguateRegionNames } from "../lib/regions"; +import { disambiguateRegionNames, lensRegionsPayload, isBuiltinQn } from "../lib/regions"; import { surprisingCouplings, suggestedQuestions } from "../lib/firstread"; import { AddToPromptButton } from "./PromptBasket"; import type { RegionsPayload } from "../lib/types"; @@ -44,8 +44,8 @@ function shortQn(qn: string): string { function Card({ title, children, wide }: { title: string; children: React.ReactNode; wide?: boolean }) { return ( -
-

{title}

+
+

{title}

{children}
); @@ -61,6 +61,7 @@ export function OverviewTab({ const [arch, setArch] = useState(null); const [regions, setRegions] = useState(null); const [error, setError] = useState(null); + const [includeTests, setIncludeTests] = useState(false); useEffect(() => { let cancelled = false; @@ -81,14 +82,22 @@ export function OverviewTab({ }; }, [project]); + const lensed = useMemo( + () => (regions ? lensRegionsPayload(regions, includeTests) : null), + [regions, includeTests], + ); const displayRegions = useMemo( - () => (regions ? disambiguateRegionNames(regions.regions) : []), - [regions], + () => (lensed ? disambiguateRegionNames(lensed.regions) : []), + [lensed], ); - const couplings = useMemo(() => (regions ? surprisingCouplings(regions, 4) : []), [regions]); - const questions = useMemo(() => (regions ? suggestedQuestions(regions, {}, 4) : []), [regions]); + const couplings = useMemo(() => (lensed ? surprisingCouplings(lensed, 4) : []), [lensed]); + const questions = useMemo(() => (lensed ? suggestedQuestions(lensed, {}, 4) : []), [lensed]); - const hotspots = useMemo(() => archRows(arch, "hotspots"), [arch]); + const hiddenTestRegions = regions && lensed ? regions.regions.length - lensed.regions.length : 0; + const hotspots = useMemo( + () => archRows(arch, "hotspots").filter((row) => !isBuiltinQn(row.qn)), + [arch], + ); const entries = useMemo(() => archRows(arch, "entry_points"), [arch]); const boundaries = useMemo(() => archRows(arch, "boundaries"), [arch]); const languages = useMemo(() => archRows(arch, "languages"), [arch]); @@ -105,7 +114,7 @@ export function OverviewTab({ if (!arch && !regions) { return (
-

Reading the map…

+

Reading the map…

); } @@ -129,7 +138,7 @@ export function OverviewTab({ }, ].map((stat) => (
-

{stat.label}

+

{stat.label}

{stat.value}

@@ -137,7 +146,7 @@ export function OverviewTab({ ))} {regions && regions.unmapped_nodes > 0 && (
-

Unmapped

+

Unmapped

{regions.unmapped_nodes.toLocaleString("en-US")}

@@ -148,12 +157,22 @@ export function OverviewTab({
{/* Regions — the map */} + {hiddenTestRegions > 0 && ( + + )}
{displayRegions.slice(0, 12).map((region) => ( ))} @@ -177,7 +196,7 @@ export function OverviewTab({ {displayRegions.length > 12 && ( @@ -191,18 +210,18 @@ export function OverviewTab({
- + {Number(hotspot.fan_in).toLocaleString("en-US")}
))} {hotspots.length === 0 && ( -

No hotspot data.

+

No hotspot data.

)}
@@ -217,21 +236,21 @@ export function OverviewTab({ className="flex items-center gap-2 w-full text-left py-[3px] group" title={entry.qn} > - + {shortQn(entry.qn)} - + {entry.file} ))} {entries.length === 0 && ( -

No entry points detected.

+

No entry points detected.

)}
@@ -242,16 +261,16 @@ export function OverviewTab({
{couplings.map((coupling) => (
-

+

{coupling.source.name} - ⇄ + ⇄ {coupling.target.name}

-

{coupling.reasons.join(" · ")}

+

{coupling.reasons.join(" · ")}

))} {couplings.length === 0 && ( -

+

No cross-region couplings stand out.

)} @@ -264,8 +283,8 @@ export function OverviewTab({ {questions.map((question) => (
-

{question.question}

-

{question.why}

+

{question.question}

+

{question.why}

))} {questions.length === 0 && ( -

Nothing stands out structurally.

+

Nothing stands out structurally.

)}
@@ -282,9 +301,9 @@ export function OverviewTab({ {/* Boundaries */}
- +
- + @@ -303,7 +322,7 @@ export function OverviewTab({
from to calls
{boundaries.length === 0 && ( -

No cross-package calls recorded.

+

No cross-package calls recorded.

)}
diff --git a/graph-ui/src/components/ProjectCard.tsx b/graph-ui/src/components/ProjectCard.tsx index 930e9e850..c9ccca01d 100644 --- a/graph-ui/src/components/ProjectCard.tsx +++ b/graph-ui/src/components/ProjectCard.tsx @@ -16,11 +16,11 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { const totalEdges = schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; return ( -
+

{project.name}

-

+

{project.root_path}

@@ -34,7 +34,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { {schema && ( <> -
+
{formatNumber(totalNodes)} nodes {formatNumber(totalEdges)} edges
@@ -54,7 +54,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { {l.label} - {formatNumber(l.count)} + {formatNumber(l.count)} ))}
@@ -63,7 +63,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { )} {!schema && ( -

Loading schema...

+

Loading schema...

)}
); diff --git a/graph-ui/src/components/PromptBasket.tsx b/graph-ui/src/components/PromptBasket.tsx index 1e672fa1d..3dfeb39b9 100644 --- a/graph-ui/src/components/PromptBasket.tsx +++ b/graph-ui/src/components/PromptBasket.tsx @@ -78,9 +78,9 @@ export function AddToPromptButton({ item, small }: { item: BasketItem; small?: b )} @@ -160,27 +160,27 @@ export function PromptBasketDrawer({ project }: { project: string | null }) { placeholder="What do you want your agent to do?" value={goal} onChange={(event) => setGoal(event.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 transition-all" + className="w-full bg-popover border border-border rounded-md px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 transition-all" />
{basket.items.length === 0 ? ( -

+

Cite symbols, regions, flows or questions with “+ prompt” anywhere in Atlas.

) : ( basket.items.map((item) => (
- + {item.kind} - + {itemLabel(item)} @@ -189,7 +189,7 @@ export function PromptBasketDrawer({ project }: { project: string | null }) { )}
-
+      
         {prompt}
       
diff --git a/graph-ui/src/components/RegionPanel.tsx b/graph-ui/src/components/RegionPanel.tsx index 69bb0a886..83c07b494 100644 --- a/graph-ui/src/components/RegionPanel.tsx +++ b/graph-ui/src/components/RegionPanel.tsx @@ -11,7 +11,7 @@ interface RegionPanelProps { * its representative symbols, and the door into its full-detail layout. */ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { return ( -
+
@@ -24,27 +24,27 @@ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { {region.name}
- + Region
{region.why && ( -

+

{region.why}

)} @@ -56,7 +56,7 @@ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { { label: "Cohesion", value: region.cohesion.toFixed(2) }, ].map((stat) => (
-

+

{stat.label}

@@ -69,20 +69,20 @@ export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) {

-

+

Most connected

{region.top_nodes.map((name) => (

{name}

))} {region.top_nodes.length === 0 && ( -

+

No callable members

)} diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 52a76a8ae..52a0b1f1d 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -78,15 +78,15 @@ function TreeItem({ dir, depth, onSelect, onSelectNode, selectedPath }: { {expanded && ( <> @@ -95,12 +95,12 @@ function TreeItem({ dir, depth, onSelect, onSelectNode, selectedPath }: { ))} @@ -125,7 +125,7 @@ export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: Sid return (
- + {t.graph.folders}
@@ -136,7 +136,7 @@ export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: Sid placeholder={t.graph.search} value={search} onChange={(e) => 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" + className="w-full bg-popover border border-border rounded-md px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-surface-3 transition-all" />
@@ -145,7 +145,7 @@ export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: Sid
{filtered ? ( filtered.length === 0 ? ( -

+

{t.common.noMatches}

) : ( @@ -153,11 +153,11 @@ export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: Sid )) ) @@ -171,7 +171,7 @@ export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: Sid
diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index fd32d39eb..95673c121 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -54,9 +54,9 @@ function HealthDot({ name }: { name: string }) { /> {/* Tooltip */}
-
+

{label}

- {info &&

{info}

} + {info &&

{info}

}
@@ -105,10 +105,10 @@ function AdrButton({ project }: { project: string }) { <> +
{updatedAt && ( -

{t.adr.lastUpdated}: {updatedAt}

+

{t.adr.lastUpdated}: {updatedAt}

)}