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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-08-24 16:31:56 +02:00
1 parent a306569596
commit d8f95946c2
7 files changed
+257 -2

No files matched your search

+99 -1
View File
@@ -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<ArchitectureJson | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [blast, setBlast] = useState<BlastPayload | null>(null);
const [regionNames, setRegionNames] = useState<Map<number, string>>(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<string>();
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 (
<ScrollArea className="h-full">
<div className="max-w-[1000px] mx-auto px-6 py-6">
@@ -111,6 +171,44 @@ export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) {
)}
</div>
{blast && blast.regions.length > 0 && (
<div className="bg-card border border-border/40 rounded-md p-4 mb-4">
<p className="text-[13px] text-foreground/80 mb-2">
Your change reaches{" "}
<span className="tabular-nums font-medium">{blast.files}</span> file
{blast.files === 1 ? "" : "s"} across{" "}
<span
className={`tabular-nums font-medium ${blast.regions.length >= 3 ? "text-warn" : ""}`}
>
{blast.regions.length}
</span>{" "}
region{blast.regions.length === 1 ? "" : "s"}
{blast.unmapped > 0 && ` (+${blast.unmapped} outside regions)`}
{blast.truncated && " — file list truncated at 200"}
</p>
<div className="flex flex-wrap gap-1.5">
{[...blast.regions]
.sort((a, b) => b.count - a.count)
.map((region) => (
<span
key={region.region}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-popover border border-border/50 text-[12px] text-foreground/60 tabular-nums"
>
{regionNames.get(region.region) ?? region.name ?? `region ${region.region}`}
<span className="text-foreground/40">× {region.count}</span>
</span>
))}
</div>
{blast.regions.length >= 3 && (
<p className="text-[12px] text-warn/80 mt-2">
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.
</p>
)}
</div>
)}
{error && <p className="text-red-400/80 text-[12px] mb-3">{error}</p>}
{!result && !loading && !error && (
<p className="text-[12.5px] text-foreground/40 max-w-[65ch] leading-relaxed">
+14
View File
@@ -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<BlastPayload> {
const params = new URLSearchParams({ project, files: files.join(",") });
return getJson(`/api/blast?${params}`);
}
+2
View File
@@ -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);
+79
View File
@@ -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, &region_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;
}
+24
View File
@@ -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);
+1 -1
View File
@@ -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++) {
+38
View File
@@ -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);
}