diff --git a/TRACKER.md b/TRACKER.md
index 430aa27..62eb6f0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 3 — Journey graph
-- **Next step:** 3.3 — Journey query (lazy expansion)
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.2
+- **Next step:** 3.4 — Form libraries & non-JSX events
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.3
- **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000)
## What CodeRadar is
@@ -196,7 +196,7 @@ The heart of the project. C1 and B1 live here.
- `setState`/setters → `writes-state` on local state
**Accept:** fixture `b3-programmatic-nav` green; every effect kind covered by a unit test.
-### [ ] 3.3 Journey query — lazy expansion
+### [x] 3.3 Journey query — lazy expansion
**Failure modes:** B5, B6
**Build:** `journeys(graph, startInstanceId | routePath, { depth })` in core:
- BFS over event → effect → route → page-instance → its events…, expanding paths **at query time**; per-path visited-set for cycle handling (a node may repeat across paths, not within one); cap + `truncated` flag.
diff --git a/eval/fixtures/b6-cyclic-journeys/app/routes.tsx b/eval/fixtures/b6-cyclic-journeys/app/routes.tsx
new file mode 100644
index 0000000..3fbb0c0
--- /dev/null
+++ b/eval/fixtures/b6-cyclic-journeys/app/routes.tsx
@@ -0,0 +1,9 @@
+import { createBrowserRouter } from "react-router-dom";
+
+import { UserDetail } from "./screens/UserDetail";
+import { UsersList } from "./screens/UsersList";
+
+export const router = createBrowserRouter([
+ { path: "/users", element: },
+ { path: "/users/:userId", element: },
+]);
diff --git a/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx b/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx
new file mode 100644
index 0000000..2c54379
--- /dev/null
+++ b/eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx
@@ -0,0 +1,17 @@
+import { useNavigate } from "react-router-dom";
+
+export function UserDetail() {
+ const navigate = useNavigate();
+
+ // Navigate back to the list — the return edge that closes the cycle.
+ const back = () => navigate("/users");
+ const reload = () => fetch("/api/users/current");
+
+ return (
+
+ User profile
+
+
+
+ );
+}
diff --git a/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx b/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx
new file mode 100644
index 0000000..7a47786
--- /dev/null
+++ b/eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx
@@ -0,0 +1,28 @@
+import { useNavigate } from "react-router-dom";
+
+interface User {
+ id: string;
+ name: string;
+}
+
+export function UsersList() {
+ const navigate = useNavigate();
+ const users: User[] = [];
+
+ const refresh = () => fetch("/api/users");
+
+ return (
+
+ All users
+
+
+ {users.map((user) => (
+ -
+ {/* Navigate into the detail page — the forward edge of the loop. */}
+
+
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/b6-cyclic-journeys/golden.json b/eval/fixtures/b6-cyclic-journeys/golden.json
new file mode 100644
index 0000000..7b7daf3
--- /dev/null
+++ b/eval/fixtures/b6-cyclic-journeys/golden.json
@@ -0,0 +1,29 @@
+{
+ "failureMode": "B6",
+ "note": "Journey query with lazy expansion over a list ↔ detail loop. From /users a click navigates to /users/:userId, whose 'Back' click navigates to /users — a cycle. journeys() expands paths at query time with a per-path visited-set, so the loop yields a finite path that ends 'cycle' rather than hanging. Terminal paths end at a fetch. A depth-n request on the cyclic graph must terminate quickly (asserted in unit tests).",
+ "expect": {
+ "components": [
+ { "name": "UsersList", "instances": 0 },
+ { "name": "UserDetail", "instances": 0 }
+ ],
+ "routes": [
+ { "path": "/users", "component": "UsersList" },
+ { "path": "/users/:userId", "component": "UserDetail" }
+ ],
+ "journeys": [
+ {
+ "start": "/users",
+ "depth": 3,
+ "expect": [
+ { "pages": ["/users", "/users/:userId", "/users"], "end": "cycle" },
+ { "pages": ["/users", "/users/:userId"], "end": "terminal" },
+ { "pages": ["/users"], "end": "terminal" }
+ ]
+ }
+ ],
+ "queries": [
+ { "terms": ["All users"], "status": "ok", "top": "UsersList" },
+ { "terms": ["User profile"], "status": "ok", "top": "UserDetail" }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index f499edb..c23e757 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -1,6 +1,7 @@
/** Run one fixture's golden checks against its scanned graph. */
import {
+ journeys,
type LineageGraph,
matchComponentsByText,
traceLineage,
@@ -165,6 +166,32 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
);
}
+ for (const expected of golden.expect.journeys ?? []) {
+ for (const want of expected.expect) {
+ const id = `journey:${expected.start}=>${want.pages.join(">")}${want.end !== undefined ? `[${want.end}]` : ""}`;
+ const result = journeys(graph, expected.start, { depth: expected.depth ?? 3 });
+ const found = (result.candidates[0]?.value ?? []).find((path) => {
+ const pages = path.steps.filter((s) => s.kind === "page").map((s) => s.label);
+ return (
+ pages.length === want.pages.length &&
+ pages.every((p, i) => p === want.pages[i]) &&
+ (want.end === undefined || path.end === want.end)
+ );
+ });
+ finalize(
+ "journeys",
+ id,
+ result.status === "ok" && found !== undefined,
+ expected.expectedFail,
+ result.status !== "ok"
+ ? `journeys(${expected.start}) returned ${result.status}`
+ : found === undefined
+ ? `no path visiting [${want.pages.join(" → ")}]${want.end !== undefined ? ` ending ${want.end}` : ""}`
+ : undefined,
+ );
+ }
+ }
+
for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+")}`;
const result = matchComponentsByText(graph, query.terms);
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index 7cbc479..f46865a 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -69,6 +69,19 @@ export interface GoldenEffect {
expectedFail?: string;
}
+export interface GoldenJourney {
+ /** Entry point: a route path ("/users") or a component name. */
+ start: string;
+ depth?: number;
+ /**
+ * Journey paths that must appear, identified by the ordered page labels
+ * (route paths) they visit. `end` asserts how the path terminates —
+ * "cycle" is the B6 guarantee that a list ↔ detail loop stays finite.
+ */
+ expect: Array<{ pages: string[]; end?: "terminal" | "cycle" | "depth-limit" }>;
+ expectedFail?: string;
+}
+
export interface Golden {
failureMode: string;
note?: string;
@@ -90,6 +103,8 @@ export interface Golden {
forbiddenRoutes?: string[];
/** Action effects (step 3.2): event → navigates-to / triggers / writes-state. */
effects?: GoldenEffect[];
+ /** Journey paths (step 3.3): page → event → effect → page, lazily expanded. */
+ journeys?: GoldenJourney[];
};
}
@@ -98,7 +113,7 @@ export type CheckStatus = "pass" | "fail" | "xfail" | "unexpected-pass";
export interface CheckResult {
/** e.g. "components:DataTable", "attribution:DataTable@pages/UsersPage.tsx" */
id: string;
- kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects";
+ kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects" | "journeys";
status: CheckStatus;
detail?: string;
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 68aa15e..52a5f42 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -6,6 +6,8 @@ import {
type Candidate,
collectGraphMeta,
type ComponentMatch,
+ type JourneyPath,
+ journeys,
type LineageGraph,
loadGraph as loadGraphFile,
matchComponentsByText,
@@ -121,6 +123,50 @@ program
console.log(` confidence: ${result.candidates[0].confidence.level}`);
});
+program
+ .command("journeys")
+ .description("Trace user-journey paths from a page or component (click → navigate → click…)")
+ .argument("", "route path (/users/:id), component name, or instance id")
+ .option("-g, --graph ", "graph file", "coderadar.graph.json")
+ .option("-d, --depth ", "max navigation levels per path", "3")
+ .action((start: string, opts: { graph: string; depth: string }) => {
+ const graph = loadGraph(opts.graph);
+ const depth = Number.parseInt(opts.depth, 10);
+ const result = journeys(graph, start, { depth: Number.isNaN(depth) ? 3 : depth });
+ if (result.status === "declined" || result.candidates[0] === undefined) {
+ console.error(`No journeys from ${start} (${result.declineReason ?? "no result"}).`);
+ process.exitCode = 1;
+ return;
+ }
+ const paths = result.candidates[0].value;
+ if (paths.length === 0) {
+ console.log(`No user actions found on ${start}.`);
+ return;
+ }
+ console.log(`${paths.length} journey path(s) from ${start}:\n`);
+ for (const path of paths) printJourneyPath(path);
+ });
+
+const STEP_ARROW: Record = {
+ page: "▸",
+ event: "•",
+ navigate: "→",
+ fetch: "⇢",
+ "state-write": "✎",
+};
+
+function printJourneyPath(path: JourneyPath): void {
+ const parts = path.steps.map((step) => {
+ const glyph = STEP_ARROW[step.kind] ?? "·";
+ const cond = step.condition !== undefined ? ` [${step.condition.expression}]` : "";
+ const label =
+ step.kind === "event" ? `${step.label}()` : step.kind === "fetch" ? `fetch ${step.label}` : step.label;
+ return `${glyph} ${label}${cond}`;
+ });
+ const tag = path.end === "cycle" ? " ↩ cycle" : path.end === "depth-limit" ? " … (depth limit)" : "";
+ console.log(` ${parts.join(" ")}${tag}`);
+}
+
function printMatchCandidate(candidate: Candidate): void {
const match = candidate.value;
console.log(
diff --git a/packages/core/src/journeys.test.ts b/packages/core/src/journeys.test.ts
new file mode 100644
index 0000000..554c276
--- /dev/null
+++ b/packages/core/src/journeys.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it } from "vitest";
+
+import { journeys } from "./query.js";
+import type {
+ ComponentNode,
+ DataSourceNode,
+ EdgeKind,
+ EventNode,
+ LineageEdge,
+ LineageGraph,
+ LineageNode,
+ RouteNode,
+} from "./types.js";
+import { instanceId, nodeId } from "./types.js";
+
+const loc = (file: string, line = 1) => ({ file, line, endLine: line });
+
+function component(name: string): ComponentNode {
+ return {
+ id: nodeId("component", `${name}.tsx`, name),
+ kind: "component",
+ name,
+ loc: loc(`${name}.tsx`),
+ exportName: name,
+ props: [],
+ renderedText: [],
+ rendersComponents: [],
+ };
+}
+
+function route(path: string): RouteNode {
+ return {
+ id: nodeId("route", "routes.tsx", path),
+ kind: "route",
+ name: path,
+ loc: loc("routes.tsx"),
+ path,
+ router: "react-router",
+ layout: null,
+ guards: [],
+ };
+}
+
+function event(owner: string, name: string, handler: string): EventNode {
+ return {
+ id: nodeId("event", `${owner}.tsx`, `${owner}.${name}:${handler}`),
+ kind: "event",
+ name,
+ loc: loc(`${owner}.tsx`),
+ event: name,
+ handler,
+ };
+}
+
+function dataSource(endpoint: string): DataSourceNode {
+ return {
+ id: nodeId("data-source", "api.ts", `fetch:${endpoint}`),
+ kind: "data-source",
+ name: endpoint,
+ loc: loc("api.ts"),
+ sourceKind: "fetch",
+ method: "GET",
+ endpoint,
+ raw: endpoint,
+ resolved: "full",
+ };
+}
+
+const edge = (from: string, to: string, kind: EdgeKind, extra: Partial = {}): LineageEdge => ({
+ from,
+ to,
+ kind,
+ ...extra,
+});
+
+function graphOf(nodes: LineageNode[], edges: LineageEdge[]): LineageGraph {
+ return {
+ version: 2,
+ root: "/app",
+ generatedAt: "2026-01-01T00:00:00.000Z",
+ generator: "test",
+ nodes,
+ edges,
+ };
+}
+
+/** Two pages that navigate to each other — the B6 list ↔ detail loop. */
+function cyclicGraph(): LineageGraph {
+ const a = component("PageA");
+ const b = component("PageB");
+ const ra = route("/a");
+ const rb = route("/b");
+ const goB = event("PageA", "onClick", "goB");
+ const fetchA = event("PageA", "onClick", "load");
+ const goA = event("PageB", "onClick", "goA");
+ const ds = dataSource("/api/a");
+ return graphOf(
+ [a, b, ra, rb, goB, fetchA, goA, ds],
+ [
+ edge(ra.id, a.id, "routes-to"),
+ edge(rb.id, b.id, "routes-to"),
+ edge(a.id, goB.id, "handles"),
+ edge(a.id, fetchA.id, "handles"),
+ edge(b.id, goA.id, "handles"),
+ edge(goB.id, rb.id, "navigates-to"),
+ edge(goA.id, ra.id, "navigates-to"),
+ edge(fetchA.id, ds.id, "triggers"),
+ ],
+ );
+}
+
+const pagesOf = (steps: { kind: string; label: string }[]): string[] =>
+ steps.filter((s) => s.kind === "page").map((s) => s.label);
+
+describe("journeys() — lazy expansion (TRACKER 3.3, B5/B6)", () => {
+ it("expands a page's events into navigate and fetch paths", () => {
+ const result = journeys(cyclicGraph(), "/a", { depth: 3 });
+ expect(result.status).toBe("ok");
+ const paths = result.candidates[0]?.value ?? [];
+ const signatures = paths.map((p) => `${pagesOf(p.steps).join(">")}|${p.end}`);
+ expect(signatures).toContain("/a|terminal"); // /a → onClick → fetch /api/a
+ expect(signatures).toContain("/a>/b>/a|cycle"); // list ↔ detail loop, finite
+ });
+
+ it("closes a list ↔ detail loop as a finite 'cycle' path instead of looping", () => {
+ const paths = journeys(cyclicGraph(), "/a", { depth: 3 }).candidates[0]?.value ?? [];
+ const loopPath = paths.find((p) => p.end === "cycle");
+ expect(loopPath).toBeDefined();
+ expect(pagesOf(loopPath!.steps)).toEqual(["/a", "/b", "/a"]);
+ });
+
+ it("terminates on a cyclic graph even at large depth (never hangs)", () => {
+ const started = Date.now();
+ const paths = journeys(cyclicGraph(), "/a", { depth: 50 }).candidates[0]?.value ?? [];
+ expect(Date.now() - started).toBeLessThan(1000);
+ // Every path ends explicitly; no path is left dangling mid-expansion.
+ expect(paths.every((p) => ["terminal", "cycle", "depth-limit"].includes(p.end))).toBe(true);
+ });
+
+ it("caps a non-cyclic chain at the requested depth with a depth-limit end", () => {
+ const [a, b, c, d] = ["A", "B", "C", "D"].map(component);
+ const [ra, rb, rc, rd] = ["/a", "/b", "/c", "/d"].map(route);
+ const eAB = event("A", "onClick", "toB");
+ const eBC = event("B", "onClick", "toC");
+ const eCD = event("C", "onClick", "toD");
+ const g = graphOf(
+ [a, b, c, d, ra, rb, rc, rd, eAB, eBC, eCD],
+ [
+ edge(ra.id, a.id, "routes-to"),
+ edge(rb.id, b.id, "routes-to"),
+ edge(rc.id, c.id, "routes-to"),
+ edge(rd.id, d.id, "routes-to"),
+ edge(a.id, eAB.id, "handles"),
+ edge(b.id, eBC.id, "handles"),
+ edge(c.id, eCD.id, "handles"),
+ edge(eAB.id, rb.id, "navigates-to"),
+ edge(eBC.id, rc.id, "navigates-to"),
+ edge(eCD.id, rd.id, "navigates-to"),
+ ],
+ );
+ const paths = journeys(g, "/a", { depth: 3 }).candidates[0]?.value ?? [];
+ const deepest = paths.find((p) => p.end === "depth-limit");
+ expect(deepest).toBeDefined();
+ expect(pagesOf(deepest!.steps)).toEqual(["/a", "/b", "/c"]); // stops before /d
+ });
+
+ it("resolves the start from a route path, a component name, or an instance id", () => {
+ const g = cyclicGraph();
+ const inst = {
+ id: instanceId("Host.tsx", 4, "PageA"),
+ kind: "instance" as const,
+ name: "PageA",
+ loc: loc("Host.tsx", 4),
+ definitionId: nodeId("component", "PageA.tsx", "PageA"),
+ parentInstanceId: null,
+ staticProps: {},
+ };
+ expect(journeys(g, "/a").status).toBe("ok");
+ expect(journeys(g, "PageA").status).toBe("ok");
+ expect(journeys(graphOf([...g.nodes, inst], g.edges), inst.id).status).toBe("ok");
+ expect(journeys(g, "NoSuchThing").status).toBe("declined");
+ });
+
+ it("carries an edge condition onto the journey step it gates", () => {
+ const g = cyclicGraph();
+ const gated = g.edges.map((e) =>
+ e.kind === "navigates-to" && e.from.includes("goB")
+ ? { ...e, condition: { kind: "role" as const, expression: "isAdmin" } }
+ : e,
+ );
+ const paths = journeys(graphOf(g.nodes, gated), "/a", { depth: 3 }).candidates[0]?.value ?? [];
+ const navStep = paths
+ .flatMap((p) => p.steps)
+ .find((s) => s.kind === "navigate" && s.condition !== undefined);
+ expect(navStep?.condition?.expression).toBe("isAdmin");
+ });
+});
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index b406037..41948c4 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -18,12 +18,15 @@ import { normalizeText, textMatches } from "./text.js";
import type {
ComponentNode,
DataSourceNode,
+ EdgeCondition,
EventNode,
Evidence,
InstanceNode,
LineageEdge,
LineageGraph,
LineageNode,
+ RouteNode,
+ SourceLocation,
StateNode,
} from "./types.js";
@@ -262,6 +265,217 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult {
+ const depth = options.depth ?? 3;
+ const maxPaths = options.maxPaths ?? 256;
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
+ const out = new Map();
+ for (const e of graph.edges) {
+ const list = out.get(e.from);
+ if (list) list.push(e);
+ else out.set(e.from, [e]);
+ }
+ const outEdges = (id: string): LineageEdge[] => out.get(id) ?? [];
+
+ // Resolve the entry point to a page component + the label to show for it.
+ let startComponentId: string;
+ let startLabel: string;
+ const route = graph.nodes.find((n): n is RouteNode => n.kind === "route" && n.path === start);
+ if (route !== undefined) {
+ const pageEdge = outEdges(route.id).find((e) => e.kind === "routes-to");
+ if (pageEdge === undefined) return declined("invalid-target");
+ startComponentId = pageEdge.to;
+ startLabel = route.path;
+ } else {
+ const node = byId.get(start) ?? graph.nodes.find((n) => n.kind === "component" && n.name === start);
+ if (node === undefined) return declined("not-found");
+ if (node.kind === "instance") {
+ startComponentId = node.definitionId;
+ startLabel = byId.get(node.definitionId)?.name ?? start;
+ } else if (node.kind === "component") {
+ startComponentId = node.id;
+ startLabel = node.name;
+ } else {
+ return declined("invalid-target");
+ }
+ }
+ if (!byId.has(startComponentId)) return declined("not-found");
+
+ // The page a route lands on, indexed for the navigate → route → page hop.
+ const pageOfRoute = (routeId: string): { componentId: string; label: string } | null => {
+ const edge = outEdges(routeId).find((e) => e.kind === "routes-to");
+ const routeNode = byId.get(routeId);
+ if (edge === undefined || routeNode === undefined || routeNode.kind !== "route") return null;
+ return { componentId: edge.to, label: routeNode.path };
+ };
+
+ // All events on a screen: those the page component handles plus those of any
+ // component in its render subtree (a button living in a child component is
+ // still on this page). Memoized; the subtree walk is cycle-guarded.
+ const eventsMemo = new Map();
+ const screenEvents = (componentId: string): string[] => {
+ const cached = eventsMemo.get(componentId);
+ if (cached !== undefined) return cached;
+ const subtree = new Set([componentId]);
+ const stack = [componentId];
+ while (stack.length > 0) {
+ const cid = stack.pop() as string;
+ for (const edge of outEdges(cid)) {
+ if (edge.kind !== "renders") continue;
+ const inst = byId.get(edge.to);
+ if (inst === undefined || inst.kind !== "instance") continue;
+ if (!subtree.has(inst.definitionId) && byId.get(inst.definitionId)?.kind === "component") {
+ subtree.add(inst.definitionId);
+ stack.push(inst.definitionId);
+ }
+ }
+ }
+ const events: string[] = [];
+ for (const cid of subtree) {
+ for (const edge of outEdges(cid)) {
+ if (edge.kind === "handles" && byId.get(edge.to)?.kind === "event") events.push(edge.to);
+ }
+ }
+ eventsMemo.set(componentId, events);
+ return events;
+ };
+
+ const paths: JourneyPath[] = [];
+ let truncated = false;
+ const pageStep = (componentId: string, label: string): JourneyStep => {
+ const node = byId.get(componentId);
+ return { kind: "page", nodeId: componentId, label, ...(node ? { loc: node.loc } : {}) };
+ };
+
+ // Depth-first expansion. `visitedPages` is copied down each branch so a page
+ // may appear in sibling paths but a single path never revisits one.
+ const expand = (
+ componentId: string,
+ label: string,
+ prefix: JourneyStep[],
+ visitedPages: Set,
+ ): void => {
+ if (paths.length >= maxPaths) {
+ truncated = true;
+ return;
+ }
+ const pagePath = [...prefix, pageStep(componentId, label)];
+ if (visitedPages.has(componentId)) {
+ paths.push({ steps: pagePath, end: "cycle" });
+ return;
+ }
+ if (visitedPages.size + 1 >= depth) {
+ // One more page would exceed the depth budget — stop here.
+ paths.push({ steps: pagePath, end: "depth-limit" });
+ truncated = true;
+ return;
+ }
+ const nextVisited = new Set(visitedPages).add(componentId);
+
+ let branched = false;
+ for (const eventId of screenEvents(componentId)) {
+ const event = byId.get(eventId);
+ if (event === undefined || event.kind !== "event") continue;
+ for (const effect of outEdges(eventId)) {
+ const eventStep: JourneyStep = {
+ kind: "event",
+ nodeId: eventId,
+ label: event.event,
+ loc: event.loc,
+ ...(effect.condition ? { condition: effect.condition } : {}),
+ };
+ if (effect.kind === "navigates-to") {
+ const page = pageOfRoute(effect.to);
+ if (page === null) continue;
+ branched = true;
+ const navStep: JourneyStep = {
+ kind: "navigate",
+ nodeId: effect.to,
+ label: byId.get(effect.to)?.name ?? effect.to,
+ ...(byId.get(effect.to) ? { loc: byId.get(effect.to)!.loc } : {}),
+ ...(effect.condition ? { condition: effect.condition } : {}),
+ };
+ expand(page.componentId, page.label, [...pagePath, eventStep, navStep], nextVisited);
+ } else if (effect.kind === "triggers" || effect.kind === "writes-state") {
+ const target = byId.get(effect.to);
+ if (target === undefined) continue;
+ branched = true;
+ const leaf: JourneyStep =
+ target.kind === "data-source"
+ ? { kind: "fetch", nodeId: target.id, label: target.endpoint, loc: target.loc }
+ : { kind: "state-write", nodeId: target.id, label: target.name, loc: target.loc };
+ if (paths.length >= maxPaths) {
+ truncated = true;
+ return;
+ }
+ paths.push({ steps: [...pagePath, eventStep, leaf], end: "terminal" });
+ }
+ }
+ }
+ // A screen with no expandable events is itself a terminal path.
+ if (!branched) paths.push({ steps: pagePath, end: "terminal" });
+ };
+
+ expand(startComponentId, startLabel, [], new Set());
+
+ const evidence: Evidence[] = [
+ {
+ kind: "edge-chain",
+ detail:
+ `Expanded ${paths.length} journey path(s) from ${startLabel} to depth ${depth}` +
+ `${truncated ? " (truncated)" : ""}`,
+ loc: byId.get(startComponentId)?.loc,
+ },
+ ];
+ return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]);
+}
+
function groupInstances(graph: LineageGraph): Map {
const byDefinition = new Map();
for (const node of graph.nodes) {