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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
210 changes: 210 additions & 0 deletions __tests__/react-router-framework.test.ts
Original file line number Diff line number Diff line change
@@ -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 <div/>; }\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 <div/>; }');
write('app/auth/layout.tsx', 'export default function Layout() { return <div/>; }');
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 <div/>; }');
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 () => <div/>;`,
);
await cg.sync();
expect(routeRoots(cg, cg.getNodesByKind('route')).size).toBe(0);
write('app/new.tsx', 'const Real = () => <div/>;\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 = () => <div/>;\nconst Next = () => <div/>;\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 <div/>; }');
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 <div/>; }');
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 }),
);
});
});
46 changes: 46 additions & 0 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -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/) |
Loading