diff --git a/Makefile.cbm b/Makefile.cbm index 431872f95..e34ad3515 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -390,6 +390,7 @@ UI_SRCS = \ src/ui/layout_regions.c \ src/ui/atlas_api.c \ src/ui/atlas_flows.c \ + src/ui/atlas_handout.c \ src/ui/atlas_metrics.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/graph-ui/src/components/OverviewTab.tsx b/graph-ui/src/components/OverviewTab.tsx index ab1cf4265..fa9679e94 100644 --- a/graph-ui/src/components/OverviewTab.tsx +++ b/graph-ui/src/components/OverviewTab.tsx @@ -252,6 +252,18 @@ export function OverviewTab({ return (
+
+ + Handout ↗ + +
+ {/* The four slots: is anything wrong? */}
=0) or qualified name. */ +char *cbm_atlas_handout_html(cbm_store_t *store, const char *project); + char *cbm_atlas_blast_json(cbm_store_t *store, const char *project, const char *files_csv); char *cbm_atlas_bridges_json(cbm_store_t *store, const char *project); diff --git a/src/ui/atlas_handout.c b/src/ui/atlas_handout.c new file mode 100644 index 000000000..18a08eb22 --- /dev/null +++ b/src/ui/atlas_handout.c @@ -0,0 +1,307 @@ +/* atlas_handout.c — the codebase handout: a self-contained HTML document + * that explains a project to a newcomer, generated purely from the graph. + * + * Every fact is deterministic (regions, spanners, flows, churn×complexity); + * no model-written prose. Sections that would only say "none" for this + * project are omitted, caps are declared inline, and no host paths leak + * into the artifact (it is meant to be shared). */ + +#include "foundation/constants.h" +#include "ui/atlas.h" +#include "ui/layout3d.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +/* ── Growable output buffer ─────────────────────────────────────── */ + +typedef struct { + char *data; + size_t len; + size_t cap; + bool oom; +} hd_buf_t; + +static void hd_put(hd_buf_t *b, const char *s) { + if (b->oom || !s) + return; + size_t n = strlen(s); + if (b->len + n + 1 > b->cap) { + size_t nc = b->cap ? b->cap * 2 : 16384; + while (nc < b->len + n + 1) + nc *= 2; + char *grown = realloc(b->data, nc); + if (!grown) { + b->oom = true; + return; + } + b->data = grown; + b->cap = nc; + } + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static void hd_putf(hd_buf_t *b, const char *fmt, ...) { + char tmp[CBM_SZ_2K]; + va_list ap; + va_start(ap, fmt); + vsnprintf(tmp, sizeof(tmp), fmt, ap); + va_end(ap); + hd_put(b, tmp); +} + +/* HTML-escape into the buffer — every graph-derived string passes here. */ +static void hd_esc(hd_buf_t *b, const char *s) { + if (!s) + return; + for (const char *c = s; *c; c++) { + switch (*c) { + case '&': + hd_put(b, "&"); + break; + case '<': + hd_put(b, "<"); + break; + case '>': + hd_put(b, ">"); + break; + case '"': + hd_put(b, """); + break; + default: { + char one[2] = {*c, 0}; + hd_put(b, one); + } + } + } +} + +static const char *hd_cohesion_word(double cohesion) { + if (cohesion >= 0.7) + return "tightly connected"; + if (cohesion >= 0.4) + return "moderately connected"; + return "loosely connected"; +} + +/* ── The document ───────────────────────────────────────────────── */ + +char *cbm_atlas_handout_html(cbm_store_t *store, const char *project) { + if (!store || !project) + return NULL; + + char *regions_json = cbm_layout_regions_json(store, project); + char *metrics_json = cbm_atlas_metrics_json(store, project); + char *flows_json = cbm_atlas_flows_json(store, project); + char *bridges_json = cbm_atlas_bridges_json(store, project); + + yyjson_doc *regions_doc = + regions_json ? yyjson_read(regions_json, strlen(regions_json), 0) : NULL; + yyjson_doc *metrics_doc = + metrics_json ? yyjson_read(metrics_json, strlen(metrics_json), 0) : NULL; + yyjson_doc *flows_doc = flows_json ? yyjson_read(flows_json, strlen(flows_json), 0) : NULL; + yyjson_doc *bridges_doc = + bridges_json ? yyjson_read(bridges_json, strlen(bridges_json), 0) : NULL; + + hd_buf_t b = {0}; + hd_put(&b, "\n\n\n\n" + "\n"); + hd_put(&b, ""); + hd_esc(&b, project); + hd_put(&b, " — codebase handout\n\n\n\n
\n"); + + /* Header */ + hd_put(&b, "

"); + hd_esc(&b, project); + hd_put(&b, "

\n

A handout for newcomers, generated from the code " + "graph. Every number below is computed from indexed structure — no summaries " + "were written by a model. Verify anything with the " + "codebase-memory-mcp tools.

\n"); + + long long total_nodes = 0, unmapped = 0; + yyjson_val *regions_arr = NULL; + if (regions_doc) { + yyjson_val *root = yyjson_doc_get_root(regions_doc); + total_nodes = yyjson_get_int(yyjson_obj_get(root, "total_nodes")); + unmapped = yyjson_get_int(yyjson_obj_get(root, "unmapped_nodes")); + regions_arr = yyjson_obj_get(root, "regions"); + } + if (regions_arr) { + hd_putf(&b, "

%lld symbols in %zu regions", total_nodes, + yyjson_arr_size(regions_arr)); + if (unmapped > 0) + hd_putf(&b, " (%lld outside any region)", unmapped); + hd_put(&b, ".

\n"); + } + + /* The map */ + if (regions_arr && yyjson_arr_size(regions_arr) > 0) { + hd_put(&b, "

The map — regions of related code

\n" + "

Regions are call communities: code that talks to itself " + "far more than to the rest. Names come from the dominant folder; the hub " + "is the most-connected symbol inside.

\n\n" + "\n"); + size_t shown = 0; + for (size_t i = 0; i < yyjson_arr_size(regions_arr) && shown < 20; i++, shown++) { + yyjson_val *rv = yyjson_arr_get(regions_arr, i); + hd_put(&b, ""); + hd_putf(&b, "", + (long long)yyjson_get_int(yyjson_obj_get(rv, "members")), + (long long)yyjson_get_int(yyjson_obj_get(rv, "files"))); + double cohesion = yyjson_get_real(yyjson_obj_get(rv, "cohesion")); + hd_putf(&b, "\n", hd_cohesion_word(cohesion), cohesion); + } + hd_put(&b, "
RegionHubSymbolsFilesCohesion
"); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(rv, "name"))); + hd_put(&b, ""); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(rv, "hub"))); + hd_put(&b, "%lld%lld%s (%.2f)
\n"); + if (yyjson_arr_size(regions_arr) > shown) + hd_putf(&b, "

%zu of %zu regions shown — the rest are smaller.

\n", + shown, yyjson_arr_size(regions_arr)); + } + + /* Boundary spanners */ + yyjson_val *bridges_arr = + bridges_doc ? yyjson_obj_get(yyjson_doc_get_root(bridges_doc), "bridges") : NULL; + if (bridges_arr && yyjson_arr_size(bridges_arr) > 0) { + hd_put(&b, "

Boundary spanners — where regions meet

\n" + "

These call into the most distinct regions. Changes here " + "ripple furthest; mention them when a task spans areas.

\n\n" + "" + "\n"); + for (size_t i = 0; i < yyjson_arr_size(bridges_arr); i++) { + yyjson_val *rv = yyjson_arr_get(bridges_arr, i); + hd_put(&b, "\n", + (long long)yyjson_get_int(yyjson_obj_get(rv, "regions")), + (long long)yyjson_get_int(yyjson_obj_get(rv, "cross_calls"))); + } + hd_put(&b, "
SymbolFileRegions reachedCross calls
"); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(rv, "name"))); + hd_put(&b, ""); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(rv, "file_path"))); + hd_putf(&b, "%lld%lld
\n"); + } + + /* Main journeys */ + if (flows_doc) { + yyjson_val *froot = yyjson_doc_get_root(flows_doc); + yyjson_val *flows_arr = yyjson_obj_get(froot, "flows"); + if (flows_arr && yyjson_arr_size(flows_arr) > 0) { + hd_put(&b, "

Main journeys — entry to effect

\n" + "

Ranked walks from an entry point to a terminal " + "effect over resolved calls. Read one before touching the code it " + "crosses.

\n\n" + "\n"); + size_t shown = 0; + for (size_t i = 0; i < yyjson_arr_size(flows_arr) && shown < 10; i++, shown++) { + yyjson_val *fv = yyjson_arr_get(flows_arr, i); + hd_put(&b, "\n", + (long long)yyjson_get_int(yyjson_obj_get(fv, "steps")), + yyjson_get_bool(yyjson_obj_get(fv, "cross_region")) ? "crosses regions" + : "one region"); + } + hd_put(&b, "
JourneyStepsScope
"); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(fv, "label"))); + hd_putf(&b, "%lld%s
\n"); + long long callable_total = yyjson_get_int(yyjson_obj_get(froot, "callable_total")); + hd_putf(&b, + "

%zu of %zu detected journeys shown, walked from %lld " + "callables.

\n", + shown, yyjson_arr_size(flows_arr), callable_total); + } + } + + /* Risk — churn × complexity */ + if (metrics_doc) { + yyjson_val *mroot = yyjson_doc_get_root(metrics_doc); + bool churn_ok = yyjson_get_bool(yyjson_obj_get(mroot, "churn_available")); + yyjson_val *risky = yyjson_obj_get(mroot, "top_churn_complex"); + if (churn_ok && risky && yyjson_arr_size(risky) > 0) { + hd_put(&b, "

Where change concentrates — churn × complexity

\n" + "

Files that are both complicated and edited often: " + "the head of this list is where refactoring pays first.

\n" + "\n" + "\n"); + size_t shown = 0; + for (size_t i = 0; i < yyjson_arr_size(risky) && shown < 10; i++, shown++) { + yyjson_val *rv = yyjson_arr_get(risky, i); + hd_put(&b, "\n", + (long long)yyjson_get_int(yyjson_obj_get(rv, "value")), + (long long)yyjson_get_int(yyjson_obj_get(rv, "commits"))); + } + hd_put(&b, "
FileComplexityCommits (1y)
"); + hd_esc(&b, yyjson_get_str(yyjson_obj_get(rv, "file"))); + hd_putf(&b, "%lld%lld
\n"); + } else if (!churn_ok) { + hd_put(&b, "

Where change concentrates

\n

No readable " + "git history at the indexed root, so churn cannot be computed — this " + "section would be guesswork and is omitted.

\n"); + } + } + + /* Footer */ + { + char stamp[64] = {0}; + time_t now = time(NULL); + struct tm tm_utc; +#ifdef _WIN32 + gmtime_s(&tm_utc, &now); +#else + gmtime_r(&now, &tm_utc); +#endif + strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M UTC", &tm_utc); + hd_putf(&b, + "

Generated %s by CBM Atlas from " + "the indexed graph. Regenerate after re-indexing; do not edit by hand.

\n", + stamp); + } + hd_put(&b, "
\n\n\n"); + + if (regions_doc) + yyjson_doc_free(regions_doc); + if (metrics_doc) + yyjson_doc_free(metrics_doc); + if (flows_doc) + yyjson_doc_free(flows_doc); + if (bridges_doc) + yyjson_doc_free(bridges_doc); + free(regions_json); + free(metrics_json); + free(flows_json); + free(bridges_json); + + if (b.oom) { + free(b.data); + return NULL; + } + return b.data; +} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 6f6d98cd4..8170d4994 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -77,6 +77,7 @@ * which makes these statics safe. */ static char g_cors[256]; /* CORS headers only */ static char g_cors_json[512]; /* CORS + Content-Type: application/json */ +static char g_cors_html[512]; /* CORS + Content-Type: text/html */ static bool origin_is_same_server(const char *origin, int port) { char expected[128]; @@ -113,6 +114,8 @@ static void update_cors(const cbm_http_req_t *req, int port) { "Access-Control-Allow-Headers: Content-Type\r\n"); } snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors); + snprintf(g_cors_html, sizeof(g_cors_html), "%sContent-Type: text/html; charset=utf-8\r\n", + g_cors); } static const char *detect_ui_lang(const char *accept_language) { @@ -1848,6 +1851,22 @@ static void handle_atlas_trace(cbm_http_conn_t *c, const cbm_http_req_t *req) { atlas_reply_json(c, json, 500, "{\"error\":\"trace failed\"}"); } +/* GET /api/handout?project=X — the self-contained newcomer document. */ +static void handle_atlas_handout(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 *html = cbm_atlas_handout_html(store, project); + cbm_store_close(store); + if (!html) { + cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"handout generation failed\"}"); + return; + } + cbm_http_replyf(c, 200, g_cors_html, "%s", html); + free(html); +} + /* GET /api/blast?project=X&files=a,b,c — bucket a file list by region. */ static void handle_atlas_blast(cbm_http_conn_t *c, const cbm_http_req_t *req) { char project[256] = {0}; @@ -2214,6 +2233,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + /* GET /api/handout → CBM Atlas newcomer document */ + if (is_get && cbm_http_path_match(req->path, "/api/handout*")) { + handle_atlas_handout(c, req); + return; + } + /* GET /api/blast → CBM Atlas region bucket for a file list */ if (is_get && cbm_http_path_match(req->path, "/api/blast*")) { handle_atlas_blast(c, req); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 9ccd9f3d4..4f5f5f849 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1191,9 +1191,9 @@ TEST(ui_server_atlas_routes_are_wired) { * falls through to the static handler's 404. This pins the /api/flow * (singular) route, which once used a "?*" pattern that never matched * because req->path carries no query string. */ - static const char *const routes[] = {"/api/tree", "/api/symbol", "/api/flows", - "/api/flow", "/api/metrics", "/api/trace", - "/api/scent", "/api/bridges", "/api/blast"}; + static const char *const routes[] = { + "/api/tree", "/api/symbol", "/api/flows", "/api/flow", "/api/metrics", + "/api/trace", "/api/scent", "/api/bridges", "/api/blast", "/api/handout"}; char req[256]; char resp[4096]; for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { diff --git a/tests/test_ui.c b/tests/test_ui.c index 440e8e6f0..ffaee4155 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -1690,6 +1690,33 @@ TEST(atlas_blast_buckets_files_by_region) { PASS(); } +TEST(atlas_handout_is_selfcontained_and_escaped) { + cbm_layout_regions_cache_clear(); + cbm_atlas_flows_cache_clear(); + cbm_atlas_metrics_cache_clear(); + cbm_store_t *store = atlas_fixture(NULL); + ASSERT_NOT_NULL(store); + + char *html = cbm_atlas_handout_html(store, "regions-test"); + ASSERT_NOT_NULL(html); + ASSERT_NOT_NULL(strstr(html, "")); + ASSERT_NOT_NULL(strstr(html, "regions-test")); + /* The map renders the fixture regions. */ + ASSERT_NOT_NULL(strstr(html, "src/alpha")); + /* No host paths leak into the shareable artifact. */ + ASSERT_NULL(strstr(html, "/tmp/")); + ASSERT_NULL(strstr(html, "/Users/")); + /* Honest churn: fixture root has no git history → the section says so. */ + ASSERT_NOT_NULL(strstr(html, "No readable git history")); + free(html); + + cbm_store_close(store); + cbm_atlas_metrics_cache_clear(); + cbm_atlas_flows_cache_clear(); + cbm_layout_regions_cache_clear(); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -1733,4 +1760,5 @@ SUITE(ui) { RUN_TEST(atlas_bridges_rank_by_region_reach); RUN_TEST(atlas_symbol_overflow_tail_and_dataflow_presence); RUN_TEST(atlas_blast_buckets_files_by_region); + RUN_TEST(atlas_handout_is_selfcontained_and_escaped); }