diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5eade051e..2e6382ab5 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
+- RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots.
+
- Astro pages now link to their exact components and source-declared navigation; exported HTTP methods in `.ts`/`.js` endpoints link to their handlers, while type-only declarations and `.mjs` files are excluded.
- Remix default file routes and registered React Router `flatRoutes()` pages now link to their components and navigation, including optional segments and configuration-only sync changes.
diff --git a/__tests__/redwood-routes.test.ts b/__tests__/redwood-routes.test.ts
new file mode 100644
index 000000000..a568cec13
--- /dev/null
+++ b/__tests__/redwood-routes.test.ts
@@ -0,0 +1,205 @@
+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 { extractRedwoodRoutes } from '../src/resolution/frameworks/redwood';
+import { routeRoots } from '../src/ui-server/api/route-roots';
+
+beforeAll(async () => {
+ await initGrammars();
+ await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
+});
+const imports = `import {route, index, render, layout, prefix} from 'rwsdk/router';
+import {defineApp} from 'rwsdk/worker';\n`;
+const extract = (body: string) => extractRedwoodRoutes('src/worker.tsx', imports + body);
+
+describe('registered RedwoodSDK routes', () => {
+ it('composes aliases, constant arrays, prefixes, index and layouts', () => {
+ const result = extractRedwoodRoutes(
+ 'src/worker.tsx',
+ `import {route as r, render as page, prefix as under, index, layout} from 'rwsdk/router';
+import {defineApp as app} from 'rwsdk/worker';
+const children = [index(Home), r('users/:id', [auth, User])];
+const worker = app([page(Document, [under('/admin', layout(Layout, children))])]);
+export default {fetch: worker.fetch};`,
+ );
+ expect(result.nodes.map((n) => n.name)).toEqual(['ANY /admin', 'ANY /admin/users/:id']);
+ expect(result.references.map((r) => r.referenceName)).toEqual(['Home', 'User']);
+ });
+ it('reads standard method tables without config or interrupter handlers', () => {
+ const result = extract(
+ `export default defineApp([route('/api', {get: [auth, list], post: create, head: head, config: {disableOptions: true}})]);`,
+ );
+ expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api', 'HEAD /api']);
+ expect(result.references.map((r) => r.referenceName)).toEqual(['list', 'create', 'head']);
+ });
+ it('keeps inline JSX pages and response endpoints distinct', () => {
+ const result = extract(
+ `export default defineApp([render(Document, [route('/page', () => ), route('/health', () => new Response('ok')), route('/nested', () => {const unused = () => ; return new Response('ok')})])]);`,
+ );
+ expect(result.nodes.map((n) => n.name)).toEqual(['/page', 'ANY /health', 'ANY /nested']);
+ expect(result.references.map((r) => r.referenceName)).toEqual(['Home']);
+ });
+ it('reads shorthand handlers and ordinary method definitions', () => {
+ const result = extract(
+ `export default defineApp([route('/api', {get, post(){return save()}})]);`,
+ );
+ expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api']);
+ expect(result.references.map((r) => r.referenceName)).toEqual(['get', 'save']);
+ });
+ it.each([
+ `const unused = route('/orphan', Home); export default defineApp([]);`,
+ `function wrapper(route){return [route('/shadow', Home)]} export default defineApp(wrapper(other));`,
+ `export default defineApp([prefix(dynamic, [route('/child', Home)])]);`,
+ `export default defineApp([route(path, Home)]);`,
+ `export default defineApp([() => route('/middleware', Home)]);`,
+ `export default defineApp([route('/api', {...methods, get: list})]);`,
+ `export default defineApp([route('/api', {[method]: list})]);`,
+ `export default defineApp([route('/api', {get: list, get: other})]);`,
+ `export default defineApp([route('/api', {get get(){return list}})]);`,
+ `export default defineApp([route('/api', {get: list, [method](){return other()}})]);`,
+ `export default defineApp([route('/api', dynamic())]);`,
+ `export default defineApp([route('/api', [...handlers])]);`,
+ `const example = "route('/fake', Home)"; export default defineApp([]);`,
+ `const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, fetch: other};`,
+ `const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, ...other};`,
+ ])('ignores unsupported/unregistered declarations: %s', (body) =>
+ expect(extract(body).nodes).toEqual([]),
+ );
+ it('requires runtime framework helper imports', () => {
+ for (const source of [
+ `import {route} from 'other'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
+ `import type {route} from 'rwsdk/router'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
+ `import {route} from 'rwsdk/router'; import type {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
+ ])
+ expect(extractRedwoodRoutes('src/worker.tsx', source).nodes).toEqual([]);
+ });
+});
+
+describe('RedwoodSDK through indexing', () => {
+ let cg: CodeGraph | undefined;
+ let dir: string;
+ const write = (file: string, source: string) => {
+ fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
+ fs.writeFileSync(path.join(dir, file), source);
+ };
+ afterEach(() => {
+ cg?.close();
+ cg = undefined;
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
+ });
+ // redwoodjs/sdk@39da7118f712bd86450e493cb2c213815b1893bb (1.7.3),
+ // playground/typed-routes/src/worker.tsx: expected route/handler pairs.
+ it('binds the pinned worker pages, API handlers and incremental changes', async () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-'));
+ write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
+ for (const page of ['Home', 'UserProfile', 'FileViewer', 'BlogPost'])
+ write(`src/pages/${page}.tsx`, `export function ${page}(){return
}`);
+ write('src/other/Home.tsx', 'export function Home(){return }');
+ write(
+ 'src/api.ts',
+ `export function list(){return new Response('list')}
+export function create(){return new Response('created')}
+export function Misleading(){return new Response('not a page')}`,
+ );
+ const worker =
+ imports +
+ `import {Home} from './pages/Home'; import {UserProfile} from './pages/UserProfile';
+import {FileViewer} from './pages/FileViewer'; import {BlogPost} from './pages/BlogPost';
+import {list,create,Misleading} from './api';
+function auth(){return undefined}
+export const app = defineApp([render(Document, [route('/old', () => new Response(null,{status:301})), route('/',Home), route('/users/:id',[auth,UserProfile]), route('/files/*',FileViewer), route('/blog/:year/:slug',BlogPost), route('/misleading',Misleading)]), route('/api',{get:list,post:create})]);
+export default {fetch: app.fetch};`;
+ write('src/worker.tsx', worker);
+ cg = await CodeGraph.init(dir, { index: true });
+ const routes = cg.getNodesByKind('route');
+ expect(routes.map((n) => n.name).sort()).toEqual(
+ [
+ 'ANY /old',
+ '/',
+ '/users/:id',
+ '/files/*',
+ '/blog/:year/:slug',
+ 'ANY /misleading',
+ 'GET /api',
+ 'POST /api',
+ ].sort(),
+ );
+ const roots = routeRoots(cg, routes);
+ for (const [url, handler, file] of [
+ ['/', 'Home', 'src/pages/Home.tsx'],
+ ['/users/:id', 'UserProfile', 'src/pages/UserProfile.tsx'],
+ ['/files/*', 'FileViewer', 'src/pages/FileViewer.tsx'],
+ ['/blog/:year/:slug', 'BlogPost', 'src/pages/BlogPost.tsx'],
+ ['GET /api', 'list', 'src/api.ts'],
+ ['POST /api', 'create', 'src/api.ts'],
+ ]) {
+ const root = roots.get(routes.find((n) => n.name === url)!.id)!.node;
+ expect([root.name, root.filePath]).toEqual([handler, file]);
+ }
+ const userRoute = routes.find((n) => n.name === '/users/:id')!;
+ expect(cg.getOutgoingEdges(userRoute.id).map((e) => cg!.getNode(e.target)?.name)).not.toContain(
+ 'auth',
+ );
+ write(
+ 'src/pages/Home.tsx',
+ `export function Home(){const unused = () => ; return new Response('now an endpoint')}`,
+ );
+ await cg.sync({ paths: ['src/pages/Home.tsx'] });
+ expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /')).toBe(true);
+ fs.unlinkSync(path.join(dir, 'src/pages/UserProfile.tsx'));
+ await cg.sync();
+ expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /users/:id')).toBe(true);
+ write(
+ 'src/worker.tsx',
+ imports + `export default defineApp([route('/new', () => new Response('ok'))]);`,
+ );
+ await cg.sync();
+ expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']);
+ fs.unlinkSync(path.join(dir, 'src/worker.tsx'));
+ await cg.sync();
+ expect(cg.getNodesByKind('route')).toEqual([]);
+ });
+ it.each([false, true])('discovers a newly added SDK registration (scoped=%s)', async (scoped) => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-new-'));
+ write('package.json', '{}');
+ write('src/plain.ts', 'export function plain(){}');
+ cg = await CodeGraph.init(dir, { index: true });
+ write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
+ write(
+ 'src/worker.tsx',
+ imports + `export default defineApp([route('/new', () => new Response('ok'))]);`,
+ );
+ await cg.sync(scoped ? { paths: ['src/worker.tsx'] } : undefined);
+ expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']);
+ });
+ it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
+ 'classifies pages in fresh compiled workers',
+ () => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-workers-'));
+ write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
+ write('src/Home.tsx', 'export function Home(){return }');
+ write(
+ 'src/worker.tsx',
+ imports + `import {Home} from './Home'; export default defineApp([route('/',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 r=cg.getNodesByKind('route')[0];
+console.log(JSON.stringify([r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').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']]);
+ },
+ );
+});
diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md
index 45a8ddaca..234d34b8f 100644
--- a/docs/design/PLAN-application-router-coverage.md
+++ b/docs/design/PLAN-application-router-coverage.md
@@ -1,10 +1,10 @@
-Status: 4/13 — preparing step 4 PR (Astro route completion); steps 1–3 published as PRs #3–5
+Status: 5/13 — preparing step 5 PR (RedwoodSDK); steps 1–4 published as PRs #3–6
- [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.
- [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear.
- [x] 3 Remix and React Router file conventions — registered default conventions bind exact page components, optional navigation and explicit config coexistence; layout/resource controls and config-only full/scoped/reopened sync pass; build passes, 102 WASM tests pass, full native suite 4,415 pass / 46 skip; independent review clear.
- [x] 4 Astro route completion — exact page components, declared method handlers and local source navigation; false-anchor/type/export controls and full/scoped/new-framework sync pass; build passes, 195 WASM tests pass, full native suite 4,435 pass / 46 skip; independent review clear.
-- [ ] 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.
+- [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear.
- [ ] 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.
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index 1691a0cd4..40a9accc1 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -43,6 +43,9 @@ guessed.
| 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) |
| Astro | `frameworks/astro.ts` | — | `astro-routes.test.ts` | pinned endpoint fixture; exact page components and source navigation |
+| RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots |
+
+RedwoodSDK follows imported `defineApp` registrations with literal/local-constant arrays, `route`, `index`, `render`, `layout` and `prefix`. Handler arrays bind only their last handler. Standard method tables remain method-qualified; ordinary handlers remain `ANY` unless the handler returns JSX. [Pinned worker fixture](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/playground/typed-routes/src/worker.tsx), [router semantics](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/sdk/src/runtime/lib/router.ts#L682). Tests cover handler classification changes/deletion and fresh workers. Cross-file route arrays, custom methods, mutated builders, dynamic paths, duplicate/computed/spread method tables and wrapped/anonymous exported components remain unsupported; no navigation is inferred.
Remix default `app/routes/` conventions and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages and immediate `folder/route` modules. [Pinned filename parser](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/packages/react-router-fs-routes/flatRoutes.ts#L351): dot nesting, index/pathless segments, parameters, optional segments, splats and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Config-only full/scoped sync and reopening an index refresh existing pages. Custom configuration, folder `index` fallback, Markdown/MDX, anonymous defaults and re-exports remain unsupported.
diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md
index c9546e46c..40760614c 100644
--- a/site/src/content/docs/guides/framework-routes.md
+++ b/site/src/content/docs/guides/framework-routes.md
@@ -36,9 +36,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **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/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages |
+| **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification |
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.
+RedwoodSDK handler arrays use the final handler as the route root. A route stays `ANY /path` until its handler is shown to return JSX. Cross-file route arrays, custom methods, mutations, dynamic paths, ambiguous method tables and wrapped/anonymous exported components remain unsupported.
+
Astro supports default roots, `[param]`/`[...rest]` filenames and exported handlers, including `ALL` as `ANY`. Type-only exports, underscore-prefixed paths and `.mjs` endpoints are excluded. Custom routing configuration, Markdown/MDX, cross-file re-exports, client transition calls and navigation to rest routes remain unsupported.
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.
diff --git a/src/index.ts b/src/index.ts
index 70eb4d330..dda95a202 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -579,7 +579,7 @@ 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:')))
+ if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
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.
@@ -834,7 +834,7 @@ export class CodeGraph {
// (regex over *.module.ts only).
if (result.filesAdded > 0 || result.filesModified > 0) {
this.resolver.initialize();
- if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:')))
+ if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
} else if (result.filesRemoved > 0) {
@@ -846,6 +846,10 @@ export class CodeGraph {
// sees the post-removal state. (runPostExtract above clears caches
// itself, so the changed-files branch is already covered.)
this.resolver.clearCaches();
+ if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:redwood:'))) {
+ await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
+ this.resolver.runPostExtract();
+ }
}
// Resolve references if files were updated
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index 5c168188a..11c2fb1e8 100644
--- a/src/resolution/frameworks/index.ts
+++ b/src/resolution/frameworks/index.ts
@@ -20,6 +20,7 @@ import { svelteKitRouterResolver } from './sveltekit-router';
import { svelteResolver } from './svelte';
import { vueResolver } from './vue';
import { astroResolver } from './astro';
+import { redwoodResolver } from './redwood';
import { djangoResolver, flaskResolver, fastapiResolver } from './python';
import { railsResolver } from './ruby';
import { springResolver } from './java';
@@ -63,6 +64,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
// Vue Router — `createRouter({ routes })` → route nodes; `router.push({ name })` / `router.push('/x')` → navigates edges
vueRouterResolver,
astroResolver,
+ redwoodResolver,
// Python
djangoResolver,
flaskResolver,
diff --git a/src/resolution/frameworks/redwood.ts b/src/resolution/frameworks/redwood.ts
new file mode 100644
index 000000000..1b845c575
--- /dev/null
+++ b/src/resolution/frameworks/redwood.ts
@@ -0,0 +1,334 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import type { Node } from '../../types';
+import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types';
+import { detectLanguage, getParser } from '../../extraction/grammars';
+import { resolveViaImport } from '../import-resolver';
+import { dependsOn } from './package-deps';
+import { httpHandlerReferences } from './http-routing';
+
+const FUNCTIONS = new Set([
+ 'arrow_function',
+ 'function_expression',
+ 'function_declaration',
+ 'method_definition',
+]);
+const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head']);
+const unwrap = (raw: SyntaxNode | null | undefined): SyntaxNode | null => {
+ let node = raw ?? null;
+ while (
+ node &&
+ ['parenthesized_expression', 'as_expression', 'satisfies_expression'].includes(node.type)
+ )
+ node = node.namedChildren[0] ?? null;
+ return node;
+};
+const literal = (node: SyntaxNode | null | undefined): string | null =>
+ node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null;
+function returnedJsx(handler: SyntaxNode): SyntaxNode[] {
+ const found: SyntaxNode[] = [];
+ const expression = (node: SyntaxNode): void => {
+ if (FUNCTIONS.has(node.type)) return;
+ if (['jsx_element', 'jsx_self_closing_element', 'jsx_fragment'].includes(node.type))
+ found.push(node);
+ else for (const child of node.namedChildren) expression(child);
+ };
+ const statements = (node: SyntaxNode): void => {
+ if (FUNCTIONS.has(node.type)) return;
+ if (node.type === 'return_statement') {
+ for (const child of node.namedChildren) expression(child);
+ } else for (const child of node.namedChildren) statements(child);
+ };
+ const body = handler.childForFieldName('body');
+ if (body?.type === 'statement_block') statements(body);
+ else if (body) expression(body);
+ return found;
+}
+
+/** Only a registered defineApp tree contributes routes; middleware bodies are opaque. */
+export function extractRedwoodRoutes(filePath: string, content: string): FrameworkExtractionResult {
+ const result: FrameworkExtractionResult = { nodes: [], references: [] };
+ if (!content.includes('rwsdk/')) return result;
+ const language = detectLanguage(filePath)!;
+ if (!['typescript', 'javascript', 'tsx', 'jsx'].includes(language)) return result;
+ const parser = getParser(language);
+ if (!parser) throw new Error(`RedwoodSDK extraction requires the ${language} grammar`);
+ const tree = parser.parse(content);
+ if (!tree) return result;
+ try {
+ const helpers = new Map();
+ const bindings = new Map();
+ for (const statement of tree.rootNode.namedChildren) {
+ if (
+ statement.type === 'import_statement' &&
+ !statement.children.some((n) => n.type === 'type')
+ ) {
+ const source = literal(statement.childForFieldName('source'));
+ for (const spec of statement.descendantsOfType('import_specifier')) {
+ if (spec.children.some((n) => n.type === 'type')) continue;
+ const name = spec.childForFieldName('name')?.text;
+ const alias = spec.childForFieldName('alias')?.text ?? name;
+ if (
+ name &&
+ alias &&
+ ((source === 'rwsdk/worker' && name === 'defineApp') ||
+ (source === 'rwsdk/router' &&
+ ['route', 'index', 'render', 'layout', 'prefix'].includes(name)))
+ )
+ helpers.set(alias, name);
+ }
+ }
+ const declaration =
+ statement.type === 'export_statement'
+ ? statement.childForFieldName('declaration')
+ : statement;
+ if (
+ declaration?.type === 'lexical_declaration' &&
+ declaration.children.some((n) => n.type === 'const')
+ ) {
+ for (const variable of declaration.namedChildren) {
+ const name = variable.childForFieldName('name');
+ const value = variable.childForFieldName('value');
+ if (name?.type === 'identifier' && value) bindings.set(name.text, value);
+ }
+ }
+ }
+ const resolve = (
+ raw: SyntaxNode | null | undefined,
+ seen = new Set(),
+ ): SyntaxNode | null => {
+ const node = unwrap(raw);
+ if (node?.type !== 'identifier' || !bindings.has(node.text)) return node;
+ if (seen.has(node.text)) return null;
+ seen.add(node.text);
+ return resolve(bindings.get(node.text), seen);
+ };
+ const emit = (
+ site: SyntaxNode,
+ routePath: string,
+ method: string,
+ raw: SyntaxNode | null,
+ ): void => {
+ let handler = unwrap(raw);
+ if (handler?.type === 'array') {
+ if (handler.namedChildren.some((n) => n.type === 'spread_element')) return;
+ handler = unwrap(handler.namedChildren.filter((n) => n.type !== 'comment').at(-1));
+ }
+ if (
+ !handler ||
+ (!FUNCTIONS.has(handler.type) &&
+ !['identifier', 'shorthand_property_identifier'].includes(handler.type))
+ )
+ return;
+ const jsx = method === 'ANY' && FUNCTIONS.has(handler.type) ? returnedJsx(handler) : [];
+ const page = jsx.length > 0;
+ const node: Node = {
+ id: `route:redwood:${filePath}:${site.startIndex}:${method}:${routePath}`,
+ kind: 'route',
+ name: page ? routePath : `${method} ${routePath}`,
+ qualifiedName: `${filePath}::${method} ${routePath}`,
+ filePath,
+ language,
+ startLine: site.startPosition.row + 1,
+ endLine: site.endPosition.row + 1,
+ startColumn: site.startPosition.column,
+ endColumn: site.endPosition.column,
+ updatedAt: Date.now(),
+ };
+ if (result.nodes.some((n) => n.id === node.id)) return;
+ result.nodes.push(node);
+ result.references.push(...httpHandlerReferences(node, handler));
+ if (page)
+ for (const tag of jsx.flatMap((n) =>
+ n.descendantsOfType(['jsx_opening_element', 'jsx_self_closing_element']),
+ )) {
+ const name = tag.childForFieldName('name');
+ if (name && /^[A-Z][\w$]*$/.test(name.text))
+ result.references.push(...httpHandlerReferences(node, name));
+ }
+ };
+ const visit = (raw: SyntaxNode | null | undefined, prefix: string, depth = 0): void => {
+ if (depth > 32) return;
+ const node = resolve(raw);
+ if (!node) return;
+ if (node.type === 'array') {
+ for (const child of node.namedChildren)
+ visit(
+ child.type === 'spread_element' ? child.namedChildren[0] : child,
+ prefix,
+ depth + 1,
+ );
+ return;
+ }
+ if (node.type !== 'call_expression') return;
+ const fn = node.childForFieldName('function');
+ const helper = fn?.type === 'identifier' ? helpers.get(fn.text) : undefined;
+ const args =
+ node.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ??
+ [];
+ if (helper === 'render' || helper === 'layout') {
+ visit(args[1], prefix, depth + 1);
+ return;
+ }
+ if (helper === 'prefix') {
+ const part = literal(args[0]);
+ if (part !== null) visit(args[1], prefix + '/' + part.replace(/^\/+|\/+$/g, ''), depth + 1);
+ return;
+ }
+ if (helper !== 'route' && helper !== 'index') return;
+ const part = helper === 'index' ? '/' : literal(args[0]);
+ if (part === null) return;
+ const routePath = (prefix + '/' + part.replace(/^\/+|\/+$/g, '')).replace(/\/$/, '') || '/';
+ const rawHandler = unwrap(args[helper === 'index' ? 0 : 1]);
+ const resolvedHandler = resolve(rawHandler);
+ const handler =
+ resolvedHandler && ['object', 'array'].includes(resolvedHandler.type)
+ ? resolvedHandler
+ : rawHandler;
+ if (handler?.type !== 'object') {
+ emit(node, routePath, 'ANY', handler);
+ return;
+ }
+ if (
+ handler.namedChildren.some(
+ (n) =>
+ n.type === 'spread_element' ||
+ (n.childForFieldName('key') ?? n.childForFieldName('name'))?.type ===
+ 'computed_property_name' ||
+ (n.type === 'method_definition' &&
+ n.children.some((c) => c.type === 'get' || c.type === 'set')),
+ )
+ )
+ return;
+ const keys = handler.namedChildren
+ .map((n) => {
+ const key =
+ n.childForFieldName('key') ??
+ n.childForFieldName('name') ??
+ (n.type === 'shorthand_property_identifier' ? n : null);
+ return literal(key) ?? key?.text;
+ })
+ .filter((key) => key !== undefined);
+ if (new Set(keys).size !== keys.length) return;
+ for (const pair of handler.namedChildren) {
+ const key =
+ pair.childForFieldName('key') ??
+ pair.childForFieldName('name') ??
+ (pair.type === 'shorthand_property_identifier' ? pair : null);
+ const method = literal(key) ?? key?.text;
+ if (method && METHODS.has(method))
+ emit(
+ node,
+ routePath,
+ method.toUpperCase(),
+ pair.type === 'method_definition' || pair.type === 'shorthand_property_identifier'
+ ? pair
+ : pair.childForFieldName('value'),
+ );
+ }
+ };
+ const register = (raw: SyntaxNode | null | undefined): void => {
+ let node = resolve(raw);
+ if (node?.type === 'object') {
+ if (
+ node.namedChildren.some(
+ (n) =>
+ n.type === 'spread_element' ||
+ n.childForFieldName('key')?.type === 'computed_property_name',
+ )
+ )
+ return;
+ const fields = node.namedChildren.filter(
+ (n) =>
+ (literal(n.childForFieldName('key')) ?? n.childForFieldName('key')?.text) === 'fetch',
+ );
+ if (fields.length !== 1) return;
+ const fetch = fields[0]!.childForFieldName('value');
+ if (
+ fetch?.type === 'member_expression' &&
+ fetch.childForFieldName('property')?.text === 'fetch'
+ )
+ node = resolve(fetch.childForFieldName('object'));
+ }
+ const fn = node?.childForFieldName('function');
+ if (
+ node?.type === 'call_expression' &&
+ fn?.type === 'identifier' &&
+ helpers.get(fn.text) === 'defineApp'
+ )
+ visit(node.childForFieldName('arguments')?.namedChildren[0], '');
+ };
+ for (const statement of tree.rootNode.namedChildren) {
+ if (statement.type !== 'export_statement') continue;
+ if (statement.children.some((n) => n.type === 'default'))
+ register(statement.childForFieldName('value'));
+ const declaration = statement.childForFieldName('declaration');
+ if (declaration?.type === 'lexical_declaration')
+ for (const variable of declaration.namedChildren)
+ register(variable.childForFieldName('value'));
+ }
+ return result;
+ } finally {
+ tree.delete();
+ }
+}
+
+/** Promote only handlers that return JSX; names and nested helpers are not evidence. */
+function finalizePages(context: ResolutionContext): Node[] {
+ const updates: Node[] = [];
+ const routes = context.getNodesByKind('route').filter((n) => n.id.startsWith('route:redwood:'));
+ for (const file of new Set(routes.map((n) => n.filePath))) {
+ const source = context.readFile(file);
+ if (source === null) continue;
+ const extracted = extractRedwoodRoutes(file, source);
+ for (const route of extracted.nodes) {
+ if (!route.name.startsWith('ANY ')) continue;
+ const ref = extracted.references.find(
+ (r) => r.fromNodeId === route.id && r.referenceKind === 'references',
+ );
+ if (!ref) continue;
+ const imported = resolveViaImport(ref, context);
+ const target = imported
+ ? context.getNodeById?.(imported.targetNodeId)
+ : context
+ .getNodesInFile(file)
+ .find(
+ (n) => n.name === ref.referenceName && ['function', 'component'].includes(n.kind),
+ );
+ let page = target?.kind === 'component';
+ if (target?.kind === 'function') {
+ const source = context.readFile(target.filePath);
+ const parser = getParser(target.language);
+ const tree = source === null ? null : parser?.parse(source);
+ if (tree)
+ try {
+ const 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 === target.name);
+ const handler =
+ declaration?.type === 'variable_declarator'
+ ? unwrap(declaration.childForFieldName('value'))
+ : declaration;
+ page = !!handler && returnedJsx(handler).length > 0;
+ } finally {
+ tree.delete();
+ }
+ }
+ const existing = routes.find((n) => n.id === route.id);
+ const name = page ? route.name.slice(4) : route.name;
+ if (existing && existing.name !== name) updates.push({ ...existing, name });
+ }
+ }
+ return updates;
+}
+
+export const redwoodResolver: FrameworkResolver = {
+ name: 'redwood',
+ languages: ['typescript', 'javascript', 'tsx', 'jsx'],
+ detect: (context) => dependsOn(context, 'rwsdk'),
+ resolve: () => null,
+ extract: extractRedwoodRoutes,
+ postExtract: finalizePages,
+};