From 9c6bc23b2169c9bfbcae729e3169d81524c941be Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 28 Aug 2026 14:40:14 -0500 Subject: [PATCH] =?UTF-8?q?feat(screens):=20Next.js=20as=20a=20Screens=20a?= =?UTF-8?q?pp=20=E2=80=94=20pages,=20route=20handlers,=20links=20and=20red?= =?UTF-8?q?irects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frameworks/nextjs.ts (split out of react.ts): App Router app/**/page.tsx and Pages Router pages → routes named by path ((group) stripped, [slug] → :slug, [...all] → :all*), bound to the default export; app/**/route.ts exports → METHOD /api/… endpoints referencing their functions; pages/api → ANY; resolve() claims router.push/replace/prefetch, redirect/permanentRedirect and NextResponse.redirect(new URL(…)) into navigates edges via the Expo href readers, against a Next-only route table gated on the app's root - next-router-synthesizer.ts: and internal → dashed navigates edges from the component (next-link, registeredAt) - expo-router.ts: href readers exported; matcher accepts :param / :all* segments - steps.ts: a Next page's own work fires from page load; a Next page makes the project a web app; {status: 201} read off the call site (branch-guards CallSiteText.status) for response rows - frameworks/package-deps.ts: nested package.json files probed on disk (getAllFiles lists only sources); Express/React/Expo/Nest detectors use it; routing manifest names constant handlers - tests: nextjs.test.ts (file→route rules, extract, verbs, end to end with Screens and Steps); frameworks.test.ts Next cases moved to the Next resolver - docs: CHANGELOG, spec §3.12 frameworks paragraph, CLAUDE.md, synthesis doc, plan P4 built, playbook rows for Next / MERN / Nest channels Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- __tests__/frameworks.test.ts | 10 +- __tests__/nextjs.test.ts | 314 +++++++++++++++++ docs/design/callback-edge-synthesis.md | 9 + docs/design/codegraph-ui-design-spec.md | 12 + .../dynamic-dispatch-coverage-playbook.md | 3 + ...8-28-steps-and-screens-for-apis-and-web.md | 13 +- src/graph/branch-guards.ts | 24 +- src/resolution/callback-synthesizer.ts | 3 + src/resolution/frameworks/expo-router.ts | 18 +- src/resolution/frameworks/index.ts | 3 + src/resolution/frameworks/nextjs.ts | 320 ++++++++++++++++++ src/resolution/frameworks/package-deps.ts | 13 +- src/resolution/frameworks/react.ts | 78 +---- src/resolution/next-router-synthesizer.ts | 102 ++++++ src/ui-server/api/steps.ts | 18 +- 17 files changed, 851 insertions(+), 93 deletions(-) create mode 100644 __tests__/nextjs.test.ts create mode 100644 src/resolution/frameworks/nextjs.ts create mode 100644 src/resolution/next-router-synthesizer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 641f2e9..c0f55c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading. + - **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up. - **The Steps tab now draws an API as well as an app.** Anchor on an endpoint — `POST /users` in Express, NestJS, Fastify, FastAPI, Flask, Django, Spring (Java or Kotlin), ASP.NET, Vapor or Gin — and the viewer starts at the handler the route runs (or at the route itself when the handler is an inline arrow), says what fires it (`FIRES FROM POST /users · after authenticate, validate(…)` — the middleware arguments at the registration, or the guard decorators on the method and its class, or a FastAPI `dependencies=[…]`), and draws what the request sets in motion: the database calls with the model and whether they read or write (`prisma.user.create({ data })` · `database · user · write`), jobs put on a queue, emails, payments, cache reads, token checks, calls to other services, files and processes — and the **responses**, one box per handler whose label is the status codes it can send (`201 · 404`) and whose panel rows are the endpoint's contract as the code has it: `WHEN NOT user → 404 · NotFoundException('no such user')`, `always → 201 · res.status(201).json(user)`. A queue consumer or a scheduled job anchored by name says the decorator that fires it (`@Process('email')`). The legend, the panel and the chooser use the project's own words — endpoint, data call, another tier — and the bare Steps tab lists an API's endpoints by router file when there are no screens. Re-index is not needed: everything new is read from the source at request time. diff --git a/CLAUDE.md b/CLAUDE.md index 1573d06..526a0ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the - `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend). - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. -- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). - `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws), `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree. - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 6064706..a1b0c33 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1516,6 +1516,7 @@ app.get( }); import { reactResolver } from '../src/resolution/frameworks/react'; +import { nextjsResolver } from '../src/resolution/frameworks/nextjs'; import { svelteResolver } from '../src/resolution/frameworks/svelte'; import { astroResolver } from '../src/resolution/frameworks/astro'; @@ -1555,13 +1556,14 @@ describe('reactResolver.extract — React Router', () => { }); it('does not treat config files or a nextjs-pages dir as Next.js routes', () => { - const cfg = reactResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}'); + const cfg = nextjsResolver.extract!('apps/nextjs-pages/next.config.mjs', 'export default {}'); expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); - const vite = reactResolver.extract!('src/pages/vite.config.ts', 'export default {}'); + const vite = nextjsResolver.extract!('src/pages/vite.config.ts', 'export default {}'); expect(vite.nodes.filter((n) => n.kind === 'route')).toHaveLength(0); - // a real page still works - const page = reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}'); + // a real page still works — and the React resolver leaves it to the Next one + const page = nextjsResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}'); expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']); + expect(reactResolver.extract!('src/pages/about.tsx', 'export default function About(){return null}').nodes).toHaveLength(0); }); }); diff --git a/__tests__/nextjs.test.ts b/__tests__/nextjs.test.ts new file mode 100644 index 0000000..86ec3e4 --- /dev/null +++ b/__tests__/nextjs.test.ts @@ -0,0 +1,314 @@ +/** + * Next.js as a Screens app (`src/resolution/frameworks/nextjs.ts`, + * `src/resolution/next-router-synthesizer.ts`): pages from files, route + * handlers as endpoints, navigation from ``, `router.push`, `redirect` + * and `NextResponse.redirect`, and the Screens / Steps pictures they make. + * Mirrors `expo-router.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { buildScreens } from '../src/ui-server/api/screens'; +import { buildSteps } from '../src/ui-server/api/steps'; +import { nextjsResolver, nextRouteForFile, nextNavVerb } from '../src/resolution/frameworks/nextjs'; +import type { Node } from '../src/types'; + +// ============================================================================= +// Route paths from file names +// ============================================================================= + +describe('nextjs: nextRouteForFile', () => { + it.each([ + ['app/page.tsx', 'page', '/', ''], + ['src/app/users/page.tsx', 'page', '/users', ''], + ['apps/web/app/(marketing)/about/page.tsx', 'page', '/about', 'apps/web/'], + ['app/blog/[slug]/page.tsx', 'page', '/blog/:slug', ''], + ['app/docs/[...all]/page.tsx', 'page', '/docs/:all*', ''], + ['app/docs/[[...all]]/page.jsx', 'page', '/docs/:all*', ''], + ['app/api/users/route.ts', 'handler', '/api/users', ''], + ['app/api/users/[id]/route.ts', 'handler', '/api/users/:id', ''], + ['pages/index.tsx', 'page', '/', ''], + ['pages/about.tsx', 'page', '/about', ''], + ['src/pages/blog/[slug].tsx', 'page', '/blog/:slug', ''], + ['pages/api/users.ts', 'api', '/api/users', ''], + ['apps/web/pages/api/users/[id].ts', 'api', '/api/users/:id', 'apps/web/'], + ])('%s → %s %s (root %s)', (file, kind, route, root) => { + expect(nextRouteForFile(file)).toEqual({ kind, path: route, root }); + }); + + it.each([ + 'app/layout.tsx', + 'app/loading.tsx', + 'app/users/error.tsx', + 'app/@modal/photo/page.tsx', + 'app/(.)photo/[id]/page.tsx', + 'pages/_app.tsx', + 'pages/_document.tsx', + 'src/pages/vite.config.ts', + 'apps/nextjs-pages/next.config.mjs', + 'app/users/__tests__/page.tsx', + 'src/components/button.tsx', + ])('%s is not a route', (file) => { + expect(nextRouteForFile(file)).toBeNull(); + }); +}); + +describe('nextjs: extract', () => { + it('a page is a route named by its path, calling its default export', () => { + const { nodes, references } = nextjsResolver.extract!('app/users/page.tsx', "export default function UsersPage() {\n return null\n}\n"); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ kind: 'route', name: '/users', language: 'tsx' }); + expect(references).toEqual([expect.objectContaining({ fromNodeId: nodes[0]!.id, referenceName: 'UsersPage', referenceKind: 'calls', line: 1 })]); + }); + + it('a route handler file is one endpoint per exported method, each naming its function', () => { + const src = "import { NextResponse } from 'next/server'\nexport async function GET() {\n return NextResponse.json([])\n}\nexport const POST = async (req) => {\n return NextResponse.json({}, { status: 201 })\n}\n"; + const { nodes, references } = nextjsResolver.extract!('app/api/users/route.ts', src); + expect(nodes.map((n) => n.name)).toEqual(['GET /api/users', 'POST /api/users']); + expect(nodes.map((n) => n.startLine)).toEqual([2, 5]); + expect(references.map((r) => [r.referenceName, r.referenceKind])).toEqual([ + ['GET', 'references'], + ['POST', 'references'], + ]); + }); + + it('a Pages Router API file is ANY on its path, bound to the default export', () => { + const { nodes, references } = nextjsResolver.extract!('pages/api/users.ts', 'export default async function handler(req, res) {\n res.status(200).json([])\n}\n'); + expect(nodes.map((n) => n.name)).toEqual(['ANY /api/users']); + expect(references[0]).toMatchObject({ referenceName: 'handler', referenceKind: 'references' }); + }); + + it('emits nothing for a layout or a component file', () => { + expect(nextjsResolver.extract!('app/layout.tsx', 'export default function L() {}').nodes).toHaveLength(0); + expect(nextjsResolver.extract!('components/nav.tsx', 'export default function Nav() {}').nodes).toHaveLength(0); + }); + + it('claims the navigation calls and names their verb', () => { + expect(nextNavVerb('router.push')).toBe('push'); + expect(nextNavVerb('router.replace')).toBe('replace'); + expect(nextNavVerb('redirect')).toBe('redirect'); + expect(nextNavVerb('permanentRedirect')).toBe('permanentRedirect'); + expect(nextNavVerb('NextResponse.redirect')).toBe('response.redirect'); + expect(nextNavVerb('router.back')).toBeNull(); + expect(nextNavVerb('fetch')).toBeNull(); + expect(nextjsResolver.claimsReference!('redirect')).toBe(true); + expect(nextjsResolver.claimsReference!('Redirect')).toBe(false); + }); +}); + +// ============================================================================= +// End to end: a small App Router site +// ============================================================================= + +describe('nextjs: end to end', () => { + let tmpDir: string; + let cg: CodeGraph; + + function write(rel: string, content: string): void { + const full = path.join(tmpDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nextjs-')); + write('package.json', JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19', '@prisma/client': '5' } })); + write('lib/db.ts', "import { PrismaClient } from '@prisma/client'\nexport const prisma = new PrismaClient()\n"); + write('app/layout.tsx', 'export default function RootLayout({ children }) {\n return children\n}\n'); + write( + 'app/page.tsx', + "import Link from 'next/link'\n" + + 'export default function Home() {\n' + + ' return (\n' + + '
\n' + + ' Users\n' + + ' Log in\n' + + ' Elsewhere\n' + + '
\n' + + ' )\n' + + '}\n' + ); + write('app/login/page.tsx', 'export default function LoginPage() {\n return null\n}\n'); + write( + 'app/users/page.tsx', + "import { NewUserForm } from '../../components/new-user-form'\n" + + "import { prisma } from '../../lib/db'\n" + + 'export default async function UsersPage() {\n' + + ' const users = await prisma.user.findMany()\n' + + ' return \n' + + '}\n' + ); + write('app/users/[id]/page.tsx', 'export default function UserPage({ params }) {\n return Back\n}\n'); + write( + 'components/new-user-form.tsx', + "'use client'\n" + + "import { useCallback, useState } from 'react'\n" + + "import { useRouter } from 'next/navigation'\n" + + "import { createUserAction } from '../app/actions'\n" + + 'export function NewUserForm({ count }) {\n' + + " const [email, setEmail] = useState('')\n" + + ' const router = useRouter()\n' + + ' const handleSubmit = useCallback(async (e) => {\n' + + ' e.preventDefault()\n' + + ' const user = await createUserAction({ email })\n' + + ' if (user.ok) router.push(`/users/${user.id}`)\n' + + ' }, [email])\n' + + ' return
setEmail(e.target.value)} />
\n' + + '}\n' + ); + write( + 'app/actions.ts', + "'use server'\n" + + "import { redirect } from 'next/navigation'\n" + + "import { prisma } from '../lib/db'\n" + + 'export async function createUserAction(data) {\n' + + ' const user = await prisma.user.create({ data })\n' + + " if (!user.verified) redirect('/users')\n" + + ' return { ok: true, id: user.id }\n' + + '}\n' + ); + write( + 'app/api/users/route.ts', + "import { NextResponse } from 'next/server'\n" + + "import { prisma } from '../../../lib/db'\n" + + 'export async function GET() {\n' + + ' return NextResponse.json(await prisma.user.findMany())\n' + + '}\n' + + 'export async function POST(req) {\n' + + ' const data = await req.json()\n' + + ' const user = await prisma.user.create({ data })\n' + + ' return NextResponse.json(user, { status: 201 })\n' + + '}\n' + ); + write( + 'middleware.ts', + "import { NextResponse } from 'next/server'\n" + + 'export function middleware(req) {\n' + + " if (!req.cookies.get('session')) {\n" + + " return NextResponse.redirect(new URL('/login', req.url))\n" + + ' }\n' + + ' return NextResponse.next()\n' + + '}\n' + ); + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.close(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const route = (name: string): Node => { + const r = cg.getNodesByKind('route').find((r) => r.name === name); + if (!r) throw new Error(`no route ${name}: ${cg.getNodesByKind('route').map((r) => r.name).join(', ')}`); + return r; + }; + const sym = (name: string): Node => { + const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import'); + if (!n) throw new Error(`no symbol ${name}`); + return n; + }; + const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates'); + + it('names every page and endpoint, and binds a page to its component and an endpoint to its function', () => { + expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual(['/', '/login', '/users', '/users/:id', 'GET /api/users', 'POST /api/users']); + const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls'); + expect(cg.getNode(home!.target)?.name).toBe('Home'); + const post = cg.getOutgoingEdges(route('POST /api/users').id).find((e) => e.kind === 'references'); + expect(cg.getNode(post!.target)).toMatchObject({ name: 'POST', kind: 'function', filePath: 'app/api/users/route.ts' }); + }); + + it('a and an internal navigate from the component that renders them; an external one does not', () => { + const fromHome = navs(sym('Home')); + const byHref = new Map(fromHome.map((e) => [(e.metadata as Record).href, e])); + expect([...byHref.keys()].sort()).toEqual(['/login', '/users']); + const users = byHref.get('/users')!; + expect(users.target).toBe(route('/users').id); + expect(users.provenance).toBe('heuristic'); + expect(users.metadata).toEqual({ synthesizedBy: 'next-link', href: '/users', navMethod: 'link', registeredAt: 'app/page.tsx:5' }); + expect((byHref.get('/login')!.metadata as Record).navMethod).toBe('a'); + expect(navs(sym('UserPage')).map((e) => cg.getNode(e.target)?.name)).toEqual(['/users']); + }); + + it('router.push with a template hole reaches the [id] page; redirect() and NextResponse.redirect(new URL(…)) reach theirs', () => { + const push = navs(sym('handleSubmit')); + expect(push).toHaveLength(1); + expect(push[0]!.target).toBe(route('/users/:id').id); + expect(push[0]!.metadata).toMatchObject({ href: '/users/${…}', navMethod: 'push', refKind: 'calls' }); + const redirect = navs(sym('createUserAction')); + expect(redirect).toHaveLength(1); + expect(redirect[0]!.target).toBe(route('/users').id); + expect(redirect[0]!.metadata).toMatchObject({ href: '/users', navMethod: 'redirect' }); + const guard = navs(sym('middleware')); + expect(guard).toHaveLength(1); + expect(guard[0]!.target).toBe(route('/login').id); + expect(guard[0]!.metadata).toMatchObject({ href: '/login', navMethod: 'response.redirect' }); + }); + + it('lands on the Screens tab: the entry page, its links, and the form’s push attributed back to its page with the condition', async () => { + const screens = await buildScreens(cg, tmpDir); + expect(screens.routed).toBe(true); + const home = screens.screens.find((s) => s.path === '/')!; + expect(screens.entry).toBe(home.id); + expect(home.component?.name).toBe('Home'); + const users = screens.screens.find((s) => s.path === '/users')!; + const user = screens.screens.find((s) => s.path === '/users/:id')!; + const link = screens.links.find((l) => l.from === home.id && l.to === users.id)!; + expect(link.via).toEqual([]); + expect(link.synthesized).toBe(true); + expect(link.sites[0]).toMatchObject({ href: '/users', method: 'return' }); + const push = screens.links.find((l) => l.from === users.id && l.to === user.id)!; + expect(push).toBeDefined(); + expect(push.via.map((v) => v.name)).toEqual(['NewUserForm', 'handleSubmit']); + expect(push.when).toBe('user.ok'); + expect(push.sites[0]).toMatchObject({ href: '/users/${…}', method: 'push' }); + // The middleware's redirect starts from no page: an origin. + expect(screens.origins.map((o) => o.node.name)).toContain('middleware'); + expect(screens.dropped).toBe(0); + }); + + it('a page’s Steps picture fires from its load, crosses to the server action, and draws the pages it leads to as boundaries', async () => { + const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('/users').id })); + expect(p.project).toBe('web'); + const anchor = p.steps.find((s) => s.anchor)!; + expect(anchor.kind).toBe('screen'); + expect(anchor.sub).toBe('UsersPage'); + expect(anchor.trigger).toEqual({ kind: 'load', name: 'GET', of: '/users', in: 'page.tsx' }); + const loadRead = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'UsersPage')!; + expect(loadRead.label).toBe('prisma.user.findMany()'); + const handler = p.steps.find((s) => s.kind === 'trigger' && s.node?.name === 'handleSubmit')!; + expect(handler.trigger).toMatchObject({ kind: 'prop', name: 'onSubmit', of: 'form' }); + const action = p.steps.find((s) => s.node?.name === 'createUserAction')!; + expect(action.kind).toBe('bridge'); + const toAction = p.links.find((l) => l.to === action.id)!; + expect(toAction.label).toContain('server action'); + const write = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database' && s.effect.by.name === 'createUserAction')!; + expect(write.label).toBe('prisma.user.create({ data })'); + const detail = p.steps.find((s) => s.kind === 'screen' && s.screen?.path === '/users/:id')!; + expect(detail.cut).toBe('screen'); + const toDetail = p.links.find((l) => l.to === detail.id)!; + expect(toDetail.kind).toBe('navigates'); + expect(toDetail.when).toBe('user.ok'); + expect(toDetail.sites[0]!.text).toBe('push /users/${…}'); + const back = p.links.find((l) => l.from === action.id && l.to === anchor.id)!; + expect(back.sites[0]).toMatchObject({ text: 'redirect /users', when: '!user.verified' }); + }); + + it('an endpoint anchors as any server route does', async () => { + const p = await buildSteps(cg, tmpDir, new URLSearchParams({ anchor: route('POST /api/users').id })); + const anchor = p.steps.find((s) => s.anchor)!; + expect(anchor.sub).toBe('POST'); + expect(anchor.trigger).toEqual({ kind: 'request', name: 'POST', of: '/api/users', in: 'route.ts' }); + const db = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'database')!; + expect(db.effect).toMatchObject({ model: 'user', access: 'write', by: { name: 'POST' } }); + const res = p.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'response')!; + expect(res.label).toBe('201'); + }); +}); diff --git a/docs/design/callback-edge-synthesis.md b/docs/design/callback-edge-synthesis.md index a03cfc7..a915dc6 100644 --- a/docs/design/callback-edge-synthesis.md +++ b/docs/design/callback-edge-synthesis.md @@ -85,6 +85,15 @@ the call at request time from the directive. Validated on `bradtraversy/proshop_ all correct on inspection, after Express mounts + chained `router.route()` landed) and `nestjs/nest` (`sample/26-queues`, `sample/30-event-emitter`); test `__tests__/ui-steps-cross-tier.test.ts`. +## Next.js links (`src/resolution/next-router-synthesizer.ts`, 2026-08-28) + +``, ``, `` and an internal `` are JSX +attributes — no reference is ever extracted for them — so this pass reads them from the source, attributes each to the +component it is written in, matches the href against the Next page table (`frameworks/nextjs.ts`) and synthesizes a +`navigates` edge (`synthesizedBy:'next-link'`, `href`, `navMethod: 'link' | 'a'`, `registeredAt` = the JSX site). Only files +under a Next app's root, never test files; ≤ 24 links per component (a navigation menu is not a decision); an external `` +is nothing. The Screens view walks back from these edges exactly as from `router.push`; they draw dashed. + ## The hole ```ts diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 209d4db..38d254a 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -440,6 +440,18 @@ for a pair with several. Placement: at the FAR end of the line; first lane centr lane; never over a box; overflow counted in the panel. Panel row hover: that pill prints the whole condition (wraps at 360px), its line at 1.0, the rest at 0.38; a hovered line tints its row `--press`. Legend bottom-left, remembered per browser. +**Frameworks.** The picture is a pure function of `route` nodes bound to the component that renders them and `navigates` edges +from the function that pushes a path to the route it names, so any framework that produces those facts lands here. Expo Router +(`resolution/frameworks/expo-router.ts`): `app/**` screen files, `router.push` / `navigate` / `replace` and a helper's return value. +Next.js (`frameworks/nextjs.ts`, `next-router-synthesizer.ts`): App Router `app/**/page.tsx` and Pages Router pages (`(group)` +stripped, `[slug]` → `:slug`, `[...all]` → `:all*`; `@slot` and `(.)intercepting` routes not modelled), bound to the default export; +`router.push` / `replace` / `prefetch`, `redirect` / `permanentRedirect`, `NextResponse.redirect(new URL(…))` read like an Expo href +(string, template with holes, `{ pathname }`, a conditional whose arms agree, a local `const href`) and matched against the Next +pages only, from files under a Next app's root; `` and an internal `` are markup, not calls, so a synthesizer +reads them and draws a dashed `navigates` edge from the component (`synthesizedBy: 'next-link'`, the site as `registeredAt`). +`app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens (`POST /api/users`, `ANY /api/users`), so the same +index is a web app: pages on this tab, endpoints in Entry points, and a page's Steps picture firing from its load. + ### 3.13 Steps (`#/steps?anchor=` | `?symbol=`, `&depth=`) What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's layout, tracks, pills, nearest-line pointer, panel) over a different node universe. `/api/steps` walks FORWARD from diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index c61fb9f..2eabf3c 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -277,6 +277,9 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. (Verify the exact supported set against `src/extraction/languages/` and `src/resolution/frameworks/` before starting — this table is a starting point.) +| TypeScript/JS | Next.js (App Router + Pages Router) | page → `` / `router.push` / `redirect` → page; page load → data; client component → `'use server'` action → DB → `redirect`; `route.ts` handler → DB → response | R + S | ✅ 2026-08-28 (`frameworks/nextjs.ts`, `next-router-synthesizer.ts`, Steps `load` trigger + server-action crossing): fixture `__tests__/nextjs.test.ts` end to end (Screens `routed`, the push attributed back under its condition, the action's redirect); `leerob/next-saas-starter` indexes its pages and links. 🔬 agent A/B (`--model sonnet`, ≥2 runs/arm) not run yet | +| TypeScript/JS | Express + React (MERN monorepo) | client `axios` / `fetch` literal path → own route → handler → Mongoose → response rows | S + R | ✅ 2026-08-28 (`tier-synthesizer.ts` `http-client` + Express mounts / chained `router.route()` / wrapped `const h = asyncHandler(…)` handlers / nested `package.json` detection): `bradtraversy/proshop_mern` 49 routes (19 pages + 30 endpoints), 23 client→route edges, every one spot-checked correct; `login → ⇢ POST /api/users/login → User.findOne → 401 rows → jwt.sign`. Node count stable across re-index. 🔬 A/B not run | +| TypeScript/JS | NestJS queues / events / sockets | `queue.add('job')` → `@Process('job')`; `emit('x')` → `@OnEvent('x')`; `socket.emit` → `@SubscribeMessage` and `server.emit` → `socket.on` | S | ✅ 2026-08-28 (`tier-synthesizer.ts` `queue-job`, `event-bus`): `nestjs/nest` `sample/26-queues` (`transcode` → `handleTranscode`) and `sample/30-event-emitter` (`order.created` → its listener) exact, 0 wrong edges after the `e2e/` and generic-event guards; `immich-app/immich` 0 edges — a generated SDK client and a wrapped queue API carry no literal, so silence (correct). 🔬 A/B not run | ### Retrieval A/Bs that are not coverage work diff --git a/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md b/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md index 7a48312..9193e3e 100644 --- a/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md +++ b/docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md @@ -2,8 +2,7 @@ **Status:** plan, written 2026-08-28 at the end of the session that built the Steps view and the readings it rests on (Expo + React Native app, `amniservices-mobile-app`). **Updated the same day, later -sessions: P0, P1, P2, P3, P5 and P6 are built** (see the per-item notes marked *Built*); P4 (Next.js as a -Screens app) and P7's agent A/B numbers are open. Every claim about what a +sessions: P0–P6 are built** (see the per-item notes marked *Built*); P7's agent A/B numbers are open. Every claim about what a resolver emits *today* was verified against the source on this date — re-verify before building on it, the resolvers move. What was learned building it, beyond the plan: the index keeps only the LAST segment of a deep member call (`create` for `prisma.user.create`) and name-matches it — often to the @@ -335,6 +334,16 @@ prisma.user.create → response`, dashed where synthesized, with `registeredAt` ### P4 — Next.js as a Screens app +*Built* (2026-08-28, later session) — `frameworks/nextjs.ts` split out of `react.ts` (App Router pages and `route.ts` handlers, +Pages Router pages and `pages/api`, `(group)` stripped, `[slug]` → `:slug`, `[...all]` → `:all*`, parallel / intercepting +routes skipped; a page's `calls` ref to its default export via `defaultExportName`; `resolve()` claiming `router.push|replace| +prefetch`, `redirect`, `permanentRedirect`, `NextResponse.redirect(new URL(…))` through the Expo href readers — now exported — +against a Next-only route table gated on the app's root), `next-router-synthesizer.ts` (``, internal `` → +dashed `navigates` from the component), `scoreMatch` accepting `:param` / `:all*`, the `load` trigger and `project: 'web'` for a +Next page in `steps.ts`, `{ status: 201 }` read off the call site for response rows. Test `__tests__/nextjs.test.ts`. +Route-handler references resolve by name with the same-file preference (`GET` / `POST` are common names). **Not built:** `revalidatePath` +as a refresh, `middleware.ts` `config.matcher` as a global guard, a helper's return value as a destination (Expo has it). + *Where:* `resolution/frameworks/react.ts` (split a `nextjs.ts` out of it — the pages/app routing is already there), a `next-router-synthesizer.ts` modelled on `expo-router-synthesizer.ts`. diff --git a/src/graph/branch-guards.ts b/src/graph/branch-guards.ts index d9259d2..c6e4ced 100644 --- a/src/graph/branch-guards.ts +++ b/src/graph/branch-guards.ts @@ -433,6 +433,26 @@ export interface CallSiteText { args: string; /** The same arguments one by one — a registration site's middleware chain is `argList.slice(1, -1)`. */ argList: string[]; + /** A status code written as an object property in the arguments (`{ status: 201 }`), which the abbreviation to keys would hide. */ + status?: number; +} + +const STATUS_KEY = /^(?:status|statusCode|status_code|code)$/; + +/** `{ status: 201 }` inside an argument — the literal an abbreviated object hides. */ +function statusPropertyIn(node: SyntaxNode, depth = 0): number | null { + if (depth > 2) return null; + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i)!; + if (c.type === 'pair' || c.type === 'keyword_argument' || c.type === 'named_argument' || c.type === 'property_assignment' || c.type === 'object_property') { + const key = c.childForFieldName('key') ?? c.childForFieldName('name') ?? c.namedChild(0); + const value = c.childForFieldName('value') ?? c.namedChild(c.namedChildCount - 1); + if (key && value && STATUS_KEY.test(key.text.replace(/['"]/g, '')) && /^[1-5]\d{2}$/.test(value.text)) return Number(value.text); + } + const inner = statusPropertyIn(c, depth + 1); + if (inner !== null) return inner; + } + return null; } /** Longest callee text kept before it is cut. */ @@ -473,13 +493,15 @@ export function callSiteInTree(root: SyntaxNode, source: string, line: number, c const callee = calleeChainText(call, container); if (container.type === 'lambda_literal') return { callee, args: '{ … }', argList: ['{ … }'] }; const parts: string[] = []; + let status: number | null = null; for (let i = 0; i < container.namedChildCount; i++) { const c = container.namedChild(i); if (!c || c.type === 'comment') continue; parts.push(abbreviateArgument(c, source)); + if (status === null && c.type !== 'comment') status = statusPropertyIn(c); } const text = parts.join(', '); - return { callee, args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text, argList: parts }; + return { callee, args: text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text, argList: parts, ...(status !== null ? { status } : {}) }; } /** The text of a call before its arguments, normalised to a member chain. */ diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 98167aa..de9aa9d 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -29,6 +29,7 @@ import { stripCommentsForRegex } from './strip-comments'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; import { expoRouterReturnEdges } from './expo-router-synthesizer'; +import { nextLinkEdges } from './next-router-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { crossTierEdges } from './tier-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; @@ -3601,6 +3602,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [ { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) }, // `router.push(await helper())` — the helper's return literals are the screens. { name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) }, + // `` / an internal `` — markup, not a call; the component navigates. + { name: 'nextLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => nextLinkEdges(c, y) }, { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, ]; diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts index b2f04a9..e6cf3bf 100644 --- a/src/resolution/frameworks/expo-router.ts +++ b/src/resolution/frameworks/expo-router.ts @@ -307,7 +307,7 @@ export function toHref(literal: string | null): HrefLiteral | null { * with a literal `pathname`, or a conditional whose two arms are each one of * those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static. */ -function parseHrefExpression(expr: string): HrefLiteral | null { +export function parseHrefExpression(expr: string): HrefLiteral | null { // `expr as any` / `expr satisfies Href` — a cast says nothing about the value. let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, ''); // `(a ? b : c)` — unwrap one layer of grouping parens. @@ -351,7 +351,7 @@ export function readHrefArgument( } /** The source text of the navigation call's first argument, or null when there is no call there. */ -function firstArgumentText( +export function firstArgumentText( lines: readonly string[], line: number, column: number, @@ -542,12 +542,22 @@ export function matchRoute(segs: string[], table: RouteTable): Node | null { return best && !tied ? best.node : null; } +/** A route segment that takes a value: Expo's `[id]`, or the `:id` every other framework's routes use. */ +function isParamSegment(seg: string): boolean { + return (seg.startsWith('[') && seg.endsWith(']')) || (seg.startsWith(':') && !seg.endsWith('*')); +} + +/** A route segment that takes the rest of the path: `[...slug]`, or `:slug*`. */ +function isCatchAllSegment(seg: string): boolean { + return (seg.startsWith('[...') && seg.endsWith(']')) || (seg.startsWith(':') && seg.endsWith('*')); +} + function scoreMatch(href: string[], route: string[]): number | null { let score = 0; let i = 0; for (let r = 0; r < route.length; r++) { const seg = route[r]!; - if (seg.startsWith('[...') && seg.endsWith(']')) { + if (isCatchAllSegment(seg)) { // Catch-all: needs at least one segment and takes the rest. if (i >= href.length) return null; score += href.length - i; @@ -556,7 +566,7 @@ function scoreMatch(href: string[], route: string[]): number | null { } if (i >= href.length) return null; const h = href[i]!; - if (seg.startsWith('[') && seg.endsWith(']')) score += 2; + if (isParamSegment(seg)) score += 2; else if (h === seg) score += 3; else if (h === '*') score += 1; else return null; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 921e049..bf7a88c 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -11,6 +11,7 @@ import { laravelResolver } from './laravel'; import { expressResolver } from './express'; import { nestjsResolver } from './nestjs'; import { reactResolver } from './react'; +import { nextjsResolver } from './nextjs'; import { svelteResolver } from './svelte'; import { vueResolver } from './vue'; import { astroResolver } from './astro'; @@ -42,6 +43,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expressResolver, nestjsResolver, reactResolver, + // Next.js — `app/**/page.tsx` + `pages/**` → route nodes; `route.ts` exports → endpoints; `router.push('/x')` / `redirect('/x')` → navigates edges + nextjsResolver, svelteResolver, vueResolver, astroResolver, diff --git a/src/resolution/frameworks/nextjs.ts b/src/resolution/frameworks/nextjs.ts new file mode 100644 index 0000000..8a1a21a --- /dev/null +++ b/src/resolution/frameworks/nextjs.ts @@ -0,0 +1,320 @@ +/** + * Next.js — file-based pages and route handlers, and string-keyed navigation. + * + * Two things static extraction cannot see on its own, and that together are + * most of what "how does the site flow" means in a Next app: + * + * 1. **A page is a file.** `app/users/page.tsx` is `/users`, `app/(marketing)/ + * about/page.tsx` is `/about` (a `(group)` is invisible in the URL), + * `app/blog/[slug]/page.tsx` is `/blog/:slug`, `app/docs/[...all]/page.tsx` + * is `/docs/:all*`; the Pages Router's `pages/about.tsx` is `/about`. + * `extract()` emits one `route` node per page, named by its path, with a + * `calls` ref to the file's default export so the route reaches the + * component that renders it — exactly as Expo Router's screens do. + * `app/api/users/route.ts` exports `GET` / `POST` / … — one route node per + * method, `POST /api/users`, with a `references` ref to that function, as + * every server resolver names a handler; `pages/api/users.ts` is + * `ANY /api/users` bound to its default export. + * + * 2. **Navigation is a string.** `router.push('/users')` (`next/navigation`, + * `next/router`), `redirect('/login')` / `permanentRedirect` in a server + * action or a page, `NextResponse.redirect(new URL('/login', req.url))` in + * the middleware or a route handler: the extractor records each as a call + * that resolves to nothing, because the target is a path. `resolve()` + * claims those refs, reads the argument off the source (the Expo Router + * readers — a string, a template with holes, a `{ pathname }` object, a + * conditional whose arms agree, a local `const href = …`), matches it + * against this framework's own route table, and returns a **`navigates`** + * edge carrying the href. `` and an internal `` are + * JSX attributes, not calls, so a synthesizer (`next-router-synthesizer.ts`) + * reads them from the source instead. + * + * Precision rests on the string resolving to a real page: a computed href, a + * path no page serves, a relative href, or a conditional that forks are left + * unresolved rather than guessed. Parallel (`@slot`) and intercepting + * (`(.)photo`) routes are not modelled; `layout` / `loading` / `error` / + * `template` files are not routes. + */ + +import type { Language, Node } from '../../types'; +import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; +import { dependsOn } from './package-deps'; +import { + HOLE, + defaultExportName, + firstArgumentText, + matchRoute, + parseHrefExpression, + readHrefViaLocal, + type HrefLiteral, + type RouteTable, +} from './expo-router'; + +const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; +const HTTP_EXPORTS = 'GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS'; + +// ============================================================================= +// Route files +// ============================================================================= + +export interface NextRouteFile { + /** A page component, an App Router `route.ts` handler file, or a Pages Router API file. */ + kind: 'page' | 'handler' | 'api'; + /** `/blog/:slug` — the path, in the form every other framework's routes use. */ + path: string; + /** The directory the Next app lives in (`''`, `apps/web/`) — what its navigation calls are gated on. */ + root: string; +} + +/** `[slug]` → `:slug`, `[...all]` / `[[...all]]` → `:all*`; anything else as written. */ +function nextSegment(seg: string): string { + const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(seg); + if (optional) return `:${optional[1]}*`; + const rest = /^\[\.\.\.([^\]]+)\]$/.exec(seg); + if (rest) return `:${rest[1]}*`; + const param = /^\[([^\]]+)\]$/.exec(seg); + if (param) return `:${param[1]}`; + return seg; +} + +/** What a file is to the router, or null for a file that is not a route. */ +export function nextRouteForFile(filePath: string): NextRouteFile | null { + if (/(?:^|\/)(?:__tests__|__mocks__|node_modules)\//.test(filePath)) return null; + const app = /^((?:[^/]+\/)*?)(?:src\/)?app\/(.+)$/.exec(filePath); + if (app) { + const m = /^(.*?)(?:^|\/)(page|route)\.(?:tsx|ts|jsx|js|mjs|cjs|mdx?)$/.exec(app[2]!); + if (!m) return null; + const segs = m[1]!.split('/').filter(Boolean); + // Parallel and intercepting routes are a picture of their own; not modelled. + if (segs.some((s) => s.startsWith('@') || /^\(\.{1,3}\)/.test(s))) return null; + const kept = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).map(nextSegment); + return { kind: m[2] === 'page' ? 'page' : 'handler', path: '/' + kept.join('/'), root: app[1]! }; + } + const pages = /^((?:[^/]+\/)*?)(?:src\/)?pages\/(.+)$/.exec(filePath); + if (pages) { + const rel = pages[2]!; + const ext = /\.(?:tsx|ts|jsx|js|mjs|cjs|mdx?)$/.exec(rel); + if (!ext) return null; + const bare = rel.slice(0, ext.index); + const segs = bare.split('/'); + const base = segs[segs.length - 1]!; + if (base.startsWith('_') || /\.(?:test|spec|stories|config|d)$/.test(bare)) return null; + if (segs[segs.length - 1] === 'index') segs.pop(); + return { kind: segs[0] === 'api' ? 'api' : 'page', path: '/' + segs.map(nextSegment).join('/'), root: pages[1]! }; + } + return null; +} + +function languageForFile(filePath: string): Language { + if (filePath.endsWith('.tsx')) return 'tsx'; + if (filePath.endsWith('.jsx')) return 'jsx'; + if (/\.(?:ts|mts|cts)$/.test(filePath)) return 'typescript'; + return 'javascript'; +} + +// ============================================================================= +// Route table — this framework's pages, matched the Expo Router way +// ============================================================================= + +interface NextTable extends RouteTable { + /** The directories Next apps live in — a navigation call is only read from under one. */ + roots: string[]; +} + +const tables = new WeakMap(); + +export function nextRouteTable(context: ResolutionContext): NextTable { + const all = context.getNodesByKind('route'); + const cached = tables.get(context); + if (cached && cached.source === all) return cached; + const exact = new Map(); + const dynamic: RouteTable['dynamic'] = []; + const roots = new Set(); + for (const node of all) { + const file = nextRouteForFile(node.filePath); + if (!file || file.kind !== 'page' || file.path !== node.name) continue; + exact.set(node.name, node); + if (node.name.includes(':')) dynamic.push({ node, segs: node.name.split('/').slice(1) }); + roots.add(file.root); + } + const table: NextTable = { source: all, exact, dynamic, roots: [...roots] }; + tables.set(context, table); + return table; +} + +/** `/users/${…}?tab=x` → `['users', '*']`; an absolute URL keeps its path; a relative href is nothing. */ +function hrefSegments(href: HrefLiteral): string[] | null { + let p = href.path; + const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p); + if (absolute) p = absolute[1] ?? '/'; + if (!p.startsWith('/')) return null; + return p + .split('/') + .slice(1) + .filter((s) => s.length > 0) + .map((s) => (s.includes(HOLE) ? '*' : decode(s))); +} + +function decode(s: string): string { + try { + return decodeURIComponent(s); + } catch { + return s; + } +} + +/** The page an href names in this table, or null when none or several do (a fork), exactly as Expo Router decides. */ +export function pageForHref(href: HrefLiteral, table: RouteTable): Node | null { + const segs = hrefSegments(href); + if (segs === null) return null; + const target = matchRoute(segs, table); + if (!target) return null; + if (href.alternate) { + const alt = hrefSegments(href.alternate); + if (alt === null || matchRoute(alt, table)?.id !== target.id) return null; + } + return target; +} + +// ============================================================================= +// Navigation calls +// ============================================================================= + +/** `router.push` / `.replace` / `.prefetch`, `redirect` / `permanentRedirect`, `NextResponse.redirect`. */ +const NAV_CALL = /(?:^|\.)(push|replace|prefetch)$|^(redirect|permanentRedirect)$|^(?:NextResponse|Response)\.(redirect)$/; + +/** The verb a navigation call name stands for, or null. */ +export function nextNavVerb(name: string): string | null { + const m = NAV_CALL.exec(name); + if (!m) return null; + if (m[3]) return 'response.redirect'; + return m[1] ?? m[2]!; +} + +// ============================================================================= +// The resolver +// ============================================================================= + +export const nextjsResolver: FrameworkResolver = { + name: 'nextjs', + languages: [...ROUTE_LANGUAGES], + + detect(context: ResolutionContext): boolean { + if (dependsOn(context, 'next')) return true; + const files = context.getAllFiles(); + const hasConfig = files.some((f) => /(?:^|\/)next\.config\.[cm]?[jt]s$/.test(f)); + return hasConfig && files.some((f) => nextRouteForFile(f) !== null); + }, + + claimsReference(name: string): boolean { + return NAV_CALL.test(name); + }, + + extract(filePath: string, content: string) { + const file = nextRouteForFile(filePath); + if (!file) return { nodes: [], references: [] }; + const language = languageForFile(filePath); + const now = Date.now(); + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const stripped = stripCommentsForRegex(content, 'typescript'); + const lineOf = (index: number): number => stripped.slice(0, index).split('\n').length; + + if (file.kind === 'handler') { + // `export async function GET(req) {…}` / `export const POST = …` — one route per method. + const seen = new Set(); + const decl = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+(${HTTP_EXPORTS})\\b|\\bexport\\s+(?:const|let)\\s+(${HTTP_EXPORTS})\\s*=`, 'g'); + let m: RegExpExecArray | null; + while ((m = decl.exec(stripped)) !== null) { + const method = (m[1] ?? m[2])!; + if (seen.has(method)) continue; + seen.add(method); + const line = lineOf(m.index); + const node: Node = { + id: `route:${filePath}:${line}:${method}:${file.path}`, + kind: 'route', + name: `${method} ${file.path}`, + qualifiedName: `${filePath}::${method}:${file.path}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: m[0].length, + language, + isExported: true, + updatedAt: now, + }; + nodes.push(node); + references.push({ fromNodeId: node.id, referenceName: method, referenceKind: 'references', line, column: 0, filePath, language, candidates: [method] }); + } + return { nodes, references }; + } + + // A page, or a Pages Router API file: the default export is what runs. + const name = file.kind === 'api' ? `ANY ${file.path}` : file.path; + const node: Node = { + id: file.kind === 'api' ? `route:${filePath}:1:ANY:${file.path}` : `route:${filePath}:${file.path}`, + kind: 'route', + name, + qualifiedName: file.kind === 'api' ? `${filePath}::ANY:${file.path}` : `${filePath}::route:${file.path}`, + filePath, + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + language, + isExported: true, + updatedAt: now, + }; + nodes.push(node); + const exported = defaultExportName(stripped); + if (exported) { + references.push({ + fromNodeId: node.id, + referenceName: exported.name, + referenceKind: file.kind === 'api' ? 'references' : 'calls', + line: lineOf(exported.index), + column: 0, + filePath, + language, + candidates: [exported.name], + }); + } + return { nodes, references }; + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; + const verb = nextNavVerb(ref.referenceName); + if (!verb) return null; + if (!ROUTE_LANGUAGES.includes(ref.language)) return null; + const table = nextRouteTable(context); + if (table.exact.size === 0 || !table.roots.some((root) => ref.filePath.startsWith(root))) return null; + const callee = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1); + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null; + if (!lines) return null; + + let arg = firstArgumentText(lines, ref.line, ref.column, callee); + if (arg === null) return null; + // `NextResponse.redirect(new URL('/login', req.url))` — the path is the URL's first argument. + if (/^\s*new\s+URL\s*\(/.test(arg)) arg = firstArgumentText([arg], 1, 0, 'URL'); + let href = arg === null ? null : parseHrefExpression(arg); + if (!href) { + const enclosing = context.getNodeById?.(ref.fromNodeId); + const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40); + href = readHrefViaLocal(lines, ref.line, ref.column, callee, start); + } + if (!href) return null; + const target = pageForHref(href, table); + if (!target) return null; + return { + original: ref, + targetNodeId: target.id, + confidence: 0.95, + resolvedBy: 'framework', + edgeKind: 'navigates', + metadata: { href: href.display, navMethod: verb }, + }; + }, +}; diff --git a/src/resolution/frameworks/package-deps.ts b/src/resolution/frameworks/package-deps.ts index 2e49ec1..22c0db9 100644 --- a/src/resolution/frameworks/package-deps.ts +++ b/src/resolution/frameworks/package-deps.ts @@ -19,10 +19,19 @@ export function declaredDependencies(context: ResolutionContext): Set { const cached = cache.get(context); if (cached) return cached; const names = new Set(); - const manifests = ['package.json']; + // The index lists source files, never manifests: the candidate directories + // are the first one or two segments of what IS indexed, probed on disk. + const dirs = new Set(); for (const file of context.getAllFiles()) { + const segs = file.split('/'); + if (segs.length > 1) dirs.add(segs[0] + '/'); + if (segs.length > 2) dirs.add(segs[0] + '/' + segs[1] + '/'); + if (dirs.size > MAX_MANIFESTS * 8) break; + } + const manifests = ['package.json']; + for (const dir of dirs) { if (manifests.length > MAX_MANIFESTS) break; - if (/^(?:[^/]+\/){1,2}package\.json$/.test(file) && !file.includes('node_modules/')) manifests.push(file); + if (!dir.includes('node_modules') && context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json'); } for (const manifest of manifests) { const content = context.readFile(manifest); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index a871253..28776cd 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -1,7 +1,8 @@ /** * React Framework Resolver * - * Handles React and Next.js patterns. + * Handles React patterns: React Router routes, components, hooks, contexts. + * Next.js pages, route handlers and navigation are `nextjs.ts`'s. */ import { Node } from '../../types'; @@ -180,31 +181,7 @@ export const reactResolver: FrameworkResolver = { } } - // Extract Next.js pages/routes (pages directory convention) - if (filePath.includes('pages/') || filePath.includes('app/')) { - // Default export in pages becomes a route - if (content.includes('export default')) { - const routePath = filePathToRoute(filePath); - if (routePath) { - const line = content.indexOf('export default'); - const lineNum = content.slice(0, line).split('\n').length; - - nodes.push({ - id: `route:${filePath}:${routePath}:${lineNum}`, - kind: 'route', - name: routePath, - qualifiedName: `${filePath}::route:${routePath}`, - filePath, - startLine: lineNum, - endLine: lineNum, - startColumn: 0, - endColumn: 0, - language: filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') ? 'typescript' : 'javascript', - updatedAt: now, - }); - } - } - } + // Next.js pages and route handlers are `frameworks/nextjs.ts`'s. return { nodes, references }; }, @@ -308,52 +285,3 @@ function resolveContext(name: string, context: ResolutionContext): string | null return candidates[0]!.id; } - -/** - * Convert file path to Next.js route - */ -function filePathToRoute(filePath: string): string | null { - // pages/index.tsx -> / - // pages/about.tsx -> /about - // pages/blog/[slug].tsx -> /blog/:slug - // app/page.tsx -> / - // app/about/page.tsx -> /about - - // Only real page-component files are routes. Exclude non-page extensions - // (.mjs/.json/.cjs), config files (next.config.ts, vite.config.ts…), and - // Next.js special files (_app/_document). This also stops a `*.config.mjs` - // with `export default` in a dir like `nextjs-pages/` from being a "route". - const base = filePath.split('/').pop() ?? ''; - if (!/\.(tsx?|jsx?)$/.test(base)) return null; - if (base.startsWith('_') || /\.config\.[a-z]+$/.test(base)) return null; - - // Match pages/ and app/ as PATH SEGMENTS (not a substring — `nextjs-pages/` - // must not count as a `pages/` router dir). - if (/(?:^|\/)pages\//.test(filePath)) { - let route = filePath - .replace(/^.*pages\//, '/') - .replace(/\/index\.(tsx?|jsx?)$/, '') - .replace(/\.(tsx?|jsx?)$/, '') - .replace(/\[([^\]]+)\]/g, ':$1'); - - if (route === '') route = '/'; - return route; - } - - if (/(?:^|\/)app\//.test(filePath)) { - // App router - only page.tsx files are routes - if (!filePath.includes('page.')) { - return null; - } - - let route = filePath - .replace(/^.*app\//, '/') - .replace(/\/page\.(tsx?|jsx?)$/, '') - .replace(/\[([^\]]+)\]/g, ':$1'); - - if (route === '') route = '/'; - return route; - } - - return null; -} diff --git a/src/resolution/next-router-synthesizer.ts b/src/resolution/next-router-synthesizer.ts new file mode 100644 index 0000000..0589bee --- /dev/null +++ b/src/resolution/next-router-synthesizer.ts @@ -0,0 +1,102 @@ +/** + * Next.js — navigation written as markup. + * + * Users + * … + * … + * Pricing + * + * A JSX attribute is not a call, so the extractor records no reference for + * it and the resolver in `frameworks/nextjs.ts` — which binds `router.push` + * and `redirect` — never sees it. This pass reads every `` and + * internal `` out of the source, attributes it to the component + * (the innermost function) it is written in, matches the href against the + * Next route table, and synthesizes one `navigates` edge from the component + * to the page. That is the edge the Screens view walks back from, so a + * page's links are its transitions exactly as a screen's taps are. + * + * Edges are `provenance:'heuristic'`, `synthesizedBy:'next-link'`, with the + * href as written and `registeredAt` = the JSX site. A computed href + * (`href={href}`) is nothing; a path no page serves is nothing. Nothing here + * runs on a project with no Next pages. + */ + +import type { Edge } from '../types'; +import type { ResolutionContext } from './types'; +import type { MaybeYield } from './cooperative-yield'; +import { stripCommentsForRegex } from './strip-comments'; +import { isTestPath } from '../search/query-utils'; +import { readStringAt, toHref } from './frameworks/expo-router'; +import { nextRouteTable, pageForHref } from './frameworks/nextjs'; +import { enclosingFn, makeLineAt } from './synth-utils'; + +const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/; + +/** `]*?)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*)/g; + +/** Links a single component may carry before it is a navigation menu, not a decision. */ +const MAX_LINKS_PER_COMPONENT = 24; + +export async function nextLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { + const table = nextRouteTable(ctx); + if (table.exact.size === 0) return []; + const edges: Edge[] = []; + const seen = new Set(); + const perComponent = new Map(); + let scanned = 0; + for (const file of ctx.getAllFiles()) { + if (!JSX_FILE.test(file) || isTestPath(file)) continue; + if (!table.roots.some((root) => file.startsWith(root))) continue; + if ((++scanned & 63) === 0) await onYield(); + const source = ctx.readFile(file); + if (!source || !source.includes('href')) continue; + const safe = stripCommentsForRegex(source, 'typescript'); + const nodes = ctx.getNodesInFile(file); + const lineOf = makeLineAt(safe, 1); + LINK_TAG.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LINK_TAG.exec(safe)) !== null) { + const tag = m[1]!; + let literal: string | null = m[3] ?? m[4] ?? null; + if (literal === null) { + // `href={…}`: a string, a template, or an object with a literal pathname. + const at = m.index + m[0].length; + const ch = safe[at]; + if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(safe, at); + else if (ch === '{') { + const key = /\bpathname\s*:\s*/y; + key.lastIndex = at; + const close = safe.indexOf('}', at); + const head = key.exec(safe.slice(0, close < 0 ? undefined : close).slice(at)); + if (head) literal = readStringAt(safe, at + head.index + head[0].length); + } + } + if (literal === null) continue; + // An external `` is a link out of the site, not a transition. + if (tag === 'a' && !literal.startsWith('/')) continue; + const href = toHref(literal); + if (!href) continue; + const line = lineOf(m.index); + const component = enclosingFn(nodes, line); + if (!component) continue; + const page = pageForHref(href, table); + if (!page) continue; + const key = `${component.id}>${page.id}`; + if (seen.has(key)) continue; + const count = (perComponent.get(component.id) ?? 0) + 1; + perComponent.set(component.id, count); + if (count > MAX_LINKS_PER_COMPONENT) continue; + seen.add(key); + edges.push({ + source: component.id, + target: page.id, + kind: 'navigates', + line, + provenance: 'heuristic', + metadata: { synthesizedBy: 'next-link', href: href.display, navMethod: tag === 'a' ? 'a' : 'link', registeredAt: `${file}:${line}` }, + }); + } + } + return edges; +} diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index 88c7b65..4867f04 100644 --- a/src/ui-server/api/steps.ts +++ b/src/ui-server/api/steps.ts @@ -44,6 +44,7 @@ import { createSiteReader } from './when'; import type { SiteTrigger } from '../../graph/branch-guards'; import { classifyEffect, responseStatus, type Effect } from './effects'; import { looksLikeComponent, routeRoots } from './route-roots'; +import { nextRouteForFile } from '../../resolution/frameworks/nextjs'; import { splitRouteName } from './routes'; import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire'; import { isTestPath } from '../../search/query-utils'; @@ -452,10 +453,13 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS return [...new Set(after)]; }; - /** The request a route's handler serves, as its trigger. */ + /** The request a route's handler serves, as its trigger; a Next page's own work fires from its load. */ const requestTrigger = async (route: Node, root: Node | null): Promise => { const { method, path } = splitRouteName(route.name); - if (method === null) return null; + if (method === null) { + if (nextRouteForFile(route.filePath)?.kind === 'page') return { kind: 'load', name: 'GET', of: path, in: basename(route.filePath) }; + return null; + } const after = await chainFor(route, root); return { kind: 'request', name: method, of: path, in: basename(route.filePath), ...(after.length > 0 ? { after } : {}) }; }; @@ -633,7 +637,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' }; if (args !== null) wireSite.args = args; if (effect.category === 'response') { - const status = responseStatus(text, args, ref.referenceKind); + // `NextResponse.json(user, { status: 201 })`: the code sits in an object + // the abbreviation reduced to its keys; the site reader kept it. + const status = responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null); if (status !== null) wireSite.status = status; } link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, trigger ?? (await triggerAt(fold.node, at))); @@ -1180,7 +1186,11 @@ export function projectKind(routes: readonly Node[], navigates: number): 'app' | let pages = 0; for (const r of routes) { if (splitRouteName(r.name).method !== null) endpoints++; - else if (r.name.startsWith('/')) pages++; + else if (r.name.startsWith('/')) { + pages++; + // A Next page is a web page whatever else the index holds. + if (nextRouteForFile(r.filePath)?.kind === 'page') return 'web'; + } } if (endpoints === 0) return 'app'; return navigates > 0 || pages > 0 ? 'web' : 'api';