diff --git a/CHANGELOG.md b/CHANGELOG.md
index 657917c..bd91365 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
- Added Next.js route mapping for `src/app` and `src/pages` layouts, thanks @obatried.
- Added first-pass Python mapping for project metadata, console scripts, source groups, pytest suites, and conservative validation defaults, thanks @xiamx.
- Added progress output for `clawpatch revalidate`, thanks @twidtwid.
+- Added React Router and React component mapping, thanks @moritzscheele.
- Improved Node/TypeScript mapping for large workspaces by splitting package source trees into bounded review groups with package-local tests.
- Added generic nested SwiftPM, Apple/Xcode, and Gradle/Android app mapping.
- Fixed Codex provider execution on Windows paths with spaces and npm `.cmd` shims, thanks @1berto.
diff --git a/README.md b/README.md
index 7f7989b..5def92a 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,7 @@ validation commands and records a patch attempt under `.clawpatch/`.
- Nx project metadata from `project.json`, including project-scoped validation
targets
- Next.js `app/` and `pages/` routes, including routes inside monorepo apps
+- React Router routes and React components
- Go package slices from `go list ./...`, including command packages
- Go package tests and same-repo imports as review context
- Java/Kotlin Gradle source groups and root Gradle build/test commands
diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md
index 706c01b..f1817ef 100644
--- a/docs/feature-mapping.md
+++ b/docs/feature-mapping.md
@@ -31,6 +31,9 @@ Supported deterministic mappers today:
- Node/TypeScript workspace packages from `package.json` workspaces, `pnpm-workspace.yaml`, and common package folders
- Nx project metadata from `project.json`, including project names, source roots, project types, and target names
- bounded Node/TypeScript source groups under `src/`, `lib/`, `app/`, `pages/`, and `scripts/`
+- React Router `` declarations and React components in
+ root or nested frontend packages such as `frontend/`, `client/`, `web/`,
+ workspaces, and packages under `apps/` or `packages/`
- Next.js `app/` and `pages/` routes at the repo root or inside discovered monorepo projects
- Go `cmd/*/main.go`
- Go `internal/*` packages
@@ -72,6 +75,12 @@ clawpatch next --project web
When an Nx project target is available, nearby tests use the project-scoped
command, such as `yarn nx test web`, instead of a repository-wide test command.
+React mapping discovers packages with a React dependency, including common
+nested frontend directories. It maps React Router route declarations to the
+component they render when the component can be resolved from a local import or
+lazy import, and also maps page/component files under `src/pages` and
+`src/components` as UI-flow slices.
+
Native app mappers use the same bounded grouping model. SwiftPM packages can be
discovered below the repo root, Apple projects are grouped by Swift source area,
and Gradle modules are grouped from `src/main`, `src/test`, and `src/androidTest`.
diff --git a/src/mapper.test.ts b/src/mapper.test.ts
index 7872b55..9b6a3f1 100644
--- a/src/mapper.test.ts
+++ b/src/mapper.test.ts
@@ -269,6 +269,88 @@ describe("mapFeatures", () => {
);
});
+ it("keeps Nx target commands on the workspace package manager", async () => {
+ const root = await fixtureRoot("clawpatch-map-nx-root-package-manager-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2),
+ );
+ await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - apps/*\n");
+ await writeFixture(
+ root,
+ "apps/web/project.json",
+ JSON.stringify({ name: "web", sourceRoot: "apps/web/src", targets: { test: {} } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "apps/web/package.json",
+ JSON.stringify({ name: "web", scripts: { test: "vitest run" } }, null, 2),
+ );
+ await writeFixture(root, "apps/web/package-lock.json", "{}\n");
+ await writeFixture(
+ root,
+ "apps/web/src/app/home/page.tsx",
+ "export default function Home() { return null; }\n",
+ );
+ await writeFixture(root, "apps/web/src/app/home/page.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const route = result.features.find((feature) => feature.title === "web route /home");
+
+ expect(route?.tests).toEqual([
+ { path: "apps/web/src/app/home/page.test.tsx", command: "pnpm nx test web" },
+ ]);
+ });
+
+ it("uses Nx target commands for React route tests", async () => {
+ const root = await fixtureRoot("clawpatch-map-react-nx-test-command-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2),
+ );
+ await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - apps/*\n");
+ await writeFixture(
+ root,
+ "apps/web/project.json",
+ JSON.stringify({ name: "web", sourceRoot: "apps/web/src", targets: { test: {} } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "apps/web/package.json",
+ JSON.stringify(
+ { name: "web", dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "apps/web/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "apps/web/src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "apps/web/src/pages/HomePage.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const route = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(route?.tests).toEqual([
+ { path: "apps/web/src/pages/HomePage.test.tsx", command: "pnpm nx test web" },
+ ]);
+ });
+
it("does not map src app-shaped routes without a Next project signal", async () => {
const root = await fixtureRoot("clawpatch-map-src-non-next-");
await writeFixture(root, "package.json", JSON.stringify({ name: "plain-app" }, null, 2));
@@ -755,6 +837,1253 @@ describe("mapFeatures", () => {
]);
});
+ it("uses package-local locks for fallback Node package roots", async () => {
+ const root = await fixtureRoot("clawpatch-node-fallback-package-lock-");
+ await writeFixture(
+ root,
+ "frontend/package.json",
+ JSON.stringify({ name: "frontend", scripts: { test: "vitest run" } }, null, 2),
+ );
+ await writeFixture(root, "frontend/pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
+ await writeFixture(root, "frontend/src/index.ts", "export const frontend = true;\n");
+ await writeFixture(root, "frontend/src/index.test.ts", "import './index';\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(
+ result.features.find((feature) => feature.title === "Node source frontend/src")?.tests,
+ ).toEqual([{ path: "frontend/src/index.test.ts", command: "pnpm --dir frontend test" }]);
+ });
+
+ it("uses package-local pnpm workspace markers for fallback Node package roots", async () => {
+ const root = await fixtureRoot("clawpatch-node-fallback-package-workspace-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ name: "root", scripts: { test: "node root.test.js" } }, null, 2),
+ );
+ await writeFixture(root, "package-lock.json", "{}\n");
+ await writeFixture(
+ root,
+ "frontend/package.json",
+ JSON.stringify({ name: "frontend", scripts: { test: "vitest run" } }, null, 2),
+ );
+ await writeFixture(root, "frontend/pnpm-workspace.yaml", "packages:\n - packages/*\n");
+ await writeFixture(root, "frontend/src/index.ts", "export const frontend = true;\n");
+ await writeFixture(root, "frontend/src/index.test.ts", "import './index';\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(
+ result.features.find((feature) => feature.title === "Node source frontend/src")?.tests,
+ ).toEqual([{ path: "frontend/src/index.test.ts", command: "pnpm --dir frontend test" }]);
+ });
+
+ it("maps React Router routes and components in a nested frontend app", async () => {
+ const root = await fixtureRoot("clawpatch-react-router-map-");
+ await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - frontend\n");
+ await writeFixture(
+ root,
+ "frontend/package.json",
+ JSON.stringify(
+ {
+ name: "fixture-frontend",
+ scripts: { test: "vitest run" },
+ dependencies: {
+ react: "1.0.0",
+ "react-dom": "1.0.0",
+ "react-router-dom": "1.0.0",
+ },
+ devDependencies: { vite: "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "frontend/src/App.tsx",
+ [
+ "import React, { lazy, Suspense } from 'react';",
+ "import { Navigate, Route, Routes } from 'react-router-dom';",
+ "const CasesPage = lazy(() => import('./pages/CasesPage'));",
+ "const ReactLazyPage = React.lazy(() => import('./pages/ReactLazyPage'));",
+ "import HomePage, { loader } from './pages/HomePage';",
+ "import ReportsPage from './pages/ReportsPage';",
+ "import SettingsPage from './pages/SettingsPage';",
+ "import SuspensePage from './pages/SuspensePage';",
+ "import UserPage from './pages/UserPage';",
+ "import LinkedPage from './pages/LinkedPage';",
+ "import ErrorPage from './pages/ErrorPage';",
+ "import DashboardPage from './pages/DashboardPage';",
+ "import Icon from './pages/Icon';",
+ "import Widget from './pages/Widget';",
+ "import RequireAuth from './RequireAuth';",
+ "import EscapePage from '../../../outside';",
+ "export default function App() {",
+ " return ",
+ ' {/* } /> */}',
+ ' // } />',
+ " } />",
+ ' } />',
+ ' } />',
+ ' } />',
+ ' ',
+ ' } />',
+ " ",
+ " } />",
+ ' } />',
+ ' } errorElement={} />',
+ ' } />} />',
+ ' } />',
+ ' } /> }} element={} />',
+ ' } />',
+ " } />",
+ ' } />',
+ ' } />',
+ ' } path="/reports" />',
+ ' } />',
+ ' } />',
+ ' } />',
+ ' ; // } />',
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "../outside.tsx",
+ "export default function EscapePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/App.test.tsx",
+ [
+ "import { MemoryRouter, Route, Routes } from 'react-router-dom';",
+ "function TestOnlyPage() { return null; }",
+ "test('fixture route', () => ",
+ ' } />',
+ ");",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/CasesPage.tsx",
+ "export default function CasesPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/CasesPage.test.tsx",
+ "test('cases page', () => {});\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "frontend/src/shared/util.test.tsx", "test('util', () => {});\n");
+ await writeFixture(
+ root,
+ "frontend/src/pages/SettingsPage.tsx",
+ "export default function SettingsPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/SuspensePage.tsx",
+ "export default function SuspensePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/ReportsPage.tsx",
+ "export default function ReportsPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/ErrorPage.tsx",
+ "export default function ErrorPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/DashboardPage.tsx",
+ "export default function DashboardPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/Icon.tsx",
+ "export default function Icon() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/Widget.tsx",
+ "export default function Widget() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/RequireAuth.tsx",
+ "export default function RequireAuth({ children }: { children: unknown }) { return children; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/ReactLazyPage.tsx",
+ "export default function ReactLazyPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/UserPage.tsx",
+ "export default function UserPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "../outside-linked.tsx",
+ "export default function LinkedPage() { return null; }\n",
+ );
+ await symlink(
+ join(root, "../outside-linked.tsx"),
+ join(root, "frontend/src/pages/LinkedPage.tsx"),
+ );
+ await writeFixture(
+ root,
+ "frontend/src/components/Dialog.tsx",
+ "export default function Dialog() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+ const home = result.features.find((feature) => feature.title === "React route /");
+ const cases = result.features.find((feature) => feature.title === "React route /cases");
+ const reactLazy = result.features.find(
+ (feature) => feature.title === "React route /react-lazy",
+ );
+ const reports = result.features.find((feature) => feature.title === "React route /reports");
+ const settings = result.features.find((feature) => feature.title === "React route /settings");
+ const suspense = result.features.find((feature) => feature.title === "React route /suspense");
+ const withError = result.features.find(
+ (feature) => feature.title === "React route /with-error",
+ );
+ const dashboard = result.features.find((feature) => feature.title === "React route /dashboard");
+ const nestedWrapper = result.features.find(
+ (feature) => feature.title === "React route /nested-wrapper",
+ );
+ const handle = result.features.find((feature) => feature.title === "React route /handle");
+ const quotedId = result.features.find((feature) => feature.title === "React route /quoted-id");
+ const quoted = result.features.find((feature) => feature.title === "React route /quoted");
+ const user = result.features.find((feature) => feature.title === "React route /users/:id");
+ const linked = result.features.find((feature) => feature.title === "React route /linked");
+ const escape = result.features.find((feature) => feature.title === "React route /escape");
+ const dialog = result.features.find((feature) => feature.title === "React component Dialog");
+
+ expect(titles).toContain("Node package fixture-frontend");
+ expect(home?.entrypoints[0]?.path).toBe("frontend/src/pages/HomePage.tsx");
+ expect(cases?.source).toBe("react-router-route");
+ expect(cases?.entrypoints[0]?.path).toBe("frontend/src/pages/CasesPage.tsx");
+ expect(cases?.contextFiles).toContainEqual({
+ path: "frontend/src/App.tsx",
+ reason: "route declaration",
+ });
+ expect(cases?.tests).toEqual([
+ {
+ path: "frontend/src/pages/CasesPage.test.tsx",
+ command: "pnpm --dir frontend test",
+ },
+ ]);
+ expect(reactLazy?.entrypoints[0]?.path).toBe("frontend/src/pages/ReactLazyPage.tsx");
+ expect(reports?.entrypoints[0]?.path).toBe("frontend/src/pages/ReportsPage.tsx");
+ expect(withError?.entrypoints[0]?.path).toBe("frontend/src/pages/ReportsPage.tsx");
+ expect(dashboard?.entrypoints[0]?.path).toBe("frontend/src/pages/DashboardPage.tsx");
+ expect(nestedWrapper?.entrypoints[0]?.path).toBe("frontend/src/pages/ReportsPage.tsx");
+ expect(handle?.entrypoints[0]?.path).toBe("frontend/src/pages/SettingsPage.tsx");
+ expect(quotedId?.entrypoints[0]?.path).toBe("frontend/src/pages/SettingsPage.tsx");
+ expect(quoted?.entrypoints[0]?.path).toBe("frontend/src/pages/ReportsPage.tsx");
+ expect(settings?.entrypoints[0]?.path).toBe("frontend/src/pages/SettingsPage.tsx");
+ expect(suspense?.entrypoints[0]?.path).toBe("frontend/src/pages/SuspensePage.tsx");
+ expect(user?.entrypoints[0]?.path).toBe("frontend/src/pages/UserPage.tsx");
+ expect(linked?.entrypoints[0]?.path).toBe("frontend/src/App.tsx");
+ expect(escape?.entrypoints[0]?.path).toBe("frontend/src/App.tsx");
+ expect(titles).not.toContain("React route /old");
+ expect(titles).not.toContain("React route /line-old");
+ expect(titles).not.toContain("React route /trailing-old");
+ expect(titles).not.toContain("React route /test-only");
+ expect(titles).not.toContain("React route /inner");
+ expect(titles).not.toContain("React route /HOME");
+ expect(titles.filter((title) => title === "React route /")).toHaveLength(1);
+ expect(dialog?.source).toBe("react-component");
+ expect(dialog?.ownedFiles).toEqual([
+ { path: "frontend/src/components/Dialog.tsx", reason: "component implementation" },
+ ]);
+ });
+
+ it("does not map custom React Route components as React Router routes", async () => {
+ const root = await fixtureRoot("clawpatch-react-custom-route-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "// import { Route } from 'react-router-dom';",
+ "const example = \"import { Route } from 'react-router-dom';\";",
+ "function Route(_props: { path: string }) { return null; }",
+ "function Page() { return null; }",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(result.features.map((feature) => feature.title)).not.toContain("React route /custom");
+ });
+
+ it("maps React Router routes through aliased Route imports only", async () => {
+ const root = await fixtureRoot("clawpatch-react-aliased-route-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route as RouterRoute, Routes } from 'react-router-dom';",
+ "import RealPage from './RealPage';",
+ "function Route(_props: { path: string }) { return null; }",
+ "function FakePage() { return null; }",
+ "export function App() { return ",
+ ' ',
+ ' } />',
+ "; }",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/RealPage.tsx",
+ "export default function RealPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(titles).toContain("React route /real");
+ expect(titles).not.toContain("React route /custom");
+ });
+
+ it("does not map React Router children under unresolved parent paths", async () => {
+ const root = await fixtureRoot("clawpatch-react-unresolved-parent-route-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import AdminUsers from './AdminUsers';",
+ "import PublicPage from './PublicPage';",
+ "const ADMIN_BASE = '/admin';",
+ "export function App() {",
+ " return ",
+ " ",
+ ' } />',
+ " ",
+ " }>",
+ ' } />',
+ " ",
+ " ;",
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/AdminUsers.tsx",
+ "export default function AdminUsers() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/PublicPage.tsx",
+ "export default function PublicPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(titles).not.toContain("React route /users");
+ expect(titles).toContain("React route /public");
+ });
+
+ it("keeps React index tests scoped to their component directory", async () => {
+ const root = await fixtureRoot("clawpatch-react-index-tests-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import Home from './pages/Home';",
+ "export function App() {",
+ " return ",
+ ' } />',
+ " ;",
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/Home/index.tsx",
+ "export default function Home() { return null; }\n",
+ );
+ await writeFixture(root, "src/pages/Home/index.test.tsx", "test('home', () => {});\n");
+ await writeFixture(root, "src/pages/Other/index.test.tsx", "test('other', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const home = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(home?.entrypoints[0]?.path).toBe("src/pages/Home/index.tsx");
+ expect(home?.tests).toEqual([
+ { path: "src/pages/Home/index.test.tsx", command: "npm run test" },
+ ]);
+ });
+
+ it("unwraps React Router fragment and member-expression route wrappers", async () => {
+ const root = await fixtureRoot("clawpatch-react-route-wrappers-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import React from 'react';",
+ "import { Route, Routes } from 'react-router-dom';",
+ "import FragmentPage from './pages/FragmentPage';",
+ "import SuspensePage from './pages/SuspensePage';",
+ "// import FragmentPage from './pages/WrongPage';",
+ "const example = '} />';",
+ "export function App() {",
+ " return ",
+ ' >} />',
+ ' } />',
+ " ;",
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/FragmentPage.tsx",
+ "export default function FragmentPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/pages/SuspensePage.tsx",
+ "export default function SuspensePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/pages/WrongPage.tsx",
+ "export default function WrongPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const fragment = result.features.find((feature) => feature.title === "React route /fragment");
+ const member = result.features.find((feature) => feature.title === "React route /member");
+
+ expect(fragment?.entrypoints[0]?.path).toBe("src/pages/FragmentPage.tsx");
+ expect(member?.entrypoints[0]?.path).toBe("src/pages/SuspensePage.tsx");
+ expect(result.features.map((feature) => feature.title)).not.toContain("React route /fake");
+ });
+
+ it("preserves React Router wildcard paths while stripping block comments", async () => {
+ const root = await fixtureRoot("clawpatch-react-wildcard-comment-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import AdminPage from './AdminPage';",
+ "import FallbackPage from './FallbackPage';",
+ "export function App() {",
+ " return ",
+ ' } />',
+ " {/* old catch-all route */}",
+ ' } />',
+ " ;",
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/AdminPage.tsx",
+ "export default function AdminPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/FallbackPage.tsx",
+ "export default function FallbackPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(titles).toContain("React route /admin/*");
+ expect(titles).toContain("React route /*");
+ });
+
+ it("maps unambiguous React Router conditional route elements", async () => {
+ const root = await fixtureRoot("clawpatch-react-conditional-element-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Navigate, Route, Routes } from 'react-router-dom';",
+ "import AdminPage from './AdminPage';",
+ "import DashboardPage from './DashboardPage';",
+ "import LoginPage from './LoginPage';",
+ "export function App() {",
+ " return ",
+ ' : } />',
+ ' : } />',
+ " ;",
+ "}",
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/AdminPage.tsx",
+ "export default function AdminPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/DashboardPage.tsx",
+ "export default function DashboardPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/LoginPage.tsx",
+ "export default function LoginPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const login = result.features.find((feature) => feature.title === "React route /login");
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(login?.entrypoints[0]?.path).toBe("src/LoginPage.tsx");
+ expect(titles).not.toContain("React route /ambiguous");
+ });
+
+ it("does not discover React packages through symlinked package roots", async () => {
+ const root = await fixtureRoot("clawpatch-react-symlink-package-");
+ const outside = join(root, "../outside-react-package");
+ const outsidePackages = join(root, "../outside-react-packages");
+ await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n");
+ await writeFixture(
+ root,
+ "../outside-react-package/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "../outside-react-package/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "function OutsidePage() { return null; }",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "../outside-react-packages/app/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "../outside-react-packages/app/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "function WorkspacePage() { return null; }",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await symlink(outside, join(root, "frontend"), "dir");
+ await symlink(outsidePackages, join(root, "packages"), "dir");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(result.features.map((feature) => feature.title)).not.toContain("React route /outside");
+ expect(result.features.map((feature) => feature.title)).not.toContain(
+ "React route /workspace-outside",
+ );
+ });
+
+ it("discovers React packages from workspace globs and honors excludes", async () => {
+ const root = await fixtureRoot("clawpatch-react-workspace-glob-");
+ await writeFixture(
+ root,
+ "pnpm-workspace.yaml",
+ "packages:\n - libs/*\n - libs/**/plugins/*\n - packages/*\n - '!./packages/legacy'\n",
+ );
+ await writeFixture(
+ root,
+ "libs/web/package.json",
+ JSON.stringify(
+ { peerDependencies: { react: "1.0.0" }, dependencies: { "react-router-dom": "1.0.0" } },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "libs/web/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "libs/web/src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "libs/suite/plugins/admin/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "libs/suite/plugins/admin/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import PluginPage from './PluginPage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "libs/suite/plugins/admin/src/PluginPage.tsx",
+ "export default function PluginPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "apps/web/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "apps/web/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import WebPage from './WebPage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "apps/web/src/WebPage.tsx",
+ "export default function WebPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "packages/legacy/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "packages/legacy/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import LegacyPage from './LegacyPage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "packages/legacy/src/LegacyPage.tsx",
+ "export default function LegacyPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "libs/suite/node_modules/bad/plugins/ignored/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "libs/suite/node_modules/bad/plugins/ignored/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import IgnoredPage from './IgnoredPage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "libs/suite/node_modules/bad/plugins/ignored/src/IgnoredPage.tsx",
+ "export default function IgnoredPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(titles).toContain("React route /home");
+ expect(titles).toContain("React route /plugin");
+ expect(titles).toContain("React route /web");
+ expect(titles).not.toContain("React route /legacy");
+ expect(titles).not.toContain("React route /ignored");
+ });
+
+ it("uses nested React package manager lockfiles for test commands", async () => {
+ const root = await fixtureRoot("clawpatch-react-nested-pm-");
+ await writeFixture(
+ root,
+ "frontend/package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(root, "frontend/pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
+ await writeFixture(
+ root,
+ "frontend/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "frontend/src/pages/HomePage.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const home = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(home?.tests).toEqual([
+ { path: "frontend/src/pages/HomePage.test.tsx", command: "pnpm --dir frontend test" },
+ ]);
+ });
+
+ it("honors package-local npm lockfiles in React packages", async () => {
+ const root = await fixtureRoot("clawpatch-react-nested-npm-");
+ await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - frontend\n");
+ await writeFixture(
+ root,
+ "frontend/package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(root, "frontend/package-lock.json", "{}\n");
+ await writeFixture(
+ root,
+ "frontend/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "frontend/src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "frontend/src/pages/HomePage.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const home = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(home?.tests).toEqual([
+ { path: "frontend/src/pages/HomePage.test.tsx", command: "npm --prefix frontend run test" },
+ ]);
+ });
+
+ it("keeps React routes after block comments with URL-looking text", async () => {
+ const root = await fixtureRoot("clawpatch-react-block-comment-url-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "/* see https://example.com */",
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(result.features.map((feature) => feature.title)).toContain("React route /home");
+ });
+
+ it("includes app-root React route tests", async () => {
+ const root = await fixtureRoot("clawpatch-react-app-tests-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "app/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './routes/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "app/routes/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "app/routes/HomePage.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const route = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(route?.tests).toEqual([
+ { path: "app/routes/HomePage.test.tsx", command: "npm run test" },
+ ]);
+ });
+
+ it("uses bun run for root React package scripts", async () => {
+ const root = await fixtureRoot("clawpatch-react-root-bun-");
+ await writeFixture(root, "bun.lockb", "");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(root, "src/pages/HomePage.test.tsx", "test('home', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const home = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(home?.tests).toEqual([{ path: "src/pages/HomePage.test.tsx", command: "bun run test" }]);
+ });
+
+ it("ignores import-like strings when resolving React route components", async () => {
+ const root = await fixtureRoot("clawpatch-react-string-import-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import Home from './Home';",
+ "const example = \"import Home from './Fake';\";",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(root, "src/Home.tsx", "export default function Home() { return null; }\n");
+ await writeFixture(root, "src/Fake.tsx", "export default function Fake() { return null; }\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const home = result.features.find((feature) => feature.title === "React route /home");
+
+ expect(home?.entrypoints[0]?.path).toBe("src/Home.tsx");
+ });
+
+ it("ignores import-like strings when collecting React context files", async () => {
+ const root = await fixtureRoot("clawpatch-react-string-context-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ dependencies: { react: "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/components/Dialog.tsx",
+ [
+ "const example = \"import Admin from '../Admin';\";",
+ "import './Dialog.css';",
+ "export default function Dialog() { return null; }",
+ ].join("\n"),
+ );
+ await writeFixture(root, "src/components/Dialog.css", ".dialog { color: red; }\n");
+ await writeFixture(root, "src/Admin.tsx", "export default function Admin() { return null; }\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const dialog = result.features.find((feature) => feature.title === "React component Dialog");
+
+ expect(dialog?.contextFiles).not.toContainEqual({
+ path: "src/Admin.tsx",
+ reason: "direct import",
+ });
+ expect(dialog?.contextFiles).toContainEqual({
+ path: "src/components/Dialog.css",
+ reason: "direct import",
+ });
+ });
+
+ it("keeps React routes after quoted JSX text", async () => {
+ const root = await fixtureRoot("clawpatch-react-jsx-text-quote-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ "function Copy() { return
Don't miss this
; }",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(result.features.map((feature) => feature.title)).toContain("React route /home");
+ });
+
+ it("does not add binary React imports as context files", async () => {
+ const root = await fixtureRoot("clawpatch-react-binary-import-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/components/Logo.tsx",
+ [
+ "import logo from './logo.png';",
+ "import './Logo.css';",
+ "export default function Logo() { return
; }",
+ ].join("\n"),
+ );
+ await writeFixture(root, "src/components/logo.png", "not real png\n");
+ await writeFixture(root, "src/components/Logo.css", ".logo { display: block; }\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const logo = result.features.find((feature) => feature.title === "React component Logo");
+
+ expect(logo?.contextFiles).not.toContainEqual({
+ path: "src/components/logo.png",
+ reason: "direct import",
+ });
+ expect(logo?.contextFiles).toContainEqual({
+ path: "src/components/Logo.css",
+ reason: "direct import",
+ });
+ });
+
+ it("does not map React Storybook support files as route or component features", async () => {
+ const root = await fixtureRoot("clawpatch-react-storybook-support-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import HomePage from './pages/HomePage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/HomePage.tsx",
+ "export default function HomePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/pages/HomePage.stories.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "function StoryOnlyPage() { return null; }",
+ 'export default { title: "HomePage" };',
+ 'export const Story = () => } />;',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/components/Button.stories.tsx",
+ "export default { title: 'Button' };\n",
+ );
+ await writeFixture(
+ root,
+ "src/stories/StoryPage.tsx",
+ "export default function StoryPage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/components/fixtures/FakeRoute.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "function FakePage() { return null; }",
+ 'export const Fake = () => } />;',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/components/__fixtures__/FixturePage.tsx",
+ "export default function FixturePage() { return null; }\n",
+ );
+ await writeFixture(
+ root,
+ "src/testdata/TestDataPage.tsx",
+ "export default function TestDataPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const titles = result.features.map((feature) => feature.title);
+
+ expect(titles).toContain("React route /home");
+ expect(titles).not.toContain("React route /story");
+ expect(titles).not.toContain("React route /fixture");
+ expect(titles).not.toContain("React component HomePage.stories");
+ expect(titles).not.toContain("React component Button.stories");
+ expect(titles).not.toContain("React component StoryPage");
+ expect(titles).not.toContain("React component FakeRoute");
+ expect(titles).not.toContain("React component FixturePage");
+ expect(titles).not.toContain("React component TestDataPage");
+ });
+
+ it("discovers nested React packages without recursive file walks", async () => {
+ const root = await fixtureRoot("clawpatch-react-nested-fallback-package-");
+ await writeFixture(
+ root,
+ "frontend/packages/admin/package.json",
+ JSON.stringify({ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" } }, null, 2),
+ );
+ await writeFixture(
+ root,
+ "frontend/packages/admin/src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import AdminPage from './AdminPage';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "frontend/packages/admin/src/AdminPage.tsx",
+ "export default function AdminPage() { return null; }\n",
+ );
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+
+ expect(result.features.map((feature) => feature.title)).toContain("React route /admin");
+ });
+
+ it("prioritizes exact React tests before same-directory fallback tests", async () => {
+ const root = await fixtureRoot("clawpatch-react-exact-tests-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ scripts: { test: "vitest run" },
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import Foo from './pages/Foo';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/Foo.tsx",
+ "export default function Foo() { return null; }\n",
+ );
+ for (let index = 0; index < 9; index += 1) {
+ await writeFixture(root, `src/pages/A${index}.test.tsx`, "test('nearby', () => {});\n");
+ }
+ await writeFixture(root, "src/pages/Foo.test.tsx", "test('foo', () => {});\n");
+
+ const project = await detectProject(root);
+ const result = await mapFeatures(root, project, []);
+ const foo = result.features.find((feature) => feature.title === "React route /foo");
+
+ expect(foo?.tests[0]).toEqual({ path: "src/pages/Foo.test.tsx", command: "npm run test" });
+ });
+
+ it("refreshes React direct import context between map runs", async () => {
+ const root = await fixtureRoot("clawpatch-react-cache-refresh-");
+ await writeFixture(
+ root,
+ "package.json",
+ JSON.stringify(
+ {
+ dependencies: { react: "1.0.0", "react-router-dom": "1.0.0" },
+ },
+ null,
+ 2,
+ ),
+ );
+ await writeFixture(
+ root,
+ "src/App.tsx",
+ [
+ "import { Route, Routes } from 'react-router-dom';",
+ "import Home from './pages/Home';",
+ 'export function App() { return } />; }',
+ ].join("\n"),
+ );
+ await writeFixture(
+ root,
+ "src/pages/Home.tsx",
+ ["import A from './A';", "export default function Home() { return ; }"].join("\n"),
+ );
+ await writeFixture(root, "src/pages/A.tsx", "export default function A() { return null; }\n");
+ await writeFixture(root, "src/pages/B.tsx", "export default function B() { return null; }\n");
+
+ const project = await detectProject(root);
+ const first = await mapFeatures(root, project, []);
+ await writeFixture(
+ root,
+ "src/pages/Home.tsx",
+ ["import B from './B';", "export default function Home() { return ; }"].join("\n"),
+ );
+ const second = await mapFeatures(root, project, first.features);
+ const route = second.features.find((feature) => feature.title === "React route /home");
+
+ expect(route?.contextFiles).toContainEqual({
+ path: "src/pages/B.tsx",
+ reason: "direct import",
+ });
+ expect(route?.contextFiles).not.toContainEqual({
+ path: "src/pages/A.tsx",
+ reason: "direct import",
+ });
+ });
+
it("maps nested SwiftPM, Apple, and Android Gradle app surfaces", async () => {
const root = await fixtureRoot("clawpatch-native-app-map-");
await writeFixture(root, "package.json", JSON.stringify({ name: "native-root" }, null, 2));
diff --git a/src/mapper.ts b/src/mapper.ts
index 6f8f836..7b1bf9d 100644
--- a/src/mapper.ts
+++ b/src/mapper.ts
@@ -7,6 +7,7 @@ import { gradleSeeds } from "./mappers/gradle.js";
import { nextSeeds } from "./mappers/next.js";
import { nodeSeeds } from "./mappers/node.js";
import { pythonSeeds } from "./mappers/python.js";
+import { reactSeeds } from "./mappers/react.js";
import { discoverNodeProjects } from "./mappers/projects.js";
import { rubySeeds } from "./mappers/ruby.js";
import { rustSeeds } from "./mappers/rust.js";
@@ -25,6 +26,7 @@ export type MapResult = {
const featureMappers: FeatureMapper[] = [
{ name: "node", map: nodeSeeds },
{ name: "next", map: nextSeeds },
+ { name: "react", map: reactSeeds },
{ name: "go", map: goSeeds },
{ name: "python", map: pythonSeeds },
{ name: "ruby", map: rubySeeds },
diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts
index 8db7b2f..6d57d2f 100644
--- a/src/mappers/projects.ts
+++ b/src/mappers/projects.ts
@@ -28,6 +28,7 @@ export type NodeProjectInfo = {
projectType: string | null;
targets: Record;
packageManager: string;
+ nxPackageManager: string;
};
type CandidateContextFile = {
@@ -37,7 +38,7 @@ type CandidateContextFile = {
export async function discoverNodeProjects(root: string): Promise {
const rootPackage = await readPackageJson(root);
- const packageManager = await detectNodePackageManager(root);
+ const rootPackageManager = await detectNodePackageManager(root);
const byRoot = new Map();
for (const packageRoot of await discoverPackageRoots(root, rootPackage)) {
@@ -55,7 +56,8 @@ export async function discoverNodeProjects(root: string): Promise left.root.localeCompare(right.root));
}
+async function nodePackageManagerForPackage(
+ root: string,
+ packageRoot: string,
+ rootPackageManager: string,
+): Promise {
+ if (packageRoot === ".") {
+ return rootPackageManager;
+ }
+ const packageDir = join(root, packageRoot);
+ for (const lockfile of [
+ "pnpm-lock.yaml",
+ "pnpm-workspace.yaml",
+ "yarn.lock",
+ "bun.lockb",
+ "package-lock.json",
+ ]) {
+ if (await pathExists(join(packageDir, lockfile))) {
+ return detectNodePackageManager(packageDir);
+ }
+ }
+ return rootPackageManager;
+}
+
export function projectTags(project: NodeProjectInfo): string[] {
const tags = [`project:${project.name}`, `project-root:${project.root}`];
if (project.projectType !== null) {
@@ -108,7 +134,7 @@ export function projectContextFiles(
export function projectTargetCommand(project: NodeProjectInfo, target: string): string | null {
if (project.targets[target] !== undefined) {
- return nxCommand(project.packageManager, target, project.name);
+ return nxCommand(project.nxPackageManager, target, project.name);
}
if (project.packageJson !== null && packageScripts(project.packageJson)[target] !== undefined) {
return scriptCommand(project.packageManager, project.root, target);
@@ -122,6 +148,9 @@ export function packageRelativePath(packageRoot: string, path: string): string {
export function scriptCommand(packageManager: string, packageRoot: string, script: string): string {
if (packageRoot === ".") {
+ if (packageManager === "bun") {
+ return `bun run ${script}`;
+ }
return packageManager === "npm" ? `npm run ${script}` : `${packageManager} ${script}`;
}
if (packageManager === "pnpm") {
@@ -233,8 +262,17 @@ async function workspacePatterns(root: string, pkg: NodePackageJson | null): Pro
patterns.add(pattern);
}
}
- for (const fallback of ["packages/*", "apps/*", "extensions/*", "plugins/*"]) {
- if (await pathExists(join(root, fallback.slice(0, -2)))) {
+ for (const fallback of [
+ "frontend",
+ "client",
+ "web",
+ "ui",
+ "packages/*",
+ "apps/*",
+ "extensions/*",
+ "plugins/*",
+ ]) {
+ if (await pathExists(join(root, fallback.replace(/\/\*$/u, "")))) {
patterns.add(fallback);
}
}
diff --git a/src/mappers/react.ts b/src/mappers/react.ts
new file mode 100644
index 0000000..9175229
--- /dev/null
+++ b/src/mappers/react.ts
@@ -0,0 +1,1359 @@
+import { readFileSync, realpathSync } from "node:fs";
+import { lstat, readFile, readdir } from "node:fs/promises";
+import { basename, dirname, extname, join } from "node:path";
+import { pathExists } from "../fs.js";
+import {
+ detectNodePackageManager,
+ isSafeDirectory,
+ isSampleProjectPath,
+ nodeScriptCommand,
+ normalize,
+ pathMatchesPrefix,
+ pathInsideRoot,
+ shouldSkip,
+ walk,
+} from "./shared.js";
+import { projectTargetCommand } from "./projects.js";
+import { FeatureSeed, MapperContext, SeedFileRef, SeedTestRef } from "./types.js";
+import type { NodeProjectInfo } from "./projects.js";
+
+type PackageJson = {
+ name?: unknown;
+ dependencies?: unknown;
+ devDependencies?: unknown;
+ peerDependencies?: unknown;
+ optionalDependencies?: unknown;
+ scripts?: unknown;
+};
+
+type ReactPackage = {
+ root: string;
+ packageJsonPath: string;
+ packageJson: PackageJson;
+ packageManager: string;
+ testCommand: string | null;
+};
+
+type RouteMatch = {
+ path: string;
+ component: string;
+ declarationPath: string;
+};
+
+type RouteDeclaration = {
+ path: string;
+ component: string | null;
+};
+
+const lazyImportRe =
+ /const\s+([A-Z][A-Za-z0-9_]*)\s*=\s*(?:React\.)?lazy\(\s*\(\)\s*=>\s*import\(\s*["']([^"']+)["']\s*\)\s*\)/gu;
+const defaultImportRe =
+ /import\s+([A-Z][A-Za-z0-9_]*)(?:\s*,\s*\{[^}]*\})?\s+from\s+["']([^"']+)["']/gu;
+const namedImportRe = /import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/gu;
+const anyImportRe = /(?:import\s+["']([^"']+)["']|from\s+["']([^"']+)["'])/gu;
+const packageRootCandidates = ["", "frontend", "client", "web", "ui", "app", "apps", "packages"];
+const sourceRoots = ["src", "app"];
+const componentRoots = ["src/pages", "src/components"];
+const testRoots = ["src", "app", "test", "tests", "__tests__", "e2e"];
+const contextImportExtensions = new Set([
+ ".css",
+ ".js",
+ ".jsx",
+ ".json",
+ ".less",
+ ".md",
+ ".mdx",
+ ".mjs",
+ ".sass",
+ ".scss",
+ ".svg",
+ ".ts",
+ ".tsx",
+]);
+
+export async function reactSeeds(root: string, context: MapperContext): Promise {
+ syncFileCache.clear();
+ const packages = await discoverReactPackages(root, context.projects);
+ const seeds: FeatureSeed[] = [];
+ for (const info of packages) {
+ seeds.push(...(await routeSeeds(root, info)));
+ seeds.push(...(await componentSeeds(root, info, seeds)));
+ }
+ return seeds;
+}
+
+async function routeSeeds(root: string, info: ReactPackage): Promise {
+ const files = await packageSourceFiles(root, info, sourceRoots);
+ const routeFiles = files
+ .filter((file) => /\.(tsx|jsx|ts|js)$/u.test(file))
+ .filter((file) => !isJsTestPath(file));
+ const testCommand = packageTestCommand(info);
+ const tests = await packageTestFiles(root, info);
+ const seeds: FeatureSeed[] = [];
+
+ for (const file of routeFiles) {
+ const source = await readFile(join(root, file), "utf8");
+ const parsedSource = stripJsxComments(source);
+ const routeTagNames = reactRouterRouteTagNames(parsedSource);
+ if (routeTagNames.size === 0) {
+ continue;
+ }
+ const routes = routeMatches(parsedSource, file, routeTagNames);
+ if (routes.length === 0) {
+ continue;
+ }
+ const imports = componentImports(root, file, parsedSource);
+ for (const route of routes) {
+ if (isFrameworkRouteComponent(route.component)) {
+ continue;
+ }
+ const entryPath = imports.get(route.component) ?? route.declarationPath;
+ const routeTests = associatedTests([entryPath], tests, testCommand);
+ seeds.push({
+ title: `React route ${route.path}`,
+ summary: `React Router route '${route.path}' rendered by ${route.component}.`,
+ kind: "route",
+ source: "react-router-route",
+ confidence: entryPath === route.declarationPath ? "medium" : "high",
+ entryPath,
+ symbol: route.component,
+ route: route.path,
+ command: null,
+ ownedFiles:
+ entryPath === route.declarationPath
+ ? [{ path: route.declarationPath, reason: "route declaration" }]
+ : [{ path: entryPath, reason: "route component" }],
+ contextFiles: uniqueFileRefs([
+ { path: info.packageJsonPath, reason: "package manifest" },
+ ...(entryPath === route.declarationPath
+ ? []
+ : [{ path: route.declarationPath, reason: "route declaration" }]),
+ ...directImportRefs(root, entryPath),
+ ...routeTests.map((test) => ({ path: test.path, reason: "associated test" })),
+ ]),
+ tests: routeTests,
+ tags: ["react", "react-router", "web"],
+ trustBoundaries: ["user-input", "network", "serialization"],
+ skipNearbyTests: true,
+ });
+ }
+ }
+ return seeds;
+}
+
+function isFrameworkRouteComponent(component: string): boolean {
+ return new Set(["Navigate", "Outlet"]).has(component);
+}
+
+async function componentSeeds(
+ root: string,
+ info: ReactPackage,
+ existingSeeds: FeatureSeed[],
+): Promise {
+ const routeOwnedFiles = new Set(
+ existingSeeds
+ .filter((seed) => seed.source === "react-router-route")
+ .flatMap((seed) => (seed.ownedFiles ?? [{ path: seed.entryPath }]).map((file) => file.path)),
+ );
+ const files = await packageSourceFiles(root, info, componentRoots);
+ const componentFiles = files
+ .filter(isReactComponentFile)
+ .filter((file) => !routeOwnedFiles.has(file))
+ .slice(0, 100);
+ const testCommand = packageTestCommand(info);
+ const tests = await packageTestFiles(root, info);
+
+ return componentFiles.map((file) => {
+ const componentName = basename(file).replace(/\.[^.]+$/u, "");
+ const componentTests = associatedTests([file], tests, testCommand);
+ return {
+ title: `React component ${componentName}`,
+ summary: `React component implemented by ${file}.`,
+ kind: "ui-flow",
+ source: "react-component",
+ confidence: "medium",
+ entryPath: file,
+ symbol: componentName,
+ route: null,
+ command: null,
+ ownedFiles: [{ path: file, reason: "component implementation" }],
+ contextFiles: uniqueFileRefs([
+ { path: info.packageJsonPath, reason: "package manifest" },
+ ...directImportRefs(root, file),
+ ...componentTests.map((test) => ({ path: test.path, reason: "associated test" })),
+ ]),
+ tests: componentTests,
+ tags: ["react", "component", "web"],
+ trustBoundaries: ["user-input", "network", "serialization"],
+ skipNearbyTests: true,
+ };
+ });
+}
+
+async function discoverReactPackages(
+ root: string,
+ projects: NodeProjectInfo[],
+): Promise {
+ const packages: ReactPackage[] = [];
+ const rootPackageManager = await detectNodePackageManager(root);
+ for (const packageJsonPath of await packageJsonPaths(root)) {
+ const packageJson = await readPackageJsonAt(root, packageJsonPath);
+ if (packageJson === null || !hasReactDependency(packageJson)) {
+ continue;
+ }
+ const packageRoot = dirname(packageJsonPath) === "." ? "." : dirname(packageJsonPath);
+ const project = projects.find((candidate) => candidate.root === packageRoot);
+ const packageManager =
+ project?.packageManager ??
+ (await packageManagerForReactPackage(root, packageRoot, rootPackageManager));
+ const projectTestCommand = project === undefined ? null : projectTargetCommand(project, "test");
+ packages.push({
+ root: packageRoot,
+ packageJsonPath,
+ packageJson,
+ packageManager,
+ testCommand:
+ projectTestCommand ?? packageJsonTestCommand(packageJson, packageManager, packageRoot),
+ });
+ }
+ return packages;
+}
+
+async function packageManagerForReactPackage(
+ root: string,
+ packageRoot: string,
+ fallback: string,
+): Promise {
+ if (packageRoot === "." || !(await hasPackageManagerMarker(root, packageRoot))) {
+ return fallback;
+ }
+ return detectNodePackageManager(join(root, packageRoot));
+}
+
+async function hasPackageManagerMarker(root: string, packageRoot: string): Promise {
+ return (
+ (await pathExists(join(root, packageRoot, "pnpm-lock.yaml"))) ||
+ (await pathExists(join(root, packageRoot, "pnpm-workspace.yaml"))) ||
+ (await pathExists(join(root, packageRoot, "package-lock.json"))) ||
+ (await pathExists(join(root, packageRoot, "yarn.lock"))) ||
+ (await pathExists(join(root, packageRoot, "bun.lockb")))
+ );
+}
+
+async function packageJsonPaths(root: string): Promise {
+ const paths = new Set();
+ const patterns = await workspacePatterns(root);
+ const excludes = patterns
+ .filter((pattern) => pattern.startsWith("!"))
+ .flatMap((pattern) => {
+ const normalized = normalizeWorkspacePattern(pattern.slice(1));
+ return normalized === null ? [] : [normalized];
+ });
+ for (const candidate of packageRootCandidates) {
+ const packageJsonPath = candidate === "" ? "package.json" : `${candidate}/package.json`;
+ if (
+ !isExcludedWorkspace(candidate === "" ? "." : candidate, excludes) &&
+ (await pathExists(join(root, packageJsonPath)))
+ ) {
+ paths.add(packageJsonPath);
+ }
+ }
+ for (const path of await fallbackPackageJsonPaths(root)) {
+ const packageRoot = dirname(path);
+ if (!isExcludedWorkspace(packageRoot, excludes)) {
+ paths.add(path);
+ }
+ }
+ for (const path of await workspacePackageJsonPaths(root, patterns, excludes)) {
+ paths.add(path);
+ }
+ return [...paths].toSorted();
+}
+
+async function fallbackPackageJsonPaths(root: string): Promise {
+ const paths: string[] = [];
+ for (const prefix of ["apps", "packages", "frontend", "client", "web"]) {
+ await collectPackageJsonPaths(root, prefix, 4, paths);
+ }
+ return paths.toSorted();
+}
+
+async function collectPackageJsonPaths(
+ root: string,
+ prefix: string,
+ remainingDepth: number,
+ paths: string[],
+): Promise {
+ if (remainingDepth < 0 || shouldSkip(prefix) || isSampleProjectPath(prefix)) {
+ return;
+ }
+ if (await pathExists(join(root, prefix, "package.json"))) {
+ paths.push(`${prefix}/package.json`);
+ }
+ if (remainingDepth === 0) {
+ return;
+ }
+ for (const entry of await safeDirectoryEntries(root, prefix)) {
+ await collectPackageJsonPaths(root, `${prefix}/${entry}`, remainingDepth - 1, paths);
+ }
+}
+
+async function workspacePatterns(root: string): Promise {
+ const patterns = new Set();
+ const rootPackage = await readPackageJsonAt(root, "package.json");
+ if (rootPackage !== null) {
+ for (const pattern of packageWorkspacePatterns(rootPackage)) {
+ patterns.add(pattern);
+ }
+ }
+ if (await pathExists(join(root, "pnpm-workspace.yaml"))) {
+ for (const pattern of parsePnpmWorkspace(
+ await readFile(join(root, "pnpm-workspace.yaml"), "utf8"),
+ )) {
+ patterns.add(pattern);
+ }
+ }
+ return [...patterns];
+}
+
+async function workspacePackageJsonPaths(
+ root: string,
+ patterns: string[],
+ excludes: string[],
+): Promise {
+ const paths: string[] = [];
+ for (const pattern of patterns.filter((entry) => !entry.startsWith("!"))) {
+ for (const packageRoot of await expandWorkspacePattern(root, pattern)) {
+ if (!isExcludedWorkspace(packageRoot, excludes)) {
+ paths.push(packageRelativePath(packageRoot, "package.json"));
+ }
+ }
+ }
+ return paths;
+}
+
+function packageWorkspacePatterns(pkg: PackageJson): string[] {
+ const workspaces = (pkg as { workspaces?: unknown }).workspaces;
+ if (Array.isArray(workspaces)) {
+ return workspaces.filter((entry): entry is string => typeof entry === "string");
+ }
+ if (
+ typeof workspaces === "object" &&
+ workspaces !== null &&
+ Array.isArray((workspaces as { packages?: unknown }).packages)
+ ) {
+ return (workspaces as { packages: unknown[] }).packages.filter(
+ (entry): entry is string => typeof entry === "string",
+ );
+ }
+ return [];
+}
+
+function parsePnpmWorkspace(source: string): string[] {
+ const patterns: string[] = [];
+ let inPackages = false;
+ for (const rawLine of source.split("\n")) {
+ const line = rawLine.replace(/#.*/u, "");
+ if (/^\S/u.test(line)) {
+ inPackages = /^packages\s*:/u.test(line);
+ }
+ if (!inPackages) {
+ continue;
+ }
+ const match = /^\s*-\s*["']?([^"'\s]+)["']?\s*$/u.exec(line);
+ if (match?.[1] !== undefined) {
+ patterns.push(match[1]);
+ }
+ }
+ return patterns;
+}
+
+async function expandWorkspacePattern(root: string, pattern: string): Promise {
+ const normalized = normalizeWorkspacePattern(pattern);
+ if (normalized === null) {
+ return [];
+ }
+ if (normalized === "." || normalized === "") {
+ return ["."];
+ }
+ if (normalized.endsWith("/*")) {
+ const parent = normalized.slice(0, -2);
+ if (hasWorkspaceGlob(parent)) {
+ return expandWorkspaceGlob(root, normalized);
+ }
+ const packageRoots = (await safeDirectoryEntries(root, parent)).map(
+ (entry) => `${parent}/${entry}`,
+ );
+ const existing: string[] = [];
+ for (const packageRoot of packageRoots) {
+ if (await pathExists(join(root, packageRoot, "package.json"))) {
+ existing.push(packageRoot);
+ }
+ }
+ return existing;
+ }
+ if (hasWorkspaceGlob(normalized)) {
+ return expandWorkspaceGlob(root, normalized);
+ }
+ return (await pathExists(join(root, normalized, "package.json"))) ? [normalized] : [];
+}
+
+function normalizeWorkspacePattern(pattern: string): string | null {
+ const normalized = normalize(pattern)
+ .replace(/^\.\//u, "")
+ .replace(/\/package\.json$/u, "")
+ .replace(/\/$/u, "");
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
+ return null;
+ }
+ return normalized;
+}
+
+function isExcludedWorkspace(packageRoot: string, excludes: string[]): boolean {
+ return excludes.some((pattern) => workspacePatternMatches(pattern, packageRoot));
+}
+
+function workspacePatternMatches(pattern: string, packageRoot: string): boolean {
+ if (pattern === packageRoot) {
+ return true;
+ }
+ if (hasWorkspaceGlob(pattern)) {
+ return workspaceGlobMatches(pattern, packageRoot);
+ }
+ if (pattern.endsWith("/**")) {
+ return pathMatchesPrefix(packageRoot, pattern.slice(0, -3));
+ }
+ if (pattern.endsWith("/*")) {
+ const parent = pattern.slice(0, -2);
+ if (!pathMatchesPrefix(packageRoot, parent)) {
+ return false;
+ }
+ return packageRoot.slice(parent.length + 1).split("/").length === 1;
+ }
+ return false;
+}
+
+async function expandWorkspaceGlob(root: string, pattern: string): Promise {
+ const packages: string[] = [];
+
+ async function visit(base: string, remaining: string[]): Promise {
+ if (shouldSkip(base)) {
+ return;
+ }
+ const [segment, ...rest] = remaining;
+ if (segment === undefined) {
+ if (base.length > 0 && (await pathExists(join(root, base, "package.json")))) {
+ packages.push(base);
+ }
+ return;
+ }
+ if (!hasWorkspaceGlob(segment)) {
+ await visit(base.length === 0 ? segment : `${base}/${segment}`, rest);
+ return;
+ }
+ if (segment === "**") {
+ await visit(base, rest);
+ for (const entry of await safeDirectoryEntries(root, base)) {
+ const child = base.length === 0 ? entry : `${base}/${entry}`;
+ if (!shouldSkip(child)) {
+ await visit(child, remaining);
+ }
+ }
+ return;
+ }
+ const matcher = globSegmentRegExp(segment);
+ for (const entry of await safeDirectoryEntries(root, base)) {
+ const child = base.length === 0 ? entry : `${base}/${entry}`;
+ if (matcher.test(entry) && !shouldSkip(child)) {
+ await visit(child, rest);
+ }
+ }
+ }
+
+ await visit("", pattern.split("/"));
+ return packages.toSorted();
+}
+
+async function safeDirectoryEntries(root: string, prefix: string): Promise {
+ const dir = join(root, prefix);
+ if (!(await isSafeDirectory(root, dir))) {
+ return [];
+ }
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
+ return entries
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
+ .map((entry) => entry.name)
+ .toSorted();
+}
+
+function hasWorkspaceGlob(pattern: string): boolean {
+ return /[*?]/u.test(pattern);
+}
+
+function workspaceGlobMatches(pattern: string, packageRoot: string): boolean {
+ return globSegmentsMatch(pattern.split("/"), packageRoot.split("/"));
+}
+
+function globSegmentsMatch(pattern: string[], candidate: string[]): boolean {
+ const [segment, ...remainingPattern] = pattern;
+ if (segment === undefined) {
+ return candidate.length === 0;
+ }
+ if (segment === "**") {
+ return (
+ globSegmentsMatch(remainingPattern, candidate) ||
+ (candidate.length > 0 && globSegmentsMatch(pattern, candidate.slice(1)))
+ );
+ }
+ const [candidateSegment, ...remainingCandidate] = candidate;
+ if (candidateSegment === undefined || !globSegmentRegExp(segment).test(candidateSegment)) {
+ return false;
+ }
+ return globSegmentsMatch(remainingPattern, remainingCandidate);
+}
+
+function globSegmentRegExp(segment: string): RegExp {
+ const escaped = segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
+ return new RegExp(`^${escaped.replace(/\*/gu, "[^/]*").replace(/\?/gu, "[^/]")}$`, "u");
+}
+
+function hasReactDependency(pkg: PackageJson): boolean {
+ return (
+ dependencyFieldHas(pkg.dependencies, "react") ||
+ dependencyFieldHas(pkg.devDependencies, "react") ||
+ dependencyFieldHas(pkg.peerDependencies, "react") ||
+ dependencyFieldHas(pkg.optionalDependencies, "react")
+ );
+}
+
+function dependencyFieldHas(field: unknown, name: string): boolean {
+ return typeof field === "object" && field !== null && Object.hasOwn(field, name);
+}
+
+async function packageSourceFiles(
+ root: string,
+ info: ReactPackage,
+ prefixes: string[],
+): Promise {
+ return (
+ await walk(
+ root,
+ prefixes.map((prefix) => packageRelativePath(info.root, prefix)),
+ )
+ )
+ .filter((file) => pathMatchesPrefix(file, info.root === "." ? "" : info.root))
+ .filter(isReviewableReactSourceFile);
+}
+
+async function packageTestFiles(root: string, info: ReactPackage): Promise {
+ return (
+ await walk(
+ root,
+ testRoots.map((prefix) => packageRelativePath(info.root, prefix)),
+ )
+ )
+ .filter(isJsTestPath)
+ .slice(0, 200);
+}
+
+function routeMatches(
+ source: string,
+ declarationPath: string,
+ routeTagNames: ReadonlySet,
+): RouteMatch[] {
+ const routes: RouteMatch[] = [];
+ for (const route of routeDeclarations(source, routeTagNames)) {
+ if (route.component === null) {
+ continue;
+ }
+ routes.push({ path: route.path, component: route.component, declarationPath });
+ }
+ return routes;
+}
+
+function routeDeclarations(source: string, routeTagNames: ReadonlySet): RouteDeclaration[] {
+ const routes: RouteDeclaration[] = [];
+ const pathStack: Array = [];
+ const strippedSource = stripJsxComments(source);
+ const tagPattern = routeTagPattern(routeTagNames);
+ for (const match of strippedSource.matchAll(tagPattern)) {
+ if (isInsideJsString(strippedSource, match.index)) {
+ continue;
+ }
+ if (match[0].startsWith("")) {
+ pathStack.pop();
+ continue;
+ }
+ const tagName = match[1] ?? match[2];
+ if (tagName === undefined) {
+ continue;
+ }
+ const tag = readRouteTag(strippedSource, match.index + 1 + tagName.length);
+ if (tag === null) {
+ continue;
+ }
+ const declaredPath = topLevelPropValue(tag.props, "path") ?? undefined;
+ const hasPathProp = topLevelPropExists(tag.props, "path");
+ const indexProp = topLevelPropValue(tag.props, "index");
+ const isIndexRoute = indexProp === null || indexProp === "true";
+ const parentPath = pathStack.length === 0 ? "" : (pathStack[pathStack.length - 1] ?? null);
+ const path = reactRoutePath(parentPath, declaredPath, hasPathProp, isIndexRoute);
+ if (path !== null && (declaredPath !== undefined || isIndexRoute)) {
+ routes.push({ path, component: routeElementComponent(tag.props) });
+ }
+ if (!tag.selfClosing) {
+ pathStack.push(path);
+ }
+ }
+ return routes;
+}
+
+function reactRoutePath(
+ parentPath: string | null,
+ declaredPath: string | undefined,
+ hasPathProp: boolean,
+ isIndexRoute: boolean,
+): string | null {
+ if (parentPath === null) {
+ return null;
+ }
+ if (declaredPath !== undefined) {
+ return joinReactRoutePaths(parentPath, declaredPath);
+ }
+ if (hasPathProp) {
+ return null;
+ }
+ return isIndexRoute ? parentPath || "/" : parentPath;
+}
+
+function reactRouterRouteTagNames(source: string): Set {
+ const names = new Set();
+ for (const match of source.matchAll(
+ /import\s+\{([^}]+)\}\s+from\s+["']react-router(?:-dom)?["']/gu,
+ )) {
+ if (isInsideJsString(source, match.index)) {
+ continue;
+ }
+ const imports = match[1];
+ if (imports === undefined) {
+ continue;
+ }
+ for (const item of imports.split(",")) {
+ const importMatch = /^\s*Route(?:\s+as\s+([A-Z][A-Za-z0-9_]*))?\s*$/u.exec(item);
+ if (importMatch !== null) {
+ names.add(importMatch[1] ?? "Route");
+ }
+ }
+ }
+ return names;
+}
+
+function routeTagPattern(routeTagNames: ReadonlySet): RegExp {
+ const names = [...routeTagNames].map((name) => name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"));
+ return new RegExp(`(${names.join("|")})\\s*>|<(${names.join("|")})\\b`, "gu");
+}
+
+function routeElementComponent(props: string): string | null {
+ const element = readJsxExpressionProp(props, "element");
+ if (element === undefined) {
+ return null;
+ }
+ const expression = unwrapParenthesizedExpression(element.trim());
+ if (!expression.startsWith("<")) {
+ return conditionalElementComponent(expression);
+ }
+ const root = readJsxOpeningTag(expression, 0);
+ if (root === null) {
+ return null;
+ }
+ let current = root;
+ while (!current.selfClosing && isRouteWrapperComponent(current.name)) {
+ const child = readJsxOpeningTag(expression, current.end);
+ if (child === null) {
+ break;
+ }
+ current = child;
+ }
+ return current.name;
+}
+
+function unwrapParenthesizedExpression(expression: string): string {
+ let current = expression;
+ while (current.startsWith("(") && current.endsWith(")")) {
+ current = current.slice(1, -1).trim();
+ }
+ return current;
+}
+
+function conditionalElementComponent(expression: string): string | null {
+ if (!expression.includes("?") || !expression.includes(":")) {
+ return null;
+ }
+ const candidates = new Set(
+ jsxOpeningTagNames(expression).filter(
+ (name) => !isFrameworkRouteComponent(name) && !isRouteWrapperComponent(name),
+ ),
+ );
+ return candidates.size === 1 ? ([...candidates][0] ?? null) : null;
+}
+
+function jsxOpeningTagNames(source: string): string[] {
+ const names: string[] = [];
+ let cursor = 0;
+ while (cursor < source.length) {
+ const openIndex = source.indexOf("<", cursor);
+ if (openIndex === -1) {
+ break;
+ }
+ if (source[openIndex + 1] === "/") {
+ cursor = openIndex + 2;
+ continue;
+ }
+ const tag = readJsxOpeningTag(source, openIndex);
+ if (tag === null) {
+ cursor = openIndex + 1;
+ continue;
+ }
+ names.push(tag.name);
+ cursor = tag.end;
+ }
+ return names;
+}
+
+function topLevelPropValue(props: string, name: string): string | null | undefined {
+ return readTopLevelProp(props, name)?.value;
+}
+
+function topLevelPropExists(props: string, name: string): boolean {
+ return readTopLevelProp(props, name) !== undefined;
+}
+
+function readTopLevelProp(
+ props: string,
+ name: string,
+): { value: string | null | undefined } | undefined {
+ let braceDepth = 0;
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = 0; index < props.length; index += 1) {
+ const char = props[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ continue;
+ }
+ if (char === "{") {
+ braceDepth += 1;
+ continue;
+ }
+ if (char === "}") {
+ braceDepth = Math.max(0, braceDepth - 1);
+ continue;
+ }
+ if (braceDepth === 0 && propNameMatchesAt(props, name, index)) {
+ return { value: readTopLevelPropValue(props, index + name.length) };
+ }
+ }
+ return undefined;
+}
+
+function propNameMatchesAt(source: string, name: string, index: number): boolean {
+ return (
+ source.slice(index, index + name.length) === name &&
+ !/[A-Za-z0-9_-]/u.test(source[index - 1] ?? "") &&
+ !/[A-Za-z0-9_-]/u.test(source[index + name.length] ?? "")
+ );
+}
+
+function readTopLevelPropValue(props: string, index: number): string | null | undefined {
+ let cursor = index;
+ while (/\s/u.test(props[cursor] ?? "")) {
+ cursor += 1;
+ }
+ if (props[cursor] !== "=") {
+ return null;
+ }
+ cursor += 1;
+ while (/\s/u.test(props[cursor] ?? "")) {
+ cursor += 1;
+ }
+ const quote = props[cursor];
+ if (quote === '"' || quote === "'") {
+ const end = props.indexOf(quote, cursor + 1);
+ return end === -1 ? "" : props.slice(cursor + 1, end);
+ }
+ if (props[cursor] === "{") {
+ const end = props.indexOf("}", cursor + 1);
+ if (end === -1) {
+ return undefined;
+ }
+ const expression = props.slice(cursor + 1, end).trim();
+ if (expression === "true") {
+ return "true";
+ }
+ const stringMatch = /^(["'])(.*)\1$/su.exec(expression);
+ return stringMatch?.[2];
+ }
+ return null;
+}
+
+function readJsxExpressionProp(props: string, propName: string): string | undefined {
+ const start = topLevelExpressionPropStart(props, propName);
+ if (start === undefined) {
+ return undefined;
+ }
+ let depth = 1;
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = start; index < props.length; index += 1) {
+ const char = props[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ } else if (char === "{") {
+ depth += 1;
+ } else if (char === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ return props.slice(start, index);
+ }
+ }
+ }
+ return undefined;
+}
+
+function topLevelExpressionPropStart(props: string, name: string): number | undefined {
+ let braceDepth = 0;
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = 0; index < props.length; index += 1) {
+ const char = props[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ continue;
+ }
+ if (char === "{") {
+ braceDepth += 1;
+ continue;
+ }
+ if (char === "}") {
+ braceDepth = Math.max(0, braceDepth - 1);
+ continue;
+ }
+ if (braceDepth !== 0 || !propNameMatchesAt(props, name, index)) {
+ continue;
+ }
+ let cursor = index + name.length;
+ while (/\s/u.test(props[cursor] ?? "")) {
+ cursor += 1;
+ }
+ if (props[cursor] !== "=") {
+ return undefined;
+ }
+ cursor += 1;
+ while (/\s/u.test(props[cursor] ?? "")) {
+ cursor += 1;
+ }
+ return props[cursor] === "{" ? cursor + 1 : undefined;
+ }
+ return undefined;
+}
+
+function readJsxOpeningTag(
+ source: string,
+ start: number,
+): { name: string; end: number; selfClosing: boolean } | null {
+ const openIndex = source.indexOf("<", start);
+ if (openIndex === -1 || source[openIndex + 1] === "/") {
+ return null;
+ }
+ const name =
+ source[openIndex + 1] === ">"
+ ? "Fragment"
+ : /^<([A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*)(?=[\s/>])/u.exec(
+ source.slice(openIndex),
+ )?.[1];
+ if (name === undefined) {
+ return null;
+ }
+ let braceDepth = 0;
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = openIndex + 1; index < source.length; index += 1) {
+ const char = source[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ } else if (char === "{") {
+ braceDepth += 1;
+ } else if (char === "}") {
+ braceDepth = Math.max(0, braceDepth - 1);
+ } else if (char === ">" && braceDepth === 0) {
+ return {
+ name,
+ end: index + 1,
+ selfClosing: source.slice(openIndex, index).trimEnd().endsWith("/"),
+ };
+ }
+ }
+ return null;
+}
+
+function isRouteWrapperComponent(name: string): boolean {
+ const component = name.split(".").at(-1) ?? name;
+ return new Set([
+ "Fragment",
+ "Suspense",
+ "RequireAuth",
+ "ProtectedRoute",
+ "PrivateRoute",
+ "AuthGuard",
+ ]).has(component);
+}
+
+function stripJsxComments(source: string): string {
+ return stripBlockComments(source).split("\n").map(stripLineComment).join("\n");
+}
+
+function stripLineComment(line: string): string {
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = 0; index < line.length - 1; index += 1) {
+ const char = line[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ continue;
+ }
+ if (char === "/" && line[index + 1] === "/") {
+ return line.slice(0, index);
+ }
+ }
+ return line;
+}
+
+function stripBlockComments(source: string): string {
+ let output = "";
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = 0; index < source.length; index += 1) {
+ const char = source[index] ?? "";
+ if (quote !== null) {
+ output += char;
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ output += char;
+ continue;
+ }
+ if (source.startsWith("{/*", index)) {
+ const end = source.indexOf("*/}", index + 3);
+ const close = end === -1 ? source.length : end + 3;
+ output += blankComment(source.slice(index, close));
+ index = close - 1;
+ continue;
+ }
+ if (source.startsWith("/*", index)) {
+ const end = source.indexOf("*/", index + 2);
+ const close = end === -1 ? source.length : end + 2;
+ output += blankComment(source.slice(index, close));
+ index = close - 1;
+ continue;
+ }
+ output += char;
+ }
+ return output;
+}
+
+function blankComment(source: string): string {
+ return source.replace(/[^\n]/gu, " ");
+}
+
+function readRouteTag(
+ source: string,
+ start: number,
+): { props: string; selfClosing: boolean } | null {
+ let braceDepth = 0;
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = start; index < source.length; index += 1) {
+ const char = source[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ } else if (char === "{") {
+ braceDepth += 1;
+ } else if (char === "}") {
+ braceDepth = Math.max(0, braceDepth - 1);
+ } else if (char === ">" && braceDepth === 0) {
+ const props = source.slice(start, index);
+ return { props, selfClosing: props.trimEnd().endsWith("/") };
+ }
+ }
+ return null;
+}
+
+function joinReactRoutePaths(parent: string, child: string): string {
+ if (child.startsWith("/")) {
+ return child;
+ }
+ if (child.length === 0) {
+ return parent.length === 0 ? "/" : parent;
+ }
+ if (parent.length === 0 || parent === "/") {
+ return `/${child.replace(/^\//u, "")}`;
+ }
+ return `${parent.replace(/\/$/u, "")}/${child.replace(/^\//u, "")}`;
+}
+
+function componentImports(root: string, fromPath: string, source: string): Map {
+ const imports = new Map();
+ for (const match of source.matchAll(lazyImportRe)) {
+ const component = match[1];
+ const importPath = match[2];
+ if (
+ component === undefined ||
+ importPath === undefined ||
+ isInsideJsString(source, match.index)
+ ) {
+ continue;
+ }
+ const resolved = resolveImport(root, fromPath, importPath);
+ if (resolved !== null) {
+ imports.set(component, resolved);
+ }
+ }
+ for (const match of source.matchAll(defaultImportRe)) {
+ const component = match[1];
+ const importPath = match[2];
+ if (
+ component === undefined ||
+ importPath === undefined ||
+ isInsideJsString(source, match.index)
+ ) {
+ continue;
+ }
+ const resolved = resolveImport(root, fromPath, importPath);
+ if (resolved !== null) {
+ imports.set(component, resolved);
+ }
+ }
+ for (const match of source.matchAll(namedImportRe)) {
+ const importList = match[1];
+ const importPath = match[2];
+ if (
+ importList === undefined ||
+ importPath === undefined ||
+ isInsideJsString(source, match.index)
+ ) {
+ continue;
+ }
+ const resolved = resolveImport(root, fromPath, importPath);
+ if (resolved === null) {
+ continue;
+ }
+ for (const component of importedNames(importList)) {
+ imports.set(component, resolved);
+ }
+ }
+ return imports;
+}
+
+function importedNames(importList: string): string[] {
+ return importList
+ .split(",")
+ .map((entry) => entry.trim())
+ .flatMap((entry) => {
+ const alias = /\bas\s+([A-Z][A-Za-z0-9_]*)$/u.exec(entry)?.[1];
+ const name = /^([A-Z][A-Za-z0-9_]*)/u.exec(entry)?.[1];
+ return alias ?? name ?? [];
+ });
+}
+
+function directImportRefs(root: string, path: string): SeedFileRef[] {
+ const fullPath = join(root, path);
+ if (!pathExistsSyncMemo(fullPath)) {
+ return [];
+ }
+ const rawSource = readFileSyncMemo(fullPath);
+ if (rawSource === null) {
+ return [];
+ }
+ const source = stripJsxComments(rawSource);
+ const refs: SeedFileRef[] = [];
+ for (const match of source.matchAll(anyImportRe)) {
+ const importPath = match[1] ?? match[2];
+ if (
+ importPath === undefined ||
+ !importPath.startsWith(".") ||
+ isInsideJsString(source, match.index)
+ ) {
+ continue;
+ }
+ const resolved = resolveImport(root, path, importPath);
+ if (resolved !== null) {
+ refs.push({ path: resolved, reason: "direct import" });
+ }
+ }
+ return uniqueFileRefs(refs);
+}
+
+function isInsideJsString(source: string, offset: number): boolean {
+ let quote: string | null = null;
+ let escaped = false;
+ for (let index = 0; index < offset; index += 1) {
+ const char = source[index];
+ if (quote !== null) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === quote) {
+ quote = null;
+ }
+ continue;
+ }
+ if ((char === '"' || char === "'" || char === "`") && !isLikelyJsxTextQuote(source, index)) {
+ quote = char;
+ }
+ }
+ return quote !== null;
+}
+
+function isLikelyJsxTextQuote(source: string, index: number): boolean {
+ const lastTagEnd = source.lastIndexOf(">", index);
+ if (lastTagEnd === -1 || source.lastIndexOf("<", index) > lastTagEnd) {
+ return false;
+ }
+ return !source.slice(lastTagEnd + 1, index).includes("{");
+}
+
+const syncFileCache = new Map();
+
+function pathExistsSyncMemo(path: string): boolean {
+ return readFileSyncMemo(path) !== null;
+}
+
+function readFileSyncMemo(path: string): string | null {
+ if (syncFileCache.has(path)) {
+ return syncFileCache.get(path) ?? null;
+ }
+ try {
+ const source = readFileSync(path, "utf8");
+ syncFileCache.set(path, source);
+ return source;
+ } catch {
+ syncFileCache.set(path, null);
+ return null;
+ }
+}
+
+function resolveImport(root: string, fromPath: string, importPath: string): string | null {
+ if (!importPath.startsWith(".")) {
+ return null;
+ }
+ const base = join(dirname(fromPath), importPath);
+ const candidates = [
+ base,
+ `${base}.tsx`,
+ `${base}.ts`,
+ `${base}.jsx`,
+ `${base}.js`,
+ `${base}.css`,
+ join(base, "index.tsx"),
+ join(base, "index.ts"),
+ join(base, "index.jsx"),
+ join(base, "index.js"),
+ ];
+ for (const candidate of candidates.map(normalize).filter(isTextContextImportCandidate)) {
+ const fullPath = join(root, candidate);
+ if (
+ !shouldSkip(candidate) &&
+ pathInsideRoot(root, fullPath) &&
+ realPathInsideRoot(root, fullPath) &&
+ pathExistsSyncMemo(fullPath)
+ ) {
+ return candidate;
+ }
+ }
+ return null;
+}
+
+function isTextContextImportCandidate(path: string): boolean {
+ const extension = extname(path);
+ return extension.length === 0 || contextImportExtensions.has(extension);
+}
+
+function realPathInsideRoot(root: string, path: string): boolean {
+ try {
+ return pathInsideRoot(realpathSync(root), realpathSync(path));
+ } catch {
+ return false;
+ }
+}
+
+function associatedTests(files: string[], tests: string[], command: string | null): SeedTestRef[] {
+ const dirs = new Set(files.map((file) => dirname(file)));
+ const exact = tests.filter((test) => files.some((file) => isExactTestForFile(file, test)));
+ const nearby = tests.filter(
+ (test) => !exact.includes(test) && [...dirs].some((dir) => pathMatchesPrefix(test, dir)),
+ );
+ return [...exact, ...nearby].slice(0, 8).map((path) => ({ path, command }));
+}
+
+function isExactTestForFile(file: string, test: string): boolean {
+ const fileStem = basename(file).replace(/\.[^.]+$/u, "");
+ const testStem = basename(test).replace(/\.(test|spec)\.[^.]+$/u, "");
+ if (fileStem !== testStem) {
+ return false;
+ }
+ return fileStem !== "index" || dirname(file) === dirname(test);
+}
+
+function packageTestCommand(info: ReactPackage): string | null {
+ return info.testCommand;
+}
+
+function packageJsonTestCommand(
+ packageJson: PackageJson,
+ packageManager: string,
+ packageRoot: string,
+): string | null {
+ if (!packageScripts(packageJson).has("test")) {
+ return null;
+ }
+ return nodeScriptCommand(packageManager, packageRoot, "test");
+}
+
+function packageScripts(pkg: PackageJson): Set {
+ if (typeof pkg.scripts !== "object" || pkg.scripts === null) {
+ return new Set();
+ }
+ return new Set(
+ Object.entries(pkg.scripts)
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string")
+ .map(([script]) => script),
+ );
+}
+
+function isReviewableReactSourceFile(path: string): boolean {
+ return (
+ /\.(tsx|jsx|ts|js)$/u.test(path) &&
+ !isJsTestPath(path) &&
+ !/\.d\.[cm]?ts$/u.test(path) &&
+ !isReactSupportPath(path)
+ );
+}
+
+function isReactComponentFile(path: string): boolean {
+ return /\.(tsx|jsx)$/u.test(path) && isReviewableReactSourceFile(path);
+}
+
+function isReactSupportPath(path: string): boolean {
+ return (
+ /(^|\/)(\.storybook|stories|__stories__)(\/|$)/u.test(path) ||
+ /(^|\/)(fixtures|__fixtures__|testdata)(\/|$)/u.test(path) ||
+ /\.(stories|story)\.[^.]+$/u.test(path)
+ );
+}
+
+function isJsTestPath(path: string): boolean {
+ return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u.test(path);
+}
+
+function packageRelativePath(packageRoot: string, path: string): string {
+ return packageRoot === "." ? normalize(path) : normalize(join(packageRoot, path));
+}
+
+async function readPackageJsonAt(root: string, path: string): Promise {
+ if (!(await safeFile(root, path))) {
+ return null;
+ }
+ const parsed: unknown = JSON.parse(await readFile(join(root, path), "utf8"));
+ return typeof parsed === "object" && parsed !== null ? (parsed as PackageJson) : null;
+}
+
+async function safeFile(root: string, path: string): Promise {
+ const fullPath = join(root, path);
+ if (shouldSkip(path) || !(await pathExists(fullPath)) || !realPathInsideRoot(root, fullPath)) {
+ return false;
+ }
+ const info = await lstat(fullPath);
+ return info.isFile() && !info.isSymbolicLink();
+}
+
+function uniqueFileRefs(refs: SeedFileRef[]): SeedFileRef[] {
+ const seen = new Set();
+ const output: SeedFileRef[] = [];
+ for (const ref of refs) {
+ if (seen.has(ref.path)) {
+ continue;
+ }
+ seen.add(ref.path);
+ output.push(ref);
+ }
+ return output;
+}
diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts
index cf27a9a..9be1926 100644
--- a/src/mappers/shared.ts
+++ b/src/mappers/shared.ts
@@ -230,6 +230,45 @@ export function pathMatchesPrefix(path: string, prefix: string): boolean {
return normalized === "" || path === normalized || path.startsWith(`${normalized}/`);
}
+export async function detectNodePackageManager(root: string): Promise {
+ if (
+ (await pathExists(join(root, "pnpm-lock.yaml"))) ||
+ (await pathExists(join(root, "pnpm-workspace.yaml")))
+ ) {
+ return "pnpm";
+ }
+ if (await pathExists(join(root, "yarn.lock"))) {
+ return "yarn";
+ }
+ if (await pathExists(join(root, "bun.lockb"))) {
+ return "bun";
+ }
+ return "npm";
+}
+
+export function nodeScriptCommand(
+ packageManager: string,
+ packageRoot: string,
+ script: string,
+): string {
+ if (packageRoot === ".") {
+ if (packageManager === "bun") {
+ return `bun run ${script}`;
+ }
+ return packageManager === "npm" ? `npm run ${script}` : `${packageManager} ${script}`;
+ }
+ if (packageManager === "pnpm") {
+ return `pnpm --dir ${packageRoot} ${script}`;
+ }
+ if (packageManager === "yarn") {
+ return `yarn --cwd ${packageRoot} ${script}`;
+ }
+ if (packageManager === "bun") {
+ return `bun --cwd ${packageRoot} run ${script}`;
+ }
+ return `npm --prefix ${packageRoot} run ${script}`;
+}
+
function isTestPath(path: string): boolean {
return (
isJsTestPath(path) ||