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 ( +
+ + {children} +
+ ); +} 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 ( + + +
Nimbus Analytics
+ {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
Admin control panel
; +} 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 Audit trail entries
; +} 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
Workspace settings
; +} + +// 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 ( +
+

All users

+
    {users.length}
+
+ ); +} 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": {