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);
}