feat(ui): CBM Atlas data services — folder tree, symbol bundle, flows

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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-08-24 00:35:21 +02:00
1 parent bbbc015304
commit 61c4afe82f
7 files changed
+1647 -1

No files matched your search

+2
View File
@@ -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
+45
View File
@@ -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 <stdint.h>
/* ── 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 */
+547
View File
@@ -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 <sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── 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, &region_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;
}
+587
View File
@@ -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 <sqlite3.h>
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+126
View File
@@ -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);
+46 -1
View File
@@ -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;
}
+294
View File
@@ -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 <yyjson/yyjson.h>
@@ -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(&params, 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, &params, &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);
}