mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
* fix(react-router): bound route scans to their declarations (#1348) Fixed scan windows borrowed paths and components from neighboring or nested routes. Scan JSX opening tags and data-router objects structurally, pairing only their own attributes and properties. Add persisted-graph regressions across JavaScript and TypeScript route declarations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(react-router): bound route scans to their declarations (#1348) Fixed scan windows borrowed paths and components from neighboring or nested routes. Scan JSX opening tags and data-router objects structurally, pairing only their own attributes and properties. Add persisted-graph regressions across JavaScript and TypeScript route declarations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
* is what the app-root gate has to get right. Mirrors `nextjs.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -495,3 +495,101 @@ describe('react-router: the shapes proshop is written in', () => {
|
||||
expect(screens.dropped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('react-router: route declaration boundaries (#1348)', () => {
|
||||
let tmpDir: string;
|
||||
let cg: CodeGraph | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cg?.close();
|
||||
cg = undefined;
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function index(source: string, extension: string) {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-boundaries-'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '18' } }));
|
||||
fs.writeFileSync(path.join(tmpDir, `App.${extension}`), source);
|
||||
cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
const routes = cg.getNodesByKind('route');
|
||||
return {
|
||||
paths: routes.map((route) => route.name).sort(),
|
||||
bindings: routes.flatMap((route) => cg!.getOutgoingEdges(route.id)
|
||||
.filter((edge) => edge.kind === 'references')
|
||||
.map((edge) => `${route.name}->${cg!.getNode(edge.target)?.name}`)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
it.each(['tsx', 'jsx', 'js'])('keeps nested/index JSX routes and long attributes local in %s', async (extension) => {
|
||||
const result = await index(`
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
function DashboardHome() { return null; }
|
||||
function Settings() { return null; }
|
||||
function Shell() { return null; }
|
||||
const comparison = count<limit;
|
||||
const fake = '<Route path="/fake" element={<DashboardHome/>}/>';
|
||||
export function App() {
|
||||
return <Routes>
|
||||
<Route path="/dashboard">
|
||||
<Route index element={<DashboardHome/>}/>
|
||||
<Route path="settings" element={<Settings/>}/>
|
||||
</Route>
|
||||
<Route path="/empty"></Route>
|
||||
<Route element={<Settings/>} path="/sibling"/>
|
||||
<Route element={<Shell title="a > b"><Settings path="/nested"/></Shell>}
|
||||
check={/}/.test('}')}
|
||||
handle={{ text: 'path="/borrowed"', nested: { element: <DashboardHome/> } }}
|
||||
title="${'x'.repeat(600)}" path="/long"/>
|
||||
<Route path="/no-element" handle={{ element: <DashboardHome/> }}/>
|
||||
<Route component={Settings} path="/legacy"/>
|
||||
</Routes>;
|
||||
}
|
||||
`, extension);
|
||||
expect(result).toEqual({
|
||||
paths: ['/dashboard', '/empty', '/legacy', '/long', '/no-element', '/sibling', 'settings'],
|
||||
bindings: ['/legacy->Settings', '/long->Shell', '/sibling->Settings', 'settings->Settings'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['tsx', 'jsx', 'ts', 'js'])('pairs only direct data-router properties in either order in %s', async (extension) => {
|
||||
const result = await index(`
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
function DataIndex() { return null; }
|
||||
function DataSettings() { return null; }
|
||||
const routes = createBrowserRouter([
|
||||
{ path: '/data', children: [
|
||||
{ index: true, Component: DataIndex },
|
||||
{ Component: DataSettings, path: 'prefs' }
|
||||
] },
|
||||
{ path: '/empty' },
|
||||
{ Component: DataSettings, path: '/sibling' },
|
||||
{ path: '/metadata', handle: { Component: DataIndex } },
|
||||
{ Component: DataSettings, handle: { path: '/not-own' } },
|
||||
{ path: '/long', handle: { text: '${'x'.repeat(600)}' }, Component: DataSettings },
|
||||
{ 'Component': DataSettings, /* path: '/fake' */ 'path': '/quoted' /* trailing comment */ },
|
||||
{ path: '', Component: DataSettings }
|
||||
]);
|
||||
`, extension);
|
||||
expect(result).toEqual({
|
||||
paths: ['/', '/long', '/quoted', '/sibling', 'prefs'],
|
||||
bindings: ['/->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings', 'prefs->DataSettings'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps nested JSX and comma-containing expressions inside their data-router property', async () => {
|
||||
const result = await index(`
|
||||
import { createMemoryRouter } from 'react-router-dom';
|
||||
function Shell() { return null; }
|
||||
function Child() { return null; }
|
||||
const router = createMemoryRouter([
|
||||
{ element: <Shell title="a > b"><Child path="/fake"/>hello, world</Shell>,
|
||||
handle: { text: "}, path: '/fake'", callback: () => ({ path: '/also-fake' }) }, path: '/shell' },
|
||||
{ path: '/none', handle: { element: <Child/> } },
|
||||
{ path: '/child', element: <Child/> }
|
||||
]);
|
||||
`, 'tsx');
|
||||
expect(result).toEqual({ paths: ['/child', '/shell'], bindings: ['/child->Child', '/shell->Shell'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,21 +98,10 @@ export const reactResolver: FrameworkResolver = {
|
||||
// which is why 'tsx'/'jsx' are now in `languages` — `<Route>`/`element={<X/>}`
|
||||
// routes live in JSX files and were previously skipped entirely.
|
||||
|
||||
// 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;
|
||||
// Read only each opening tag's own attributes, including expression values.
|
||||
const declarations = scanRouteDeclarations(content, !/\.(?:ts|mts|cts)$/.test(filePath));
|
||||
for (const { path: routePath, component, at } of declarations) {
|
||||
const line = content.slice(0, at).split('\n').length;
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:${routePath}`,
|
||||
kind: 'route',
|
||||
@@ -127,51 +116,10 @@ export const reactResolver: FrameworkResolver = {
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(routeNode);
|
||||
if (compMatch) {
|
||||
if (component) {
|
||||
references.push({
|
||||
fromNodeId: routeNode.id,
|
||||
referenceName: compMatch[1]!,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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]!,
|
||||
referenceName: component,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
@@ -187,6 +135,152 @@ export const reactResolver: FrameworkResolver = {
|
||||
},
|
||||
};
|
||||
|
||||
interface RouteDeclaration {
|
||||
path: string;
|
||||
component?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Structural scanner: strings, comments, JSX and balanced expressions are units. */
|
||||
function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclaration[] {
|
||||
const routes: RouteDeclaration[] = [];
|
||||
const dataRouter = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(source);
|
||||
if (!dataRouter && !/<Route\b/.test(source)) return routes;
|
||||
const literal = (value: string): string | undefined => {
|
||||
const match = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/.exec(value.trim());
|
||||
return match ? match[1] ?? match[2] : undefined;
|
||||
};
|
||||
const componentName = (value: string | undefined, jsx: boolean): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
const match = jsx
|
||||
? /^\s*<\s*([A-Z][\w]*)\s*(?=[\s/>])/.exec(value)
|
||||
: /^\s*([A-Z][\w]*)\s*$/.exec(value);
|
||||
return match?.[1];
|
||||
};
|
||||
// The few characters before `at`, trailing whitespace skipped: enough for the
|
||||
// end-anchored checks below without copying the whole prefix per `/` or `<`.
|
||||
const tokenBefore = (at: number): string => {
|
||||
let j = at - 1;
|
||||
while (j >= 0 && /\s/.test(source[j]!)) j--;
|
||||
return source.slice(Math.max(0, j - 11), j + 1);
|
||||
};
|
||||
const trivia = (at: number): number => {
|
||||
while (at < source.length) {
|
||||
if (/\s/.test(source[at]!)) { at++; continue; }
|
||||
if (source.startsWith('//', at)) {
|
||||
const end = source.indexOf('\n', at + 2);
|
||||
at = end < 0 ? source.length : end;
|
||||
} else if (source.startsWith('/*', at)) {
|
||||
const end = source.indexOf('*/', at + 2);
|
||||
at = end < 0 ? source.length : end + 2;
|
||||
} else break;
|
||||
}
|
||||
return at;
|
||||
};
|
||||
function unit(at: number): number {
|
||||
const ch = source[at];
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
let i = at + 1;
|
||||
while (i < source.length) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === ch) return i + 1;
|
||||
if (ch === '`' && source.startsWith('${', i)) { i = unit(i + 1); continue; }
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
// A regex can contain braces or JSX-looking text without ending an expression.
|
||||
if (ch === '/') {
|
||||
const before = tokenBefore(at);
|
||||
if (!before || /[=(:,[!&|?{};]$/.test(before) || /\b(?:return|throw|case|yield)\s*$/.test(before)) {
|
||||
let inClass = false;
|
||||
for (let i = at + 1; i < source.length && source[i] !== '\n'; i++) {
|
||||
if (source[i] === '\\') { i++; continue; }
|
||||
if (source[i] === '[') inClass = true;
|
||||
else if (source[i] === ']') inClass = false;
|
||||
else if (source[i] === '/' && !inClass) {
|
||||
i++;
|
||||
while (/[a-z]/i.test(source[i] ?? '') && i < source.length) i++;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allowJsx && ch === '<' && /^<(?:[A-Za-z][\w.:-]*|>)/.test(source.slice(at))) {
|
||||
const before = tokenBefore(at);
|
||||
// `count<limit` and `factory<Type>()` are not JSX opening tags.
|
||||
if (!/[\w$)\]'"]$/.test(before) || /\breturn$/.test(before)) return jsx(at);
|
||||
}
|
||||
const close = ch === '{' ? '}' : ch === '[' ? ']' : ch === '(' ? ')' : undefined;
|
||||
if (!close) return at + 1;
|
||||
let i = at + 1;
|
||||
const fields = new Map<string, { value: string; at: number }>();
|
||||
while ((i = trivia(i)) < source.length && source[i] !== close) {
|
||||
// A property must start at an object entry, never inside its value.
|
||||
const key = ch === '{' ? /^(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:/.exec(source.slice(i)) : null;
|
||||
if (key) {
|
||||
const start = i;
|
||||
const valueAt = trivia(i + key[0].length);
|
||||
i = valueAt;
|
||||
let valueEnd = i;
|
||||
while ((i = trivia(i)) < source.length && source[i] !== ',' && source[i] !== close) {
|
||||
i = unit(i);
|
||||
valueEnd = i;
|
||||
}
|
||||
fields.set(key[1] ?? key[2]!, { value: source.slice(valueAt, valueEnd).trim(), at: start });
|
||||
} else {
|
||||
// Skip a whole entry (spread, method, shorthand), but still visit nested units.
|
||||
while ((i = trivia(i)) < source.length && source[i] !== ',' && source[i] !== close) i = unit(i);
|
||||
}
|
||||
if (source[i] === ',') i++;
|
||||
}
|
||||
if (dataRouter && ch === '{' && source[i] === close) {
|
||||
const pathField = fields.get('path');
|
||||
const path = pathField && literal(pathField.value);
|
||||
const component = componentName(fields.get('element')?.value, true)
|
||||
?? componentName(fields.get('Component')?.value, false);
|
||||
if (path !== undefined && component) routes.push({ path: path || '/', component, at: pathField!.at });
|
||||
}
|
||||
return i < source.length ? i + 1 : i;
|
||||
}
|
||||
function jsx(at: number): number {
|
||||
const tag = /^<([\w.:-]*)/.exec(source.slice(at))!;
|
||||
let i = at + tag[0].length;
|
||||
const attrs = new Map<string, string>();
|
||||
while ((i = trivia(i)) < source.length && source[i] !== '>' && !source.startsWith('/>', i)) {
|
||||
const attr = /^[\w:-]+/.exec(source.slice(i));
|
||||
if (!attr) { i = unit(i); continue; }
|
||||
i = trivia(i + attr[0].length);
|
||||
if (source[i] !== '=') continue;
|
||||
i = trivia(i + 1);
|
||||
const start = i;
|
||||
i = unit(i);
|
||||
attrs.set(attr[0], source.slice(start, i));
|
||||
}
|
||||
if (tag[1] === 'Route' && i < source.length) {
|
||||
const path = literal(attrs.get('path') ?? '');
|
||||
const expression = (name: string) => attrs.get(name)?.replace(/^\{([\s\S]*)\}$/, '$1');
|
||||
const component = componentName(expression('component'), false) ?? componentName(expression('element'), true);
|
||||
if (path) routes.push({ path, component, at });
|
||||
}
|
||||
if (source.startsWith('/>', i)) return i + 2;
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
if (source.startsWith('</', i)) {
|
||||
const end = source.indexOf('>', i + 2);
|
||||
return end < 0 ? source.length : end + 1;
|
||||
}
|
||||
if (source[i] === '<' && /^<(?:[A-Za-z]|>)/.test(source.slice(i))) i = jsx(i);
|
||||
else if (source[i] === '{') i = unit(i);
|
||||
else i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
let at = 0;
|
||||
while ((at = trivia(at)) < source.length) at = unit(at);
|
||||
return routes.sort((a, b) => a.at - b.at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is PascalCase
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user