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) {