diff --git a/TRACKER.md b/TRACKER.md
index 187490f..abc59fa 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 3 — Journey graph
-- **Next step:** 3.1 — Router adapters
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5
+- **Next step:** 3.2 — Action 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
@@ -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` / `` 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.
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx
new file mode 100644
index 0000000..b038ec4
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/(marketing)/pricing/page.tsx
@@ -0,0 +1,3 @@
+export default function PricingPage() {
+ return Simple, transparent pricing;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx
new file mode 100644
index 0000000..5142a83
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/layout.tsx
@@ -0,0 +1,8 @@
+export default function DashboardLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx
new file mode 100644
index 0000000..64996d4
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/dashboard/page.tsx
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+export default function DashboardPage() {
+ const [metrics, setMetrics] = useState([]);
+ useEffect(() => {
+ fetch("/api/metrics").then((r) => r.json()).then(setMetrics);
+ }, []);
+ return (
+
+ Key metrics
+ {metrics.length}
+
+ );
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx
new file mode 100644
index 0000000..e56a1fe
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/docs/[...slug]/page.tsx
@@ -0,0 +1,3 @@
+export default function DocsPage() {
+ return Documentation chapter;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx
new file mode 100644
index 0000000..8f5e1f2
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/layout.tsx
@@ -0,0 +1,10 @@
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx
new file mode 100644
index 0000000..eff4f9d
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/page.tsx
@@ -0,0 +1,3 @@
+export default function HomePage() {
+ return Your analytics at a glance
;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx b/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx
new file mode 100644
index 0000000..ba0c4fa
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/app/users/[userId]/page.tsx
@@ -0,0 +1,3 @@
+export default function UserProfilePage() {
+ return Member profile and activity;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs b/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs
new file mode 100644
index 0000000..8cfba81
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/next.config.mjs
@@ -0,0 +1,2 @@
+/** Marks this fixture as a Next.js project for router detection. */
+export default {};
diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx b/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx
new file mode 100644
index 0000000..c8492c1
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/pages/_app.tsx
@@ -0,0 +1,3 @@
+export default function MyApp({ Component, pageProps }: { Component: React.ComponentType; pageProps: Record }) {
+ return ;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts b/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts
new file mode 100644
index 0000000..a0dac74
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/pages/api/health.ts
@@ -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 });
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx b/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx
new file mode 100644
index 0000000..87436e0
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/app/pages/legacy/[id].tsx
@@ -0,0 +1,4 @@
+// Pages-router leftover in an app-router project — both must be scanned.
+export default function LegacyDetailPage() {
+ return Legacy record viewer
;
+}
diff --git a/eval/fixtures/b4-nextjs-approuter/golden.json b/eval/fixtures/b4-nextjs-approuter/golden.json
new file mode 100644
index 0000000..19b6af5
--- /dev/null
+++ b/eval/fixtures/b4-nextjs-approuter/golden.json
@@ -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" }
+ ]
+ }
+}
diff --git a/eval/fixtures/b4-react-router/app/ReportsApp.tsx b/eval/fixtures/b4-react-router/app/ReportsApp.tsx
new file mode 100644
index 0000000..9363b3d
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/ReportsApp.tsx
@@ -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 (
+
+ }>
+ } />
+ } />
+
+
+ );
+}
diff --git a/eval/fixtures/b4-react-router/app/ReportsLayout.tsx b/eval/fixtures/b4-react-router/app/ReportsLayout.tsx
new file mode 100644
index 0000000..c98ef8c
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/ReportsLayout.tsx
@@ -0,0 +1,10 @@
+import { Outlet } from "react-router-dom";
+
+export function ReportsLayout() {
+ return (
+
+
Reporting
+
+
+ );
+}
diff --git a/eval/fixtures/b4-react-router/app/RequireAuth.tsx b/eval/fixtures/b4-react-router/app/RequireAuth.tsx
new file mode 100644
index 0000000..36a713d
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/RequireAuth.tsx
@@ -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 Please sign in to continue
;
+ }
+ return children !== undefined ? <>{children}> : ;
+}
diff --git a/eval/fixtures/b4-react-router/app/RootLayout.tsx b/eval/fixtures/b4-react-router/app/RootLayout.tsx
new file mode 100644
index 0000000..437249c
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/RootLayout.tsx
@@ -0,0 +1,10 @@
+import { Outlet } from "react-router-dom";
+
+export function RootLayout() {
+ return (
+
+
+
+
+ );
+}
diff --git a/eval/fixtures/b4-react-router/app/main.tsx b/eval/fixtures/b4-react-router/app/main.tsx
new file mode 100644
index 0000000..0dd64c9
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/main.tsx
@@ -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: ,
+ children: [
+ { index: true, element: },
+ { path: "users", element: },
+ { path: "users/:userId", element: },
+ {
+ // Pathless guard route: everything below requires auth.
+ element: ,
+ children: [{ path: "admin", element: }],
+ },
+ // Guard wrapping the element inline instead of via a parent route.
+ { path: "audit", element: },
+ // Lazy route module (react-router data-router convention).
+ { path: "settings", lazy: () => import("./pages/SettingsPage") },
+ ],
+ },
+]);
diff --git a/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx b/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx
new file mode 100644
index 0000000..6a50568
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/AdminPanel.tsx
@@ -0,0 +1,3 @@
+export function AdminPanel() {
+ return ;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx b/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx
new file mode 100644
index 0000000..03b698b
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/AuditLog.tsx
@@ -0,0 +1,3 @@
+export function AuditLog() {
+ return ;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/HomePage.tsx b/eval/fixtures/b4-react-router/app/pages/HomePage.tsx
new file mode 100644
index 0000000..9f051d0
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/HomePage.tsx
@@ -0,0 +1,3 @@
+export function HomePage() {
+ return Welcome to Acme Console
;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx b/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx
new file mode 100644
index 0000000..1d32e7e
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/ReportDetail.tsx
@@ -0,0 +1,3 @@
+export function ReportDetail() {
+ return Quarterly report breakdown;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx b/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx
new file mode 100644
index 0000000..720383d
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/ReportsHome.tsx
@@ -0,0 +1,3 @@
+export function ReportsHome() {
+ return Choose a report to view
;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx b/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx
new file mode 100644
index 0000000..08851da
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/SettingsPage.tsx
@@ -0,0 +1,6 @@
+export function SettingsPage() {
+ return ;
+}
+
+// react-router lazy-route convention: the module exports `Component`.
+export { SettingsPage as Component };
diff --git a/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx b/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx
new file mode 100644
index 0000000..24c5282
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/UserDetail.tsx
@@ -0,0 +1,3 @@
+export function UserDetail() {
+ return User profile details;
+}
diff --git a/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx b/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx
new file mode 100644
index 0000000..8780f7a
--- /dev/null
+++ b/eval/fixtures/b4-react-router/app/pages/UsersPage.tsx
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+export function UsersPage() {
+ const [users, setUsers] = useState([]);
+ useEffect(() => {
+ fetch("/api/users").then((r) => r.json()).then(setUsers);
+ }, []);
+ return (
+
+ );
+}
diff --git a/eval/fixtures/b4-react-router/golden.json b/eval/fixtures/b4-react-router/golden.json
new file mode 100644
index 0000000..4702a39
--- /dev/null
+++ b/eval/fixtures/b4-react-router/golden.json
@@ -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 / 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" }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index 3bc0f89..f9af332 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -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);
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index f4cfec6..7f7cfb3 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -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;
@@ -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[];
};
}
@@ -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;
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index d5ac678..afb30e7 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -27,7 +27,8 @@ export type NodeKind =
| "instance"
| "data-source"
| "state"
- | "event";
+ | "event"
+ | "route";
export interface BaseNode {
/** Stable id, unique within a graph. See nodeId()/instanceId(). */
@@ -171,13 +172,45 @@ export interface EventNode extends BaseNode {
handler: string | null;
}
+/** Which routing system declared a route. */
+export type RouterKind = "react-router" | "nextjs-app" | "nextjs-pages";
+
+/**
+ * A URL route and the page component it renders (TRACKER step 3.1, failure
+ * mode B4). Routes are journey-graph entry points: a journey step like
+ * "navigate to /users/:id" resolves through the route to the page component's
+ * instance tree.
+ */
+export interface RouteNode extends BaseNode {
+ kind: "route";
+ /**
+ * Route pattern with dynamic segments in :param form — "/users/:id",
+ * catch-alls as ":param*". Matches the endpoint-pattern convention so
+ * navigate("/users/" + id) effects (step 3.2) join on the same shape.
+ */
+ path: string;
+ router: RouterKind;
+ /**
+ * Name of the layout component wrapping this route's page (a pathless
+ * layout route in React Router; the nearest layout.tsx in Next.js app
+ * router). Null when the page renders bare.
+ */
+ layout: string | null;
+ /**
+ * Guard components between the route and its page — auth/role wrappers
+ * like around the element or as pathless ancestor routes.
+ */
+ guards: string[];
+}
+
export type LineageNode =
| ComponentNode
| InstanceNode
| HookNode
| DataSourceNode
| StateNode
- | EventNode;
+ | EventNode
+ | RouteNode;
export type EdgeKind =
| "renders" // component|instance -> instance (definition-level until Phase 2.1)
@@ -188,7 +221,8 @@ export type EdgeKind =
| "reads-state" // component | hook -> state
| "writes-state" // data-source | event -> state (Phase 2.4)
| "handles" // component -> event
- | "triggers"; // event -> data-source | state (handler causes a fetch / state write)
+ | "triggers" // event -> data-source | state (handler causes a fetch / state write)
+ | "routes-to"; // route -> page component definition (its instances form the page tree)
/** A statically-detected condition guarding an edge (feature flag, role, branch). */
export interface EdgeCondition {
diff --git a/packages/parser-react/src/routes.test.ts b/packages/parser-react/src/routes.test.ts
new file mode 100644
index 0000000..6262ab5
--- /dev/null
+++ b/packages/parser-react/src/routes.test.ts
@@ -0,0 +1,124 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type { LineageGraph, RouteNode } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { scanReact } from "./scan.js";
+
+const fixturesDir = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures",
+);
+
+const reactRouterGraph = scanReact({ root: path.join(fixturesDir, "b4-react-router/app") });
+const nextGraph = scanReact({ root: path.join(fixturesDir, "b4-nextjs-approuter/app") });
+
+function routes(graph: LineageGraph): RouteNode[] {
+ return graph.nodes.filter((n): n is RouteNode => n.kind === "route");
+}
+
+function routeByPath(graph: LineageGraph, routePath: string): RouteNode {
+ const route = routes(graph).find((r) => r.path === routePath);
+ if (route === undefined) throw new Error(`route ${routePath} not found`);
+ return route;
+}
+
+function pageOf(graph: LineageGraph, route: RouteNode): string | null {
+ const edge = graph.edges.find((e) => e.kind === "routes-to" && e.from === route.id);
+ if (edge === undefined) return null;
+ return graph.nodes.find((n) => n.id === edge.to)?.name ?? null;
+}
+
+describe("React Router adapter (b4 fixture)", () => {
+ it("emits exactly the declared routes", () => {
+ expect(routes(reactRouterGraph).map((r) => r.path).sort()).toEqual([
+ "/",
+ "/admin",
+ "/audit",
+ "/reports",
+ "/reports/:reportId",
+ "/settings",
+ "/users",
+ "/users/:userId",
+ ]);
+ });
+
+ it("maps index and nested object routes to their pages with the layout", () => {
+ const home = routeByPath(reactRouterGraph, "/");
+ expect(pageOf(reactRouterGraph, home)).toBe("HomePage");
+ expect(home.layout).toBe("RootLayout");
+ expect(home.router).toBe("react-router");
+
+ const detail = routeByPath(reactRouterGraph, "/users/:userId");
+ expect(pageOf(reactRouterGraph, detail)).toBe("UserDetail");
+ expect(detail.layout).toBe("RootLayout");
+ });
+
+ it("records pathless guard routes on their descendants", () => {
+ const admin = routeByPath(reactRouterGraph, "/admin");
+ expect(pageOf(reactRouterGraph, admin)).toBe("AdminPanel");
+ expect(admin.guards).toEqual(["RequireAuth"]);
+ expect(admin.layout).toBe("RootLayout");
+ });
+
+ it("records inline element wrappers as guards, innermost tag as the page", () => {
+ const audit = routeByPath(reactRouterGraph, "/audit");
+ expect(pageOf(reactRouterGraph, audit)).toBe("AuditLog");
+ expect(audit.guards).toEqual(["RequireAuth"]);
+ });
+
+ it("resolves lazy route modules through the dynamic import's Component export", () => {
+ const settings = routeByPath(reactRouterGraph, "/settings");
+ expect(pageOf(reactRouterGraph, settings)).toBe("SettingsPage");
+ expect(settings.flags).toBeUndefined();
+ });
+
+ it("walks JSX trees with nested layout and index routes", () => {
+ const reportsHome = routeByPath(reactRouterGraph, "/reports");
+ expect(pageOf(reactRouterGraph, reportsHome)).toBe("ReportsHome");
+ expect(reportsHome.layout).toBe("ReportsLayout");
+
+ const reportDetail = routeByPath(reactRouterGraph, "/reports/:reportId");
+ expect(pageOf(reactRouterGraph, reportDetail)).toBe("ReportDetail");
+ expect(reportDetail.layout).toBe("ReportsLayout");
+ });
+});
+
+describe("Next.js adapter (b4 fixture)", () => {
+ it("emits exactly the file-derived routes — no _app, no api handlers", () => {
+ expect(routes(nextGraph).map((r) => r.path).sort()).toEqual([
+ "/",
+ "/dashboard",
+ "/docs/:slug*",
+ "/legacy/:id",
+ "/pricing",
+ "/users/:userId",
+ ]);
+ });
+
+ it("maps app-router segments: [param], [...catchAll], (group) dropped", () => {
+ expect(pageOf(nextGraph, routeByPath(nextGraph, "/users/:userId"))).toBe("UserProfilePage");
+ expect(pageOf(nextGraph, routeByPath(nextGraph, "/docs/:slug*"))).toBe("DocsPage");
+ expect(pageOf(nextGraph, routeByPath(nextGraph, "/pricing"))).toBe("PricingPage");
+ });
+
+ it("assigns the nearest layout.tsx, falling back to the root layout", () => {
+ expect(routeByPath(nextGraph, "/dashboard").layout).toBe("DashboardLayout");
+ expect(routeByPath(nextGraph, "/pricing").layout).toBe("RootLayout");
+ expect(routeByPath(nextGraph, "/").layout).toBe("RootLayout");
+ });
+
+ it("tags router kinds for app vs pages files", () => {
+ expect(routeByPath(nextGraph, "/dashboard").router).toBe("nextjs-app");
+ const legacy = routeByPath(nextGraph, "/legacy/:id");
+ expect(legacy.router).toBe("nextjs-pages");
+ expect(pageOf(nextGraph, legacy)).toBe("LegacyDetailPage");
+ });
+
+ it("does not run Next.js detection on non-Next projects", () => {
+ // The React Router fixture has a pages/ directory but no next.config —
+ // none of its files may produce nextjs routes.
+ expect(routes(reactRouterGraph).every((r) => r.router === "react-router")).toBe(true);
+ });
+});
diff --git a/packages/parser-react/src/routes.ts b/packages/parser-react/src/routes.ts
new file mode 100644
index 0000000..c4e5aec
--- /dev/null
+++ b/packages/parser-react/src/routes.ts
@@ -0,0 +1,583 @@
+/**
+ * Router adapters (TRACKER step 3.1, failure mode B4).
+ *
+ * Routes are the journey graph's entry points: "navigate to /users/:id" only
+ * means something if the graph knows which page component that path renders.
+ * Two families are detected:
+ *
+ * - React Router — createBrowserRouter/createHashRouter/createMemoryRouter
+ * object trees, and JSX trees (inside or
+ * createRoutesFromElements). Nested paths join, index routes take the
+ * parent path, pathless parents contribute layout/guard context, and lazy
+ * routes (`lazy: () => import(...)` or `React.lazy`) resolve through the
+ * dynamic import to the module's Component/default export.
+ * - Next.js file-based — app router (app/**\/page.tsx with [param],
+ * [...catchAll], (group) segments and nearest-ancestor layout.tsx) and
+ * pages router (pages/**\/*.tsx). Only active when the scan root looks
+ * like a Next.js project (next.config.* or a "next" dependency), so a
+ * plain React repo with a folder named pages/ doesn't sprout fake routes.
+ *
+ * Every route becomes a RouteNode with a routes-to edge to the page
+ * component's definition; routes whose page can't be resolved are emitted
+ * flagged "unresolved-page" — visible, never silent.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+
+import {
+ type LineageEdge,
+ type LineageNode,
+ nodeId,
+ type RouteNode,
+ type RouterKind,
+ type SourceLocation,
+} from "@coderadar/core";
+import {
+ type Identifier,
+ type JsxAttribute,
+ type JsxChild,
+ type JsxElement,
+ type JsxSelfClosingElement,
+ Node,
+ type Project,
+ type SourceFile,
+ SyntaxKind,
+} from "ts-morph";
+
+/** Auth/role wrapper components recorded as guards rather than layouts. */
+const GUARD_NAME = /^(Require|Protected?|Private)|Guard$/;
+
+const ROUTER_FACTORIES = new Set([
+ "createBrowserRouter",
+ "createHashRouter",
+ "createMemoryRouter",
+]);
+
+export function detectRoutes(
+ project: Project,
+ root: string,
+ nodes: Map,
+ addEdge: (edge: LineageEdge) => void,
+): void {
+ const adapter = new RouteAdapter(project, root, nodes, addEdge);
+ adapter.reactRouter();
+ if (looksLikeNextProject(root)) {
+ adapter.nextAppRouter();
+ adapter.nextPagesRouter();
+ }
+}
+
+function looksLikeNextProject(root: string): boolean {
+ for (const config of ["next.config.js", "next.config.mjs", "next.config.ts"]) {
+ if (fs.existsSync(path.join(root, config))) return true;
+ }
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8")) as {
+ dependencies?: Record;
+ devDependencies?: Record;
+ };
+ return pkg.dependencies?.["next"] !== undefined || pkg.devDependencies?.["next"] !== undefined;
+ } catch {
+ return false;
+ }
+}
+
+/** Layout/guard context accumulated from ancestor routes. */
+interface RouteContext {
+ basePath: string;
+ layout: string | null;
+ guards: string[];
+}
+
+class RouteAdapter {
+ constructor(
+ private readonly project: Project,
+ private readonly root: string,
+ private readonly nodes: Map,
+ private readonly addEdge: (edge: LineageEdge) => void,
+ ) {}
+
+ // ---------------------------------------------------------------- shared
+
+ private toPosix(p: string): string {
+ return p.split(path.sep).join("/");
+ }
+
+ private fileOf(node: Node): string {
+ return this.toPosix(path.relative(this.root, node.getSourceFile().getFilePath()));
+ }
+
+ private locOf(node: Node, file: string): SourceLocation {
+ return { file, line: node.getStartLineNumber(), endLine: node.getEndLineNumber() };
+ }
+
+ /**
+ * Emit a RouteNode and its routes-to edge. `page` is the resolved component
+ * definition id, or null when resolution failed (flagged, not dropped).
+ */
+ private emit(
+ router: RouterKind,
+ routePath: string,
+ file: string,
+ loc: SourceLocation,
+ page: string | null,
+ layout: string | null,
+ guards: string[],
+ ): void {
+ const id = nodeId("route", file, routePath);
+ if (this.nodes.has(id)) return; // same file + path = duplicate declaration
+ const route: RouteNode = {
+ id,
+ kind: "route",
+ name: routePath,
+ loc,
+ path: routePath,
+ router,
+ layout,
+ guards,
+ ...(page === null ? { flags: ["unresolved-page"] } : {}),
+ };
+ this.nodes.set(id, route);
+ if (page !== null) this.addEdge({ from: id, to: page, kind: "routes-to" });
+ }
+
+ /** "/users" + "settings/:id" → "/users/settings/:id"; absolute children win. */
+ private joinPaths(base: string, segment: string): string {
+ if (segment.startsWith("/")) return normalizePath(segment);
+ return normalizePath(`${base.replace(/\/+$/, "")}/${segment}`);
+ }
+
+ /** Component definition id for a name, resolved from a usage site. */
+ private resolveComponentName(name: string, at: Node): string | null {
+ // Prefer go-to-definition from the actual identifier so imports, renames,
+ // and barrels resolve exactly like instance resolution does.
+ for (const identifier of identifiersNamed(at, name)) {
+ for (const definition of identifier.getDefinitionNodes()) {
+ const lazyTarget = this.resolveLazyVariable(definition);
+ if (lazyTarget !== null) return lazyTarget;
+ const declName = Node.hasName(definition) ? definition.getName() : undefined;
+ if (declName === undefined) continue;
+ const declFile = this.fileOf(definition);
+ const candidate = nodeId("component", declFile, declName);
+ if (this.nodes.has(candidate)) return candidate;
+ }
+ }
+ // Fallback: unique name across the graph.
+ let found: string | null = null;
+ for (const node of this.nodes.values()) {
+ if (node.kind !== "component" || node.name !== name) continue;
+ if (found !== null) return null; // ambiguous — refuse to guess
+ found = node.id;
+ }
+ return found;
+ }
+
+ /** `const Settings = lazy(() => import("./Settings"))` → the imported page. */
+ private resolveLazyVariable(definition: Node): string | null {
+ if (!Node.isVariableDeclaration(definition)) return null;
+ const init = definition.getInitializer();
+ if (init === undefined || !Node.isCallExpression(init)) return null;
+ if (!/^(React\.)?lazy$/.test(init.getExpression().getText())) return null;
+ return this.resolveDynamicImport(init);
+ }
+
+ /**
+ * Resolve the first `import("...")` inside `node` to the target module's
+ * Component (react-router lazy convention) or default export component.
+ */
+ private resolveDynamicImport(node: Node): string | null {
+ for (const call of [node, ...node.getDescendantsOfKind(SyntaxKind.CallExpression)]) {
+ if (!Node.isCallExpression(call)) continue;
+ if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) continue;
+ const arg = call.getArguments()[0];
+ if (arg === undefined || !Node.isStringLiteral(arg)) continue;
+ const target = this.resolveModule(call.getSourceFile(), arg.getLiteralValue());
+ if (target === null) continue;
+ for (const exportName of ["Component", "default"]) {
+ for (const declaration of target.getExportedDeclarations().get(exportName) ?? []) {
+ const declName = Node.hasName(declaration) ? declaration.getName() : undefined;
+ if (declName === undefined) continue;
+ const candidate = nodeId("component", this.fileOf(declaration), declName);
+ if (this.nodes.has(candidate)) return candidate;
+ }
+ }
+ }
+ return null;
+ }
+
+ private resolveModule(from: SourceFile, specifier: string): SourceFile | null {
+ if (!specifier.startsWith(".")) return null;
+ const base = path.resolve(path.dirname(from.getFilePath()), specifier);
+ for (const candidate of [
+ base,
+ `${base}.tsx`,
+ `${base}.ts`,
+ `${base}.jsx`,
+ `${base}.js`,
+ path.join(base, "index.tsx"),
+ path.join(base, "index.ts"),
+ ]) {
+ const file = this.project.getSourceFile(candidate);
+ if (file !== undefined) return file;
+ }
+ return null;
+ }
+
+ // ---------------------------------------------------------- React Router
+
+ reactRouter(): void {
+ for (const sourceFile of this.project.getSourceFiles()) {
+ // Object form: createBrowserRouter([{ path, element, children }, ...])
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ if (!ROUTER_FACTORIES.has(call.getExpression().getText())) continue;
+ const routes = call.getArguments()[0];
+ if (routes !== undefined && Node.isArrayLiteralExpression(routes)) {
+ for (const route of routes.getElements()) {
+ this.objectRoute(route, { basePath: "/", layout: null, guards: [] });
+ }
+ }
+ }
+ // JSX form: / createRoutesFromElements()
+ for (const element of routeJsxElements(sourceFile)) {
+ if (hasRouteAncestor(element)) continue; // children handled by their root
+ this.jsxRoute(element, { basePath: "/", layout: null, guards: [] });
+ }
+ }
+ }
+
+ private objectRoute(route: Node, context: RouteContext): void {
+ if (!Node.isObjectLiteralExpression(route)) return;
+ const file = this.fileOf(route);
+
+ const pathInit = objectProperty(route, "path");
+ const segment =
+ pathInit !== undefined && Node.isStringLiteral(pathInit) ? pathInit.getLiteralValue() : null;
+ const isIndex = objectProperty(route, "index")?.getText() === "true";
+ const childrenInit = objectProperty(route, "children");
+ const children =
+ childrenInit !== undefined && Node.isArrayLiteralExpression(childrenInit)
+ ? childrenInit.getElements()
+ : [];
+
+ const routePath = segment !== null ? this.joinPaths(context.basePath, segment) : context.basePath;
+
+ // The rendered element: element={JSX}, Component={Identifier}, or lazy.
+ const elementInit = objectProperty(route, "element");
+ const componentInit = objectProperty(route, "Component");
+ const lazyInit = objectProperty(route, "lazy");
+ let chain: string[] = [];
+ if (elementInit !== undefined) chain = jsxComponentChain(elementInit);
+ else if (componentInit !== undefined && Node.isIdentifier(componentInit)) {
+ chain = [componentInit.getText()];
+ }
+
+ if (children.length > 0) {
+ // Parent routes wrap their children: guard-named elements guard,
+ // everything else is the nearest layout.
+ const childContext = this.wrapContext(context, chain, routePath, segment !== null);
+ for (const child of children) this.objectRoute(child, childContext);
+ return;
+ }
+
+ if (segment === null && !isIndex) return; // pathless leaf with no page
+ const loc = this.locOf(route, file);
+ if (lazyInit !== undefined) {
+ this.emit("react-router", routePath, file, loc, this.resolveDynamicImport(lazyInit), context.layout, context.guards);
+ return;
+ }
+ if (chain.length === 0) return;
+ this.emitFromChain("react-router", routePath, file, loc, chain, route, context);
+ }
+
+ private jsxRoute(element: JsxElement | JsxSelfClosingElement, context: RouteContext): void {
+ const file = this.fileOf(element);
+ const opening = Node.isJsxElement(element) ? element.getOpeningElement() : element;
+
+ const pathAttr = jsxAttribute(opening.getAttributes(), "path");
+ const segment = pathAttr !== null ? stringAttributeValue(pathAttr) : null;
+ const isIndex = jsxAttribute(opening.getAttributes(), "index") !== null;
+ const routePath = segment !== null ? this.joinPaths(context.basePath, segment) : context.basePath;
+
+ const elementAttr = jsxAttribute(opening.getAttributes(), "element");
+ const componentAttr = jsxAttribute(opening.getAttributes(), "Component");
+ let chain: string[] = [];
+ const elementExpr = elementAttr?.getInitializer();
+ if (elementExpr !== undefined && Node.isJsxExpression(elementExpr)) {
+ const inner = elementExpr.getExpression();
+ if (inner !== undefined) chain = jsxComponentChain(inner);
+ } else if (componentAttr !== null) {
+ const init = componentAttr.getInitializer();
+ if (init !== undefined && Node.isJsxExpression(init)) {
+ const inner = init.getExpression();
+ if (inner !== undefined && Node.isIdentifier(inner)) chain = [inner.getText()];
+ }
+ }
+
+ const childRoutes = Node.isJsxElement(element)
+ ? element.getJsxChildren().filter(isRouteJsx)
+ : [];
+ if (childRoutes.length > 0) {
+ const childContext = this.wrapContext(context, chain, routePath, segment !== null);
+ for (const child of childRoutes) {
+ this.jsxRoute(child as JsxElement | JsxSelfClosingElement, childContext);
+ }
+ return;
+ }
+
+ if (segment === null && !isIndex) return;
+ if (chain.length === 0) return;
+ this.emitFromChain("react-router", routePath, file, this.locOf(element, file), chain, element, context);
+ }
+
+ /** Fold a parent route's element chain into the context its children see. */
+ private wrapContext(
+ context: RouteContext,
+ chain: string[],
+ routePath: string,
+ hasSegment: boolean,
+ ): RouteContext {
+ let layout = context.layout;
+ const guards = [...context.guards];
+ for (const name of chain) {
+ if (GUARD_NAME.test(name)) guards.push(name);
+ else layout = name; // nearest layout wins
+ }
+ return { basePath: hasSegment ? routePath : context.basePath, layout, guards };
+ }
+
+ /** Innermost chain entry is the page; outer wrappers become guards/layout. */
+ private emitFromChain(
+ router: RouterKind,
+ routePath: string,
+ file: string,
+ loc: SourceLocation,
+ chain: string[],
+ at: Node,
+ context: RouteContext,
+ ): void {
+ const pageName = chain[chain.length - 1];
+ if (pageName === undefined) return;
+ let layout = context.layout;
+ const guards = [...context.guards];
+ for (const wrapper of chain.slice(0, -1)) {
+ if (GUARD_NAME.test(wrapper)) guards.push(wrapper);
+ else layout = wrapper;
+ }
+ this.emit(router, routePath, file, loc, this.resolveComponentName(pageName, at), layout, guards);
+ }
+
+ // -------------------------------------------------------------- Next.js
+
+ nextAppRouter(): void {
+ for (const sourceFile of this.project.getSourceFiles()) {
+ const file = this.fileOf(sourceFile);
+ const parsed = appRouterPath(file);
+ if (parsed === null) continue;
+ const page = this.defaultExportComponent(file);
+ this.emit(
+ "nextjs-app",
+ parsed,
+ file,
+ { file, line: 1, endLine: 1 },
+ page,
+ this.nearestAppLayout(file),
+ [],
+ );
+ }
+ }
+
+ nextPagesRouter(): void {
+ for (const sourceFile of this.project.getSourceFiles()) {
+ const file = this.fileOf(sourceFile);
+ const parsed = pagesRouterPath(file);
+ if (parsed === null) continue;
+ this.emit(
+ "nextjs-pages",
+ parsed,
+ file,
+ { file, line: 1, endLine: 1 },
+ this.defaultExportComponent(file),
+ null,
+ [],
+ );
+ }
+ }
+
+ private defaultExportComponent(file: string): string | null {
+ for (const node of this.nodes.values()) {
+ if (node.kind === "component" && node.loc.file === file && node.exportName === "default") {
+ return node.id;
+ }
+ }
+ return null;
+ }
+
+ /** Walk up from the page's directory to app/ looking for layout files. */
+ private nearestAppLayout(pageFile: string): string | null {
+ let dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
+ while (dir.length > 0) {
+ for (const candidate of ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]) {
+ const layoutFile = `${dir}/${candidate}`;
+ const layout = this.defaultExportComponent(layoutFile);
+ if (layout !== null) {
+ const node = this.nodes.get(layout);
+ return node !== undefined ? node.name : null;
+ }
+ }
+ const lastSegmentAt = dir.lastIndexOf("/");
+ if (dir === "app" || dir.endsWith("/app") || lastSegmentAt === -1) break;
+ dir = dir.slice(0, lastSegmentAt);
+ }
+ return null;
+ }
+}
+
+// ------------------------------------------------------------------ helpers
+
+function normalizePath(p: string): string {
+ const collapsed = `/${p}`.replace(/\/+/g, "/");
+ return collapsed.length > 1 ? collapsed.replace(/\/$/, "") : collapsed;
+}
+
+function objectProperty(objectLiteral: Node, name: string): Node | undefined {
+ if (!Node.isObjectLiteralExpression(objectLiteral)) return undefined;
+ const property = objectLiteral.getProperty(name);
+ if (property !== undefined && Node.isPropertyAssignment(property)) {
+ return property.getInitializer();
+ }
+ if (property !== undefined && Node.isShorthandPropertyAssignment(property)) {
+ return property.getNameNode();
+ }
+ if (property !== undefined && Node.isMethodDeclaration(property)) {
+ return property; // lazy() {} method shorthand
+ }
+ return undefined;
+}
+
+function jsxAttribute(attributes: Node[], name: string): JsxAttribute | null {
+ for (const attr of attributes) {
+ if (Node.isJsxAttribute(attr) && attr.getNameNode().getText() === name) return attr;
+ }
+ return null;
+}
+
+function stringAttributeValue(attr: JsxAttribute): string | null {
+ const init = attr.getInitializer();
+ if (init !== undefined && Node.isStringLiteral(init)) return init.getLiteralValue();
+ if (init !== undefined && Node.isJsxExpression(init)) {
+ const inner = init.getExpression();
+ if (inner !== undefined && Node.isStringLiteral(inner)) return inner.getLiteralValue();
+ }
+ return null;
+}
+
+/**
+ * → ["RequireAuth", "Users"]:
+ * outermost-to-innermost capitalized tags, following single-component nesting.
+ */
+function jsxComponentChain(expr: Node): string[] {
+ const chain: string[] = [];
+ let current: Node | undefined = expr;
+ while (current !== undefined) {
+ if (Node.isJsxSelfClosingElement(current)) {
+ pushComponentTag(chain, current.getTagNameNode().getText());
+ break;
+ }
+ if (!Node.isJsxElement(current)) break;
+ pushComponentTag(chain, current.getOpeningElement().getTagNameNode().getText());
+ const jsxChildren: JsxChild[] = current.getJsxChildren();
+ const componentChildren: Node[] = jsxChildren.filter(
+ (child) => Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child),
+ );
+ current = componentChildren.length === 1 ? componentChildren[0] : undefined;
+ }
+ return chain;
+}
+
+function pushComponentTag(chain: string[], tagName: string): void {
+ const head = tagName.split(".")[0];
+ if (head !== undefined && /^[A-Z]/.test(head)) chain.push(tagName);
+}
+
+function isRouteJsx(node: Node): boolean {
+ if (Node.isJsxSelfClosingElement(node)) {
+ return node.getTagNameNode().getText() === "Route";
+ }
+ return (
+ Node.isJsxElement(node) &&
+ node.getOpeningElement().getTagNameNode().getText() === "Route"
+ );
+}
+
+function routeJsxElements(sourceFile: SourceFile): Array {
+ const elements: Array = [];
+ for (const el of sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)) {
+ if (isRouteJsx(el)) elements.push(el);
+ }
+ for (const el of sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) {
+ if (isRouteJsx(el)) elements.push(el);
+ }
+ return elements;
+}
+
+function hasRouteAncestor(element: Node): boolean {
+ let current = element.getParent();
+ while (current !== undefined) {
+ if (isRouteJsx(current)) return true;
+ current = current.getParent();
+ }
+ return false;
+}
+
+/** Identifier usages of `name` within (or nearest to) the given node. */
+function identifiersNamed(at: Node, name: string): Identifier[] {
+ const matches: Identifier[] = [];
+ if (Node.isIdentifier(at) && at.getText() === name) matches.push(at);
+ for (const identifier of at.getDescendantsOfKind(SyntaxKind.Identifier)) {
+ if (identifier.getText() === name) matches.push(identifier);
+ }
+ if (matches.length === 0) {
+ // Next.js paths carry no identifier; scan the file's top level instead.
+ for (const identifier of at.getSourceFile().getDescendantsOfKind(SyntaxKind.Identifier)) {
+ if (identifier.getText() === name) matches.push(identifier);
+ }
+ }
+ return matches;
+}
+
+/** "app/users/[userId]/page.tsx" → "/users/:userId"; null for non-page files. */
+function appRouterPath(file: string): string | null {
+ const match = /^(?:src\/)?app\/(.*)$/.exec(file);
+ if (match === null || match[1] === undefined) return null;
+ const segments = match[1].split("/");
+ const basename = segments.pop();
+ if (basename === undefined || !/^page\.(tsx|jsx|ts|js)$/.test(basename)) return null;
+ const mapped = segments
+ .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")))
+ .filter((segment) => !segment.startsWith("@"))
+ .map(mapDynamicSegment);
+ return normalizePath(mapped.join("/"));
+}
+
+/** "pages/users/[id].tsx" → "/users/:id"; null for _app/_document/api/**. */
+function pagesRouterPath(file: string): string | null {
+ const match = /^(?:src\/)?pages\/(.*)$/.exec(file);
+ if (match === null || match[1] === undefined) return null;
+ const withoutExtension = match[1].replace(/\.(tsx|jsx|ts|js)$/, "");
+ if (withoutExtension === match[1]) return null; // not a scannable extension
+ const segments = withoutExtension.split("/");
+ if (segments[0] === "api") return null;
+ if (segments.some((segment) => segment.startsWith("_"))) return null;
+ if (segments[segments.length - 1] === "index") segments.pop();
+ return normalizePath(segments.map(mapDynamicSegment).join("/"));
+}
+
+/** [id] → :id · [...slug] and [[...slug]] → :slug* */
+function mapDynamicSegment(segment: string): string {
+ const catchAll = /^\[{1,2}\.\.\.(\w+)\]{1,2}$/.exec(segment);
+ if (catchAll !== null) return `:${catchAll[1]}*`;
+ const param = /^\[(\w+)\]$/.exec(segment);
+ if (param !== null) return `:${param[1]}`;
+ return segment;
+}
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 0488a38..acb5e6d 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -27,6 +27,7 @@ import {
import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js";
import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js";
+import { detectRoutes } from "./routes.js";
import { detectWrappers, type WrapperRegistry } from "./wrappers.js";
export interface ScanOptions {
@@ -187,6 +188,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
);
resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers);
resolveHandlerChains(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers, root);
+ detectRoutes(project, root, nodes, addEdge);
return {
version: 2,
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 23dc702..e7af142 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -87,6 +87,9 @@
},
{
"$ref": "#/definitions/EventNode"
+ },
+ {
+ "$ref": "#/definitions/RouteNode"
}
]
},
@@ -498,6 +501,74 @@
"additionalProperties": false,
"description": "A user or system event a component responds to."
},
+ "RouteNode": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Stable id, unique within a graph. See nodeId()/instanceId()."
+ },
+ "kind": {
+ "type": "string",
+ "const": "route"
+ },
+ "name": {
+ "type": "string"
+ },
+ "loc": {
+ "$ref": "#/definitions/SourceLocation"
+ },
+ "flags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Degradation markers, e.g. \"incomplete\", \"depth-limited\", \"unresolved-prop-handler\", \"external-definition\". Absent when clean."
+ },
+ "path": {
+ "type": "string",
+ "description": "Route pattern with dynamic segments in :param form — \"/users/:id\", catch-alls as \":param*\". Matches the endpoint-pattern convention so navigate(\"/users/\" + id) effects (step 3.2) join on the same shape."
+ },
+ "router": {
+ "$ref": "#/definitions/RouterKind"
+ },
+ "layout": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Name of the layout component wrapping this route's page (a pathless layout route in React Router; the nearest layout.tsx in Next.js app router). Null when the page renders bare."
+ },
+ "guards": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Guard components between the route and its page — auth/role wrappers like around the element or as pathless ancestor routes."
+ }
+ },
+ "required": [
+ "guards",
+ "id",
+ "kind",
+ "layout",
+ "loc",
+ "name",
+ "path",
+ "router"
+ ],
+ "additionalProperties": false,
+ "description": "A URL route and the page component it renders (TRACKER step 3.1, failure mode B4). Routes are journey-graph entry points: a journey step like \"navigate to /users/:id\" resolves through the route to the page component's instance tree."
+ },
+ "RouterKind": {
+ "type": "string",
+ "enum": [
+ "react-router",
+ "nextjs-app",
+ "nextjs-pages"
+ ],
+ "description": "Which routing system declared a route."
+ },
"LineageEdge": {
"type": "object",
"properties": {
@@ -536,7 +607,8 @@
"reads-state",
"writes-state",
"handles",
- "triggers"
+ "triggers",
+ "routes-to"
]
},
"EdgeCondition": {