Просмотр исходного кода

feat(resolution): React Router <Route> JSX route extraction

react.ts extracted components/hooks and Next.js file routes but returned
references: [], so React Router <Route> declarations produced no route
nodes or route→component edges. Add <Route> JSX extraction: scan a window
after each <Route (so the nested > in element={<Comp/>} doesn't truncate
the match), pull path="…" + component={C} (v5) or element={<C/>} (v6) in
any attribute order, emit a route node + component reference (resolved by
the existing PascalCase resolveComponent). The <Routes> container is
excluded via the \b boundary.

react-realworld 0→10 routes, 10/10 resolved (/login→Login,
/editor/:slug→Editor, /@:username→Profile). No regression on excalidraw
(9,290 nodes, 46 react-render synth edges intact, 0 false routes). Tests:
v5 component=, v6 element=, <Routes>-container guard. Suite green (794).

Frontier: object data-router createBrowserRouter([{path,element}]) (modern
v6) is object-based not JSX — not covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
e0f6933770
2 измененных файлов с 65 добавлено и 8 удалено
  1. 21 7
      __tests__/frameworks.test.ts
  2. 44 1
      src/resolution/frameworks/react.ts

+ 21 - 7
__tests__/frameworks.test.ts

@@ -990,14 +990,28 @@ func boot(routes: RoutesBuilder) throws {
 import { reactResolver } from '../src/resolution/frameworks/react';
 import { svelteResolver } from '../src/resolution/frameworks/svelte';
 
-describe('reactResolver.extract (smoke)', () => {
-  it('returns { nodes, references } shape', () => {
+describe('reactResolver.extract — React Router', () => {
+  it('extracts a v6 <Route path element={<Comp/>}>', () => {
     const src = `<Route path="/users" element={<UsersPage/>}/>`;
-    const result = reactResolver.extract!('App.tsx', src);
-    expect(result).toHaveProperty('nodes');
-    expect(result).toHaveProperty('references');
-    expect(Array.isArray(result.nodes)).toBe(true);
-    expect(Array.isArray(result.references)).toBe(true);
+    const { nodes, references } = reactResolver.extract!('App.tsx', src);
+    const route = nodes.find((n) => n.kind === 'route');
+    expect(route?.name).toBe('/users');
+    expect(references[0]?.referenceName).toBe('UsersPage');
+  });
+
+  it('extracts a v5 <Route path component={Comp}> with attributes in any order', () => {
+    const src = `<Route exact path="/login" component={Login} />`;
+    const { nodes, references } = reactResolver.extract!('App.jsx', src);
+    const route = nodes.find((n) => n.kind === 'route');
+    expect(route?.name).toBe('/login');
+    expect(references[0]?.referenceName).toBe('Login');
+  });
+
+  it('does not treat the <Routes> container as a route', () => {
+    const src = `<Routes><Route path="/x" element={<X/>}/></Routes>`;
+    const routes = reactResolver.extract!('App.tsx', src).nodes.filter((n) => n.kind === 'route');
+    expect(routes).toHaveLength(1);
+    expect(routes[0]?.name).toBe('/x');
   });
 });
 

+ 44 - 1
src/resolution/frameworks/react.ts

@@ -76,6 +76,7 @@ export const reactResolver: FrameworkResolver = {
 
   extract(filePath, content) {
     const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
     const now = Date.now();
 
     // Extract component definitions
@@ -143,6 +144,48 @@ export const reactResolver: FrameworkResolver = {
       });
     }
 
+    // React Router: <Route path="/x" component={Comp}/> (v5) or
+    // <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,
+    // and element={...} contains a nested `>`, so scan a window after each
+    // <Route rather than trying to match the whole (possibly multi-line) tag.
+    const routeTagRegex = /<Route\b/g;
+    let routeMatch: RegExpExecArray | null;
+    while ((routeMatch = routeTagRegex.exec(content)) !== null) {
+      const window = content.slice(routeMatch.index, routeMatch.index + 400);
+      const pathMatch = window.match(/\bpath\s*=\s*["']([^"']+)["']/);
+      if (!pathMatch) continue; // index/layout routes without a path
+      const routePath = pathMatch[1]!;
+      const compMatch =
+        window.match(/\bcomponent\s*=\s*\{\s*([A-Z][A-Za-z0-9_]*)/) ||
+        window.match(/\belement\s*=\s*\{\s*<\s*([A-Z][A-Za-z0-9_]*)/);
+      const line = content.slice(0, routeMatch.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);
+      if (compMatch) {
+        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)
     if (filePath.includes('pages/') || filePath.includes('app/')) {
       // Default export in pages becomes a route
@@ -169,7 +212,7 @@ export const reactResolver: FrameworkResolver = {
       }
     }
 
-    return { nodes, references: [] };
+    return { nodes, references };
   },
 };