From d8f95946c26cb67da5e624bf5a7d44a4494cb999 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 16:31:56 +0200 Subject: [PATCH] feat(atlas): working-set blast radius on the Changes tab /api/blast buckets a file list by region (names included, 200-file cap declared as truncated, every file accounted for: bucketed + unmapped == files). The Changes tab feeds it the impacted symbols' files (falling back to raw changed files) and renders the review signal git cannot compute: 'your change reaches N files across K regions' with per-region chips - disambiguated with the same hub-suffix rule the galaxy uses, so two 'tests' communities don't render as twins - and a seam warning at K>=3 suggesting a split or citing the region names in the prompt. Tests: blast bucketing + accounting + empty-input refusal (C), /api/blast route wiring. Signed-off-by: Martin Vogel --- graph-ui/src/components/ChangesTab.tsx | 100 ++++++++++++++++++++++++- graph-ui/src/lib/atlas.ts | 14 ++++ src/ui/atlas.h | 2 + src/ui/atlas_api.c | 79 +++++++++++++++++++ src/ui/http_server.c | 24 ++++++ tests/test_httpd.c | 2 +- tests/test_ui.c | 38 ++++++++++ 7 files changed, 257 insertions(+), 2 deletions(-) diff --git a/graph-ui/src/components/ChangesTab.tsx b/graph-ui/src/components/ChangesTab.tsx index 84e5dd036..6fb354720 100644 --- a/graph-ui/src/components/ChangesTab.tsx +++ b/graph-ui/src/components/ChangesTab.tsx @@ -3,8 +3,16 @@ * whole blast radius one click from the prompt composer. */ import { useEffect, useMemo, useState } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { fetchChanges, archRows, type ArchitectureJson } from "../lib/atlas"; +import { + fetchChanges, + fetchBlast, + archRows, + type ArchitectureJson, + type BlastPayload, +} from "../lib/atlas"; import { usePromptBasket } from "./PromptBasket"; +import { fetchRegions } from "../hooks/useGraphData"; +import { disambiguateRegionNames } from "../lib/regions"; interface ChangesTabProps { project: string; @@ -32,6 +40,8 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [blast, setBlast] = useState(null); + const [regionNames, setRegionNames] = useState>(new Map()); const basket = usePromptBasket(); const run = () => { @@ -46,6 +56,21 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { useEffect(() => { setResult(null); setError(null); + setBlast(null); + /* Region display names, disambiguated the same way the galaxy does + * (duplicate folder names get their hub appended). */ + let cancelled = false; + fetchRegions(project) + .then((payload) => { + if (cancelled) return; + setRegionNames( + new Map(disambiguateRegionNames(payload.regions).map((r) => [r.id, r.name])), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; }, [project]); const sections = useMemo(() => { @@ -76,6 +101,41 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { ); }, [sections]); + /* Blast radius: the impacted symbols' files (falling back to the raw + * changed files) bucketed by region server-side — "this change touches + * N regions" is the review signal git cannot compute. */ + useEffect(() => { + if (!result) { + setBlast(null); + return; + } + const files = new Set(); + for (const row of affected) { + if (typeof row.file === "string" && row.file) files.add(row.file); + } + const changedFiles = (result as { changed_files?: unknown }).changed_files; + if (files.size === 0 && Array.isArray(changedFiles)) { + for (const file of changedFiles) { + if (typeof file === "string" && !file.endsWith("/")) files.add(file); + } + } + if (files.size === 0) { + setBlast(null); + return; + } + let cancelled = false; + fetchBlast(project, [...files].slice(0, 200)) + .then((payload) => { + if (!cancelled) setBlast(payload); + }) + .catch(() => { + if (!cancelled) setBlast(null); + }); + return () => { + cancelled = true; + }; + }, [project, result, affected]); + return (
@@ -111,6 +171,44 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { )}
+ {blast && blast.regions.length > 0 && ( +
+

+ Your change reaches{" "} + {blast.files} file + {blast.files === 1 ? "" : "s"} across{" "} + = 3 ? "text-warn" : ""}`} + > + {blast.regions.length} + {" "} + region{blast.regions.length === 1 ? "" : "s"} + {blast.unmapped > 0 && ` (+${blast.unmapped} outside regions)`} + {blast.truncated && " — file list truncated at 200"} +

+
+ {[...blast.regions] + .sort((a, b) => b.count - a.count) + .map((region) => ( + + {regionNames.get(region.region) ?? region.name ?? `region ${region.region}`} + × {region.count} + + ))} +
+ {blast.regions.length >= 3 && ( +

+ Spanning {blast.regions.length} regions in one change is where regressions + hide — consider splitting, or cite the region names in your prompt so the + agent checks each seam. +

+ )} +
+ )} + {error &&

{error}

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

diff --git a/graph-ui/src/lib/atlas.ts b/graph-ui/src/lib/atlas.ts index 476eddf73..60c8cab4e 100644 --- a/graph-ui/src/lib/atlas.ts +++ b/graph-ui/src/lib/atlas.ts @@ -313,3 +313,17 @@ export interface BridgeRow { export function fetchBridges(project: string): Promise<{ bridges: BridgeRow[] }> { return getJson(`/api/bridges?${new URLSearchParams({ project })}`); } + +/* ── /api/blast — bucket a file list by region ─────────────────── */ + +export interface BlastPayload { + files: number; + unmapped: number; + truncated: boolean; + regions: { region: number; name?: string; count: number }[]; +} + +export function fetchBlast(project: string, files: string[]): Promise { + const params = new URLSearchParams({ project, files: files.join(",") }); + return getJson(`/api/blast?${params}`); +} diff --git a/src/ui/atlas.h b/src/ui/atlas.h index f320c74a2..9fb52203d 100644 --- a/src/ui/atlas.h +++ b/src/ui/atlas.h @@ -55,6 +55,8 @@ char *cbm_atlas_file_history_json(cbm_store_t *store, const char *project, const * Reachability between two callables over CALLS ("calls") or DATA_FLOWS * ("data"): shortest path within a bounded depth, honest about the bound. * Endpoints resolve by node id (>=0) or qualified name. */ +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); char *cbm_atlas_scent_json(cbm_store_t *store, const char *project, const char *query); diff --git a/src/ui/atlas_api.c b/src/ui/atlas_api.c index 1b916aacb..0f4a3cf84 100644 --- a/src/ui/atlas_api.c +++ b/src/ui/atlas_api.c @@ -883,3 +883,82 @@ char *cbm_atlas_bridges_json(cbm_store_t *store, const char *project) { yyjson_mut_doc_free(doc); return json; } + +/* ── Blast: bucket a file list by region ────────────────────────── + * The Changes tab hands the impacted files here; the answer is the + * graph-native review signal "this change touches N regions". */ + +char *cbm_atlas_blast_json(cbm_store_t *store, const char *project, const char *files_csv) { + if (!store || !project || !files_csv || !files_csv[0]) + return NULL; + + enum { BL_MAX_REGIONS = 4096, BL_MAX_FILES = 200 }; + long long *counts = calloc(BL_MAX_REGIONS, sizeof(long long)); + char **names = calloc(BL_MAX_REGIONS, sizeof(char *)); + if (!counts || !names) { + free(counts); + free(names); + return NULL; + } + int files = 0, unmapped = 0; + bool truncated = false; + + char *copy = strdup(files_csv); + if (!copy) { + free(counts); + free(names); + return NULL; + } + char *cursor = copy; + while (cursor && *cursor) { + char *comma = strchr(cursor, ','); + if (comma) + *comma = '\0'; + if (*cursor) { + if (files >= BL_MAX_FILES) { + truncated = true; + break; + } + files++; + char *region_name = NULL; /* strdup'd by the lookup — we own it */ + int region = cbm_layout_region_for_file(store, project, cursor, ®ion_name); + if (region >= 0 && region < BL_MAX_REGIONS) { + counts[region]++; + if (!names[region] && region_name) { + names[region] = region_name; + region_name = NULL; + } + } else { + unmapped++; + } + free(region_name); + } + cursor = comma ? comma + 1 : NULL; + } + free(copy); + + 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, "files", files); + yyjson_mut_obj_add_int(doc, root, "unmapped", unmapped); + yyjson_mut_obj_add_bool(doc, root, "truncated", truncated); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int i = 0; i < BL_MAX_REGIONS; i++) { + if (counts[i] == 0) + continue; + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, row, "region", i); + if (names[i]) + yyjson_mut_obj_add_strcpy(doc, row, "name", names[i]); + yyjson_mut_obj_add_int(doc, row, "count", counts[i]); + yyjson_mut_arr_append(arr, row); + free(names[i]); + } + yyjson_mut_obj_add_val(doc, root, "regions", arr); + free(counts); + free(names); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 6c88bb6b8..6f6d98cd4 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1848,6 +1848,24 @@ 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/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}; + cbm_store_t *store = atlas_open_project(c, req, project, sizeof(project)); + if (!store) + return; + char files[8192] = {0}; + cbm_http_query_param(req->query, "files", files, (int)sizeof(files)); + if (!files[0]) { + cbm_store_close(store); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing files parameter\"}"); + return; + } + char *json = cbm_atlas_blast_json(store, project, files); + cbm_store_close(store); + atlas_reply_json(c, json, 500, "{\"error\":\"blast failed\"}"); +} + /* GET /api/bridges?project=X — boundary spanners by distinct-region reach. */ static void handle_atlas_bridges(cbm_http_conn_t *c, const cbm_http_req_t *req) { char project[256] = {0}; @@ -2196,6 +2214,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, 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); + return; + } + /* GET /api/bridges → CBM Atlas boundary spanners */ if (is_get && cbm_http_path_match(req->path, "/api/bridges*")) { handle_atlas_bridges(c, req); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 3a2185e1c..9ccd9f3d4 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1193,7 +1193,7 @@ TEST(ui_server_atlas_routes_are_wired) { * 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/scent", "/api/bridges", "/api/blast"}; 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 4726ad82e..440e8e6f0 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -1653,6 +1653,43 @@ TEST(atlas_symbol_overflow_tail_and_dataflow_presence) { PASS(); } +TEST(atlas_blast_buckets_files_by_region) { + cbm_layout_regions_cache_clear(); + cbm_store_t *store = atlas_fixture(NULL); + ASSERT_NOT_NULL(store); + + char *json = cbm_atlas_blast_json(store, "regions-test", + "src/alpha/a1.c,src/beta/b1.c,src/alpha/a2.c,unknown.c"); + ASSERT_NOT_NULL(json); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(root, "files")), 4); + yyjson_val *regions = yyjson_obj_get(root, "regions"); + long long bucketed = 0; + int max_count = 0; + for (size_t i = 0; i < yyjson_arr_size(regions); i++) { + yyjson_val *row = yyjson_arr_get(regions, i); + ASSERT_NOT_NULL(yyjson_get_str(yyjson_obj_get(row, "name"))); + int c = (int)yyjson_get_int(yyjson_obj_get(row, "count")); + bucketed += c; + if (c > max_count) + max_count = c; + } + /* Every file is accounted for: bucketed + unmapped == files. */ + long long unmapped = yyjson_get_int(yyjson_obj_get(root, "unmapped")); + ASSERT_EQ((int)(bucketed + unmapped), 4); + ASSERT_EQ(max_count, 2); /* both alpha files land in one region */ + yyjson_doc_free(doc); + free(json); + + ASSERT_NULL(cbm_atlas_blast_json(store, "regions-test", "")); + + cbm_store_close(store); + cbm_layout_regions_cache_clear(); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -1695,4 +1732,5 @@ SUITE(ui) { RUN_TEST(atlas_scent_buckets_matches_by_region); 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); }