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.2Action effects
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1
- **Next step:** 3.3Journey query (lazy expansion)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.2
- **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 @@ -187,7 +187,7 @@ The heart of the project. C1 and B1 live here.
**Build:** `RouteNode` (`path`, `layout`, `guards`) in core. Adapters: React Router (`createBrowserRouter` / `<Route>` trees, nested + lazy) and Next.js file-based (app + pages router). `routes-to` edges route → page-component instance tree.
**Accept:** fixtures `b4-react-router`, `b4-nextjs-approuter` green: every golden route maps to its page component.

### [ ] 3.2 Action effects
### [x] 3.2 Action effects
**Failure modes:** B3, B2 (dispatch half)
**Build:** mine resolved handler bodies (from 2.3) for effects, each a typed `triggers` edge from the EventNode:
- `navigate(...)`/`router.push(...)` → `navigates-to` with route pattern (computed strings → `:param` form, B3)
Expand Down
13 changes: 13 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createBrowserRouter } from "react-router-dom";

import { CartPage } from "./screens/CartPage";
import { CheckoutPage } from "./screens/CheckoutPage";
import { UserDetail } from "./screens/UserDetail";
import { UsersPage } from "./screens/UsersPage";

export const router = createBrowserRouter([
{ path: "/users", element: <UsersPage /> },
{ path: "/users/:userId", element: <UserDetail /> },
{ path: "/cart", element: <CartPage /> },
{ path: "/checkout", element: <CheckoutPage /> },
]);
22 changes: 22 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/screens/CartPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useState } from "react";
import { useDispatch } from "react-redux";

import { clearCart } from "../store/cartSlice";

export function CartPage() {
const dispatch = useDispatch();
const [open, setOpen] = useState(false);

// Local setter → writes-state on the local useState slot.
const showDetails = () => setOpen(true);

return (
<section>
<h1>Your cart</h1>
{/* Inline dispatch of a plain action → writes-state on the cart slice. */}
<button onClick={() => dispatch(clearCart())}>Clear</button>
<button onClick={showDetails}>Details</button>
{open ? <p>Cart details</p> : null}
</section>
);
}
18 changes: 18 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/screens/CheckoutPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useNavigate } from "react-router-dom";

export function CheckoutPage() {
const navigate = useNavigate();

// Resolves to a known route → navigates-to /cart.
const goBack = () => navigate("/cart");
// Resolves to a path with no declared route → flagged unresolved-nav.
const goBroken = () => navigate("/nowhere");

return (
<section>
<h1>Checkout</h1>
<button onClick={goBack}>Back to cart</button>
<button onClick={goBroken}>Broken link</button>
</section>
);
}
7 changes: 7 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/screens/UserDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function UserDetail() {
return (
<article>
<h1>User profile</h1>
</article>
);
}
36 changes: 36 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/screens/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";

import { fetchUsers } from "../store/usersSlice";

interface Row {
id: string;
name: string;
}

export function UsersPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const rows: Row[] = [];

// Local handler dispatching a thunk → writes-state on the users slice.
const refreshUsers = () => dispatch(fetchUsers());
// Local handler issuing a fetch → triggers a data source.
const exportUsers = () => fetch("/api/users/export", { method: "POST" });

return (
<section>
<h1>All users</h1>
<button onClick={refreshUsers}>Refresh</button>
<button onClick={exportUsers}>Export</button>
<ul>
{rows.map((row) => (
<li key={row.id}>
{/* Inline navigate with a computed param → /users/:id, shape-joined to the route. */}
<button onClick={() => navigate(`/users/${row.id}`)}>{row.name}</button>
</li>
))}
</ul>
</section>
);
}
18 changes: 18 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/store/cartSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
name: "cart",
initialState: { items: [] as string[] },
reducers: {
// A plain reducer action: dispatch(clearCart()) writes this slice.
clearCart(state) {
state.items = [];
},
addItem(state, action) {
state.items.push(action.payload);
},
},
});

export const { clearCart, addItem } = cartSlice.actions;
export default cartSlice.reducer;
25 changes: 25 additions & 0 deletions eval/fixtures/b3-programmatic-nav/app/store/usersSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

// Thunk: the fetch lives here; dispatch(fetchUsers()) in a handler triggers it.
export const fetchUsers = createAsyncThunk("users/fetch", async () => {
const res = await fetch("/api/users");
return res.json();
});

const usersSlice = createSlice({
name: "users",
initialState: { list: [] as string[] },
reducers: {
selectUser(state, action) {
(state as { selected?: string }).selected = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
state.list = action.payload;
});
},
});

export const { selectUser } = usersSlice.actions;
export default usersSlice.reducer;
29 changes: 29 additions & 0 deletions eval/fixtures/b3-programmatic-nav/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"failureMode": "B3",
"note": "Action effects: resolved handler bodies mined for effects, each a typed edge from the EventNode. navigate()/router.push → navigates-to a RouteNode (computed target folded to :param form, joined by path shape); fetch in a handler → triggers a data source; dispatch(thunk|action) → writes-state on the slice; setState setter → writes-state on the local slot. A navigate to a path with no declared route is flagged unresolved-nav, never silent.",
"expect": {
"components": [
{ "name": "UsersPage", "instances": 0 },
{ "name": "CartPage", "instances": 0 },
{ "name": "CheckoutPage", "instances": 0 }
],
"routes": [
{ "path": "/users", "component": "UsersPage" },
{ "path": "/users/:userId", "component": "UserDetail" },
{ "path": "/cart", "component": "CartPage" },
{ "path": "/checkout", "component": "CheckoutPage" }
],
"effects": [
{ "component": "UsersPage", "event": "onClick", "effect": "writes-state", "to": "users" },
{ "component": "UsersPage", "event": "onClick", "effect": "triggers", "to": "/api/users/export" },
{ "component": "UsersPage", "event": "onClick", "effect": "navigates-to", "to": "/users/:userId" },
{ "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "cart" },
{ "component": "CartPage", "event": "onClick", "effect": "writes-state", "to": "open" },
{ "component": "CheckoutPage", "event": "onClick", "effect": "navigates-to", "to": "/cart" }
],
"queries": [
{ "terms": ["All users"], "status": "ok", "top": "UsersPage" },
{ "terms": ["Your cart"], "status": "ok", "top": "CartPage" }
]
}
}
37 changes: 37 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,43 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
});
}

for (const expected of golden.expect.effects ?? []) {
const id = `effect:${expected.component}.${expected.event}:${expected.effect}->${expected.to}`;
const owner = graph.nodes.find(
(n) => n.kind === "component" && n.name === expected.component,
);
// Events the component handles, filtered to the named event (onClick, …).
const eventIds = new Set(
graph.edges
.filter((e) => e.kind === "handles" && owner !== undefined && e.from === owner.id)
.map((e) => e.to)
.filter((eventId) => {
const event = graph.nodes.find((n) => n.id === eventId);
return event?.kind === "event" && event.event === expected.event;
}),
);
const matched = graph.edges.some((edge) => {
if (edge.kind !== expected.effect || !eventIds.has(edge.from)) return false;
const target = graph.nodes.find((n) => n.id === edge.to);
if (target === undefined) return false;
if (target.kind === "route") return target.path === expected.to;
if (target.kind === "data-source") return target.endpoint === expected.to;
if (target.kind === "state") return target.name === expected.to;
return false;
});
finalize(
"effects",
id,
matched,
expected.expectedFail,
matched
? undefined
: owner === undefined
? "component not found"
: `no ${expected.effect} edge from a ${expected.component}.${expected.event} event to ${expected.to}`,
);
}

for (const query of golden.expect.queries ?? []) {
const id = `query:${query.terms.join("+")}`;
const result = matchComponentsByText(graph, query.terms);
Expand Down
20 changes: 19 additions & 1 deletion eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ export interface GoldenRoute {
expectedFail?: string;
}

export interface GoldenEffect {
/** Component that owns the event (a `handles` edge from it reaches the event). */
component: string;
/** Event name the effect hangs off, e.g. "onClick", "onSubmit". */
event: string;
/** The effect edge kind asserted from the event node. */
effect: "navigates-to" | "triggers" | "writes-state";
/**
* Expected target, matched against the edge's destination node:
* a route path (navigates-to), a data-source endpoint (triggers), or a
* state/slice name (writes-state).
*/
to: string;
expectedFail?: string;
}

export interface Golden {
failureMode: string;
note?: string;
Expand All @@ -72,6 +88,8 @@ export interface Golden {
routes?: GoldenRoute[];
/** Paths that must NOT exist as routes (api handlers, _app…). Never xfails. */
forbiddenRoutes?: string[];
/** Action effects (step 3.2): event → navigates-to / triggers / writes-state. */
effects?: GoldenEffect[];
};
}

Expand All @@ -80,7 +98,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";
kind: "components" | "attributions" | "forbidden" | "queries" | "routes" | "effects";
status: CheckStatus;
detail?: string;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,10 @@ export type EdgeKind =
| "fetches-from" // component | hook -> data-source
| "provides-data" // data-source -> instance (via a prop; Phase 2.2)
| "reads-state" // component | hook -> state
| "writes-state" // data-source | event -> state (Phase 2.4)
| "writes-state" // data-source | event -> state (Phase 2.4; dispatch effects Phase 3.2)
| "handles" // component -> event
| "triggers" // event -> data-source | state (handler causes a fetch / state write)
| "triggers" // event -> data-source (resolved handler body issues a fetch; Phase 3.2)
| "navigates-to" // event -> route (handler calls navigate/router.push; Phase 3.2, B3)
| "routes-to"; // route -> page component definition (its instances form the page tree)

/** A statically-detected condition guarding an edge (feature flag, role, branch). */
Expand Down
92 changes: 92 additions & 0 deletions packages/parser-react/src/effects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

import type { LineageGraph, LineageNode } from "@coderadar/core";
import { describe, expect, it } from "vitest";

import { resolveHookEdges, scanReact } from "./scan.js";

const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures");

const b3 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b3-programmatic-nav/app") }));

/** The effect target reachable from a `handler`-named event by an edge of `kind`. */
function effectTargets(graph: LineageGraph, handler: string, kind: string): LineageNode[] {
const event = graph.nodes.find((n) => n.kind === "event" && n.handler === handler);
const inline = graph.nodes.filter((n) => n.kind === "event" && n.handler === null);
const events = event !== undefined ? [event] : inline;
const ids = new Set(events.map((e) => e.id));
return graph.edges
.filter((e) => e.kind === kind && ids.has(e.from))
.map((e) => graph.nodes.find((n) => n.id === e.to))
.filter((n): n is LineageNode => n !== undefined);
}

describe("action effects (b3 fixture, TRACKER 3.2)", () => {
it("navigate() in a handler → navigates-to the matching route (exact path)", () => {
const targets = effectTargets(b3, "goBack", "navigates-to");
expect(targets.map((t) => (t.kind === "route" ? t.path : t.id))).toEqual(["/cart"]);
});

it("navigate(`/users/${id}`) joins the route by param-agnostic shape", () => {
// Handler is an inline arrow (handler === null); match the /users/:userId route.
const routes = b3.edges
.filter((e) => e.kind === "navigates-to")
.map((e) => b3.nodes.find((n) => n.id === e.to))
.filter((n) => n?.kind === "route");
expect(routes.some((r) => r?.kind === "route" && r.path === "/users/:userId")).toBe(true);
});

it("a navigate to a path with no declared route is flagged, never silently dropped", () => {
const broken = b3.nodes.find((n) => n.kind === "event" && n.handler === "goBroken");
expect(broken?.flags).toContain("unresolved-nav");
expect(b3.edges.some((e) => e.kind === "navigates-to" && e.from === broken?.id)).toBe(false);
});

it("fetch() in a handler → triggers a data source", () => {
const targets = effectTargets(b3, "exportUsers", "triggers");
const endpoints = targets.map((t) => (t.kind === "data-source" ? t.endpoint : t.id));
expect(endpoints).toContain("/api/users/export");
});

it("dispatch(thunk()) → writes-state on the slice the thunk populates", () => {
const targets = effectTargets(b3, "refreshUsers", "writes-state");
const names = targets.map((t) => t.name);
expect(names).toContain("users");
});

it("dispatch(action()) of a plain reducer → writes-state on that slice", () => {
// Inline arrow onClick={() => dispatch(clearCart())}.
const writes = b3.edges
.filter((e) => e.kind === "writes-state")
.map((e) => b3.nodes.find((n) => n.id === e.to))
.filter((n) => n?.kind === "state");
const fromEvent = b3.edges.some((e) => {
if (e.kind !== "writes-state") return false;
const from = b3.nodes.find((n) => n.id === e.from);
const to = b3.nodes.find((n) => n.id === e.to);
return from?.kind === "event" && to?.kind === "state" && to.name === "cart";
});
expect(writes.some((n) => n?.name === "cart")).toBe(true);
expect(fromEvent).toBe(true);
});

it("a local setState setter → writes-state on the local slot", () => {
const targets = effectTargets(b3, "showDetails", "writes-state");
const names = targets.map((t) => t.name);
expect(names).toContain("open");
const slot = targets.find((t) => t.name === "open");
expect(slot?.kind === "state" ? slot.stateKind : undefined).toBe("useState");
});
});

describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => {
const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") }));

it("a 4-level drilled handler still emits its triggers edge", () => {
const event = b1.nodes.find((n) => n.kind === "event" && n.handler === "onSave");
const triggers = b1.edges.filter((e) => e.kind === "triggers" && e.from === event?.id);
const target = b1.nodes.find((n) => n.id === triggers[0]?.to);
expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/drafts");
});
});
Loading
Loading