diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cd81086..740a0a6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- React Router framework-mode pages now appear with their page components and navigation, including nested routes and pathless layouts. + - Endpoint discovery now recognizes literal routes in Hono, Elysia, Fastify, Hyper-Express, Koa router, H3, Bun, Effect v4 and option-free Vixeny builders, and correctly reads Nuxt 4 page groups and server route methods after re-indexing. - **Markdown is indexed, and a documentation question gets the section, not the graph.** Every `.md` file's headings, sections, tables and links are nodes (the extractor from #361), and a doc-shaped `codegraph_explore` query that names a markdown file now renders that file's best sections first and whole — the top three by idf-weighted line hits, a heading the query covers word for word counted as named, 8k characters per file — with the blast-radius, relationships and "additional files" blocks held back unless a code file rendered too. Measured on a 109-file docs corpus under headless Claude Code, 36 cells over three rounds: the right file and section in every call, median 1 tool call against 4 for Grep-then-Read, 36 of 36 correct. Code answers keep their shape: markdown nodes leave a subgraph the doc tier did not seed, a markdown body is never mistaken for a generated-file header, and the explore budget tiers count code files only, so a README-heavy repo does not cross a breakpoint. The server instructions say markdown is indexed, which the branch's own text still denied. (#361, #1439) diff --git a/__tests__/react-router-framework.test.ts b/__tests__/react-router-framework.test.ts new file mode 100644 index 000000000..e6866cb53 --- /dev/null +++ b/__tests__/react-router-framework.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { extractReactRouterConfig } from '../src/resolution/frameworks/react-router'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); +}); + +// remix-run/react-router@7aea711dd1ae2bc5a076d13ff17291829690fa74, +// docs/start/framework/routing.md:28–52. Expected URLs come from that route table. +const official = `import {type RouteConfig, route, index, layout, prefix} from '@react-router/dev/routes'; +export default [ + index('./home.tsx'), + route('about', './about.tsx'), + layout('./auth/layout.tsx', [route('login', './auth/login.tsx'), route('register', './auth/register.tsx')]), + ...prefix('concerts', [index('./concerts/home.tsx'), route(':city', './concerts/city.tsx'), route('trending', './concerts/trending.tsx')]), +] satisfies RouteConfig;`; +const expected = [ + '/', + '/about', + '/login', + '/register', + '/concerts', + '/concerts/:city', + '/concerts/trending', +]; +const extract = (source: string) => extractReactRouterConfig('app/routes.ts', source); + +describe('React Router framework route tree', () => { + it('reads the official route tree without promoting its pathless layout', () => { + expect(extract(official).nodes.map((n) => n.name)).toEqual(expected); + expect(extract(official).references.map((r) => r.referenceName)).toEqual([ + 'react-router-module:./home.tsx', + 'react-router-module:./about.tsx', + 'react-router-module:./auth/login.tsx', + 'react-router-module:./auth/register.tsx', + 'react-router-module:./concerts/home.tsx', + 'react-router-module:./concerts/city.tsx', + 'react-router-module:./concerts/trending.tsx', + ]); + }); + it('composes nested routes and recognizes import aliases and options', () => { + expect( + extract(`import {route as r, index as i} from '@react-router/dev/routes'; +export default [r('teams', './teams.tsx', {id:'teams'}, [i('./list.tsx'), r(':id?', './team.tsx')])];`).nodes.map( + (n) => n.name, + ), + ).toEqual(['/teams', '/teams/:id?']); + }); + it.each([ + `const unused = [route('unused', './unused.tsx')]; export default [];`, + `function other(route) { return [route('fake', './fake.tsx')] } export default other(route);`, + `export default [route(variable, './x.tsx'), route('x', module), ...prefix(variable, [index('./x.tsx')])];`, + `export default [route('x', './x.tsx', {...options})];`, + `// route('comment','./x.tsx')\nexport default ["route('string','./x.tsx')"];`, + ])('does not infer dynamic or unregistered routes: %s', (source) => { + expect( + extract(`import {route,index,prefix} from '@react-router/dev/routes';\n${source}`).nodes, + ).toEqual([]); + }); + it('requires the actual helper import and default config location', () => { + expect( + extract(`import {route} from 'unrelated'; export default [route('x','./x.tsx')];`).nodes, + ).toEqual([]); + expect(extractReactRouterConfig('app/other.ts', official).nodes).toEqual([]); + }); +}); + +describe('framework pages through indexing and sync', () => { + let cg: CodeGraph | undefined; + let dir: string; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'loads grammars in a fresh compiled process with parse and resolver workers', + () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-workers-')); + write('package.json', JSON.stringify({ dependencies: { 'react-router': '*' } })); + write( + 'app/routes.ts', + `import {index} from '@react-router/dev/routes'; export default [index('./home.tsx')];`, + ); + write('app/home.tsx', 'export function Home() { return
; }\nexport default Home;'); + const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))}); +(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true}); +const route=cg.getNodesByKind('route')[0]; +const edges=cg.getOutgoingEdges(route.id).filter(e=>e.kind==='references'); +console.log(JSON.stringify(edges.map(e=>cg.getNode(e.target)?.name)));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`; + const output = execFileSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 60000, + env: { + ...process.env, + CODEGRAPH_PARALLEL_RESOLVE_MIN: '1', + CODEGRAPH_RESOLVE_WORKERS: '2', + CODEGRAPH_PARSE_WORKERS: '2', + }, + }); + expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['Home']); + }, + ); + it('binds exact modules, resolves navigation, and updates routes after config edits/deletion', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-framework-')); + write( + 'package.json', + JSON.stringify({ + dependencies: { react: '*', 'react-router': '*', '@react-router/dev': '7.9.0' }, + }), + ); + write('app/routes.ts', official); + for (const file of [ + 'home', + 'about', + 'auth/login', + 'auth/register', + 'concerts/home', + 'concerts/city', + 'concerts/trending', + ]) + write(`app/${file}.tsx`, 'export default function Page() { return
; }'); + write('app/auth/layout.tsx', 'export default function Layout() { return
; }'); + write( + 'app/nav.tsx', + `import {redirect} from 'react-router'; export function leave() { return redirect('/about'); }`, + ); + cg = await CodeGraph.init(dir, { index: true }); + let routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual([...expected].sort()); + const roots = routeRoots(cg, routes); + expect(roots.get(routes.find((n) => n.name === '/')!.id)?.node.filePath).toBe('app/home.tsx'); + expect(roots.get(routes.find((n) => n.name === '/concerts')!.id)?.node.filePath).toBe( + 'app/concerts/home.tsx', + ); + expect(roots.size).toBe(7); + const leave = cg.getNodesByKind('function').find((n) => n.name === 'leave')!; + expect(cg.getOutgoingEdges(leave.id)).toContainEqual( + expect.objectContaining({ + kind: 'navigates', + target: routes.find((n) => n.name === '/about')!.id, + }), + ); + write( + 'app/routes.ts', + `import {route} from '@react-router/dev/routes'; export default [route('new','./new.tsx')];`, + ); + write('app/new.tsx', 'export default function NewPage() { return
; }'); + await cg.sync(); + routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['/new']); + expect(routeRoots(cg, routes).get(routes[0].id)?.node.name).toBe('NewPage'); + write( + 'app/new.tsx', + `const note = "export default function Fake"; +function Fake() { return null; } +export default () =>
;`, + ); + await cg.sync(); + expect(routeRoots(cg, cg.getNodesByKind('route')).size).toBe(0); + write('app/new.tsx', 'const Real = () =>
;\nexport default Real;'); + await cg.sync(); + expect(routeRoots(cg, cg.getNodesByKind('route')).get(routes[0].id)?.node.name).toBe('Real'); + write( + 'app/new.tsx', + 'const Real = () =>
;\nconst Next = () =>
;\nexport default Next;', + ); + await cg.sync(); + expect(routeRoots(cg, cg.getNodesByKind('route')).get(routes[0].id)?.node.name).toBe('Next'); + fs.unlinkSync(path.join(dir, 'app/routes.ts')); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it('uses the nested index as a navigation destination and discovers newly added config', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-added-')); + write('package.json', JSON.stringify({ dependencies: { 'react-router': '*' } })); + write('app/home.tsx', 'export default function Home() { return
; }'); + cg = await CodeGraph.init(dir, { index: true }); + write( + 'app/routes.ts', + `import {route,index} from '@react-router/dev/routes'; +export default [route('teams', './layout.tsx', [index('./home.tsx')])];`, + ); + write('app/layout.tsx', 'export default function Layout() { return
; }'); + write( + 'app/nav.tsx', + `import {redirect} from 'react-router'; export function go() { return redirect('/teams'); }`, + ); + await cg.sync(); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['/teams']); + expect(routeRoots(cg, routes).get(routes[0].id)?.node.name).toBe('Home'); + const go = cg.getNodesByKind('function').find((n) => n.name === 'go')!; + expect(cg.getOutgoingEdges(go.id)).toContainEqual( + expect.objectContaining({ kind: 'navigates', target: routes[0].id }), + ); + }); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md new file mode 100644 index 000000000..84ff1c1a9 --- /dev/null +++ b/docs/design/PLAN-application-router-coverage.md @@ -0,0 +1,46 @@ +Status: 1/13 — all steps approved; step 1 verified, preparing its PR + +- [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear. +- [ ] 2 TanStack Start server routes — method-qualified endpoints from literal `server.handlers` tables and documented `createHandlers` callback forms. Keep page and endpoint nodes when both exist; omit phantom pages for server-only files. Gate: shared proof, mixed page/API fixtures, middleware exclusion, and handler call edges. +- [ ] 3 Remix and React Router file conventions — support the pinned default file convention, including index, nested, parameter, pathless, and splat cases; require evidence that the convention is enabled. Gate: shared proof, explicit-config/file-route coexistence, and layout-only controls. +- [ ] 4 Astro route completion — connect existing page routes to components and API method exports, then navigation where the source declares it. Gate: shared proof, page/API distinction, underscore exclusions, and cross-file handler resolution. +- [ ] 5 RedwoodSDK — imported `rwsdk/router` declarations, method tables, and statically resolvable registration/prefix context. Gate: shared proof, `defineApp`/`render` composition, and interrupters excluded from page roots. +- [ ] 6 Angular Router — registered literal route tables, nested children, component references, and statically resolvable lazy modules/components. Gate: shared proof, router registration and nesting, plus unregistered objects and custom matcher negatives. +- [ ] 7 Analog — default file routes and page components using Analog-specific index, dot, parameter, and layout conventions. Gate: shared proof and fixtures that distinguish parent layouts from matching pages. +- [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives. +- [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters. +- [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions. +- [ ] 11 Vike — default `+Page` conventions and literal `+route` overrides. Gate: shared proof, parameters, exact component links, and no fallback route when an unsupported override changes routing. +- [ ] 12 Waku filesystem routes — default pages, parameters, and layout exclusions for a pinned version. Gate: shared proof and `_root`/`_layout`/`_slices` controls. +- [ ] 13 Waku programmatic routes — literal `createPage` declarations within the documented `createPages` registration. Gate: shared proof, async registration syntax without executing it, and computed path negatives. + +All 13 steps approved. Each step gets a separate stacked PR so its diff remains reviewable. Existing [PR #2](https://github.com/bompus/codegraph/pull/2) remains unchanged; the first new PR is based on its branch. + +Acceptance: supported static declarations produce accurate route paths and exact component/handler links through normal indexing. Method-specific endpoints remain distinguishable from pages. Unsupported dynamic declarations produce no invented paths or links. Keep existing Next/OpenNext, Nuxt, Express, React Router, and TanStack page behavior as controls. + +Non-goals: Wrangler routes, `run_worker_first`, asset fallbacks, deployment mappings, generated OpenNext workers, executing application configuration, arbitrary URL branch analysis, custom route roots, or new dependencies/schema. Markdown/MDX Astro pages are a separate extension. TanStack `createServerFn` is RPC rather than a declared public route; inspect existing function/call linking before proposing a separate change, and never fabricate its generated URL. + +Implementation: extend existing framework resolvers and reuse parser, module-resolution, and route-reference machinery. Config-to-module links must use resolved paths, not matching basenames. New framework files are justified by distinct framework semantics; introduce no shared routing layer unless two implementations demonstrate the same needed operation. Route discovery belongs in extraction/resolution: the existing post-extraction hook cannot add new route nodes or references. + +Shared proof for every step: + +- Pin a framework version and official source fixture before implementation; record the expected routes and handler/component targets independently of extractor output. +- Add focused positive and negative assertions for aliases, shadowed bindings, computed paths, layouts/middleware, and unrelated declarations where applicable. +- Index a real fixture through the normal pipeline and verify route roots/call edges; verify add, edit, and delete through incremental sync, including newly introduced framework detection. +- Run focused tests through native and WASM paths, existing-router controls, TypeScript/build, and the full suite at each PR boundary. Format changed files and run an independent correctness/complexity review before opening the PR. +- Update the framework coverage matrix, user guide, and changelog with supported syntax and explicit limits; mark this board as each approved step lands. + +Source references: + +| Scope | Official documentation | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cloudflare framework inventory | [Full-stack applications](https://developers.cloudflare.com/workers/static-assets/routing/full-stack-application/), [single-page applications](https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/) | +| React Router / Remix conventions | [Framework routing](https://reactrouter.com/start/framework/routing), [file routes](https://reactrouter.com/how-to/file-route-conventions) | +| TanStack Start | [Server routes](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes), [server functions](https://tanstack.com/start/latest/docs/framework/react/guide/server-functions) | +| Astro | [Routing](https://docs.astro.build/en/guides/routing/), [endpoints](https://docs.astro.build/en/guides/endpoints/) | +| RedwoodSDK | [Routing](https://docs.rwsdk.com/core/routing/) | +| Angular / Analog | [Angular route definitions](https://angular.dev/guide/routing/define-routes), [Analog routing](https://analogjs.org/docs/features/routing/overview) | +| Solid | [Router Route API](https://docs.solidjs.com/solid-router/reference/components/route), [configuration](https://docs.solidjs.com/solid-router/getting-started/config), [SolidStart routing](https://docs.solidjs.com/solid-start/v2/building-your-application/routing) | +| Qwik City | [Routing](https://qwik.dev/docs/routing/) | +| Vike | [Routing](https://vike.dev/routing) | +| Waku | [Official documentation](https://waku.gg/) | diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 558f163e1..d1ac8fbed 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,11 +38,13 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges) | +| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts` | proshop; pinned official framework config (7 pages) | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) | +React Router framework mode reads default exported literal arrays in `app/routes.ts` or `app/routes.js`, using imported `route`, `index`, `layout`, and spread `prefix` helpers. Module paths bind named default components; nested index pages take precedence over their parent. [Official source fixture](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/docs/start/framework/routing.md#L28): seven expected pages, verified through indexing and navigation. Tests also cover module/config sync and a fresh compiled process using parse/resolver workers. Custom app directories, computed arrays, `relative`, anonymous defaults, and re-exports remain unsupported. + Shared machinery all six use, in `frameworks/expo-router.ts`: `RouteTable` / `RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`, `parseHrefExpression`, `readHrefViaLocal`, `nthArgumentText`, `readStringAt`, diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 95b45eda5..475b90340 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -30,13 +30,16 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Axum / actix / Rocket** | `.route("/x", get(handler))` | | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | | **Vapor** | `app.get("x", use: handler)` | -| **React Router** / **SvelteKit** | Route component nodes | +| **React Router** | JSX/data-router pages; literal framework-mode `app/routes.ts` arrays with `route`, `index`, `layout`, and `prefix`, linked to named default components | +| **SvelteKit** | Route component nodes | | **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers | | **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware | | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) | Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync. +React Router framework mode assumes the default `app/` directory. Computed arrays, custom app directories, `relative` helpers, anonymous defaults, and re-exports remain unsupported. Layout helpers contribute nesting without creating extra pages; an index page takes precedence over its parent layout. + The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader. Computed paths, spread configuration, cross-file mounts, plugin factories, mutable router aliases, and runtime method replacement are outside this static reading. Imports and captured router bindings must precede their use in source. Vixeny builders with options are omitted because their effective paths depend on the terminal operation. Nuxt custom route configuration, page metadata overrides, non-Vue page extensions, and custom server handler wrappers are not interpreted. Re-index after upgrading to add the new endpoints to an existing graph. diff --git a/src/index.ts b/src/index.ts index 750d3d62c..edd60fcb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import * as path from 'path'; +import { readFile } from 'fs/promises'; import { Node, NodeKind, @@ -60,6 +61,8 @@ import { CodeGraphPackageVersion } from './mcp/version'; import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments'; import { createYielder } from './resolution/cooperative-yield'; import { minRefsForPool } from './resolution/resolver-pool'; +import { extractReactRouterConfig } from './resolution/frameworks/react-router'; +import { loadGrammarsForLanguages } from './extraction/grammars'; // Re-export types for consumers export * from './types'; @@ -576,6 +579,8 @@ export class CodeGraph { if (result.success && result.filesIndexed > 0) { const tReinit = Date.now(); this.resolver.initialize(); + if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:'))) + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); // Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs // before resolution so updated names show up in subsequent reads. this.resolver.runPostExtract(); @@ -828,6 +833,8 @@ export class CodeGraph { // to controllers in unchanged files. The pass is idempotent and cheap // (regex over *.module.ts only). if (result.filesAdded > 0 || result.filesModified > 0) { + if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:'))) + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); this.resolver.runPostExtract(); } else if (result.filesRemoved > 0) { // A pure-removal sync still resolves refs below — the deletion path @@ -945,6 +952,27 @@ export class CodeGraph { } } + // Config module refs name files, not symbols, so symbol-name retries cannot + // recover them when a missing/default-less page later gains its export. + if (filesChanged) { + const configFiles = new Set( + this.queries + .getNodesByKind('route') + .filter((n) => n.id.startsWith('route:react-router:')) + .map((n) => n.filePath), + ); + for (const file of configFiles) { + const source = await readFile(path.join(this.projectRoot, file), 'utf8').catch(error => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (source === null) continue; + const refs = extractReactRouterConfig(file, source).references; + for (const ref of refs) this.queries.deleteEdgesBySource(ref.fromNodeId); + await this.resolver.resolveAndPersistListYielding(refs); + } + } + // Orphan sweep (#1187). A resolution pass that dies mid-run — the #850 // daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves // the refs it never reached in unresolved_refs, and the git-scoped fast diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 85db31eee..94ac1b29d 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -36,6 +36,9 @@ */ import type { Language, Node } from '../../types'; +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { resolveImportPath } from '../import-resolver'; import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types'; import { dependsOn } from './package-deps'; import { @@ -74,8 +77,9 @@ export const reactRouterRoot = appRootFor; */ function isReactRouterRoute(node: Node): boolean { return ( - (node.language === 'tsx' || node.language === 'jsx') && - node.id === `route:${node.filePath}:${node.startLine}:${node.name}` + node.id.startsWith(`route:react-router:${node.filePath}:`) || + ((node.language === 'tsx' || node.language === 'jsx') && + node.id === `route:${node.filePath}:${node.startLine}:${node.name}`) ); } @@ -103,7 +107,8 @@ export function reactRouterTable(context: ResolutionContext): ReactRouterTable { // not a destination. A splat matches everything, so it answers nothing. if (!node.name.startsWith('/') || node.name.endsWith('*')) continue; const root = reactRouterRoot(node.filePath); - const path = node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name; + const path = + node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name; addRouteTo(tableAt(root), path, node); // React Router's optional parameter: `/cart/:id?` is the screen for // `/cart/5` AND for a bare `/cart`, which the navbar's cart icon links @@ -156,21 +161,78 @@ export const reactRouterResolver: FrameworkResolver = { languages: [...ROUTE_LANGUAGES], detect(context: ResolutionContext): boolean { - return dependsOn(context, 'react-router', 'react-router-dom', 'react-router-native'); + return dependsOn( + context, + 'react-router', + 'react-router-dom', + 'react-router-native', + '@react-router/dev', + ); }, claimsReference(name: string): boolean { - return NAV_CALL.test(name); + return NAV_CALL.test(name) || name.startsWith('react-router-module:'); }, + extract: extractReactRouterConfig, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceName.startsWith('react-router-module:')) { + if (ref.referenceKind !== 'references' || !ref.fromNodeId.startsWith('route:react-router:')) + return null; + const module = ref.referenceName.slice('react-router-module:'.length); + const targetPath = resolveImportPath('./' + module, ref.filePath, ref.language, context); + if (!targetPath) return null; + const source = context.readFile(targetPath); + const parser = getParser(detectLanguage(targetPath)!); + const tree = source === null ? null : parser?.parse(source); + if (!tree) return null; + let target: Node | undefined; + try { + const exported = tree.rootNode.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + let declaration = + exported?.childForFieldName('declaration') ?? exported?.childForFieldName('value'); + if (declaration?.type === 'identifier') { + const name = declaration.text; + declaration = tree.rootNode.namedChildren + .map((n) => + n.type === 'export_statement' ? (n.childForFieldName('declaration') ?? n) : n, + ) + .flatMap((n) => (n.type === 'lexical_declaration' ? n.namedChildren : [n])) + .find((n) => n.childForFieldName('name')?.text === name); + } + const name = declaration?.childForFieldName('name')?.text; + if (name && declaration) { + const line = declaration.startPosition.row + 1; + target = context + .getNodesInFile(targetPath) + .find( + (n) => + n.name === name && + n.startLine <= line && + n.endLine >= line && + ['function', 'class', 'component', 'constant', 'variable'].includes(n.kind), + ); + } + } finally { + tree.delete(); + } + return target + ? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'framework' } + : null; + } if (ref.referenceKind !== 'calls') return null; const verb = reactRouterNavVerb(ref.referenceName); if (!verb) return null; if (!ROUTE_LANGUAGES.includes(ref.language)) return null; const routes = routesForFile(reactRouterTable(context), ref.filePath); if (!routes || routes.exact.size === 0) return null; - const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null; + const lines = + context.getFileLines?.(ref.filePath) ?? + context.readFile(ref.filePath)?.split(/\r?\n/) ?? + null; if (!lines) return null; const arg = firstArgumentText(lines, ref.line, ref.column, verb); @@ -178,7 +240,10 @@ export const reactRouterResolver: FrameworkResolver = { let href = 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); + const start = + enclosing && enclosing.filePath === ref.filePath + ? enclosing.startLine + : Math.max(1, ref.line - 40); href = readHrefViaLocal(lines, ref.line, ref.column, verb, start); } if (!href) return null; @@ -191,7 +256,12 @@ export const reactRouterResolver: FrameworkResolver = { original: ref, targetNodeId: target.node.id, ...(targets.length > 1 - ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) } + ? { + alsoTargets: targets.slice(1).map((t) => ({ + targetNodeId: t.node.id, + metadata: { href: t.href.display, navMethod: verb }, + })), + } : {}), confidence: 0.95, resolvedBy: 'framework', @@ -200,3 +270,127 @@ export const reactRouterResolver: FrameworkResolver = { }; }, }; + +/** Framework-mode helpers are evaluated only inside the exported literal route tree. */ +export function extractReactRouterConfig(filePath: string, content: string) { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + if ( + !/(?:^|\/)app\/routes\.[jt]s$/.test(filePath.replace(/\\/g, '/')) || + !content.includes('@react-router/dev/routes') + ) + return { nodes, references }; + const language = detectLanguage(filePath)!; + const parser = getParser(language); + if (!parser) throw new Error(`React Router extraction requires the ${language} grammar`); + const tree = parser.parse(content); + if (!tree) return { nodes, references }; + const unwrap = (node: SyntaxNode | null): SyntaxNode | null => { + while ( + node && + ['satisfies_expression', 'as_expression', 'parenthesized_expression'].includes(node.type) + ) + node = node.namedChildren[0] ?? null; + return node; + }; + const literal = (node: SyntaxNode | null): string | null => { + node = unwrap(node); + return node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null; + }; + try { + const helpers = new Map(); + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type !== 'import_statement' || + literal(statement.childForFieldName('source')) !== '@react-router/dev/routes' + ) + continue; + for (const spec of statement.descendantsOfType('import_specifier')) { + if (spec.text.startsWith('type ')) continue; + const name = spec.childForFieldName('name')?.text; + const alias = spec.childForFieldName('alias')?.text ?? name; + if (name && alias && ['route', 'index', 'layout', 'prefix'].includes(name)) + helpers.set(alias, name); + } + } + const visit = (value: SyntaxNode | null, parent: string): void => { + value = unwrap(value); + if (value?.type !== 'array') return; + for (let item of value.namedChildren) { + if (item.type === 'comment') continue; + const spread = item.type === 'spread_element'; + item = (spread ? item.namedChildren[0] : item)!; + if (item?.type !== 'call_expression') continue; + const fn = item.childForFieldName('function'); + const helper = fn?.type === 'identifier' ? helpers.get(fn.text) : undefined; + if (!helper || spread !== (helper === 'prefix')) continue; + const args = + item.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? + []; + const segment = helper === 'route' || helper === 'prefix' ? literal(args[0] ?? null) : ''; + if (segment === null) continue; + const routePath = (parent + '/' + segment).replace(/\/+/g, '/').replace(/\/$/, '') || '/'; + if (helper === 'prefix') { + visit(args[1] ?? null, routePath); + continue; + } + const module = literal(args[helper === 'route' ? 1 : 0] ?? null); + if (module === null) continue; + const tail = args.slice(helper === 'route' ? 2 : 1); + // Options may carry an id, but spread/computed options are not statically known. + if ( + tail.some( + (n) => + n.type !== 'array' && + (n.type !== 'object' || + n.namedChildren.some( + (p) => + p.type !== 'pair' || + p.childForFieldName('key')?.type === 'computed_property_name', + )), + ) + ) + continue; + const children = tail.find((n) => n.type === 'array'); + const beforeChildren = nodes.length; + if (children && helper !== 'index') visit(children, routePath); + if (helper !== 'layout' && !nodes.slice(beforeChildren).some((n) => n.name === routePath)) { + const line = item.startPosition.row + 1; + const node: Node = { + id: `route:react-router:${filePath}:${item.startIndex}:${routePath}`, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + filePath, + language, + startLine: line, + endLine: item.endPosition.row + 1, + startColumn: item.startPosition.column, + endColumn: item.endPosition.column, + updatedAt: Date.now(), + }; + nodes.push(node); + references.push({ + fromNodeId: node.id, + referenceName: `react-router-module:${module}`, + referenceKind: 'references', + filePath, + language, + line, + column: item.startPosition.column, + }); + } + } + }; + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type === 'export_statement' && + statement.children.some((n) => n.type === 'default') + ) + visit(statement.childForFieldName('value'), ''); + } + return { nodes, references }; + } finally { + tree.delete(); + } +} diff --git a/src/resolution/resolver-worker.ts b/src/resolution/resolver-worker.ts index f1d302012..5b223780a 100644 --- a/src/resolution/resolver-worker.ts +++ b/src/resolution/resolver-worker.ts @@ -27,6 +27,7 @@ import { ReferenceResolver } from './index'; import { SYNTH_PASSES } from './callback-synthesizer'; import { createYielder } from './cooperative-yield'; import type { UnresolvedReference } from '../types'; +import { initGrammars, loadGrammarsForLanguages } from '../extraction/grammars'; if (!parentPort) { throw new Error('resolver-worker must be run as a worker thread'); @@ -46,7 +47,7 @@ type InMessage = let dbPath: string | null = null; -port.on('message', (msg: InMessage) => { +port.on('message', async (msg: InMessage) => { try { switch (msg.type) { case 'open': { @@ -60,6 +61,10 @@ port.on('message', (msg: InMessage) => { queries = new QueryBuilder(db); resolver = new ReferenceResolver(msg.projectRoot, queries); resolver.initialize(); + if (queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:'))) { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); + } if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] worker open: db=${tDb - tOpen}ms init=${Date.now() - tDb}ms`); port.postMessage({ type: 'ready' }); break;