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.1Router adapters
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5
- **Next step:** 3.2Action effects
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1
- **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 @@ -182,7 +182,7 @@ The heart of the project. C1 and B1 live here.

## Phase 3 — Journey graph

### [ ] 3.1 Router adapters
### [x] 3.1 Router adapters
**Failure modes:** B4
**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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function PricingPage() {
return <section>Simple, transparent pricing</section>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<aside>Dashboard menu</aside>
{children}
</section>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";

export default function DashboardPage() {
const [metrics, setMetrics] = useState<unknown[]>([]);
useEffect(() => {
fetch("/api/metrics").then((r) => r.json()).then(setMetrics);
}, []);
return (
<main>
<h2>Key metrics</h2>
<div>{metrics.length}</div>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function DocsPage() {
return <article>Documentation chapter</article>;
}
10 changes: 10 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<header>Nimbus Analytics</header>
{children}
</body>
</html>
);
}
3 changes: 3 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function HomePage() {
return <h1>Your analytics at a glance</h1>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function UserProfilePage() {
return <article>Member profile and activity</article>;
}
2 changes: 2 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Marks this fixture as a Next.js project for router detection. */
export default {};
3 changes: 3 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function MyApp({ Component, pageProps }: { Component: React.ComponentType; pageProps: Record<string, unknown> }) {
return <Component {...pageProps} />;
}
4 changes: 4 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// API route — must never become a page route.
export default function handler(_req: unknown, res: { status: (code: number) => { json: (body: unknown) => void } }) {
res.status(200).json({ ok: true });
}
4 changes: 4 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Pages-router leftover in an app-router project — both must be scanned.
export default function LegacyDetailPage() {
return <div>Legacy record viewer</div>;
}
22 changes: 22 additions & 0 deletions eval/fixtures/b4-nextjs-approuter/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"failureMode": "B4",
"note": "Next.js file-based routing: app router ([param] and [...catchAll] segments, (group) dirs dropped, nearest layout.tsx wins) plus a pages-router leftover. _app and pages/api/** must never become routes.",
"expect": {
"routes": [
{ "path": "/", "component": "HomePage", "layout": "RootLayout", "guards": [] },
{ "path": "/dashboard", "component": "DashboardPage", "layout": "DashboardLayout", "guards": [] },
{ "path": "/pricing", "component": "PricingPage", "layout": "RootLayout", "guards": [] },
{ "path": "/users/:userId", "component": "UserProfilePage", "layout": "RootLayout", "guards": [] },
{ "path": "/docs/:slug*", "component": "DocsPage", "layout": "RootLayout", "guards": [] },
{ "path": "/legacy/:id", "component": "LegacyDetailPage", "guards": [] }
],
"forbiddenRoutes": ["/api/health", "/_app"],
"attributions": [
{ "component": "DashboardPage", "endpoints": ["/api/metrics"] }
],
"queries": [
{ "terms": ["Key metrics"], "status": "ok", "top": "DashboardPage" },
{ "terms": ["transparent pricing"], "status": "ok", "top": "PricingPage" }
]
}
}
17 changes: 17 additions & 0 deletions eval/fixtures/b4-react-router/app/ReportsApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Route, Routes } from "react-router-dom";

import { ReportsLayout } from "./ReportsLayout";
import { ReportDetail } from "./pages/ReportDetail";
import { ReportsHome } from "./pages/ReportsHome";

/** JSX-declared route tree — the second React Router declaration style. */
export function ReportsApp() {
return (
<Routes>
<Route path="/reports" element={<ReportsLayout />}>
<Route index element={<ReportsHome />} />
<Route path=":reportId" element={<ReportDetail />} />
</Route>
</Routes>
);
}
10 changes: 10 additions & 0 deletions eval/fixtures/b4-react-router/app/ReportsLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";

export function ReportsLayout() {
return (
<div>
<h2>Reporting</h2>
<Outlet />
</div>
);
}
10 changes: 10 additions & 0 deletions eval/fixtures/b4-react-router/app/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Outlet, useLocation } from "react-router-dom";

export function RequireAuth({ children }: { children?: unknown }) {
const location = useLocation();
const authed = Boolean(location);
if (!authed) {
return <p>Please sign in to continue</p>;
}
return children !== undefined ? <>{children}</> : <Outlet />;
}
10 changes: 10 additions & 0 deletions eval/fixtures/b4-react-router/app/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";

export function RootLayout() {
return (
<div>
<nav>Acme Console</nav>
<Outlet />
</div>
);
}
30 changes: 30 additions & 0 deletions eval/fixtures/b4-react-router/app/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createBrowserRouter } from "react-router-dom";

import { RequireAuth } from "./RequireAuth";
import { RootLayout } from "./RootLayout";
import { AdminPanel } from "./pages/AdminPanel";
import { AuditLog } from "./pages/AuditLog";
import { HomePage } from "./pages/HomePage";
import { UserDetail } from "./pages/UserDetail";
import { UsersPage } from "./pages/UsersPage";

export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: "users", element: <UsersPage /> },
{ path: "users/:userId", element: <UserDetail /> },
{
// Pathless guard route: everything below requires auth.
element: <RequireAuth />,
children: [{ path: "admin", element: <AdminPanel /> }],
},
// Guard wrapping the element inline instead of via a parent route.
{ path: "audit", element: <RequireAuth><AuditLog /></RequireAuth> },
// Lazy route module (react-router data-router convention).
{ path: "settings", lazy: () => import("./pages/SettingsPage") },
],
},
]);
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function AdminPanel() {
return <section>Admin control panel</section>;
}
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/AuditLog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function AuditLog() {
return <table>Audit trail entries</table>;
}
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function HomePage() {
return <h1>Welcome to Acme Console</h1>;
}
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function ReportDetail() {
return <article>Quarterly report breakdown</article>;
}
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function ReportsHome() {
return <p>Choose a report to view</p>;
}
6 changes: 6 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function SettingsPage() {
return <form>Workspace settings</form>;
}

// react-router lazy-route convention: the module exports `Component`.
export { SettingsPage as Component };
3 changes: 3 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/UserDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function UserDetail() {
return <article>User profile details</article>;
}
14 changes: 14 additions & 0 deletions eval/fixtures/b4-react-router/app/pages/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";

export function UsersPage() {
const [users, setUsers] = useState<unknown[]>([]);
useEffect(() => {
fetch("/api/users").then((r) => r.json()).then(setUsers);
}, []);
return (
<section>
<h2>All users</h2>
<ul>{users.length}</ul>
</section>
);
}
27 changes: 27 additions & 0 deletions eval/fixtures/b4-react-router/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"failureMode": "B4",
"note": "React Router adapter: createBrowserRouter object trees (nested children, index route, pathless guard route, inline guard wrapper, lazy route module) plus a JSX <Routes>/<Route> tree. Every golden route must map to its page component with the right layout and guards.",
"expect": {
"routes": [
{ "path": "/", "component": "HomePage", "layout": "RootLayout", "guards": [] },
{ "path": "/users", "component": "UsersPage", "layout": "RootLayout", "guards": [] },
{ "path": "/users/:userId", "component": "UserDetail", "layout": "RootLayout", "guards": [] },
{ "path": "/admin", "component": "AdminPanel", "layout": "RootLayout", "guards": ["RequireAuth"] },
{ "path": "/audit", "component": "AuditLog", "layout": "RootLayout", "guards": ["RequireAuth"] },
{ "path": "/settings", "component": "SettingsPage", "layout": "RootLayout", "guards": [] },
{ "path": "/reports", "component": "ReportsHome", "layout": "ReportsLayout", "guards": [] },
{ "path": "/reports/:reportId", "component": "ReportDetail", "layout": "ReportsLayout", "guards": [] }
],
"components": [
{ "name": "UsersPage", "instances": 0 },
{ "name": "ReportsHome", "instances": 1 }
],
"attributions": [
{ "component": "UsersPage", "endpoints": ["/api/users"] }
],
"queries": [
{ "terms": ["All users"], "status": "ok", "top": "UsersPage" },
{ "terms": ["Workspace settings"], "status": "ok", "top": "SettingsPage" }
]
}
}
34 changes: 34 additions & 0 deletions eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,40 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
if (poisoned) attribution.falsePositives += 1;
}

for (const expected of golden.expect.routes ?? []) {
const id = `route:${expected.path}`;
const route = graph.nodes.find((n) => n.kind === "route" && n.path === expected.path);
if (route === undefined || route.kind !== "route") {
finalize("routes", id, false, expected.expectedFail, "route not found in graph");
continue;
}
const pageEdge = graph.edges.find((e) => e.kind === "routes-to" && e.from === route.id);
const page = pageEdge !== undefined ? graph.nodes.find((n) => n.id === pageEdge.to) : undefined;
let detail: string | undefined;
if (page?.name !== expected.component) {
detail = `expected page ${expected.component}, got ${page?.name ?? "none"}`;
} else if (expected.layout !== undefined && route.layout !== expected.layout) {
detail = `expected layout ${expected.layout ?? "null"}, got ${route.layout ?? "null"}`;
} else if (
expected.guards !== undefined &&
JSON.stringify(route.guards) !== JSON.stringify(expected.guards)
) {
detail = `expected guards [${expected.guards.join(", ")}], got [${route.guards.join(", ")}]`;
}
finalize("routes", id, detail === undefined, expected.expectedFail, detail);
}

for (const forbiddenPath of golden.expect.forbiddenRoutes ?? []) {
const present = graph.nodes.some((n) => n.kind === "route" && n.path === forbiddenPath);
// Like forbidden attributions, forbidden routes never xfail.
checks.push({
id: `route!${forbiddenPath}`,
kind: "routes",
status: present ? "fail" : "pass",
detail: present ? "POISON: forbidden route present in graph" : 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 @@ -41,6 +41,18 @@ export interface GoldenQuery {
expectedFail?: string;
}

export interface GoldenRoute {
/** Route pattern in :param form, e.g. "/users/:id". */
path: string;
/** Component name the route's routes-to edge must land on. */
component: string;
/** When set, the RouteNode's layout must equal this exactly (null = bare). */
layout?: string | null;
/** When set, the RouteNode's guards must equal this exactly. */
guards?: string[];
expectedFail?: string;
}

export interface Golden {
failureMode: string;
note?: string;
Expand All @@ -57,6 +69,9 @@ export interface Golden {
attributions?: GoldenAttribution[];
forbidden?: GoldenForbidden[];
queries?: GoldenQuery[];
routes?: GoldenRoute[];
/** Paths that must NOT exist as routes (api handlers, _app…). Never xfails. */
forbiddenRoutes?: string[];
};
}

Expand All @@ -65,7 +80,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";
kind: "components" | "attributions" | "forbidden" | "queries" | "routes";
status: CheckStatus;
detail?: string;
}
Expand Down
Loading
Loading