From 2df92078f9e74c45d1b0d50ca469d31845d0cdf0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 26 Aug 2026 21:05:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(atlas):=20the=20Why=20view=20=E2=80=94=20t?= =?UTF-8?q?rigger=20trees=20with=20syntactic=20guard=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The condition→action abstraction (Pennington 1987: the mental model with the highest error rates, and the one code text serves worst), served on demand. Design per the research synthesis: thin caller-only spine (thin slicing), closed question menu, guard text ON the edge in words (information scent), glyphs always paired with text (Reacher's 22% missed-icon failures), complexity-gated (Scanlan), declared elision, both directions (Green), honest footer. - /api/why?id|qn&dir=up|down: callers (or callees) with the SYNTACTIC guard chain around each call site — enclosing if/else/switch-case/loop/ternary/catch conditions, outermost first, else-arm negation detected — extracted by parsing the few caller files live with the embedded tree-sitter grammars from the call-site line the CALLS edges already carry. Zero indexing cost; works on every existing index. Per-request parse cache; 2 MB file cap; unparseable sources reported as guards_unavailable, never guessed. - /api/trace&guards=1: A-to-B paths gain per-hop guard chips - 'reachable in 5 hops' becomes 'reachable when ...' (the exact gap Reacher's users hit: they hand-derived conditionals). - Symbol page: 'When does this run?' tree - guard chips (amber unless / blue when), loop marker, unguarded honestly labeled, per-caller upward expansion, direction toggle, single-line answer when every call site is unguarded, footer stating guards are syntactic and dispatch/events are invisible. - Trace results in Flows render the same chips per hop. Tests: nested-if chain outermost-first + else-arm negation + loop flag + downward direction + callsite helper on a real temp-dir C file (C); /api/why route wiring; formatGuard/chain + the complexity gate (vitest). Trap fixed en route: the extension table keys WITH the dot ('.c'), not 'c'. Signed-off-by: Martin Vogel --- Makefile.cbm | 1 + graph-ui/src/components/FlowsTab.tsx | 16 +- graph-ui/src/components/SymbolTab.tsx | 11 + graph-ui/src/components/TriggerTree.tsx | 213 +++++++++++ graph-ui/src/lib/atlas.ts | 9 +- graph-ui/src/lib/why.test.ts | 38 ++ graph-ui/src/lib/why.ts | 76 ++++ graph-ui/src/lib/whyfetch.ts | 7 + src/ui/atlas.h | 6 + src/ui/atlas_why.c | 454 ++++++++++++++++++++++++ src/ui/http_server.c | 108 ++++++ tests/test_httpd.c | 4 +- tests/test_ui.c | 134 +++++++ 13 files changed, 1072 insertions(+), 5 deletions(-) create mode 100644 graph-ui/src/components/TriggerTree.tsx create mode 100644 graph-ui/src/lib/why.test.ts create mode 100644 graph-ui/src/lib/why.ts create mode 100644 graph-ui/src/lib/whyfetch.ts create mode 100644 src/ui/atlas_why.c diff --git a/Makefile.cbm b/Makefile.cbm index e34ad3515..51d2eb271 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -391,6 +391,7 @@ UI_SRCS = \ src/ui/atlas_api.c \ src/ui/atlas_flows.c \ src/ui/atlas_handout.c \ + src/ui/atlas_why.c \ src/ui/atlas_metrics.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx index f3148b5b1..c2b128d1c 100644 --- a/graph-ui/src/components/FlowsTab.tsx +++ b/graph-ui/src/components/FlowsTab.tsx @@ -13,6 +13,7 @@ import { type TracePayload, } from "../lib/atlas"; import { groupFlowsByEntry } from "../lib/flowgroup"; +import { formatGuard } from "../lib/why"; import { AddToPromptButton } from "./PromptBasket"; interface FlowsTabProps { @@ -255,7 +256,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab

{(trace.path ?? []).map((step, index) => ( - + {index > 0 && ( → )} @@ -266,6 +267,19 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab > {step.name} + {(step.guards ?? []).map((guard, gi) => ( + + {formatGuard(guard)} + + ))} ))}
diff --git a/graph-ui/src/components/SymbolTab.tsx b/graph-ui/src/components/SymbolTab.tsx index 829dd2168..c4d460d57 100644 --- a/graph-ui/src/components/SymbolTab.tsx +++ b/graph-ui/src/components/SymbolTab.tsx @@ -13,6 +13,7 @@ import { type SymbolRef, } from "../lib/atlas"; import { AddToPromptButton } from "./PromptBasket"; +import { TriggerTree } from "./TriggerTree"; import type { RepoInfo } from "../lib/types"; interface SymbolTabProps { @@ -314,6 +315,16 @@ export function SymbolTab({ project, symbolRef, onOpenSymbol, onOpenRegion }: Sy /> + {/* The Why view — condition→action, the abstraction code text + * serves worst (Pennington 1987). */} +
+ +
+ {/* Why & who — the rationale proxy and the knowledge map. */}
diff --git a/graph-ui/src/components/TriggerTree.tsx b/graph-ui/src/components/TriggerTree.tsx new file mode 100644 index 000000000..6ece304c4 --- /dev/null +++ b/graph-ui/src/components/TriggerTree.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { + fetchWhy, + formatGuard, + allUnguarded, + type WhyEntry, + type WhyPayload, +} from "../lib/why"; + +/* The Why view: "when does this run?" — callers on the spine, each edge + * carrying its syntactic guard chain as chips, expandable upward in place. + * Thin by default; elision declared; guards honest about being syntactic. */ + +function GuardChips({ entry }: { entry: WhyEntry }) { + if (entry.guards_unavailable) + return guards unavailable (source not readable); + if (entry.guards.length === 0 && !entry.loop) + return unguarded — always on this path; + return ( + + {entry.loop && ( + + ⟳ loop + + )} + {entry.guards.map((guard, index) => ( + + ? {formatGuard(guard)} + + ))} + + ); +} + +function WhyLevel({ + project, + refId, + direction, + depth, + onOpenSymbol, +}: { + project: string; + refId: number; + direction: "up" | "down"; + depth: number; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +}) { + const [payload, setPayload] = useState(null); + const [error, setError] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + + useEffect(() => { + let cancelled = false; + setPayload(null); + setExpanded(new Set()); + fetchWhy(project, { id: refId }, direction) + .then((p) => { + if (!cancelled) setPayload(p); + }) + .catch(() => { + if (!cancelled) setError(true); + }); + return () => { + cancelled = true; + }; + }, [project, refId, direction]); + + if (error) + return

could not load this level

; + if (!payload) + return

tracing…

; + if (payload.entries.length === 0) + return ( +

+ {direction === "up" + ? "No resolved callers — an entry point, or reached another way (dispatch, event, reflection)." + : "No resolved callees."} +

+ ); + + return ( +
0 ? "border-l border-border/40 pl-3 ml-1" : ""}> + {payload.entries.map((entry) => ( +
+
+ + {direction === "up" ? "←" : "→"} + + + + {direction === "up" && entry.more > 0 && ( + + )} +
+ {expanded.has(entry.id) && depth < 6 && ( + + )} +
+ ))} + {payload.total > payload.entries.length && ( +

+ {payload.entries.length} of {payload.total} shown — the rest via the caller list + above. +

+ )} +
+ ); +} + +export function TriggerTree({ + project, + symbolId, + onOpenSymbol, +}: { + project: string; + symbolId: number; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +}) { + const [direction, setDirection] = useState<"up" | "down">("up"); + const [root, setRoot] = useState(null); + + useEffect(() => { + let cancelled = false; + setRoot(null); + fetchWhy(project, { id: symbolId }, direction) + .then((p) => { + if (!cancelled) setRoot(p); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project, symbolId, direction]); + + return ( +
+
+

+ {direction === "up" ? "When does this run?" : "What does this trigger?"} +

+ +
+ {root && allUnguarded(root.entries) && root.entries.length > 0 && root.entries.length <= 2 ? ( +

+ {direction === "up" ? "Always runs when " : "Always triggers "} + {root.entries.map((entry, index) => ( + + {index > 0 && " and "} + + + ))} + {direction === "up" ? " runs — no conditions at the call sites." : " — unconditionally."} +

+ ) : ( + + )} +

+ Guards are the syntactic conditions around each call site — not proven path + conditions. Dispatch, events and reflection are invisible here. +

+
+ ); +} diff --git a/graph-ui/src/lib/atlas.ts b/graph-ui/src/lib/atlas.ts index 60c8cab4e..2cb4ba0a5 100644 --- a/graph-ui/src/lib/atlas.ts +++ b/graph-ui/src/lib/atlas.ts @@ -271,7 +271,12 @@ export interface TracePayload { explored?: number; error?: string; hops?: number; - path?: { id: number; name: string; file_path?: string }[]; + path?: { + id: number; + name: string; + file_path?: string; + guards?: { kind: string; cond?: string; negated?: boolean }[]; + }[]; } export function fetchTrace( @@ -280,7 +285,7 @@ export function fetchTrace( to: string, mode: "calls" | "data", ): Promise { - const params = new URLSearchParams({ project, from, to, mode }); + const params = new URLSearchParams({ project, from, to, mode, guards: "1" }); return getJson(`/api/trace?${params}`); } diff --git a/graph-ui/src/lib/why.test.ts b/graph-ui/src/lib/why.test.ts new file mode 100644 index 000000000..d89890cb1 --- /dev/null +++ b/graph-ui/src/lib/why.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { formatGuard, formatGuardChain, allUnguarded, type WhyEntry } from "./why"; + +describe("formatGuard", () => { + it("renders if/unless, case, loop and catch guards as words", () => { + expect(formatGuard({ kind: "if", cond: "mode > 2" })).toBe("when mode > 2"); + expect(formatGuard({ kind: "if", cond: "strict", negated: true })).toBe( + "unless strict", + ); + expect(formatGuard({ kind: "case", cond: "CBM_LANG_C" })).toBe("case CBM_LANG_C"); + expect(formatGuard({ kind: "loop", cond: "i < n" })).toBe("looping while i < n"); + expect(formatGuard({ kind: "loop" })).toBe("in a loop"); + expect(formatGuard({ kind: "catch" })).toBe("on error handling"); + }); + it("chains outermost-first with arrows", () => { + expect( + formatGuardChain([ + { kind: "if", cond: "mode > 2" }, + { kind: "if", cond: "strict" }, + ]), + ).toBe("when mode > 2 → when strict"); + }); +}); + +describe("allUnguarded", () => { + const entry = (guards: WhyEntry["guards"], loop?: boolean): WhyEntry => ({ + id: 1, + name: "f", + guards, + loop, + more: 0, + }); + it("is true only when no entry carries a guard or loop", () => { + expect(allUnguarded([entry([])])).toBe(true); + expect(allUnguarded([entry([{ kind: "if", cond: "x" }])])).toBe(false); + expect(allUnguarded([entry([], true)])).toBe(false); + }); +}); diff --git a/graph-ui/src/lib/why.ts b/graph-ui/src/lib/why.ts new file mode 100644 index 000000000..ad8a651cb --- /dev/null +++ b/graph-ui/src/lib/why.ts @@ -0,0 +1,76 @@ +import { fetchJsonFrom } from "./whyfetch"; + +/* The Why view's data layer. Guards are SYNTACTIC: the conditions the code + * lexically wraps a call site in — never proven path conditions. */ + +export interface Guard { + kind: "if" | "else" | "case" | "loop" | "ternary" | "catch" | "switch" | string; + cond?: string; + negated?: boolean; +} + +export interface WhyEntry { + id: number; + name: string; + qualified_name?: string; + file_path?: string; + line?: number; + guards: Guard[]; + loop?: boolean; + guards_unavailable?: boolean; + more: number; +} + +export interface WhyPayload { + symbol: { id: number; name: string; file_path?: string }; + direction: "up" | "down"; + total: number; + entries: WhyEntry[]; + guards_unavailable: number; + sources_readable: boolean; +} + +export function fetchWhy( + project: string, + ref: { id?: number; qn?: string }, + direction: "up" | "down", +): Promise { + const params = new URLSearchParams({ project, dir: direction }); + if (ref.id !== undefined) params.set("id", String(ref.id)); + else if (ref.qn) params.set("qn", ref.qn); + return fetchJsonFrom(`/api/why?${params}`); +} + +/* One guard as human text: `when mode > 2` / `unless strict` / + * `case CBM_LANG_C` / `in a loop` / `in catch`. */ +export function formatGuard(guard: Guard): string { + const cond = guard.cond ?? ""; + switch (guard.kind) { + case "if": + case "ternary": + if (!cond) return guard.negated ? "in an else arm" : "conditionally"; + return `${guard.negated ? "unless" : "when"} ${cond}`; + case "case": + return cond ? `case ${cond}` : "in a switch case"; + case "switch": + return cond ? `switch on ${cond}` : "in a switch"; + case "loop": + return cond ? `looping while ${cond}` : "in a loop"; + case "catch": + return "on error handling"; + default: + return cond || guard.kind; + } +} + +/* A whole chain as one sentence fragment, outermost first. */ +export function formatGuardChain(guards: Guard[]): string { + return guards.map(formatGuard).join(" → "); +} + +/* Trivial-trigger check: the complexity gate. A symbol whose callers are all + * unguarded earns a single line, not a tree (Scanlan: conditional diagrams + * pay off only when conditional complexity is real). */ +export function allUnguarded(entries: WhyEntry[]): boolean { + return entries.every((entry) => entry.guards.length === 0 && !entry.loop); +} diff --git a/graph-ui/src/lib/whyfetch.ts b/graph-ui/src/lib/whyfetch.ts new file mode 100644 index 000000000..baf831507 --- /dev/null +++ b/graph-ui/src/lib/whyfetch.ts @@ -0,0 +1,7 @@ +/* Tiny shared fetch used by why.ts (kept separate so the pure helpers in + * why.ts stay importable in tests without a fetch mock). */ +export async function fetchJsonFrom(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json() as Promise; +} diff --git a/src/ui/atlas.h b/src/ui/atlas.h index 20fe70901..48787b894 100644 --- a/src/ui/atlas.h +++ b/src/ui/atlas.h @@ -55,6 +55,12 @@ 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_why_json(cbm_store_t *store, const char *project, int64_t node_id, + const char *qualified_name, bool upward); + +char *cbm_atlas_callsite_guards_json(cbm_store_t *store, const char *project, const char *file_path, + int line); + 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); diff --git a/src/ui/atlas_why.c b/src/ui/atlas_why.c new file mode 100644 index 000000000..31f07693d --- /dev/null +++ b/src/ui/atlas_why.c @@ -0,0 +1,454 @@ +/* atlas_why.c — the Why view: "when does this run?" + * + * The condition→action abstraction (Pennington 1987) served on demand: for a + * symbol, its callers with the SYNTACTIC guard chain around each call site — + * the enclosing if/else/switch/loop/ternary conditions, outermost first, + * collected by walking the tree-sitter parse of the caller's file upward + * from the call line. Guards are honest approximations: what the code + * lexically wraps the call in, never proven path conditions. + * + * Zero indexing cost: CALLS edges already carry the call-site line, and the + * embedded grammars parse the few caller files live per request. */ + +#include "foundation/compat_fs.h" +#include "foundation/constants.h" +#include "ui/atlas.h" + +#include "cbm.h" +#include "discover/discover.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +/* ── Per-request parse cache ────────────────────────────────────── */ + +enum { + WHY_MAX_FILES = 32, /* distinct caller files parsed per request */ + WHY_MAX_SOURCE = 2 << 20, /* skip files above 2 MB */ + WHY_MAX_GUARDS = 12, /* ancestor conditions kept per call site */ + WHY_MAX_COND = 160, /* condition text cap (chars) */ + WHY_MAX_ENTRIES = 25, /* callers/callees per page */ + WHY_PARSE_TIMEOUT_US = 2000000, +}; + +typedef struct { + char *file_path; /* project-relative */ + char *source; + int source_len; + CBMFileResult *result; /* owns the retained TSTree */ +} why_file_t; + +typedef struct { + why_file_t files[WHY_MAX_FILES]; + int count; + const char *root_path; + const char *project; +} why_cache_t; + +static why_file_t *why_file_get(why_cache_t *cache, const char *file_path) { + for (int i = 0; i < cache->count; i++) + if (strcmp(cache->files[i].file_path, file_path) == 0) + return cache->files[i].source ? &cache->files[i] : NULL; + if (cache->count >= WHY_MAX_FILES) + return NULL; + why_file_t *slot = &cache->files[cache->count++]; + slot->file_path = strdup(file_path); + if (!slot->file_path) + return NULL; + + char full[CBM_SZ_2K]; + snprintf(full, sizeof(full), "%s/%s", cache->root_path, file_path); + FILE *fp = cbm_fopen(full, "rb"); + if (!fp) + return NULL; /* slot stays as a negative cache entry */ + fseek(fp, 0, SEEK_END); + long size = ftell(fp); + fseek(fp, 0, SEEK_SET); + if (size <= 0 || size > WHY_MAX_SOURCE) { + fclose(fp); + return NULL; + } + char *source = malloc((size_t)size + 1); + if (!source) { + fclose(fp); + return NULL; + } + size_t got = fread(source, 1, (size_t)size, fp); + fclose(fp); + source[got] = '\0'; + + const char *dot = strrchr(file_path, '.'); + CBMLanguage lang = cbm_language_for_extension(dot ? dot : ""); + if (lang == CBM_LANG_COUNT) { + free(source); + return NULL; + } + CBMFileResult *result = cbm_extract_file(source, (int)got, lang, cache->project, file_path, + WHY_PARSE_TIMEOUT_US, NULL, NULL); + if (!result || !result->cached_tree) { + if (result) + cbm_free_result(result); + free(source); + return NULL; + } + slot->source = source; + slot->source_len = (int)got; + slot->result = result; + return slot; +} + +static void why_cache_free(why_cache_t *cache) { + for (int i = 0; i < cache->count; i++) { + free(cache->files[i].file_path); + free(cache->files[i].source); + if (cache->files[i].result) + cbm_free_result(cache->files[i].result); + } + cache->count = 0; +} + +/* ── Guard extraction ───────────────────────────────────────────── */ + +typedef struct { + const char *kind; /* "if" | "else" | "case" | "loop" | "ternary" | "catch" */ + char cond[WHY_MAX_COND]; + bool negated; /* call sits in the alternative/else arm of the condition */ +} why_guard_t; + +/* Copy node source text with whitespace collapsed, capped with a … marker. */ +static void why_node_text(const char *source, int source_len, TSNode node, char *out, size_t cap) { + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + if (end > (uint32_t)source_len) + end = (uint32_t)source_len; + size_t w = 0; + bool in_space = false; + for (uint32_t i = start; i < end && w + 4 < cap; i++) { + char c = source[i]; + if (c == '\n' || c == '\t' || c == '\r' || c == ' ') { + in_space = true; + continue; + } + if (in_space && w > 0) + out[w++] = ' '; + in_space = false; + out[w++] = c; + } + if (w + 4 >= cap && end > start) + memcpy(out + w, "…", strlen("…") + 1); + else + out[w] = '\0'; + /* Strip one layer of wrapping parens for readability. */ + size_t len = strlen(out); + if (len >= 2 && out[0] == '(' && out[len - 1] == ')') { + memmove(out, out + 1, len - 2); + out[len - 2] = '\0'; + } +} + +static bool why_type_has(const char *type, const char *needle) { + return strstr(type, needle) != NULL; +} + +/* Walk ancestors from the call site to the enclosing function, collecting + * conditions innermost-first (the caller reverses for display). Returns the + * guard count; sets *in_loop when any ancestor is a loop. */ +static int why_guards_at(const char *source, int source_len, TSNode at, why_guard_t *guards, + bool *in_loop) { + int count = 0; + *in_loop = false; + TSNode prev = at; + for (TSNode node = ts_node_parent(at); !ts_node_is_null(node); + prev = node, node = ts_node_parent(node)) { + const char *type = ts_node_type(node); + if (why_type_has(type, "function") || why_type_has(type, "method") || + why_type_has(type, "lambda") || why_type_has(type, "closure")) + break; + if (count >= WHY_MAX_GUARDS) + continue; /* keep scanning for the function boundary + loops */ + + const char *kind = NULL; + TSNode cond = ts_node_child_by_field_name(node, "condition", 9); + bool negated = false; + + if (why_type_has(type, "if_") || strcmp(type, "if") == 0 || why_type_has(type, "elif")) { + kind = "if"; + TSNode alt = ts_node_child_by_field_name(node, "alternative", 11); + if (!ts_node_is_null(alt) && ts_node_start_byte(alt) <= ts_node_start_byte(prev) && + ts_node_end_byte(prev) <= ts_node_end_byte(alt)) + negated = true; + } else if (why_type_has(type, "conditional_expression") || why_type_has(type, "ternary")) { + kind = "ternary"; + TSNode alt = ts_node_child_by_field_name(node, "alternative", 11); + if (!ts_node_is_null(alt) && ts_node_start_byte(alt) <= ts_node_start_byte(prev) && + ts_node_end_byte(prev) <= ts_node_end_byte(alt)) + negated = true; + } else if (why_type_has(type, "case") || why_type_has(type, "when_")) { + kind = "case"; + if (ts_node_is_null(cond)) + cond = ts_node_child_by_field_name(node, "value", 5); + } else if (why_type_has(type, "switch") || why_type_has(type, "match")) { + /* The switch head itself: only worth a guard when the case level + * was missing (some grammars nest cases invisibly). */ + kind = "switch"; + } else if (why_type_has(type, "while") || why_type_has(type, "for") || + why_type_has(type, "do_statement") || why_type_has(type, "loop") || + why_type_has(type, "repeat")) { + kind = "loop"; + *in_loop = true; + } else if (why_type_has(type, "catch") || why_type_has(type, "except") || + why_type_has(type, "rescue")) { + kind = "catch"; + } + if (!kind) + continue; + /* Skip switch heads when a case already carries the discriminant. */ + if (strcmp(kind, "switch") == 0 && count > 0 && strcmp(guards[count - 1].kind, "case") == 0) + continue; + + why_guard_t *guard = &guards[count]; + guard->kind = kind; + guard->negated = negated; + guard->cond[0] = '\0'; + if (!ts_node_is_null(cond)) + why_node_text(source, source_len, cond, guard->cond, sizeof(guard->cond)); + count++; + } + return count; +} + +/* Locate the call at `line` (1-based) and emit its guards into `arr`. + * Returns false when the file could not be parsed. */ +static bool why_emit_guards(why_cache_t *cache, const char *file_path, int line, + yyjson_mut_doc *doc, yyjson_mut_val *entry) { + why_file_t *file = why_file_get(cache, file_path); + if (!file) + return false; + TSNode root = ts_tree_root_node(file->result->cached_tree); + TSPoint start = {(uint32_t)(line > 0 ? line - 1 : 0), 0}; + TSPoint end = {(uint32_t)(line > 0 ? line - 1 : 0), UINT32_MAX}; + TSNode at = ts_node_descendant_for_point_range(root, start, end); + if (ts_node_is_null(at)) + return false; + + why_guard_t guards[WHY_MAX_GUARDS]; + bool in_loop = false; + int count = why_guards_at(file->source, file->source_len, at, guards, &in_loop); + + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (int i = count - 1; i >= 0; i--) { /* outermost first */ + yyjson_mut_val *row = yyjson_mut_obj(doc); + yyjson_mut_obj_add_str(doc, row, "kind", guards[i].kind); + if (guards[i].cond[0]) + yyjson_mut_obj_add_strcpy(doc, row, "cond", guards[i].cond); + if (guards[i].negated) + yyjson_mut_obj_add_bool(doc, row, "negated", true); + yyjson_mut_arr_append(arr, row); + } + yyjson_mut_obj_add_val(doc, entry, "guards", arr); + if (in_loop) + yyjson_mut_obj_add_bool(doc, entry, "loop", true); + return true; +} + +/* ── Shared plumbing ────────────────────────────────────────────── */ + +static bool why_project_root(struct sqlite3 *db, const char *project, char *root, size_t root_cap) { + root[0] = '\0'; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT root_path FROM projects WHERE name=?1", -1, &st, NULL) != + SQLITE_OK) + return false; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *r = (const char *)sqlite3_column_text(st, 0); + if (r) + snprintf(root, root_cap, "%s", r); + } + sqlite3_finalize(st); + return root[0] != '\0'; +} + +static int why_edge_line(const char *properties_json) { + if (!properties_json) + return -1; + yyjson_doc *doc = yyjson_read(properties_json, strlen(properties_json), 0); + if (!doc) + return -1; + int line = (int)yyjson_get_int(yyjson_obj_get(yyjson_doc_get_root(doc), "line")); + yyjson_doc_free(doc); + return line > 0 ? line : -1; +} + +/* ── The Why endpoint ───────────────────────────────────────────── */ + +char *cbm_atlas_why_json(cbm_store_t *store, const char *project, int64_t node_id, + const char *qualified_name, bool upward) { + if (!store || !project) + return NULL; + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return NULL; + + /* Resolve the symbol. */ + sqlite3_stmt *st = NULL; + int64_t id = node_id; + char sym_name[256] = {0}; + char sym_file[1024] = {0}; + const char *resolve_sql = + id >= 0 ? "SELECT id, name, file_path FROM nodes WHERE project=?1 AND id=?2 LIMIT 1" + : "SELECT id, name, file_path FROM nodes WHERE project=?1 AND " + "qualified_name=?2 ORDER BY id LIMIT 1"; + if (sqlite3_prepare_v2(db, resolve_sql, -1, &st, NULL) != SQLITE_OK) + return NULL; + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + if (id >= 0) + sqlite3_bind_int64(st, 2, id); + else if (qualified_name) + sqlite3_bind_text(st, 2, qualified_name, -1, SQLITE_STATIC); + bool found = false; + if (sqlite3_step(st) == SQLITE_ROW) { + found = true; + id = sqlite3_column_int64(st, 0); + const char *n = (const char *)sqlite3_column_text(st, 1); + const char *f = (const char *)sqlite3_column_text(st, 2); + if (n) + snprintf(sym_name, sizeof(sym_name), "%s", n); + if (f) + snprintf(sym_file, sizeof(sym_file), "%s", f); + } + sqlite3_finalize(st); + if (!found) + return NULL; + + char root[CBM_SZ_1K]; + bool have_root = why_project_root(db, project, root, sizeof(root)); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *jroot = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, jroot); + yyjson_mut_val *sym = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, sym, "id", id); + yyjson_mut_obj_add_strcpy(doc, sym, "name", sym_name); + if (sym_file[0]) + yyjson_mut_obj_add_strcpy(doc, sym, "file_path", sym_file); + yyjson_mut_obj_add_val(doc, jroot, "symbol", sym); + yyjson_mut_obj_add_str(doc, jroot, "direction", upward ? "up" : "down"); + + why_cache_t cache = {0}; + cache.root_path = root; + cache.project = project; + + /* Callers (up) or callees (down), with the edge's call-site line. + * For `up`, the call site lives in the OTHER node's file; for `down`, + * every call site lives in this symbol's own file. */ + const char *rows_sql = + upward ? "SELECT n.id, n.name, n.qualified_name, n.file_path, e.properties, " + "(SELECT COUNT(*) FROM edges e2 WHERE e2.project=e.project AND " + "e2.target_id=n.id AND e2.type='CALLS') 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='CALLS' ORDER BY n.id LIMIT ?3" + : "SELECT n.id, n.name, n.qualified_name, n.file_path, e.properties, " + "(SELECT COUNT(*) FROM edges e2 WHERE e2.project=e.project AND " + "e2.source_id=n.id AND e2.type='CALLS') 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='CALLS' ORDER BY n.id LIMIT ?3"; + long long total = 0; + { + const char *count_sql = + upward ? "SELECT COUNT(*) FROM edges WHERE project=?1 AND target_id=?2 AND " + "type='CALLS'" + : "SELECT COUNT(*) FROM edges WHERE project=?1 AND source_id=?2 AND " + "type='CALLS'"; + 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); + if (sqlite3_step(st) == SQLITE_ROW) + total = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + } + yyjson_mut_obj_add_int(doc, jroot, "total", total); + + yyjson_mut_val *entries = yyjson_mut_arr(doc); + int parse_failures = 0; + 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, WHY_MAX_ENTRIES); + while (sqlite3_step(st) == SQLITE_ROW) { + yyjson_mut_val *entry = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, entry, "id", sqlite3_column_int64(st, 0)); + const char *n = (const char *)sqlite3_column_text(st, 1); + const char *qn = (const char *)sqlite3_column_text(st, 2); + const char *f = (const char *)sqlite3_column_text(st, 3); + const char *props = (const char *)sqlite3_column_text(st, 4); + yyjson_mut_obj_add_strcpy(doc, entry, "name", n ? n : "?"); + if (qn) + yyjson_mut_obj_add_strcpy(doc, entry, "qualified_name", qn); + if (f) + yyjson_mut_obj_add_strcpy(doc, entry, "file_path", f); + yyjson_mut_obj_add_int(doc, entry, "more", sqlite3_column_int64(st, 5)); + int line = why_edge_line(props); + if (line > 0) + yyjson_mut_obj_add_int(doc, entry, "line", line); + /* The file holding the call site. */ + const char *site_file = upward ? f : (sym_file[0] ? sym_file : NULL); + bool ok = false; + if (have_root && site_file && line > 0) + ok = why_emit_guards(&cache, site_file, line, doc, entry); + if (!ok) { + yyjson_mut_obj_add_val(doc, entry, "guards", yyjson_mut_arr(doc)); + yyjson_mut_obj_add_bool(doc, entry, "guards_unavailable", true); + parse_failures++; + } + yyjson_mut_arr_append(entries, entry); + } + sqlite3_finalize(st); + } + yyjson_mut_obj_add_val(doc, jroot, "entries", entries); + yyjson_mut_obj_add_int(doc, jroot, "guards_unavailable", parse_failures); + yyjson_mut_obj_add_bool(doc, jroot, "sources_readable", have_root); + + why_cache_free(&cache); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +/* ── Guards for one explicit call site (A→B trace hops) ─────────── */ + +char *cbm_atlas_callsite_guards_json(cbm_store_t *store, const char *project, const char *file_path, + int line) { + if (!store || !project || !file_path || line <= 0) + return NULL; + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) + return NULL; + char root[CBM_SZ_1K]; + if (!why_project_root(db, project, root, sizeof(root))) + return NULL; + why_cache_t cache = {0}; + cache.root_path = root; + cache.project = project; + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *entry = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, entry); + bool ok = why_emit_guards(&cache, file_path, line, doc, entry); + why_cache_free(&cache); + if (!ok) { + yyjson_mut_doc_free(doc); + return NULL; + } + 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 8170d4994..b9b30da6c 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1826,6 +1826,80 @@ static void handle_atlas_metrics(cbm_http_conn_t *c, const cbm_http_req_t *req) atlas_reply_json(c, json, 500, "{\"error\":\"metrics computation failed\"}"); } +/* Attach per-hop guard chains to a trace JSON (guards=1): for each + * consecutive path pair, find the CALLS edge's call-site line and run the + * on-demand guard extraction in the caller's file. Best-effort — hops + * without a resolvable site simply get no guards array. */ +static char *atlas_trace_attach_guards(cbm_store_t *store, const char *project, char *json) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) + return json; + yyjson_val *path = yyjson_obj_get(yyjson_doc_get_root(doc), "path"); + size_t hops = path ? yyjson_arr_size(path) : 0; + if (hops < 2) { + yyjson_doc_free(doc); + return json; + } + yyjson_mut_doc *mut = yyjson_doc_mut_copy(doc, NULL); + yyjson_doc_free(doc); + if (!mut) + return json; + yyjson_mut_val *mpath = yyjson_mut_obj_get(yyjson_mut_doc_get_root(mut), "path"); + struct sqlite3 *db = cbm_store_get_db(store); + for (size_t i = 0; i + 1 < hops && db; i++) { + yyjson_mut_val *from = yyjson_mut_arr_get(mpath, i); + yyjson_mut_val *to = yyjson_mut_arr_get(mpath, i + 1); + int64_t from_id = yyjson_mut_get_int(yyjson_mut_obj_get(from, "id")); + int64_t to_id = yyjson_mut_get_int(yyjson_mut_obj_get(to, "id")); + const char *from_file = yyjson_mut_get_str(yyjson_mut_obj_get(from, "file_path")); + if (!from_file) + continue; + sqlite3_stmt *st = NULL; + int line = -1; + if (sqlite3_prepare_v2(db, + "SELECT properties FROM edges WHERE project=?1 AND " + "source_id=?2 AND target_id=?3 AND type='CALLS' LIMIT 1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 2, from_id); + sqlite3_bind_int64(st, 3, to_id); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *props = (const char *)sqlite3_column_text(st, 0); + if (props) { + yyjson_doc *pd = yyjson_read(props, strlen(props), 0); + if (pd) { + line = (int)yyjson_get_int(yyjson_obj_get(yyjson_doc_get_root(pd), "line")); + yyjson_doc_free(pd); + } + } + } + sqlite3_finalize(st); + } + if (line <= 0) + continue; + char *gj = cbm_atlas_callsite_guards_json(store, project, from_file, line); + if (!gj) + continue; + yyjson_doc *gd = yyjson_read(gj, strlen(gj), 0); + free(gj); + if (!gd) + continue; + yyjson_val *garr = yyjson_obj_get(yyjson_doc_get_root(gd), "guards"); + if (garr) { + yyjson_mut_val *copy = yyjson_val_mut_copy(mut, garr); + if (copy) + yyjson_mut_obj_add_val(mut, to, "guards", copy); + } + yyjson_doc_free(gd); + } + char *out = yyjson_mut_write(mut, 0, NULL); + yyjson_mut_doc_free(mut); + if (!out) + return json; + free(json); + return out; +} + /* GET /api/trace?project=X&from=QN|#id&to=QN|#id&mode=calls|data — A→B. */ static void handle_atlas_trace(cbm_http_conn_t *c, const cbm_http_req_t *req) { char project[256] = {0}; @@ -1847,10 +1921,38 @@ static void handle_atlas_trace(cbm_http_conn_t *c, const cbm_http_req_t *req) { int64_t to_id = to_str[0] == '#' ? strtoll(to_str + 1, NULL, 10) : -1; char *json = cbm_atlas_trace_json(store, project, from_id, from_id < 0 ? from_str : NULL, to_id, to_id < 0 ? to_str : NULL, mode[0] ? mode : "calls"); + char wantg[8] = {0}; + cbm_http_query_param(req->query, "guards", wantg, (int)sizeof(wantg)); + if (json && wantg[0] == '1') + json = atlas_trace_attach_guards(store, project, json); cbm_store_close(store); atlas_reply_json(c, json, 500, "{\"error\":\"trace failed\"}"); } +/* GET /api/why?project=X&id=N|qn=Q&dir=up|down — the trigger tree. */ +static void handle_atlas_why(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 idbuf[32] = {0}; + char qn[1024] = {0}; + char dir[8] = {0}; + cbm_http_query_param(req->query, "id", idbuf, (int)sizeof(idbuf)); + cbm_http_query_param(req->query, "qn", qn, (int)sizeof(qn)); + cbm_http_query_param(req->query, "dir", dir, (int)sizeof(dir)); + if (!idbuf[0] && !qn[0]) { + cbm_store_close(store); + cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing id or qn parameter\"}"); + return; + } + int64_t id = idbuf[0] ? strtoll(idbuf, NULL, 10) : -1; + bool upward = strcmp(dir, "down") != 0; + char *json = cbm_atlas_why_json(store, project, id, qn[0] ? qn : NULL, upward); + cbm_store_close(store); + atlas_reply_json(c, json, 404, "{\"error\":\"symbol not found\"}"); +} + /* 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}; @@ -2233,6 +2335,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + /* GET /api/why → CBM Atlas trigger tree */ + if (is_get && cbm_http_path_match(req->path, "/api/why*")) { + handle_atlas_why(c, req); + return; + } + /* GET /api/handout → CBM Atlas newcomer document */ if (is_get && cbm_http_path_match(req->path, "/api/handout*")) { handle_atlas_handout(c, req); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 4f5f5f849..dd49075c0 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -1192,8 +1192,8 @@ TEST(ui_server_atlas_routes_are_wired) { * (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", "/api/handout"}; + "/api/tree", "/api/symbol", "/api/flows", "/api/flow", "/api/metrics", "/api/trace", + "/api/scent", "/api/bridges", "/api/blast", "/api/handout", "/api/why"}; 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 ffaee4155..093f916ea 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -1717,6 +1717,139 @@ TEST(atlas_handout_is_selfcontained_and_escaped) { PASS(); } +/* A real on-disk C file so the on-demand guard extraction has something to + * parse: alpha calls beta under two nested ifs, gamma inside a loop, and + * delta in the else arm. */ +static const char *WHY_SAMPLE_C = "void beta(void);\n" + "void gamma_fn(void);\n" + "void delta(void);\n" + "void alpha(int mode, int strict) {\n" + " if (mode > 2) {\n" + " if (strict) {\n" + " beta();\n" + " } else {\n" + " delta();\n" + " }\n" + " }\n" + " for (int i = 0; i < mode; i++) {\n" + " gamma_fn();\n" + " }\n" + "}\n"; + +TEST(atlas_why_extracts_guard_chains) { + char tmpl[] = "/tmp/cbm_why_XXXXXX"; + char *root = cbm_mkdtemp(tmpl); + ASSERT_NOT_NULL(root); + char dir[1024]; + snprintf(dir, sizeof(dir), "%s/src", root); + cbm_mkdir_p(dir, 0755); + char file[1024]; + snprintf(file, sizeof(file), "%s/src/w1.c", root); + FILE *fp = cbm_fopen(file, "wb"); + ASSERT_NOT_NULL(fp); + fputs(WHY_SAMPLE_C, fp); + fclose(fp); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "why-test", root); + + static const char *names[4] = {"beta", "gamma_fn", "delta", "alpha"}; + int64_t ids[4]; + for (int i = 0; i < 4; i++) { + cbm_node_t node; + memset(&node, 0, sizeof(node)); + node.project = "why-test"; + node.label = "Function"; + node.name = names[i]; + char qn[64]; + snprintf(qn, sizeof(qn), "why-test::%s", names[i]); + node.qualified_name = qn; + node.file_path = "src/w1.c"; + node.start_line = i == 3 ? 4 : 1; + node.end_line = i == 3 ? 15 : 1; + ids[i] = cbm_store_upsert_node(store, &node); + ASSERT_GT(ids[i], 0); + } + /* alpha → beta at line 7 (if mode>2 → if strict), alpha → gamma_fn at + * line 13 (loop), alpha → delta at line 9 (else arm of strict). */ + struct { + int target; + const char *props; + } calls[3] = {{0, "{\"line\":7}"}, {1, "{\"line\":13}"}, {2, "{\"line\":9}"}}; + for (int i = 0; i < 3; i++) { + cbm_edge_t edge; + memset(&edge, 0, sizeof(edge)); + edge.project = "why-test"; + edge.source_id = ids[3]; + edge.target_id = ids[calls[i].target]; + edge.type = "CALLS"; + edge.properties_json = calls[i].props; + ASSERT_GT(cbm_store_insert_edge(store, &edge), 0); + } + + /* Upward: when does beta run? One caller (alpha), guards outermost-first + * = [mode > 2, strict]. */ + char *json = cbm_atlas_why_json(store, "why-test", ids[0], NULL, true); + ASSERT_NOT_NULL(json); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root_v = yyjson_doc_get_root(doc); + yyjson_val *entries = yyjson_obj_get(root_v, "entries"); + ASSERT_EQ((int)yyjson_arr_size(entries), 1); + yyjson_val *entry = yyjson_arr_get(entries, 0); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(entry, "name")), "alpha"); + yyjson_val *guards = yyjson_obj_get(entry, "guards"); + ASSERT_EQ((int)yyjson_arr_size(guards), 2); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(guards, 0), "cond")), "mode > 2"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(guards, 1), "cond")), "strict"); + ASSERT_NULL(yyjson_obj_get(yyjson_arr_get(guards, 1), "negated")); + yyjson_doc_free(doc); + free(json); + + /* delta sits in the ELSE arm: same conditions, innermost negated. */ + json = cbm_atlas_why_json(store, "why-test", ids[2], NULL, true); + ASSERT_NOT_NULL(json); + doc = yyjson_read(json, strlen(json), 0); + entry = yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "entries"), 0); + guards = yyjson_obj_get(entry, "guards"); + ASSERT_EQ((int)yyjson_arr_size(guards), 2); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(yyjson_arr_get(guards, 1), "negated"))); + yyjson_doc_free(doc); + free(json); + + /* gamma_fn runs in a loop; downward from alpha lists all three callees + * with their guards computed from alpha's own file. */ + json = cbm_atlas_why_json(store, "why-test", ids[1], NULL, true); + ASSERT_NOT_NULL(json); + doc = yyjson_read(json, strlen(json), 0); + entry = yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "entries"), 0); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(entry, "loop"))); + yyjson_doc_free(doc); + free(json); + + json = cbm_atlas_why_json(store, "why-test", ids[3], NULL, false); + ASSERT_NOT_NULL(json); + doc = yyjson_read(json, strlen(json), 0); + root_v = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root_v, "direction")), "down"); + ASSERT_EQ((int)yyjson_arr_size(yyjson_obj_get(root_v, "entries")), 3); + yyjson_doc_free(doc); + free(json); + + /* Callsite guards helper (the A→B trace hop path). */ + json = cbm_atlas_callsite_guards_json(store, "why-test", "src/w1.c", 7); + ASSERT_NOT_NULL(json); + doc = yyjson_read(json, strlen(json), 0); + ASSERT_EQ((int)yyjson_arr_size(yyjson_obj_get(yyjson_doc_get_root(doc), "guards")), 2); + yyjson_doc_free(doc); + free(json); + + cbm_store_close(store); + th_rmtree(root); + PASS(); +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(ui) { @@ -1761,4 +1894,5 @@ SUITE(ui) { 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); + RUN_TEST(atlas_why_extracts_guard_chains); }