From 61c4afe82fe2dd067441df5aa76122012911b681 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 00:35:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20CBM=20Atlas=20data=20services=20?= =?UTF-8?q?=E2=80=94=20folder=20tree,=20symbol=20bundle,=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three read-only services the Atlas screens consume, as pure JSON producers over an open store (handlers stay thin; tests hit the functions directly): - GET /api/tree?project&path — aggregated children of one folder: files and symbol counts per child, dominant region (majority vote), missed- coverage counts from the #963 shadow project, child cap reported rather than silent. Feeds the Modules treemap at any project size without shipping nodes. - GET /api/symbol?project&id|qn — the symbol page in one response: node with docstring and entry/test/export flags, region membership, callers and callees with TRUE totals, per-edge-type counts and pagination (CALLS before CALL_REFERENCE before USAGE), inbound TESTS, co-change partners of the file (FILE_CHANGES_WITH), and near-clones (SIMILAR_TO). - GET /api/flows?project, /api/flow?project&id — named entry→terminal call journeys: entries scored from stored flags and call shape, bounded DFS over CALLS (depth 6, 4 children per step, 40 steps), deduplicated by entry/terminal pair, ranked sink-first, truncation counted; cached per (project, indexed_at) like the region level. The busiest callee leads each step so the trunk of the journey reads first. The region cache gains an O(1) file→region lookup (hash-backed) shared by tree, symbol and flows. Fixed en route: yyjson borrows object keys added via add_*(), so the per-type count keys are now copied into the document instead of dangling once their sqlite statement is finalized. Signed-off-by: Martin Vogel --- Makefile.cbm | 2 + src/ui/atlas.h | 45 +++ src/ui/atlas_api.c | 547 +++++++++++++++++++++++++++++++++++++ src/ui/atlas_flows.c | 587 ++++++++++++++++++++++++++++++++++++++++ src/ui/http_server.c | 126 +++++++++ src/ui/layout_regions.c | 47 +++- tests/test_ui.c | 294 ++++++++++++++++++++ 7 files changed, 1647 insertions(+), 1 deletion(-) create mode 100644 src/ui/atlas.h create mode 100644 src/ui/atlas_api.c create mode 100644 src/ui/atlas_flows.c diff --git a/Makefile.cbm b/Makefile.cbm index 0ea461d06..d8f43fe6c 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -388,6 +388,8 @@ UI_SRCS = \ src/ui/http_server.c \ src/ui/layout3d.c \ src/ui/layout_regions.c \ + src/ui/atlas_api.c \ + src/ui/atlas_flows.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/src/ui/atlas.h b/src/ui/atlas.h new file mode 100644 index 000000000..5774deb51 --- /dev/null +++ b/src/ui/atlas.h @@ -0,0 +1,45 @@ +/* + * atlas.h — CBM Atlas data services beyond the layout itself. + * + * Pure JSON producers over an open store; the HTTP handlers in + * http_server.c stay thin (open store → call → reply) and tests call these + * directly on in-memory stores. + */ +#ifndef CBM_UI_ATLAS_H +#define CBM_UI_ATLAS_H + +#include "store/store.h" + +#include + +/* ── Modules tree (GET /api/tree) ───────────────────────────────── + * Aggregated children of one folder: sub-folders with file/symbol counts, + * files directly inside it, per-child dominant region and missed-coverage + * counts. `path` is a repo-relative folder ("" or "." = root). */ +char *cbm_atlas_tree_json(cbm_store_t *store, const char *project, const char *path); + +/* ── Symbol bundle (GET /api/symbol) ────────────────────────────── + * Everything the symbol page needs in one response: the node (with + * docstring and flags), its region, callers and callees with TRUE totals, + * per-edge-type counts and pagination, tests, co-change partners and + * near-clones. Lookup by id, or by qualified name when id < 0. */ +char *cbm_atlas_symbol_json(cbm_store_t *store, const char *project, int64_t node_id, + const char *qualified_name, int limit, int offset); + +/* ── Flows (GET /api/flows, /api/flow) ──────────────────────────── + * Named entry→terminal call flows: entry candidates scored from stored + * flags + call shape, bounded DFS over CALLS, deduplicated and ranked. + * Computed lazily and cached per (project, indexed_at). */ +char *cbm_atlas_flows_json(cbm_store_t *store, const char *project); +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); + +/* ── 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. */ +int cbm_layout_region_for_file(cbm_store_t *store, const char *project, const char *file_path, + char **name_out); + +#endif /* CBM_UI_ATLAS_H */ diff --git a/src/ui/atlas_api.c b/src/ui/atlas_api.c new file mode 100644 index 000000000..757fbeec1 --- /dev/null +++ b/src/ui/atlas_api.c @@ -0,0 +1,547 @@ +/* + * atlas_api.c — CBM Atlas data services: the Modules tree and the Symbol + * bundle. Pure JSON producers over an open store (handlers stay in + * http_server.c; tests call these directly). + */ +#include "foundation/constants.h" +#include "ui/atlas.h" +#include "foundation/hash_table.h" + +#include +#include + +#include +#include +#include +#include +#include + +/* ── Small helpers ────────────────────────────────────────────── */ + +static char *aa_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; +} + +/* Normalize a folder path: "" and "." mean the root; strip trailing '/'. */ +static void aa_norm_path(const char *in, char *out, size_t cap) { + if (!in || !in[0] || strcmp(in, ".") == 0) { + out[0] = '\0'; + return; + } + snprintf(out, cap, "%s", in); + size_t len = strlen(out); + while (len > 0 && out[len - 1] == '/') + out[--len] = '\0'; +} + +/* The next path component of `full` under folder `prefix` ("" = root). + * Returns false when `full` is not under `prefix`. `is_direct_file` is true + * when `full` names a file directly inside `prefix` (no further '/'). */ +static bool aa_child_component(const char *full, const char *prefix, char *comp, size_t cap, + bool *is_direct_file) { + size_t plen = strlen(prefix); + const char *rest = full; + if (plen > 0) { + if (strncmp(full, prefix, plen) != 0 || full[plen] != '/') + return false; + rest = full + plen + 1; + } + if (!rest[0]) + return false; + const char *slash = strchr(rest, '/'); + size_t clen = slash ? (size_t)(slash - rest) : strlen(rest); + if (clen == 0 || clen >= cap) + return false; + memcpy(comp, rest, clen); + comp[clen] = '\0'; + *is_direct_file = slash == NULL; + return true; +} + +/* ── /api/tree ────────────────────────────────────────────────── */ + +typedef struct { + char *name; + long long symbols; + int files; + int missed; + int region; /* dominant region id (majority by file), -1 unknown */ + int region_best; /* votes for the dominant region so far */ + bool is_file; +} aa_tree_child_t; + +enum { AA_TREE_MAX_CHILDREN = 4096 }; + +typedef struct { + aa_tree_child_t *items; + int count; + int cap; + CBMHashTable *by_name; /* name → index+1 (keys borrowed from items) */ + int dropped; /* children beyond the cap (reported, not silent) */ +} aa_children_t; + +static aa_tree_child_t *aa_child(aa_children_t *set, const char *name, bool is_file) { + /* Files and folders can share a name; disambiguate the map key. */ + char key[CBM_SZ_512]; + snprintf(key, sizeof(key), "%c%s", is_file ? 'f' : 'd', name); + void *slot = cbm_ht_get(set->by_name, key); + if (slot) + return &set->items[(int)(intptr_t)slot - 1]; + if (set->count >= AA_TREE_MAX_CHILDREN) { + set->dropped++; + return NULL; + } + if (set->count >= set->cap) { + int nc = set->cap ? set->cap * 2 : 64; + aa_tree_child_t *grown = realloc(set->items, (size_t)nc * sizeof(aa_tree_child_t)); + if (!grown) + return NULL; + /* The by_name table stores indices, so growth is safe. */ + set->items = grown; + set->cap = nc; + } + aa_tree_child_t *child = &set->items[set->count]; + memset(child, 0, sizeof(*child)); + child->name = aa_strdup(name); + child->region = -1; + child->is_file = is_file; + if (!child->name) + return NULL; + set->count++; + char *owned_key = aa_strdup(key); + if (owned_key) + cbm_ht_set(set->by_name, owned_key, (void *)(intptr_t)set->count); + return child; +} + +static void aa_collect_keys(const char *key, void *value, void *userdata) { + (void)value; + /* Keys were heap copies owned by us; collect for freeing. */ + struct { + char **keys; + int count; + int cap; + } *box = userdata; + if (box->count >= box->cap) + return; + box->keys[box->count++] = (char *)key; +} + +static void aa_children_free(aa_children_t *set) { + struct { + char **keys; + int count; + int cap; + } box; + box.cap = set->count * 2 + 8; + box.keys = malloc((size_t)box.cap * sizeof(char *)); + box.count = 0; + if (set->by_name && box.keys) + cbm_ht_foreach(set->by_name, aa_collect_keys, &box); + for (int i = 0; i < box.count; i++) + free(box.keys[i]); + free(box.keys); + cbm_ht_free(set->by_name); + for (int i = 0; i < set->count; i++) + free(set->items[i].name); + free(set->items); + memset(set, 0, sizeof(*set)); +} + +static int aa_child_cmp(const void *a, const void *b) { + const aa_tree_child_t *ca = a, *cb = b; + if (ca->is_file != cb->is_file) + return ca->is_file - cb->is_file; /* folders first */ + if (cb->symbols != ca->symbols) + return cb->symbols > ca->symbols ? 1 : -1; + return strcmp(ca->name, cb->name); +} + +char *cbm_atlas_tree_json(cbm_store_t *store, const char *project, const char *path) { + if (!store || !project) + return NULL; + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return NULL; + char prefix[CBM_SZ_512]; + aa_norm_path(path, prefix, sizeof(prefix)); + + aa_children_t set; + memset(&set, 0, sizeof(set)); + set.by_name = cbm_ht_create(256); + if (!set.by_name) + return NULL; + + long long total_symbols = 0; + int total_files = 0; + + /* One pass: per-file symbol counts, folded into direct children. */ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT file_path, COUNT(*) FROM nodes WHERE project=?1 AND " + "file_path IS NOT NULL AND file_path != '' GROUP BY file_path", + -1, &st, NULL) != SQLITE_OK) { + aa_children_free(&set); + return NULL; + } + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *file = (const char *)sqlite3_column_text(st, 0); + long long symbols = sqlite3_column_int64(st, 1); + char comp[CBM_SZ_256]; + bool direct = false; + if (!file || !aa_child_component(file, prefix, comp, sizeof(comp), &direct)) + continue; + total_symbols += symbols; + total_files++; + aa_tree_child_t *child = aa_child(&set, comp, direct); + if (!child) + continue; + child->symbols += symbols; + child->files++; + int region = cbm_layout_region_for_file(store, project, file, NULL); + if (region >= 0) { + /* Majority vote, Boyer-Moore style: cheap and order-stable + * enough for a display hint. */ + if (child->region == region) + child->region_best++; + else if (child->region_best == 0) { + child->region = region; + child->region_best = 1; + } else { + child->region_best--; + } + } + } + sqlite3_finalize(st); + + /* Missed coverage: File nodes of the shadow project under each child. */ + char shadow[CBM_SZ_512]; + cbm_store_coverage_shadow_project(shadow, sizeof(shadow), project); + if (sqlite3_prepare_v2(db, + "SELECT file_path FROM nodes WHERE project=?1 AND label='File' AND " + "file_path IS NOT NULL", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, shadow, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *file = (const char *)sqlite3_column_text(st, 0); + char comp[CBM_SZ_256]; + bool direct = false; + if (!file || !aa_child_component(file, prefix, comp, sizeof(comp), &direct)) + continue; + /* Only annotate children that exist in the code graph. */ + char key[CBM_SZ_512]; + snprintf(key, sizeof(key), "%c%s", direct ? 'f' : 'd', comp); + void *slot = cbm_ht_get(set.by_name, key); + if (slot) + set.items[(int)(intptr_t)slot - 1].missed++; + } + sqlite3_finalize(st); + } + + qsort(set.items, (size_t)set.count, sizeof(aa_tree_child_t), aa_child_cmp); + + 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, "path", prefix); + yyjson_mut_obj_add_int(doc, root, "files", total_files); + yyjson_mut_obj_add_int(doc, root, "symbols", total_symbols); + if (set.dropped > 0) + yyjson_mut_obj_add_int(doc, root, "children_dropped", set.dropped); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int i = 0; i < set.count; i++) { + const aa_tree_child_t *child = &set.items[i]; + yyjson_mut_val *obj = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, obj, "name", child->name); + char full[CBM_SZ_512]; + if (prefix[0]) + snprintf(full, sizeof(full), "%s/%s", prefix, child->name); + else + snprintf(full, sizeof(full), "%s", child->name); + yyjson_mut_obj_add_strcpy(doc, obj, "path", full); + yyjson_mut_obj_add_str(doc, obj, "kind", child->is_file ? "file" : "dir"); + yyjson_mut_obj_add_int(doc, obj, "symbols", child->symbols); + yyjson_mut_obj_add_int(doc, obj, "files", child->files); + if (child->missed > 0) + yyjson_mut_obj_add_int(doc, obj, "missed", child->missed); + /* Region id only — the client already holds region names from the + * regions payload. */ + if (child->region >= 0) + yyjson_mut_obj_add_int(doc, obj, "region", child->region); + yyjson_mut_arr_append(arr, obj); + } + yyjson_mut_obj_add_val(doc, root, "children", arr); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + aa_children_free(&set); + return json; +} + +/* ── /api/symbol ──────────────────────────────────────────────── */ + +/* Call-ish edge families shown on the symbol page, most certain first. */ +#define AA_CALLISH_TYPES "'CALLS','CALL_REFERENCE','USAGE','HTTP_CALLS','ASYNC_CALLS'" + +static void aa_add_conn_rows(yyjson_mut_doc *doc, yyjson_mut_val *parent, sqlite3_stmt *st) { + yyjson_mut_val *items = yyjson_mut_arr(doc); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, row, "type", + (const char *)sqlite3_column_text(st, 0)); + yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 1)); + yyjson_mut_obj_add_strcpy(doc, row, "label", + (const char *)sqlite3_column_text(st, 2)); + yyjson_mut_obj_add_strcpy(doc, row, "name", + (const char *)sqlite3_column_text(st, 3)); + const char *file = (const char *)sqlite3_column_text(st, 4); + if (file) + yyjson_mut_obj_add_strcpy(doc, row, "file_path", file); + int line = sqlite3_column_int(st, 5); + if (line > 0) + yyjson_mut_obj_add_int(doc, row, "start_line", line); + const char *qn = (const char *)sqlite3_column_text(st, 6); + if (qn) + yyjson_mut_obj_add_strcpy(doc, row, "qualified_name", qn); + yyjson_mut_arr_append(items, row); + } + yyjson_mut_obj_add_val(doc, parent, "items", items); +} + +/* callers (inbound=true) or callees of `id`, with totals and pagination. */ +static void aa_add_connections(yyjson_mut_doc *doc, yyjson_mut_val *root, struct sqlite3 *db, + const char *project, int64_t id, bool inbound, int limit, + int offset) { + yyjson_mut_val *section = yyjson_mut_obj(doc); + const char *count_sql = + inbound ? "SELECT e.type, COUNT(*) FROM edges e WHERE e.project=?1 AND e.target_id=?2 " + "AND e.type IN (" AA_CALLISH_TYPES ") GROUP BY e.type" + : "SELECT e.type, COUNT(*) FROM edges e WHERE e.project=?1 AND e.source_id=?2 " + "AND e.type IN (" AA_CALLISH_TYPES ") GROUP BY e.type"; + sqlite3_stmt *st = NULL; + long long total = 0; + yyjson_mut_val *by_type = yyjson_mut_obj(doc); + if (sqlite3_prepare_v2(db, count_sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 2, id); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *type = (const char *)sqlite3_column_text(st, 0); + long long count = sqlite3_column_int64(st, 1); + /* Copy the key: yyjson borrows add_*() keys, and this one dies + * with the statement. */ + if (type) + yyjson_mut_obj_add(by_type, yyjson_mut_strcpy(doc, type), + yyjson_mut_sint(doc, count)); + total += count; + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_int(doc, section, "total", total); + yyjson_mut_obj_add_val(doc, section, "by_type", by_type); + yyjson_mut_obj_add_int(doc, section, "limit", limit); + yyjson_mut_obj_add_int(doc, section, "offset", offset); + + const char *rows_sql = + inbound + ? "SELECT e.type, n.id, n.label, n.name, n.file_path, n.start_line, " + "n.qualified_name FROM edges e JOIN nodes n ON n.project=e.project AND " + "n.id=e.source_id WHERE e.project=?1 AND e.target_id=?2 AND e.type IN " + "(" AA_CALLISH_TYPES ") ORDER BY CASE e.type WHEN 'CALLS' THEN 0 WHEN " + "'CALL_REFERENCE' THEN 1 ELSE 2 END, n.id LIMIT ?3 OFFSET ?4" + : "SELECT e.type, n.id, n.label, n.name, n.file_path, n.start_line, " + "n.qualified_name FROM edges e JOIN nodes n ON n.project=e.project AND " + "n.id=e.target_id WHERE e.project=?1 AND e.source_id=?2 AND e.type IN " + "(" AA_CALLISH_TYPES ") ORDER BY CASE e.type WHEN 'CALLS' THEN 0 WHEN " + "'CALL_REFERENCE' THEN 1 ELSE 2 END, n.id LIMIT ?3 OFFSET ?4"; + if (sqlite3_prepare_v2(db, rows_sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 2, id); + sqlite3_bind_int(st, 3, limit); + sqlite3_bind_int(st, 4, offset); + aa_add_conn_rows(doc, section, st); + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(doc, root, inbound ? "callers" : "callees", section); +} + +char *cbm_atlas_symbol_json(cbm_store_t *store, const char *project, int64_t node_id, + const char *qualified_name, int limit, int offset) { + if (!store || !project) + return NULL; + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return NULL; + if (limit <= 0 || limit > 500) + limit = 50; + if (offset < 0) + offset = 0; + + sqlite3_stmt *st = NULL; + const char *node_sql = + node_id >= 0 + ? "SELECT id, label, name, qualified_name, file_path, start_line, end_line, " + "json_extract(properties,'$.docstring'), " + "json_extract(properties,'$.is_entry_point'), " + "json_extract(properties,'$.is_test'), " + "json_extract(properties,'$.is_exported') " + "FROM nodes WHERE project=?1 AND id=?2" + : "SELECT id, label, name, qualified_name, file_path, start_line, end_line, " + "json_extract(properties,'$.docstring'), " + "json_extract(properties,'$.is_entry_point'), " + "json_extract(properties,'$.is_test'), " + "json_extract(properties,'$.is_exported') " + "FROM nodes WHERE project=?1 AND qualified_name=?2 ORDER BY id LIMIT 1"; + if (sqlite3_prepare_v2(db, node_sql, -1, &st, NULL) != SQLITE_OK) + return NULL; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (node_id >= 0) + sqlite3_bind_int64(st, 2, node_id); + else + sqlite3_bind_text(st, 2, qualified_name ? qualified_name : "", -1, SQLITE_STATIC); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + 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_val *node = yyjson_mut_obj(doc); + int64_t id = sqlite3_column_int64(st, 0); + yyjson_mut_obj_add_int(doc, node, "id", id); + yyjson_mut_obj_add_strcpy(doc, node, "label", (const char *)sqlite3_column_text(st, 1)); + yyjson_mut_obj_add_strcpy(doc, node, "name", (const char *)sqlite3_column_text(st, 2)); + const char *qn = (const char *)sqlite3_column_text(st, 3); + if (qn) + yyjson_mut_obj_add_strcpy(doc, node, "qualified_name", qn); + const char *file = (const char *)sqlite3_column_text(st, 4); + if (file) + yyjson_mut_obj_add_strcpy(doc, node, "file_path", file); + int start_line = sqlite3_column_int(st, 5); + int end_line = sqlite3_column_int(st, 6); + if (start_line > 0) + yyjson_mut_obj_add_int(doc, node, "start_line", start_line); + if (end_line > 0) + yyjson_mut_obj_add_int(doc, node, "end_line", end_line); + const char *docstring = (const char *)sqlite3_column_text(st, 7); + if (docstring && docstring[0]) + yyjson_mut_obj_add_strcpy(doc, node, "docstring", docstring); + if (sqlite3_column_int(st, 8)) + yyjson_mut_obj_add_bool(doc, node, "is_entry", true); + if (sqlite3_column_int(st, 9)) + yyjson_mut_obj_add_bool(doc, node, "is_test", true); + if (sqlite3_column_int(st, 10)) + yyjson_mut_obj_add_bool(doc, node, "is_exported", true); + char *file_copy = aa_strdup(file); + sqlite3_finalize(st); + yyjson_mut_obj_add_val(doc, root, "node", node); + + /* Region membership (cache-backed). */ + if (file_copy) { + char *region_name = NULL; + int region = cbm_layout_region_for_file(store, project, file_copy, ®ion_name); + if (region >= 0) { + yyjson_mut_val *robj = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, robj, "id", region); + if (region_name) + yyjson_mut_obj_add_strcpy(doc, robj, "name", region_name); + yyjson_mut_obj_add_val(doc, root, "region", robj); + } + free(region_name); + } + + aa_add_connections(doc, root, db, project, id, true, limit, offset); + aa_add_connections(doc, root, db, project, id, false, limit, offset); + + /* Tests exercising this symbol (inbound TESTS edges). */ + if (sqlite3_prepare_v2(db, + "SELECT n.id, n.name, n.file_path FROM edges e JOIN nodes n ON " + "n.project=e.project AND n.id=e.source_id WHERE e.project=?1 AND " + "e.target_id=?2 AND e.type='TESTS' ORDER BY n.id LIMIT 50", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 2, id); + yyjson_mut_val *tests = yyjson_mut_arr(doc); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(doc, row, "name", + (const char *)sqlite3_column_text(st, 1)); + const char *tf = (const char *)sqlite3_column_text(st, 2); + if (tf) + yyjson_mut_obj_add_strcpy(doc, row, "file_path", tf); + yyjson_mut_arr_append(tests, row); + } + sqlite3_finalize(st); + yyjson_mut_obj_add_val(doc, root, "tests", tests); + } + + /* Co-change partners of the symbol's FILE (either direction). */ + if (file_copy && + sqlite3_prepare_v2( + db, + "SELECT o.file_path, json_extract(e.properties,'$.coupling_score') FROM nodes f " + "JOIN edges e ON e.project=f.project AND e.type='FILE_CHANGES_WITH' AND " + "(e.source_id=f.id OR e.target_id=f.id) JOIN nodes o ON o.project=f.project AND " + "o.id=CASE WHEN e.source_id=f.id THEN e.target_id ELSE e.source_id END " + "WHERE f.project=?1 AND f.label='File' AND f.file_path=?2 ORDER BY 2 DESC " + "LIMIT 20", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, file_copy, -1, SQLITE_STATIC); + yyjson_mut_val *cochange = yyjson_mut_arr(doc); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *other = (const char *)sqlite3_column_text(st, 0); + if (!other) + continue; + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, row, "file_path", other); + double score = sqlite3_column_double(st, 1); + if (score > 0) + yyjson_mut_obj_add_real(doc, row, "score", score); + yyjson_mut_arr_append(cochange, row); + } + sqlite3_finalize(st); + yyjson_mut_obj_add_val(doc, root, "co_change", cochange); + } + + /* Near-clones (SIMILAR_TO either direction). */ + if (sqlite3_prepare_v2( + db, + "SELECT n.id, n.name, n.file_path, json_extract(e.properties,'$.score') FROM " + "edges e JOIN nodes n ON n.project=e.project AND n.id=CASE WHEN e.source_id=?2 " + "THEN e.target_id ELSE e.source_id END WHERE e.project=?1 AND " + "e.type='SIMILAR_TO' AND (e.source_id=?2 OR e.target_id=?2) ORDER BY n.id " + "LIMIT 20", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 2, id); + yyjson_mut_val *similar = yyjson_mut_arr(doc); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, row, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(doc, row, "name", + (const char *)sqlite3_column_text(st, 1)); + const char *sf = (const char *)sqlite3_column_text(st, 2); + if (sf) + yyjson_mut_obj_add_strcpy(doc, row, "file_path", sf); + double score = sqlite3_column_double(st, 3); + if (score > 0) + yyjson_mut_obj_add_real(doc, row, "score", score); + yyjson_mut_arr_append(similar, row); + } + sqlite3_finalize(st); + yyjson_mut_obj_add_val(doc, root, "similar", similar); + } + + free(file_copy); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} diff --git a/src/ui/atlas_flows.c b/src/ui/atlas_flows.c new file mode 100644 index 000000000..6f3860a02 --- /dev/null +++ b/src/ui/atlas_flows.c @@ -0,0 +1,587 @@ +/* + * atlas_flows.c — CBM Atlas flows: named entry→terminal call journeys. + * + * A flow is what a human reads to learn how the codebase RUNS: pick an + * entry point, follow CALLS with bounded depth and branching, stop at + * sinks. Deterministic (ties break by id), honest about truncation + * (dropped candidates and capped steps are counted, never silent), and + * cached per (project, indexed_at) like the region level. + * + * Entry scoring (deterministic, from stored data only): + * +4 is_entry_point / url_path (route handler) property + * +2 no callers but real callees (a root of the call DAG) + * +1 entry-ish name (main, run*, handle*, serve*, cmd*, on[A-Z]…, *_main) + * ratio bonus: callees outweigh callers + */ +#include "foundation/constants.h" +#include "ui/atlas.h" +#include "foundation/hash_table.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +enum { + FLOW_MAX_CANDIDATES = 200, /* scored entries considered */ + FLOW_MAX_FLOWS = 60, /* flows kept after dedup + rank */ + FLOW_MAX_DEPTH = 6, /* call depth per flow */ + FLOW_MAX_CHILDREN = 4, /* callees followed per step */ + FLOW_MAX_STEPS = 40, /* steps per flow */ + FLOW_MIN_STEPS = 3, /* smaller journeys teach nothing */ + FLOW_NODE_CAP = 500000, /* callable-graph size bound for the cache */ +}; + +/* ── In-memory call graph (cache) ─────────────────────────────── */ + +typedef struct { + int64_t id; + char *name; + char *file_path; + int32_t first_out; /* index into out_edges, -1 none */ + int32_t out_count; + int32_t in_count; + uint8_t flagged_entry; /* is_entry_point or route property */ +} flow_node_t; + +typedef struct { + int32_t steps[FLOW_MAX_STEPS]; /* node indices, DFS order */ + int32_t parent[FLOW_MAX_STEPS]; + uint8_t depth[FLOW_MAX_STEPS]; + int step_count; + int32_t entry; + int32_t terminal; /* deepest sink (or deepest step) */ + bool sink_terminated; + bool cross_region; + int steps_capped; +} flow_t; + +typedef struct { + char key[1152]; + flow_node_t *nodes; + int node_count; + int32_t *out_edges; /* callee node indices, grouped by caller */ + flow_t *flows; + int flow_count; + int candidates_dropped; + long long callable_total; +} flow_cache_t; + +static flow_cache_t g_flow_cache; +static pthread_mutex_t g_flow_mu = PTHREAD_MUTEX_INITIALIZER; + +static char *fl_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; +} + +static void flow_cache_clear_locked(void) { + for (int i = 0; i < g_flow_cache.node_count; i++) { + free(g_flow_cache.nodes[i].name); + free(g_flow_cache.nodes[i].file_path); + } + free(g_flow_cache.nodes); + free(g_flow_cache.out_edges); + free(g_flow_cache.flows); + memset(&g_flow_cache, 0, sizeof(g_flow_cache)); +} + +void cbm_atlas_flows_cache_clear(void) { + pthread_mutex_lock(&g_flow_mu); + flow_cache_clear_locked(); + pthread_mutex_unlock(&g_flow_mu); +} + +static void flow_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); +} + +/* ── Entry scoring ────────────────────────────────────────────── */ + +static bool fl_entryish_name(const char *name) { + if (!name || !name[0]) + return false; + if (strcmp(name, "main") == 0 || strcmp(name, "run") == 0) + return true; + static const char *const prefixes[] = {"handle", "run_", "serve", "cmd_", "do_", "start"}; + for (size_t i = 0; i < sizeof(prefixes) / sizeof(prefixes[0]); i++) + if (strncmp(name, prefixes[i], strlen(prefixes[i])) == 0) + return true; + if (name[0] == 'o' && name[1] == 'n' && isupper((unsigned char)name[2])) + return true; + size_t len = strlen(name); + if (len > 5 && strcmp(name + len - 5, "_main") == 0) + return true; + return false; +} + +static int fl_score(const flow_node_t *node) { + int score = 0; + if (node->flagged_entry) + score += 4; + if (node->in_count == 0 && node->out_count > 0) + score += 2; + if (fl_entryish_name(node->name)) + score += 1; + if (node->out_count > node->in_count) + score += 1; + return score; +} + +typedef struct { + int32_t node; + int score; +} fl_candidate_t; + +static int fl_candidate_cmp(const void *a, const void *b) { + const fl_candidate_t *ca = a, *cb = b; + if (cb->score != ca->score) + return cb->score - ca->score; + return ca->node - cb->node; /* deterministic tie-break */ +} + +/* Rank comparator: sink-terminated first, then longer, then entry order. */ +static int fl_flow_cmp(const void *a, const void *b) { + const flow_t *fa = a, *fb = b; + if (fa->sink_terminated != fb->sink_terminated) + return fb->sink_terminated - fa->sink_terminated; + if (fb->step_count != fa->step_count) + return fb->step_count - fa->step_count; + return fa->entry - fb->entry; +} + +/* ── Build ────────────────────────────────────────────────────── */ + +typedef struct { + int64_t id; + int32_t idx; +} fl_id_idx_t; + +static int fl_id_idx_cmp(const void *a, const void *b) { + int64_t ia = ((const fl_id_idx_t *)a)->id, ib = ((const fl_id_idx_t *)b)->id; + return (ia > ib) - (ia < ib); +} + +static int32_t fl_index_of(const fl_id_idx_t *map, int n, int64_t id) { + int lo = 0, hi = n - 1; + while (lo <= hi) { + int mid = lo + (hi - lo) / 2; + if (map[mid].id == id) + return map[mid].idx; + if (map[mid].id < id) + lo = mid + 1; + else + hi = mid - 1; + } + return -1; +} + +/* DFS from `entry`, children capped and ordered by (desc out_count, id): + * the busiest callee first reads as the "trunk" of the journey. */ +static void fl_walk(const flow_cache_t *cache, flow_t *flow, int32_t entry) { + memset(flow, 0, sizeof(*flow)); + flow->entry = entry; + flow->terminal = entry; + bool *visited = calloc((size_t)cache->node_count, sizeof(bool)); + if (!visited) + return; + + typedef struct { + int32_t node; + int32_t parent_step; + uint8_t depth; + } frame_t; + frame_t stack[FLOW_MAX_STEPS * FLOW_MAX_CHILDREN]; + int sp = 0; + stack[sp++] = (frame_t){entry, -1, 0}; + int deepest = -1; + while (sp > 0 && flow->step_count < FLOW_MAX_STEPS) { + frame_t frame = stack[--sp]; + if (visited[frame.node]) + continue; + visited[frame.node] = true; + int step = flow->step_count; + flow->steps[step] = frame.node; + flow->parent[step] = frame.parent_step; + flow->depth[step] = frame.depth; + flow->step_count++; + const flow_node_t *node = &cache->nodes[frame.node]; + if (frame.depth > deepest || + (frame.depth == deepest && node->out_count == 0 && !flow->sink_terminated)) { + deepest = frame.depth; + flow->terminal = frame.node; + flow->sink_terminated = node->out_count == 0; + } + if (frame.depth >= FLOW_MAX_DEPTH) + continue; + /* Collect up to FLOW_MAX_CHILDREN unvisited callees, best last so + * the busiest pops first (LIFO). */ + int32_t picked[FLOW_MAX_CHILDREN]; + int npicked = 0; + for (int e = 0; e < node->out_count; e++) { + int32_t callee = cache->out_edges[node->first_out + e]; + if (visited[callee]) + continue; + /* Insertion into a small best-of list by (out_count desc, id). */ + int pos = npicked; + while (pos > 0) { + const flow_node_t *have = &cache->nodes[picked[pos - 1]]; + const flow_node_t *cand = &cache->nodes[callee]; + if (have->out_count > cand->out_count || + (have->out_count == cand->out_count && picked[pos - 1] < callee)) + break; + pos--; + } + if (npicked < FLOW_MAX_CHILDREN) { + for (int m = npicked; m > pos; m--) + picked[m] = picked[m - 1]; + picked[pos] = callee; + npicked++; + } else if (pos < FLOW_MAX_CHILDREN) { + for (int m = FLOW_MAX_CHILDREN - 1; m > pos; m--) + picked[m] = picked[m - 1]; + picked[pos] = callee; + flow->steps_capped++; + } else { + flow->steps_capped++; + } + } + /* Push worst-first so the best is on top of the stack. */ + for (int m = npicked - 1; m >= 0; m--) { + if (sp < (int)(sizeof(stack) / sizeof(stack[0]))) + stack[sp++] = (frame_t){picked[m], step, (uint8_t)(frame.depth + 1)}; + } + } + free(visited); +} + +static int fl_rebuild_locked(cbm_store_t *store, const char *project, const char *key) { + flow_cache_clear_locked(); + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return -1; + + /* 1. Callable nodes with entry flags. */ + sqlite3_stmt *st = NULL; + const char *nsql = + "SELECT id, name, file_path, " + "COALESCE(json_extract(properties,'$.is_entry_point'),0), " + "CASE WHEN json_extract(properties,'$.url_path') IS NOT NULL THEN 1 ELSE 0 END " + "FROM nodes WHERE project=?1 AND label IN (" CBM_SQL_CALLABLE_LABELS ") " + "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, FLOW_NODE_CAP + 1); + int cap = 0, n = 0; + flow_node_t *nodes = NULL; + while (sqlite3_step(st) == SQLITE_ROW) { + if (n >= cap) { + int nc = cap ? cap * 2 : 4096; + flow_node_t *grown = realloc(nodes, (size_t)nc * sizeof(flow_node_t)); + if (!grown) + break; + nodes = grown; + cap = nc; + } + memset(&nodes[n], 0, sizeof(flow_node_t)); + nodes[n].id = sqlite3_column_int64(st, 0); + nodes[n].name = fl_strdup((const char *)sqlite3_column_text(st, 1)); + nodes[n].file_path = fl_strdup((const char *)sqlite3_column_text(st, 2)); + nodes[n].first_out = -1; + nodes[n].flagged_entry = + sqlite3_column_int(st, 3) != 0 || sqlite3_column_int(st, 4) != 0; + n++; + } + sqlite3_finalize(st); + if (n == 0 || n > FLOW_NODE_CAP) { + /* Empty graph, or beyond the honest bound — no flows rather than + * silently partial ones. */ + g_flow_cache.nodes = nodes; + g_flow_cache.node_count = n > FLOW_NODE_CAP ? 0 : n; + g_flow_cache.callable_total = n; + if (n > FLOW_NODE_CAP) { + for (int i = 0; i < n; i++) { + free(nodes[i].name); + free(nodes[i].file_path); + } + free(nodes); + g_flow_cache.nodes = NULL; + } + snprintf(g_flow_cache.key, sizeof(g_flow_cache.key), "%s", key); + return 0; + } + + fl_id_idx_t *idmap = malloc((size_t)n * sizeof(fl_id_idx_t)); + if (!idmap) { + g_flow_cache.nodes = nodes; + g_flow_cache.node_count = n; + flow_cache_clear_locked(); + return -1; + } + for (int i = 0; i < n; i++) + idmap[i] = (fl_id_idx_t){nodes[i].id, i}; + qsort(idmap, (size_t)n, sizeof(fl_id_idx_t), fl_id_idx_cmp); + + /* 2. CALLS edges → CSR adjacency (two passes over one query result + * would need materialization; count first via GROUP BY, then fill). */ + int32_t *src = NULL, *dst = NULL; + int ecap = 0, ne = 0; + if (sqlite3_prepare_v2(db, + "SELECT source_id, target_id FROM edges WHERE project=?1 AND " + "type='CALLS'", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + while (sqlite3_step(st) == SQLITE_ROW) { + int32_t si = fl_index_of(idmap, n, sqlite3_column_int64(st, 0)); + int32_t di = fl_index_of(idmap, n, sqlite3_column_int64(st, 1)); + if (si < 0 || di < 0 || si == di) + continue; + if (ne >= ecap) { + int nc = ecap ? ecap * 2 : 8192; + int32_t *ns = realloc(src, (size_t)nc * sizeof(int32_t)); + int32_t *nd = realloc(dst, (size_t)nc * sizeof(int32_t)); + if (!ns || !nd) { + free(ns ? ns : src); + free(nd ? nd : dst); + src = dst = NULL; + ne = 0; + break; + } + src = ns; + dst = nd; + ecap = nc; + } + src[ne] = si; + dst[ne] = di; + ne++; + } + sqlite3_finalize(st); + } + free(idmap); + + for (int e = 0; e < ne; e++) { + nodes[src[e]].out_count++; + nodes[dst[e]].in_count++; + } + int32_t *out_edges = malloc(ne > 0 ? (size_t)ne * sizeof(int32_t) : sizeof(int32_t)); + int32_t *cursor = calloc((size_t)n, sizeof(int32_t)); + if (out_edges && cursor) { + int32_t offset = 0; + for (int i = 0; i < n; i++) { + nodes[i].first_out = offset; + offset += nodes[i].out_count; + } + for (int e = 0; e < ne; e++) + out_edges[nodes[src[e]].first_out + cursor[src[e]]++] = dst[e]; + } + free(cursor); + free(src); + free(dst); + if (!out_edges) { + for (int i = 0; i < n; i++) { + free(nodes[i].name); + free(nodes[i].file_path); + } + free(nodes); + return -1; + } + + g_flow_cache.nodes = nodes; + g_flow_cache.node_count = n; + g_flow_cache.out_edges = out_edges; + g_flow_cache.callable_total = n; + + /* 3. Score candidates, walk flows, dedup by (entry, terminal). */ + fl_candidate_t *cands = malloc((size_t)n * sizeof(fl_candidate_t)); + if (!cands) + return -1; + int ncand = 0; + for (int i = 0; i < n; i++) { + int score = fl_score(&nodes[i]); + if (score > 0 && nodes[i].out_count > 0) + cands[ncand++] = (fl_candidate_t){i, score}; + } + qsort(cands, (size_t)ncand, sizeof(fl_candidate_t), fl_candidate_cmp); + int considered = ncand < FLOW_MAX_CANDIDATES ? ncand : FLOW_MAX_CANDIDATES; + g_flow_cache.candidates_dropped = ncand - considered; + + flow_t *flows = malloc(sizeof(flow_t) * FLOW_MAX_FLOWS * 2); + CBMHashTable *seen_pair = cbm_ht_create(256); + char **pair_keys = calloc(FLOW_MAX_FLOWS * 2, sizeof(char *)); + int nflows = 0; + if (flows && seen_pair && pair_keys) { + for (int c = 0; c < considered && nflows < FLOW_MAX_FLOWS * 2; c++) { + flow_t flow; + fl_walk(&g_flow_cache, &flow, cands[c].node); + if (flow.step_count < FLOW_MIN_STEPS) + continue; + char pair[64]; + snprintf(pair, sizeof(pair), "%lld>%lld", (long long)nodes[flow.entry].id, + (long long)nodes[flow.terminal].id); + if (cbm_ht_has(seen_pair, pair)) + continue; + pair_keys[nflows] = fl_strdup(pair); + if (pair_keys[nflows]) + cbm_ht_set(seen_pair, pair_keys[nflows], (void *)(intptr_t)1); + /* Cross-region when any two steps live in different regions. */ + int first_region = -1; + flow.cross_region = false; + for (int s2 = 0; s2 < flow.step_count && !flow.cross_region; s2++) { + const char *fp = nodes[flow.steps[s2]].file_path; + if (!fp) + continue; + int region = cbm_layout_region_for_file(store, project, fp, NULL); + if (region < 0) + continue; + if (first_region < 0) + first_region = region; + else if (region != first_region) + flow.cross_region = true; + } + flows[nflows++] = flow; + } + } + for (int i = 0; i < FLOW_MAX_FLOWS * 2; i++) + free(pair_keys ? pair_keys[i] : NULL); + free(pair_keys); + cbm_ht_free(seen_pair); + free(cands); + if (!flows) + return -1; + qsort(flows, (size_t)nflows, sizeof(flow_t), fl_flow_cmp); + if (nflows > FLOW_MAX_FLOWS) + nflows = FLOW_MAX_FLOWS; + g_flow_cache.flows = flows; + g_flow_cache.flow_count = nflows; + snprintf(g_flow_cache.key, sizeof(g_flow_cache.key), "%s", key); + return 0; +} + +static bool fl_cache_fresh_locked(const char *key) { + return g_flow_cache.key[0] && strcmp(g_flow_cache.key, key) == 0; +} + +static int fl_ensure_locked(cbm_store_t *store, const char *project) { + char key[1152]; + flow_cache_key(store, project, key, sizeof(key)); + if (fl_cache_fresh_locked(key)) + return 0; + return fl_rebuild_locked(store, project, key); +} + +/* ── JSON ─────────────────────────────────────────────────────── */ + +static void fl_add_node_ref(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *field, + const flow_node_t *node) { + yyjson_mut_val *ref = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, ref, "id", node->id); + yyjson_mut_obj_add_strcpy(doc, ref, "name", node->name ? node->name : "?"); + if (node->file_path) + yyjson_mut_obj_add_strcpy(doc, ref, "file_path", node->file_path); + yyjson_mut_obj_add_val(doc, obj, field, ref); +} + +char *cbm_atlas_flows_json(cbm_store_t *store, const char *project) { + if (!store || !project) + return NULL; + pthread_mutex_lock(&g_flow_mu); + if (fl_ensure_locked(store, project) != 0) { + pthread_mutex_unlock(&g_flow_mu); + 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_int(doc, root, "callable_total", g_flow_cache.callable_total); + yyjson_mut_obj_add_int(doc, root, "candidates_dropped", g_flow_cache.candidates_dropped); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int i = 0; i < g_flow_cache.flow_count; i++) { + const flow_t *flow = &g_flow_cache.flows[i]; + yyjson_mut_val *obj = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, obj, "id", i); + char label[CBM_SZ_512]; + snprintf(label, sizeof(label), "%s → %s", + g_flow_cache.nodes[flow->entry].name ? g_flow_cache.nodes[flow->entry].name + : "?", + g_flow_cache.nodes[flow->terminal].name + ? g_flow_cache.nodes[flow->terminal].name + : "?"); + yyjson_mut_obj_add_strcpy(doc, obj, "label", label); + fl_add_node_ref(doc, obj, "entry", &g_flow_cache.nodes[flow->entry]); + fl_add_node_ref(doc, obj, "terminal", &g_flow_cache.nodes[flow->terminal]); + yyjson_mut_obj_add_int(doc, obj, "steps", flow->step_count); + yyjson_mut_obj_add_bool(doc, obj, "sink_terminated", flow->sink_terminated); + yyjson_mut_obj_add_bool(doc, obj, "cross_region", flow->cross_region); + if (flow->steps_capped > 0) + yyjson_mut_obj_add_int(doc, obj, "steps_capped", flow->steps_capped); + yyjson_mut_arr_append(arr, obj); + } + yyjson_mut_obj_add_val(doc, root, "flows", arr); + pthread_mutex_unlock(&g_flow_mu); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +char *cbm_atlas_flow_json(cbm_store_t *store, const char *project, int flow_id) { + if (!store || !project || flow_id < 0) + return NULL; + pthread_mutex_lock(&g_flow_mu); + if (fl_ensure_locked(store, project) != 0 || flow_id >= g_flow_cache.flow_count) { + pthread_mutex_unlock(&g_flow_mu); + return NULL; + } + const flow_t *flow = &g_flow_cache.flows[flow_id]; + 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_int(doc, root, "id", flow_id); + fl_add_node_ref(doc, root, "entry", &g_flow_cache.nodes[flow->entry]); + fl_add_node_ref(doc, root, "terminal", &g_flow_cache.nodes[flow->terminal]); + yyjson_mut_obj_add_bool(doc, root, "sink_terminated", flow->sink_terminated); + yyjson_mut_obj_add_bool(doc, root, "cross_region", flow->cross_region); + if (flow->steps_capped > 0) + yyjson_mut_obj_add_int(doc, root, "steps_capped", flow->steps_capped); + yyjson_mut_val *steps = yyjson_mut_arr(doc); + for (int s = 0; s < flow->step_count; s++) { + const flow_node_t *node = &g_flow_cache.nodes[flow->steps[s]]; + yyjson_mut_val *obj = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, obj, "id", node->id); + yyjson_mut_obj_add_strcpy(doc, obj, "name", node->name ? node->name : "?"); + if (node->file_path) + yyjson_mut_obj_add_strcpy(doc, obj, "file_path", node->file_path); + yyjson_mut_obj_add_int(doc, obj, "depth", flow->depth[s]); + yyjson_mut_obj_add_int(doc, obj, "parent", flow->parent[s]); + yyjson_mut_arr_append(steps, obj); + } + yyjson_mut_obj_add_val(doc, root, "steps", steps); + pthread_mutex_unlock(&g_flow_mu); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index c7c3ac744..1b473ab4a 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -17,6 +17,7 @@ #include "ui/httpd.h" #include "ui/embedded_assets.h" #include "ui/layout3d.h" +#include "ui/atlas.h" #include "mcp/mcp.h" #include "store/store.h" #include "watcher/watcher.h" @@ -1742,6 +1743,107 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { } } +/* ── CBM Atlas data services (tree, symbol, flows) ────────────── */ + +/* Open the query store for ?project=... or reply with the right error. + * Returns NULL after replying. */ +static cbm_store_t *atlas_open_project(cbm_http_conn_t *c, const cbm_http_req_t *req, + char *project, size_t project_cap) { + if (!cbm_http_query_param(req->query, "project", project, (int)project_cap) || + project[0] == '\0') { + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}"); + return NULL; + } + char db_path[1024]; + db_path_for_project(project, db_path, sizeof(db_path)); + if (!cbm_file_exists(db_path)) { + cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}"); + return NULL; + } + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (!store) + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); + return store; +} + +static void atlas_reply_json(cbm_http_conn_t *c, char *json, int missing_status, + const char *missing_body) { + if (!json) { + cbm_http_replyf(c, missing_status, g_cors_json, "%s", missing_body); + return; + } + cbm_http_replyf(c, 200, g_cors_json, "%s", json); + free(json); +} + +/* GET /api/tree?project=X&path=src/foo — Modules aggregates. */ +static void handle_atlas_tree(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 path[512] = {0}; + cbm_http_query_param(req->query, "path", path, (int)sizeof(path)); + char *json = cbm_atlas_tree_json(store, project, path); + cbm_store_close(store); + atlas_reply_json(c, json, 500, "{\"error\":\"tree computation failed\"}"); +} + +/* GET /api/symbol?project=X&id=N (or &qn=...) — the symbol bundle. */ +static void handle_atlas_symbol(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 id_str[32] = {0}; + char qn[1024] = {0}; + char limit_str[16] = {0}; + char offset_str[16] = {0}; + int64_t id = -1; + if (cbm_http_query_param(req->query, "id", id_str, (int)sizeof(id_str)) && id_str[0]) + id = strtoll(id_str, NULL, 10); + cbm_http_query_param(req->query, "qn", qn, (int)sizeof(qn)); + int limit = 0, offset = 0; + if (cbm_http_query_param(req->query, "limit", limit_str, (int)sizeof(limit_str))) + limit = atoi(limit_str); + if (cbm_http_query_param(req->query, "offset", offset_str, (int)sizeof(offset_str))) + offset = atoi(offset_str); + if (id < 0 && qn[0] == '\0') { + cbm_store_close(store); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing id or qn parameter\"}"); + return; + } + char *json = cbm_atlas_symbol_json(store, project, id, qn[0] ? qn : NULL, limit, offset); + cbm_store_close(store); + atlas_reply_json(c, json, 404, "{\"error\":\"symbol not found\"}"); +} + +/* 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}; + cbm_store_t *store = atlas_open_project(c, req, project, sizeof(project)); + if (!store) + return; + char *json = cbm_atlas_flows_json(store, project); + cbm_store_close(store); + atlas_reply_json(c, json, 500, "{\"error\":\"flow computation failed\"}"); +} + +/* GET /api/flow?project=X&id=N — one flow's steps. */ +static void handle_atlas_flow(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 id_str[16] = {0}; + int id = -1; + if (cbm_http_query_param(req->query, "id", id_str, (int)sizeof(id_str))) + id = atoi(id_str); + char *json = cbm_atlas_flow_json(store, project, id); + cbm_store_close(store); + atlas_reply_json(c, json, 404, "{\"error\":\"unknown flow\"}"); +} + /* ── Handle JSON-RPC request ──────────────────────────────────── */ static yyjson_val *json_unique_member(yyjson_val *object, const char *name) { @@ -2017,6 +2119,30 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + /* GET /api/tree → CBM Atlas folder aggregates */ + if (is_get && cbm_http_path_match(req->path, "/api/tree*")) { + handle_atlas_tree(c, req); + return; + } + + /* GET /api/symbol → CBM Atlas symbol bundle */ + if (is_get && cbm_http_path_match(req->path, "/api/symbol*")) { + handle_atlas_symbol(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); + return; + } + + /* GET /api/flow → one CBM Atlas flow */ + if (is_get && cbm_http_path_match(req->path, "/api/flow?*")) { + handle_atlas_flow(c, req); + return; + } + /* GET /api/repo-info → git remote / branch for GitHub deep-links */ if (is_get && cbm_http_path_match(req->path, "/api/repo-info*")) { handle_repo_info(c, req); diff --git a/src/ui/layout_regions.c b/src/ui/layout_regions.c index caac04520..711262b66 100644 --- a/src/ui/layout_regions.c +++ b/src/ui/layout_regions.c @@ -74,6 +74,7 @@ typedef struct { char ***region_files; /* per region: owned file_path strings */ int *region_file_count; long long total_nodes; + CBMHashTable *file_ht; /* file_path (borrowed from region_files) → region+1 */ } region_cache_t; static region_cache_t g_region_cache; @@ -88,6 +89,7 @@ static void region_cache_clear_locked(void) { } free(g_region_cache.region_files); free(g_region_cache.region_file_count); + cbm_ht_free(g_region_cache.file_ht); memset(&g_region_cache, 0, sizeof(g_region_cache)); } @@ -999,6 +1001,7 @@ static int rg_rebuild_locked(cbm_store_t *store, const char *project, const char sizeof(char **)); g_region_cache.region_file_count = calloc(b.region_count > 0 ? (size_t)b.region_count : 1, sizeof(int)); + g_region_cache.file_ht = cbm_ht_create((uint32_t)(b.file_count > 0 ? b.file_count : 16)); 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; @@ -1011,8 +1014,12 @@ static int rg_rebuild_locked(cbm_store_t *store, const char *project, const char 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]) + if (g_region_cache.region_files[r][idx]) { g_region_cache.region_file_count[r] = idx + 1; + if (g_region_cache.file_ht) + cbm_ht_set(g_region_cache.file_ht, g_region_cache.region_files[r][idx], + (void *)(intptr_t)(r + 1)); + } } } rg_build_free(&b); @@ -1140,3 +1147,41 @@ cbm_layout_result_t *cbm_layout_compute_region(cbm_store_t *store, const char *p free(nodes); return result; } + +/* ── Cache-backed region lookup for other Atlas services ──────── */ + +int cbm_layout_region_for_file(cbm_store_t *store, const char *project, const char *file_path, + char **name_out) { + if (name_out) + *name_out = NULL; + if (!store || !project || !file_path || !file_path[0]) + return -1; + 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 -1; + } + int found = -1; + if (g_region_cache.file_ht) { + void *slot = cbm_ht_get(g_region_cache.file_ht, file_path); + if (slot) + found = (int)(intptr_t)slot - 1; + } + if (found >= 0 && name_out && g_region_cache.json) { + /* Region names live in the serialized payload; re-parse the one we + * need rather than duplicating the name store. */ + yyjson_doc *doc = yyjson_read(g_region_cache.json, strlen(g_region_cache.json), 0); + if (doc) { + yyjson_val *regions = yyjson_obj_get(yyjson_doc_get_root(doc), "regions"); + yyjson_val *rv = yyjson_arr_get(regions, (size_t)found); + const char *name = yyjson_get_str(yyjson_obj_get(rv, "name")); + if (name) + *name_out = rg_strdup(name); + yyjson_doc_free(doc); + } + } + pthread_mutex_unlock(&g_region_mu); + return found; +} diff --git a/tests/test_ui.c b/tests/test_ui.c index 6967cecd0..2d60424c5 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -10,6 +10,7 @@ #include "ui/config.h" #include "ui/embedded_assets.h" #include "ui/layout3d.h" +#include "ui/atlas.h" #include "store/store.h" #include @@ -1011,6 +1012,294 @@ TEST(layout_regions_scope_restricts_nodes) { PASS(); } + +/* ── CBM Atlas services: tree, symbol bundle, flows ───────────── */ + +/* The regions fixture plus: a docstring + entry flag on alpha_one, a test + * node TESTS-ing alpha_one, SIMILAR_TO across the communities, File nodes + * with a FILE_CHANGES_WITH edge, and one missed-coverage shadow file. */ +static cbm_store_t *atlas_fixture(int64_t *alpha_one_id_out) { + cbm_store_t *store = regions_fixture(); + if (!store) + return NULL; + /* Find alpha_one / alpha_two / beta_two ids by qualified name. */ + int64_t ids[3] = {-1, -1, -1}; + static const char *qns[3] = {"regions-test::alpha_one", "regions-test::alpha_two", + "regions-test::beta_two"}; + for (int i = 0; i < 3; i++) { + cbm_search_params_t params; + memset(¶ms, 0, sizeof(params)); + params.project = "regions-test"; + params.qn_pattern = qns[i]; + params.limit = 1; + params.min_degree = -1; + params.max_degree = -1; + cbm_search_output_t out; + memset(&out, 0, sizeof(out)); + if (cbm_store_search(store, ¶ms, &out) == CBM_STORE_OK && out.count == 1) + ids[i] = out.results[0].node.id; + cbm_store_search_free(&out); + } + if (ids[0] < 0 || ids[1] < 0 || ids[2] < 0) { + cbm_store_close(store); + return NULL; + } + /* Re-upsert alpha_one with docstring + entry flag. */ + cbm_node_t node; + memset(&node, 0, sizeof(node)); + node.project = "regions-test"; + node.label = "Function"; + node.name = "alpha_one"; + node.qualified_name = "regions-test::alpha_one"; + node.file_path = "src/alpha/a1.c"; + node.start_line = 1; + node.end_line = 5; + node.properties_json = "{\"docstring\":\"Entry of alpha.\",\"is_entry_point\":true}"; + int64_t alpha_one_new = cbm_store_upsert_node(store, &node); + if (alpha_one_new > 0) + ids[0] = alpha_one_new; + if (alpha_one_id_out) + *alpha_one_id_out = ids[0]; + + /* A test node exercising alpha_one. */ + memset(&node, 0, sizeof(node)); + node.project = "regions-test"; + node.label = "Function"; + node.name = "test_alpha_one"; + node.qualified_name = "regions-test::test_alpha_one"; + node.file_path = "tests/test_alpha.c"; + node.start_line = 1; + node.end_line = 9; + int64_t test_id = cbm_store_upsert_node(store, &node); + + /* File nodes for co-change. */ + int64_t file_ids[2]; + static const char *files[2] = {"src/alpha/a1.c", "src/beta/b1.c"}; + for (int i = 0; i < 2; i++) { + memset(&node, 0, sizeof(node)); + node.project = "regions-test"; + node.label = "File"; + node.name = files[i]; + char qn[128]; + snprintf(qn, sizeof(qn), "regions-test::file::%d", i); + node.qualified_name = qn; + node.file_path = files[i]; + file_ids[i] = cbm_store_upsert_node(store, &node); + } + + cbm_edge_t edge; + memset(&edge, 0, sizeof(edge)); + edge.project = "regions-test"; + edge.source_id = test_id; + edge.target_id = ids[0]; + edge.type = "TESTS"; + cbm_store_insert_edge(store, &edge); + + memset(&edge, 0, sizeof(edge)); + edge.project = "regions-test"; + edge.source_id = ids[1]; + edge.target_id = ids[2]; + edge.type = "SIMILAR_TO"; + edge.properties_json = "{\"score\":0.91}"; + cbm_store_insert_edge(store, &edge); + + memset(&edge, 0, sizeof(edge)); + edge.project = "regions-test"; + edge.source_id = file_ids[0]; + edge.target_id = file_ids[1]; + edge.type = "FILE_CHANGES_WITH"; + edge.properties_json = "{\"coupling_score\":0.66}"; + cbm_store_insert_edge(store, &edge); + + /* One not-fully-covered file in the coverage shadow project. */ + char shadow[512]; + cbm_store_coverage_shadow_project(shadow, sizeof(shadow), "regions-test"); + cbm_store_upsert_project(store, shadow, "/tmp/regions-test"); + memset(&node, 0, sizeof(node)); + node.project = shadow; + node.label = "File"; + node.name = "gen.sql"; + node.qualified_name = "regions-test::missed::gen.sql"; + node.file_path = "src/alpha/gen.sql"; + cbm_store_upsert_node(store, &node); + + return store; +} + +TEST(atlas_tree_aggregates_children) { + cbm_layout_regions_cache_clear(); + cbm_store_t *store = atlas_fixture(NULL); + ASSERT_NOT_NULL(store); + + char *json = cbm_atlas_tree_json(store, "regions-test", "src"); + 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 *children = yyjson_obj_get(root, "children"); + bool saw_alpha = false, saw_beta = false; + size_t idx, max; + yyjson_val *child; + yyjson_arr_foreach(children, idx, max, child) { + const char *name = yyjson_get_str(yyjson_obj_get(child, "name")); + const char *kind = yyjson_get_str(yyjson_obj_get(child, "kind")); + if (name && strcmp(name, "alpha") == 0) { + saw_alpha = true; + ASSERT_STR_EQ(kind, "dir"); + /* 2 code files + the File node's own row groups by file_path: + * a1.c has 2 fns + File node = 3 symbols, a2.c has 1. */ + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(child, "files")), 2); + ASSERT_GT((int)yyjson_get_int(yyjson_obj_get(child, "symbols")), 3); + /* The shadow file src/alpha/gen.sql marks alpha as missed. */ + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(child, "missed")), 1); + } + if (name && strcmp(name, "beta") == 0) + saw_beta = true; + } + ASSERT_TRUE(saw_alpha); + ASSERT_TRUE(saw_beta); + yyjson_doc_free(doc); + free(json); + cbm_store_close(store); + cbm_layout_regions_cache_clear(); + PASS(); +} + +TEST(atlas_symbol_bundle_totals_and_sections) { + cbm_layout_regions_cache_clear(); + int64_t alpha_one = -1; + cbm_store_t *store = atlas_fixture(&alpha_one); + ASSERT_NOT_NULL(store); + ASSERT_GT(alpha_one, 0); + + char *json = cbm_atlas_symbol_json(store, "regions-test", alpha_one, NULL, 2, 0); + 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 *node = yyjson_obj_get(root, "node"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(node, "name")), "alpha_one"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(node, "docstring")), "Entry of alpha."); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(node, "is_entry"))); + + /* Region membership resolved from the cache. */ + yyjson_val *region = yyjson_obj_get(root, "region"); + ASSERT_NOT_NULL(region); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(region, "name")), "src/alpha"); + + /* Callees: 3 CALLS total, page of 2 → items 2, total 3. */ + yyjson_val *callees = yyjson_obj_get(root, "callees"); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(callees, "total")), 3); + ASSERT_EQ((int)yyjson_arr_size(yyjson_obj_get(callees, "items")), 2); + ASSERT_EQ((int)yyjson_get_int( + yyjson_obj_get(yyjson_obj_get(callees, "by_type"), "CALLS")), + 3); + + /* Callers: alpha_three only. */ + yyjson_val *callers = yyjson_obj_get(root, "callers"); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(callers, "total")), 1); + + /* Tests / co-change / similar sections carry the fixture rows. */ + ASSERT_EQ((int)yyjson_arr_size(yyjson_obj_get(root, "tests")), 1); + yyjson_val *cochange = yyjson_obj_get(root, "co_change"); + ASSERT_EQ((int)yyjson_arr_size(cochange), 1); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(cochange, 0), "file_path")), + "src/beta/b1.c"); + + yyjson_doc_free(doc); + free(json); + + /* qn lookup resolves the same node. */ + char *by_qn = + cbm_atlas_symbol_json(store, "regions-test", -1, "regions-test::alpha_one", 10, 0); + ASSERT_NOT_NULL(by_qn); + free(by_qn); + /* Unknown symbol → NULL. */ + ASSERT_NULL(cbm_atlas_symbol_json(store, "regions-test", 999999, NULL, 10, 0)); + + cbm_store_close(store); + cbm_layout_regions_cache_clear(); + PASS(); +} + +TEST(atlas_flows_walk_and_rank) { + cbm_layout_regions_cache_clear(); + cbm_atlas_flows_cache_clear(); + cbm_store_t *store = atlas_fixture(NULL); + ASSERT_NOT_NULL(store); + + char *json = cbm_atlas_flows_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 *flows = yyjson_obj_get(root, "flows"); + ASSERT_GT((int)yyjson_arr_size(flows), 0); + + /* The flagged entry (alpha_one) must lead the ranking's entries. */ + bool alpha_flow = false; + int alpha_flow_id = -1; + size_t idx, max; + yyjson_val *flow; + yyjson_arr_foreach(flows, idx, max, flow) { + yyjson_val *entry = yyjson_obj_get(flow, "entry"); + const char *entry_name = yyjson_get_str(yyjson_obj_get(entry, "name")); + if (entry_name && strcmp(entry_name, "alpha_one") == 0) { + alpha_flow = true; + alpha_flow_id = (int)yyjson_get_int(yyjson_obj_get(flow, "id")); + ASSERT_GT((int)yyjson_get_int(yyjson_obj_get(flow, "steps")), 2); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(flow, "cross_region"))); + } + } + ASSERT_TRUE(alpha_flow); + yyjson_doc_free(doc); + free(json); + + /* Flow detail: coherent DFS structure (parent before child, depths). */ + char *detail = cbm_atlas_flow_json(store, "regions-test", alpha_flow_id); + ASSERT_NOT_NULL(detail); + yyjson_doc *ddoc = yyjson_read(detail, strlen(detail), 0); + ASSERT_NOT_NULL(ddoc); + yyjson_val *steps = yyjson_obj_get(yyjson_doc_get_root(ddoc), "steps"); + int nsteps = (int)yyjson_arr_size(steps); + ASSERT_GT(nsteps, 2); + for (int i = 0; i < nsteps; i++) { + yyjson_val *step = yyjson_arr_get(steps, (size_t)i); + int parent = (int)yyjson_get_int(yyjson_obj_get(step, "parent")); + int depth = (int)yyjson_get_int(yyjson_obj_get(step, "depth")); + if (i == 0) { + ASSERT_EQ(parent, -1); + ASSERT_EQ(depth, 0); + } else { + ASSERT_GT(parent, -1); + ASSERT_TRUE(parent < i); + yyjson_val *pstep = yyjson_arr_get(steps, (size_t)parent); + ASSERT_EQ(depth, (int)yyjson_get_int(yyjson_obj_get(pstep, "depth")) + 1); + } + } + yyjson_doc_free(ddoc); + free(detail); + + /* Determinism: fresh cache → byte-identical list. */ + char *again = NULL; + cbm_atlas_flows_cache_clear(); + again = cbm_atlas_flows_json(store, "regions-test"); + ASSERT_NOT_NULL(again); + char *first = cbm_atlas_flows_json(store, "regions-test"); + ASSERT_STR_EQ(again, first); + free(again); + free(first); + + /* Unknown flow id → NULL. */ + ASSERT_NULL(cbm_atlas_flow_json(store, "regions-test", 9999)); + + cbm_store_close(store); + cbm_atlas_flows_cache_clear(); + cbm_layout_regions_cache_clear(); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -1042,4 +1331,9 @@ SUITE(ui) { /* CBM Atlas region level */ RUN_TEST(layout_regions_two_communities); RUN_TEST(layout_regions_scope_restricts_nodes); + + /* CBM Atlas services */ + RUN_TEST(atlas_tree_aggregates_children); + RUN_TEST(atlas_symbol_bundle_totals_and_sections); + RUN_TEST(atlas_flows_walk_and_rank); }