mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
a306569596
commit
d8f95946c2
7 files changed
+257
-2
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user