mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
bbbc015304
commit
61c4afe82f
7 files changed
+1647
-1
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -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, ®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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¶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);
|
||||
}
|
||||
Reference in new issue
Block a user