From 8e8fbd7198bef6a7aa6fc4f21ad38d92cac08ebc Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Fri, 17 Jul 2026 16:53:10 +0530 Subject: [PATCH] feat(nextjs): attribute Next.js server-side data to its page (7.2, C9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side data fetching feeds a page even when the fetch isn't in the component body. - RSC async server components already worked (inline await fetch is caught by the body walk) — locked with a test. - Pages-router getServerSideProps/getStaticProps/getStaticPaths are top-level exported functions, not components/hooks, so their fetches were never scanned. The main loop now collects those functions per file and runs data-source extraction on their bodies attributed to the file's default-export page component (fetches-from). - New fixture c9-nextjs-server-data (async RSC + getServerSideProps + getStaticProps); 4 unit tests. eval 341/0/0/0, determinism 1.000, all metrics 1.000; parser-react 168 tests, typecheck + lint clean. Server actions deferred. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 10 +++-- .../c9-nextjs-server-data/app/BlogPage.tsx | 15 +++++++ .../app/DashboardPage.tsx | 11 +++++ .../c9-nextjs-server-data/app/ReportsPage.tsx | 15 +++++++ .../c9-nextjs-server-data/golden.json | 21 ++++++++++ packages/parser-react/src/nextjs.test.ts | 40 +++++++++++++++++++ packages/parser-react/src/scan.ts | 31 ++++++++++++++ 7 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 eval/fixtures/c9-nextjs-server-data/app/BlogPage.tsx create mode 100644 eval/fixtures/c9-nextjs-server-data/app/DashboardPage.tsx create mode 100644 eval/fixtures/c9-nextjs-server-data/app/ReportsPage.tsx create mode 100644 eval/fixtures/c9-nextjs-server-data/golden.json create mode 100644 packages/parser-react/src/nextjs.test.ts diff --git a/TRACKER.md b/TRACKER.md index c72a61c..11667a6 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 7 — Backend parsers & federation (v2 horizon). Phase 6 complete (Gate 6, M6). **0.5.0 published to npm.** -- **Next step:** 7.2 Next.js server data, or hold the backend-parser steps (7.4–7.6) for 0.5.0 tester feedback (still sketch-level — detail each before building). 7.1 GraphQL adapter + 7.3 push channels landed (both frontend, low-regret). 6F.6 detection half still blocked on a real failing test file from the tester. -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.1–6.5 (Phase 6 complete)**, **7.1 (GraphQL adapter), 7.3 (push channels)** · **0.5.0 released & published to npm** (tag v0.5.0; both bins verified via `npm pack`). **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. +- **Next step:** the backend-parser steps (7.4 Python, 7.5 Go, 7.6 federation) — larger, new-package work; best held for 0.5.0 tester feedback to confirm priority before building (still sketch-level). All frontend-side Phase 7 steps (7.1 GraphQL, 7.2 Next.js server data, 7.3 push channels) landed. 6F.6 detection half still blocked on a real failing test file from the tester. +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5, 6F.7–6F.10 + 6F.6 (defensive half), **6.1–6.5 (Phase 6 complete)**, **7.1 (GraphQL adapter), 7.2 (Next.js server data), 7.3 (push channels)** · **0.5.0 released & published to npm** (tag v0.5.0; both bins verified via `npm pack`). **Self-validated on Grafana frontend** (6,461 files, 15,334 nodes / 18,367 edges in 72 s): 55 RTK-query data sources, 32 routes, 1,009 coverage edges, gibberish declines — all previously 0/1 in the field run. - **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) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP) ## What CodeRadar is @@ -712,7 +712,11 @@ Sketch level for 7.2–7.6 — detail each before starting, after v1 feedback. **Build:** recognize GraphQL operations a component runs (Apollo/urql/graphql-request `useQuery`/`useMutation`/`useSubscription`/`useLazyQuery` with a `gql`/`graphql` tagged-template argument, inline or a resolved const) and emit them as data sources: operation NAME as identity, operation TYPE as method. **Accept:** fixture with query/mutation/subscription operations (imported const + inline tag) → one `graphql` data source each with the right method and a `fetches-from` edge; react-query `useQuery`/`useMutation` (non-gql arg) still classify as react-query. **Done:** new `graphql.ts` — `parseGraphqlOperation` reads the operation type/name (+ root selection fields as an anonymous-op fallback) from a gql document, ignoring `#` comments and handling shorthand `{ … }`; `graphqlOperationFromArg` resolves a hook's first argument to an operation (inline tagged template, or an identifier followed via `getDefinitionNodes()` so an imported/co-located gql const resolves cross-file). `detectDataSource` gains a GraphQL branch **before** the react-query branch (Apollo/urql reuse `useQuery`/`useMutation`, so it only claims the call when the argument is an actual gql document, else falls through). Emits `sourceKind: "graphql"`, `method` = query/mutation/subscription, `endpoint` = operation name (the value federation/attribution join on) — reusing the existing data-source node + `fetches-from` wiring, no schema change (`graphql` was already a `DataSourceKind`). New fixture `c4-graphql` (imported-const `useQuery`/`useMutation` + inline `useSubscription`); 9 unit tests incl. the react-query disambiguation on the c5 fixture. eval 326/0/0/0, determinism 1.000, all metrics 1.000; typecheck + lint clean. **Codegen response types (feeding 5.5) deferred** — needs a `.graphql`/codegen fixture; the operation→data-source spine is what unblocks federation. -### [ ] 7.2 Next.js server data (C9): RSC async components, `getServerSideProps`, server actions as first-class data sources. +### [x] 7.2 Next.js server data +**Failure modes:** C9 +**Build:** attribute server-side data fetching to the page it feeds — async RSC server components (fetch in their own body) and pages-router `getServerSideProps`/`getStaticProps`/`getStaticPaths` (fetch in a separate exported function). +**Accept:** fixture with an async RSC page, a `getServerSideProps` page, and a `getStaticProps` page → each page attributes its server-side endpoint; the data functions produce no component/hook nodes. +**Done:** RSC async server components already worked — the async component is recognized and its inline `await fetch(...)` is caught by the body walk (locked with a test). The gap was pages-router data functions: `getServerSideProps`/`getStaticProps`/`getStaticPaths` are top-level exported functions, not components/hooks, so their fetches were never scanned. The main loop now collects those functions per file and, once the file's default-export page component is known, runs the data-source extraction on their bodies attributed to that page (`fetches-from`). New fixture `c9-nextjs-server-data` (async RSC + getServerSideProps + getStaticProps); 4 unit tests. eval 341/0/0/0, determinism 1.000, all metrics 1.000; parser-react 168 tests, typecheck + lint clean. **Server actions deferred** (`"use server"` mutations bound to `
` — a mutation/event surface, fuzzier than the data-in path; revisit with feedback). ### [x] 7.3 Push channels **Failure modes:** C8 **Build:** recognize server-push channels (`new WebSocket(url)`, `new EventSource(url)`) as data sources — long-lived connections, not request/response fetches — with a `fetches-from` edge from the component that opens them. diff --git a/eval/fixtures/c9-nextjs-server-data/app/BlogPage.tsx b/eval/fixtures/c9-nextjs-server-data/app/BlogPage.tsx new file mode 100644 index 0000000..d52c209 --- /dev/null +++ b/eval/fixtures/c9-nextjs-server-data/app/BlogPage.tsx @@ -0,0 +1,15 @@ +// Pages-router: getStaticProps fetches at build time; feeds the page. +export async function getStaticProps() { + const res = await fetch("/api/posts"); + const posts = await res.json(); + return { props: { posts } }; +} + +export default function BlogPage({ posts }: { posts: { id: string }[] }) { + return ( +
+

Blog index

+ {posts.length} posts +
+ ); +} diff --git a/eval/fixtures/c9-nextjs-server-data/app/DashboardPage.tsx b/eval/fixtures/c9-nextjs-server-data/app/DashboardPage.tsx new file mode 100644 index 0000000..9510ab5 --- /dev/null +++ b/eval/fixtures/c9-nextjs-server-data/app/DashboardPage.tsx @@ -0,0 +1,11 @@ +// App-router RSC: an async server component that fetches in its own body. +export default async function DashboardPage() { + const res = await fetch("/api/dashboard/summary"); + const data = await res.json(); + return ( +
+

Dashboard totals

+

{data.total}

+
+ ); +} diff --git a/eval/fixtures/c9-nextjs-server-data/app/ReportsPage.tsx b/eval/fixtures/c9-nextjs-server-data/app/ReportsPage.tsx new file mode 100644 index 0000000..d6115f6 --- /dev/null +++ b/eval/fixtures/c9-nextjs-server-data/app/ReportsPage.tsx @@ -0,0 +1,15 @@ +// Pages-router: getServerSideProps fetches server-side; the page renders props. +export async function getServerSideProps() { + const res = await fetch("/api/reports/latest"); + const report = await res.json(); + return { props: { report } }; +} + +export default function ReportsPage({ report }: { report: { title: string } }) { + return ( +
+

Report viewer

+ {report.title} +
+ ); +} diff --git a/eval/fixtures/c9-nextjs-server-data/golden.json b/eval/fixtures/c9-nextjs-server-data/golden.json new file mode 100644 index 0000000..3805740 --- /dev/null +++ b/eval/fixtures/c9-nextjs-server-data/golden.json @@ -0,0 +1,21 @@ +{ + "failureMode": "C9", + "note": "Next.js server data (7.2): server-side data fetching feeds a page even though the fetch is not in the component body. An async RSC server component fetches inline (already covered), while pages-router getServerSideProps/getStaticProps run separately from the page — their fetches are attributed to the file's default-export page component so its lineage shows the data it renders.", + "expect": { + "components": [ + { "name": "DashboardPage", "instances": 0 }, + { "name": "ReportsPage", "instances": 0 }, + { "name": "BlogPage", "instances": 0 } + ], + "attributions": [ + { "component": "DashboardPage", "endpoints": ["/api/dashboard/summary"] }, + { "component": "ReportsPage", "endpoints": ["/api/reports/latest"] }, + { "component": "BlogPage", "endpoints": ["/api/posts"] } + ], + "queries": [ + { "terms": ["Dashboard totals"], "status": "ok", "top": "DashboardPage" }, + { "terms": ["Report viewer"], "status": "ok", "top": "ReportsPage" }, + { "terms": ["Blog index"], "status": "ok", "top": "BlogPage" } + ] + } +} diff --git a/packages/parser-react/src/nextjs.test.ts b/packages/parser-react/src/nextjs.test.ts new file mode 100644 index 0000000..9dc9722 --- /dev/null +++ b/packages/parser-react/src/nextjs.test.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { resolveHookEdges, scanReact } from "./scan.js"; + +const fixtures = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../eval/fixtures"); +const graph = resolveHookEdges(scanReact({ root: path.join(fixtures, "c9-nextjs-server-data/app") })); + +/** Endpoints a component fetches, via fetches-from edges. */ +function endpointsOf(component: string): string[] { + const id = `component:${component}.tsx#${component}`; + return graph.edges + .filter((e) => e.kind === "fetches-from" && e.from === id) + .map((e) => graph.nodes.find((n) => n.id === e.to)) + .flatMap((n) => (n?.kind === "data-source" ? [n.endpoint] : [])); +} + +describe("Next.js server data (7.2, C9)", () => { + it("attributes an async RSC server component's inline fetch to the page", () => { + expect(endpointsOf("DashboardPage")).toStrictEqual(["/api/dashboard/summary"]); + }); + + it("attributes getServerSideProps fetches to the default-export page", () => { + expect(endpointsOf("ReportsPage")).toStrictEqual(["/api/reports/latest"]); + }); + + it("attributes getStaticProps fetches to the default-export page", () => { + expect(endpointsOf("BlogPage")).toStrictEqual(["/api/posts"]); + }); + + it("does not create a component/hook node for the server-data functions", () => { + const names = graph.nodes + .filter((n) => n.kind === "component" || n.kind === "hook") + .map((n) => n.name); + expect(names).not.toContain("getServerSideProps"); + expect(names).not.toContain("getStaticProps"); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index c3122ba..5d2e901 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -151,6 +151,18 @@ const DEFAULT_FLAG_CALLEES = [ /** Heuristic for role/permission guards classified as `role` conditions (3.5). */ const ROLE_PATTERN = /\brole\b|\bisAdmin\b|\bisSuperuser\b|hasRole|hasPermission|\bcan\(|\bpermission|useRole|usePermission/i; +/** + * Next.js pages-router server-data functions (7.2, C9). These run on the server + * and fetch the data the page renders; their fetches feed the file's default- + * export page component, so they are attributed to it (they are not components + * or hooks, so the normal body walk skips them). + */ +const NEXT_DATA_FNS: ReadonlySet = new Set([ + "getServerSideProps", + "getStaticProps", + "getStaticPaths", +]); + /** The include globs a scan discovers source files with. */ function scanInclude(options: ScanOptions): string[] { return options.include ?? ["**/*.tsx", "**/*.jsx", "**/*.ts"]; @@ -241,13 +253,22 @@ export function scanProject(project: Project, root: string, options: ScanOptions // Test files are swept separately (5.4) — they exercise components, they // don't define the app's UI, so they must not produce component/hook nodes. if (isTestFile(file)) continue; + // The file's default-export page and any Next.js server-data functions, + // resolved together so the latter's fetches attribute to the former (7.2). + let pageComponentId: string | undefined; + const nextDataFns: { name: string; fn: Node }[] = []; for (const decl of collectDeclarations(sourceFile, file)) { + if (NEXT_DATA_FNS.has(decl.name)) { + nextDataFns.push({ name: decl.name, fn: decl.fn }); + continue; + } const isComponent = COMPONENT_NAME.test(decl.name) && returnsJsx(decl.fn); const isHook = HOOK_NAME.test(decl.name); if (!isComponent && !isHook) continue; const kind = isComponent ? "component" : "hook"; const id = nodeId(kind, file, decl.name); + if (isComponent && decl.exportName === "default") pageComponentId = id; if (isComponent) { // Portal components (A9): rendered into document.body etc., far from @@ -279,6 +300,16 @@ export function scanProject(project: Project, root: string, options: ScanOptions extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees); } + // Next.js server data (7.2, C9): a page's getServerSideProps/getStaticProps + // fetches on the server; those data sources feed the default-export page, + // so attribute them to it. (RSC async server components fetch in their own + // body and are already covered by the walk above.) + if (pageComponentId !== undefined) { + for (const { name, fn } of nextDataFns) { + extractBodyFacts(name, fn, pageComponentId, file, nodes, addEdge, baseUrls, wrappers, stores, handlerExprs, flagCallees); + } + } + scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores, handlerExprs, flagCallees); collectHocAliases(sourceFile, hocAliases); }