فهرست منبع

feat(resolution): React Router object data-router + Next.js route precision

- Object data-router (v6.4+): createBrowserRouter([{ path, element: <Comp/> }])
  / { path, Component: Comp } — extract route + component (gated to files using
  the data-router API; requires a component so a stray `path:` field isn't a route).
- Next.js precision: filePathToRoute treated config files (next.config.mjs,
  vite.config.ts) and a `nextjs-pages/` dir (substring of "pages/") as routes.
  Require a real page extension (.tsx/.ts/.jsx/.js), exclude *.config.* and
  _app/_document, and match pages/ + app/ as path SEGMENTS. bulletproof-react
  4 bogus config "routes" → 0.

Frontier: lazy data-router routes (path: paths.x.path + lazy: () => import())
use variable paths + lazily-imported modules — no literal path/named component.
Tests: object-router literal form, config/nextjs-pages exclusion. Suite 806.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 ماه پیش
والد
کامیت
0456915fa3
2فایلهای تغییر یافته به همراه74 افزوده شده و 2 حذف شده
  1. 21 0
      __tests__/frameworks.test.ts
  2. 53 2
      src/resolution/frameworks/react.ts

+ 21 - 0
__tests__/frameworks.test.ts

@@ -1103,6 +1103,27 @@ describe('reactResolver.extract — React Router', () => {
     expect(routes).toHaveLength(1);
     expect(routes).toHaveLength(1);
     expect(routes[0]?.name).toBe('/x');
     expect(routes[0]?.name).toBe('/x');
   });
   });
+
+  it('extracts createBrowserRouter object routes ({ path, element/Component })', () => {
+    const src = `const router = createBrowserRouter([
+      { path: "/dashboard", element: <Dashboard /> },
+      { path: "/login", Component: Login },
+    ]);`;
+    const { nodes, references } = reactResolver.extract!('router.tsx', src);
+    const routes = nodes.filter((n) => n.kind === 'route');
+    expect(routes.map((n) => n.name).sort()).toEqual(['/dashboard', '/login']);
+    expect(references.map((r) => r.referenceName).sort()).toEqual(['Dashboard', 'Login']);
+  });
+
+  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 {}');
+    expect(cfg.nodes.filter((n) => n.kind === 'route')).toHaveLength(0);
+    const vite = reactResolver.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}');
+    expect(page.nodes.filter((n) => n.kind === 'route').map((n) => n.name)).toEqual(['/about']);
+  });
 });
 });
 
 
 describe('svelteResolver.extract (smoke)', () => {
 describe('svelteResolver.extract (smoke)', () => {

+ 53 - 2
src/resolution/frameworks/react.ts

@@ -186,6 +186,47 @@ export const reactResolver: FrameworkResolver = {
       }
       }
     }
     }
 
 
+    // React Router data-router (v6.4+): createBrowserRouter([{ path, element }]).
+    // Only scan files that use the data-router API, then pull each route object's
+    // `path` + `element={<Comp/>}` / `Component: Comp` (a forward window confirms
+    // it's a route object, not a stray `path:` field).
+    if (/\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(content)) {
+      const objPathRe = /\bpath\s*:\s*['"]([^'"]*)['"]/g;
+      let om: RegExpExecArray | null;
+      while ((om = objPathRe.exec(content)) !== null) {
+        const win = content.slice(om.index, om.index + 300);
+        const compMatch =
+          win.match(/\belement\s*:\s*<\s*([A-Z][A-Za-z0-9_]*)/) ||
+          win.match(/\bComponent\s*:\s*([A-Z][A-Za-z0-9_]*)/);
+        if (!compMatch) continue; // require a component → it's a real route object
+        const routePath = om[1] || '/';
+        const line = content.slice(0, om.index).split('\n').length;
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${routePath}`,
+          kind: 'route',
+          name: routePath,
+          qualifiedName: `${filePath}::route:${routePath}`,
+          filePath,
+          startLine: line,
+          endLine: line,
+          startColumn: 0,
+          endColumn: 0,
+          language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+          updatedAt: now,
+        };
+        nodes.push(routeNode);
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: compMatch[1]!,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
+        });
+      }
+    }
+
     // Extract Next.js pages/routes (pages directory convention)
     // Extract Next.js pages/routes (pages directory convention)
     if (filePath.includes('pages/') || filePath.includes('app/')) {
     if (filePath.includes('pages/') || filePath.includes('app/')) {
       // Default export in pages becomes a route
       // Default export in pages becomes a route
@@ -322,7 +363,17 @@ function filePathToRoute(filePath: string): string | null {
   // app/page.tsx -> /
   // app/page.tsx -> /
   // app/about/page.tsx -> /about
   // app/about/page.tsx -> /about
 
 
-  if (filePath.includes('pages/')) {
+  // 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
     let route = filePath
       .replace(/^.*pages\//, '/')
       .replace(/^.*pages\//, '/')
       .replace(/\/index\.(tsx?|jsx?)$/, '')
       .replace(/\/index\.(tsx?|jsx?)$/, '')
@@ -333,7 +384,7 @@ function filePathToRoute(filePath: string): string | null {
     return route;
     return route;
   }
   }
 
 
-  if (filePath.includes('app/')) {
+  if (/(?:^|\/)app\//.test(filePath)) {
     // App router - only page.tsx files are routes
     // App router - only page.tsx files are routes
     if (!filePath.includes('page.')) {
     if (!filePath.includes('page.')) {
       return null;
       return null;