Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 3 — Journey graph
- **Next step:** 3.3Journey query (lazy expansion)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.2
- **Next step:** 3.4Form 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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions eval/fixtures/b6-cyclic-journeys/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -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: <UsersList /> },
{ path: "/users/:userId", element: <UserDetail /> },
]);
17 changes: 17 additions & 0 deletions eval/fixtures/b6-cyclic-journeys/app/screens/UserDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<article>
<h1>User profile</h1>
<button onClick={back}>Back to list</button>
<button onClick={reload}>Reload</button>
</article>
);
}
28 changes: 28 additions & 0 deletions eval/fixtures/b6-cyclic-journeys/app/screens/UsersList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<h1>All users</h1>
<button onClick={refresh}>Refresh</button>
<ul>
{users.map((user) => (
<li key={user.id}>
{/* Navigate into the detail page — the forward edge of the loop. */}
<button onClick={() => navigate(`/users/${user.id}`)}>{user.name}</button>
</li>
))}
</ul>
</section>
);
}
29 changes: 29 additions & 0 deletions eval/fixtures/b6-cyclic-journeys/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
27 changes: 27 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Run one fixture's golden checks against its scanned graph. */

import {
journeys,
type LineageGraph,
matchComponentsByText,
traceLineage,
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
};
}

Expand All @@ -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;
}
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type Candidate,
collectGraphMeta,
type ComponentMatch,
type JourneyPath,
journeys,
type LineageGraph,
loadGraph as loadGraphFile,
matchComponentsByText,
Expand Down Expand Up @@ -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("<start>", "route path (/users/:id), component name, or instance id")
.option("-g, --graph <file>", "graph file", "coderadar.graph.json")
.option("-d, --depth <n>", "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<string, string> = {
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<ComponentMatch>): void {
const match = candidate.value;
console.log(
Expand Down
Loading
Loading