Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<form action>` — 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.
Expand Down
15 changes: 15 additions & 0 deletions eval/fixtures/c9-nextjs-server-data/app/BlogPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Blog index</h1>
<span>{posts.length} posts</span>
</main>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/c9-nextjs-server-data/app/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Dashboard totals</h1>
<p>{data.total}</p>
</main>
);
}
15 changes: 15 additions & 0 deletions eval/fixtures/c9-nextjs-server-data/app/ReportsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Report viewer</h1>
<span>{report.title}</span>
</main>
);
}
21 changes: 21 additions & 0 deletions eval/fixtures/c9-nextjs-server-data/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
40 changes: 40 additions & 0 deletions packages/parser-react/src/nextjs.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
31 changes: 31 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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"];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
Loading