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