feat(atlas): the Why view — trigger trees with syntactic guard chains

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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-08-26 21:05:56 +02:00
1 parent 67ba264002
commit 2df92078f9
13 files changed
+1072 -5

No files matched your search

+1
View File
@@ -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
+15 -1
View File
@@ -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
</p>
<div className="flex items-center gap-1 flex-wrap">
{(trace.path ?? []).map((step, index) => (
<span key={index} className="flex items-center gap-1">
<span key={index} className="flex items-center gap-1 flex-wrap">
{index > 0 && (
<span className="text-foreground/25 text-[12px]">→</span>
)}
@@ -266,6 +267,19 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
>
{step.name}
</button>
{(step.guards ?? []).map((guard, gi) => (
<span
key={gi}
className={`text-[10px] px-1 rounded-full border font-mono ${
guard.negated
? "border-warn/50 text-warn/80"
: "border-primary/40 text-primary/80"
}`}
title="syntactic guard at this hop's call site"
>
{formatGuard(guard)}
</span>
))}
</span>
))}
</div>
+11
View File
@@ -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
/>
</div>
{/* The Why view — condition→action, the abstraction code text
* serves worst (Pennington 1987). */}
<div className="mb-4">
<TriggerTree
project={project}
symbolId={bundle.node.id}
onOpenSymbol={onOpenSymbol}
/>
</div>
{/* Why & who — the rationale proxy and the knowledge map. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<div className="bg-card border border-border/50 rounded-md p-4">
+213
View File
@@ -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 <span className="text-[12px] text-foreground/35">guards unavailable (source not readable)</span>;
if (entry.guards.length === 0 && !entry.loop)
return <span className="text-[12px] text-foreground/35">unguarded — always on this path</span>;
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{entry.loop && (
<span className="text-[11px] px-1.5 rounded-full border border-border/60 text-foreground/50" title="the call site sits inside a loop">
⟳ loop
</span>
)}
{entry.guards.map((guard, index) => (
<span
key={index}
className={`text-[11px] px-1.5 rounded-full border font-mono ${
guard.negated
? "border-warn/50 text-warn/90 bg-warn/10"
: "border-primary/40 text-primary/90 bg-primary/10"
}`}
title={`syntactic ${guard.kind} guard around the call site — not a proven path condition`}
>
? {formatGuard(guard)}
</span>
))}
</span>
);
}
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<WhyPayload | null>(null);
const [error, setError] = useState(false);
const [expanded, setExpanded] = useState<Set<number>>(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 <p className="text-[12px] text-foreground/35">could not load this level</p>;
if (!payload)
return <p className="text-[12px] text-foreground/35">tracing…</p>;
if (payload.entries.length === 0)
return (
<p className="text-[13px] text-foreground/40">
{direction === "up"
? "No resolved callers — an entry point, or reached another way (dispatch, event, reflection)."
: "No resolved callees."}
</p>
);
return (
<div className={depth > 0 ? "border-l border-border/40 pl-3 ml-1" : ""}>
{payload.entries.map((entry) => (
<div key={entry.id} className="py-[3px]">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-foreground/35 text-[12px] shrink-0">
{direction === "up" ? "←" : "→"}
</span>
<button
onClick={() => onOpenSymbol({ id: entry.id })}
className="text-[13px] font-mono text-foreground/75 hover:text-primary transition-colors"
title={entry.file_path && entry.line ? `${entry.file_path}:${entry.line}` : entry.file_path}
>
{entry.name}
</button>
<GuardChips entry={entry} />
{direction === "up" && entry.more > 0 && (
<button
onClick={() =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(entry.id)) next.delete(entry.id);
else next.add(entry.id);
return next;
})
}
className="text-[12px] text-foreground/40 hover:text-primary transition-colors"
>
{expanded.has(entry.id)
? "− collapse"
: `⌄ ${entry.more} caller${entry.more === 1 ? "" : "s"} above`}
</button>
)}
</div>
{expanded.has(entry.id) && depth < 6 && (
<WhyLevel
project={project}
refId={entry.id}
direction={direction}
depth={depth + 1}
onOpenSymbol={onOpenSymbol}
/>
)}
</div>
))}
{payload.total > payload.entries.length && (
<p className="text-[12px] text-foreground/35 mt-1">
{payload.entries.length} of {payload.total} shown — the rest via the caller list
above.
</p>
)}
</div>
);
}
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<WhyPayload | null>(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 (
<div className="bg-card border border-border/50 rounded-md p-4">
<div className="flex items-baseline gap-2 mb-2">
<p className="text-[12px] uppercase tracking-widest text-foreground/40">
{direction === "up" ? "When does this run?" : "What does this trigger?"}
</p>
<button
onClick={() => setDirection((d) => (d === "up" ? "down" : "up"))}
className="ml-auto text-[12px] text-foreground/45 hover:text-primary transition-colors"
>
{direction === "up" ? "→ what it triggers" : "← when it runs"}
</button>
</div>
{root && allUnguarded(root.entries) && root.entries.length > 0 && root.entries.length <= 2 ? (
<p className="text-[13px] text-foreground/60">
{direction === "up" ? "Always runs when " : "Always triggers "}
{root.entries.map((entry, index) => (
<span key={entry.id}>
{index > 0 && " and "}
<button
onClick={() => onOpenSymbol({ id: entry.id })}
className="font-mono text-foreground/80 hover:text-primary transition-colors"
>
{entry.name}
</button>
</span>
))}
{direction === "up" ? " runs — no conditions at the call sites." : " — unconditionally."}
</p>
) : (
<WhyLevel
project={project}
refId={symbolId}
direction={direction}
depth={0}
onOpenSymbol={onOpenSymbol}
/>
)}
<p className="text-[11px] text-foreground/30 mt-2">
Guards are the syntactic conditions around each call site — not proven path
conditions. Dispatch, events and reflection are invisible here.
</p>
</div>
);
}
+7 -2
View File
@@ -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<TracePayload> {
const params = new URLSearchParams({ project, from, to, mode });
const params = new URLSearchParams({ project, from, to, mode, guards: "1" });
return getJson(`/api/trace?${params}`);
}
+38
View File
@@ -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);
});
});
+76
View File
@@ -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<WhyPayload> {
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);
}
+7
View File
@@ -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<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
+6
View File
@@ -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);
+454
View File
@@ -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 <sqlite3.h>
#include <tree_sitter/api.h>
#include <yyjson/yyjson.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── 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;
}
+108
View File
@@ -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);
+2 -2
View File
@@ -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++) {
+134
View File
@@ -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);
}