diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 55c15df..6d244da 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", + "tabWidth": 2, + "useTabs": false, "ignorePatterns": [] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 570182c..ed313f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Fixed first-time `clawpatch open-pr` branch creation to start from the recorded patch base. - Fixed command execution so providers that exit before reading stdin do not surface benign `EPIPE` errors. - Fixed `clawpatch ci --since` empty-review output so it reports `reviewed: 0`. +- Fixed formatter configuration so `oxfmt` uses two-space indentation consistently across platforms. +- Added generic package-less monorepo app-root mapping for Node/Next projects under roots such as `apps/*` and `packages/*` when positive source or framework signals are present. +- Added a release-prep checklist for auditing changelog, package metadata, and dry-run package contents without publishing. - Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. diff --git a/README.md b/README.md index 5ee40b4..b22269f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ validation commands and records a patch attempt under `.clawpatch/`. `lint`, `typecheck`, `format` - Node/TypeScript workspace packages under `apps/*`, `packages/*`, and package workspace patterns +- package-less Node/TypeScript app roots under monorepo folders such as + `apps/*` and `packages/*` when source or positive framework signals are + present - generic extension/plugin packages under workspace roots such as `extensions/*` and `plugins/*`, including package metadata, source, docs, and nearby tests - semantic Node source groups for large packages, including runtime, commands, diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 1d0e04e..ad84a6c 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -31,9 +31,13 @@ Supported deterministic mappers today: - npm package bins - selected root and workspace package scripts - Node/TypeScript workspace packages from `package.json` workspaces, `pnpm-workspace.yaml`, and common package folders +- package-less Node/TypeScript app roots under monorepo folders such as + `apps/*` and `packages/*` when source files or positive framework signals are + present - Nx project metadata from `project.json`, including project names, source roots, project types, and target names - Turborepo `turbo.json` metadata for workspace-aware validation commands and feature context -- bounded Node/TypeScript source groups under `src/`, `lib/`, `app/`, `pages/`, and `scripts/` +- bounded Node/TypeScript source groups under `src/`, `lib/`, `app/`, + `pages/`, `scripts/`, `server/`, and `api/` - React Router `` declarations and React components in root or nested frontend packages such as `frontend/`, `client/`, `web/`, workspaces, and packages under `apps/` or `packages/` @@ -95,10 +99,13 @@ and `plugins/*` are tagged as extension packages and keep package metadata, source, docs, and tests together as review context. In JavaScript/TypeScript monorepos, project discovery runs before framework -mapping. Workspace packages and Nx projects are normalized into project roots, -so framework mappers can apply the same heuristics to `apps/*` and `packages/*` -that they apply at the repository root. Feature tags include project name and -project root metadata, enabling commands such as: +mapping. Workspace packages, Nx projects, and package-less app roots with source +or positive framework signals are normalized into project roots, so framework +mappers can apply the same heuristics to `apps/*` and `packages/*` that they +apply at the repository root. Hoisted Next route mapping uses positive evidence +such as local Next commands, local Next config, App Router files, or Pages API +files instead of trying to enumerate every non-Next config file. Feature tags +include project name and project root metadata, enabling commands such as: ```bash clawpatch review --project apps/web --limit 10 diff --git a/docs/index.md b/docs/index.md index 6e7f64a..b3688b6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,6 +59,7 @@ stderr so pipes stay parseable. - [Validation](validation.md) - [Providers](providers.md) - [Safety](safety.md) +- [Release Prep](release-prep.md) - [E2E with Gitcrawl](e2e-gitcrawl.md) - [Initialization](initialization.md) diff --git a/docs/release-prep.md b/docs/release-prep.md new file mode 100644 index 0000000..6833360 --- /dev/null +++ b/docs/release-prep.md @@ -0,0 +1,54 @@ +--- +title: Release Prep +description: "No-publish checks for preparing a clawpatch release" +--- + +# Release Prep + +This checklist audits release readiness only. It does not publish, tag, create a +GitHub release, or change the package version. + +## Current Snapshot + +As of 2026-05-18: + +- GitHub latest full release: `v0.3.0` +- `package.json` version: `0.3.0` +- npm `clawpatch` version: `0.3.0` +- `pnpm pack:smoke` passed +- `npm pack --dry-run --json --ignore-scripts` included expected package + contents such as `dist/`, `README.md`, `LICENSE`, and `package.json` + +Prepare the next release only after the maintainer confirms the target version +and timing. + +## Audit Commands + +```bash +gh release list --repo openclaw/clawpatch --limit 20 --json tagName,isPrerelease,isDraft,publishedAt,isLatest +node -p "require('./package.json').version" +npm view clawpatch version --json +``` + +## Validation Commands + +```bash +pnpm typecheck +pnpm lint +pnpm format:check +pnpm exec vitest run --maxWorkers=1 +pnpm build +pnpm pack:smoke +npm pack --dry-run --json --ignore-scripts +``` + +## Manual Checks + +- Confirm `CHANGELOG.md` has all user-visible, operational, or security-relevant + changes under the next unreleased version. +- Confirm README and docs mention any new mapper behavior, commands, or safety + constraints. +- Confirm the dry-run package includes built `dist/` files and excludes local + state, fixtures that should not ship, and private paths. +- Confirm no release action has been run unless release timing is explicitly + approved. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5265b83..8be949d 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -164,13 +164,17 @@ describe("mapFeatures", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2), + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), ); await writeFixture(root, "yarn.lock", ""); await writeFixture( root, "apps/web/package.json", - JSON.stringify({ name: "web", dependencies: { next: "1.0.0" } }, null, 2), + JSON.stringify({ name: "web", scripts: { build: "next build" } }, null, 2), ); await writeFixture( root, @@ -204,7 +208,7 @@ describe("mapFeatures", () => { await writeFixture( root, "apps/admin/package.json", - JSON.stringify({ name: "admin", dependencies: { next: "1.0.0" } }, null, 2), + JSON.stringify({ name: "admin", scripts: { dev: "next dev" } }, null, 2), ); await writeFixture( root, @@ -244,9 +248,73 @@ describe("mapFeatures", () => { expect(adminRoute?.tests.every((test) => test.command === "yarn nx test admin")).toBe(true); }); + it("maps hoisted Next routes for workspace packages with Next scripts", async () => { + const root = await fixtureRoot("clawpatch-map-next-hoisted-package-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/site/package.json", + JSON.stringify({ name: "site", scripts: { dev: "next dev" } }, null, 2), + ); + await writeFixture( + root, + "apps/site/src/pages/about.tsx", + "export default function About() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.find((feature) => feature.title === "site route /about")?.entrypoints[0] + ?.path, + ).toBe("apps/site/src/pages/about.tsx"); + }); + + it("does not treat package scripts without Next commands as hoisted Next projects", async () => { + const root = await fixtureRoot("clawpatch-map-next-hoisted-script-helper-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/site/package.json", + JSON.stringify({ name: "site", scripts: { sitemap: "next-sitemap" } }, null, 2), + ); + await writeFixture( + root, + "apps/site/src/pages/about.tsx", + "export default function About() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/site/src"); + expect(result.features.some((feature) => feature.title === "site route /about")).toBe(false); + }); + it("maps Next routes inside Nx projects without package manifests", async () => { const root = await fixtureRoot("clawpatch-map-next-nx-no-package-"); - await writeFixture(root, "package.json", JSON.stringify({ name: "workspace-root" }, null, 2)); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", dependencies: { next: "1.0.0" } }, null, 2), + ); await writeFixture(root, "pnpm-lock.yaml", ""); await writeFixture( root, @@ -290,12 +358,436 @@ describe("mapFeatures", () => { ); }); + it("does not treat project.json pages folders as hoisted Next projects", async () => { + const root = await fixtureRoot("clawpatch-map-next-nx-pages-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", dependencies: { next: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/admin/project.json", + JSON.stringify({ name: "admin", sourceRoot: "apps/admin/src" }, null, 2), + ); + await writeFixture( + root, + "apps/admin/src/pages/settings.tsx", + "export default function Settings() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/admin/src"); + expect(result.features.some((feature) => feature.title === "admin route /settings")).toBe( + false, + ); + }); + + it("maps generic package-less app roots and Next routes", async () => { + const root = await fixtureRoot("clawpatch-map-generic-monorepo-root-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", dependencies: { next: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/storefront/src/app/checkout/page.tsx", + "export default function Checkout() { return null; }\n", + ); + await writeFixture( + root, + "apps/storefront/src/app/checkout/page.test.tsx", + "test('checkout', () => {});\n", + ); + await writeFixture(root, "apps/worker/src/index.ts", "export const worker = true;\n"); + await writeFixture(root, "apps/worker/src/index.test.ts", "test('worker', () => {});\n"); + await writeFixture(root, "apps/api/server/index.ts", "export const api = true;\n"); + await writeFixture(root, "apps/api/server/index.test.ts", "test('api', () => {});\n"); + await writeFixture( + root, + "apps/admin/src/pages/About.tsx", + "export default function About() { return null; }\n", + ); + await writeFixture( + root, + "apps/pagesapp/src/pages/about.tsx", + "export default function About() { return null; }\n", + ); + await writeFixture(root, "apps/pagesapp/next.config.js", "module.exports = {};\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "storefront route /checkout"); + const worker = result.features.find( + (feature) => feature.title === "Node source apps/worker/src", + ); + const api = result.features.find((feature) => feature.title === "Node source apps/api/server"); + + expect(route?.entrypoints[0]?.path).toBe("apps/storefront/src/app/checkout/page.tsx"); + expect(route?.tags).toEqual( + expect.arrayContaining(["project:storefront", "project-root:apps/storefront"]), + ); + expect(route?.tests).toContainEqual({ + path: "apps/storefront/src/app/checkout/page.test.tsx", + command: null, + }); + expect(worker?.ownedFiles).toContainEqual({ + path: "apps/worker/src/index.ts", + reason: "source group apps/worker/src", + }); + expect(worker?.tags).toEqual( + expect.arrayContaining(["generic-project", "project:worker", "project-root:apps/worker"]), + ); + expect(worker?.tests).toContainEqual({ + path: "apps/worker/src/index.test.ts", + command: null, + }); + expect(api?.ownedFiles).toContainEqual({ + path: "apps/api/server/index.ts", + reason: "source group apps/api/server", + }); + expect(api?.tests).toContainEqual({ + path: "apps/api/server/index.test.ts", + command: null, + }); + expect(result.features.some((feature) => feature.title === "admin route /About")).toBe(false); + expect( + result.features.find((feature) => feature.title === "pagesapp route /about")?.entrypoints[0] + ?.path, + ).toBe("apps/pagesapp/src/pages/about.tsx"); + }); + + it("does not duplicate generic roots under package workspaces", async () => { + const root = await fixtureRoot("clawpatch-map-generic-nested-package-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/**"] }, null, 2), + ); + await writeFixture(root, "apps/web/package.json", JSON.stringify({ name: "web" }, null, 2)); + await writeFixture(root, "apps/web/src/lib/foo.ts", "export const foo = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src"); + expect( + result.features.some((feature) => feature.tags.includes("project-root:apps/web/src")), + ).toBe(false); + }); + + it("keeps recursive package-less project discovery to the shallowest root", async () => { + const root = await fixtureRoot("clawpatch-map-generic-recursive-root-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/**"] }, null, 2), + ); + await writeFixture(root, "apps/web/src/app/page.tsx", "export default function Page() {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src"); + expect( + result.features.some((feature) => feature.tags.includes("project-root:apps/web/src")), + ).toBe(false); + }); + + it("does not treat recursive workspace containers as package-less projects", async () => { + const root = await fixtureRoot("clawpatch-map-generic-recursive-container-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/**"] }, null, 2), + ); + await writeFixture(root, "apps/api/server/index.ts", "export const api = true;\n"); + await writeFixture(root, "apps/web/src/index.ts", "export const web = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(titles).toContain("Node source apps/api/server"); + expect(titles).toContain("Node source apps/web/src"); + expect(result.features.some((feature) => feature.tags.includes("project-root:apps"))).toBe( + false, + ); + }); + + it("maps package-less projects under bare recursive workspace globs", async () => { + const root = await fixtureRoot("clawpatch-map-generic-bare-recursive-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["**"] }, null, 2), + ); + await writeFixture(root, "services/api/src/index.ts", "export const api = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find( + (feature) => feature.title === "Node source services/api/src", + ); + + expect(source?.ownedFiles).toContainEqual({ + path: "services/api/src/index.ts", + reason: "source group services/api/src", + }); + expect(source?.tags).toEqual( + expect.arrayContaining(["project:api", "project-root:services/api"]), + ); + expect(result.features.some((feature) => feature.tags.includes("project-root:services"))).toBe( + false, + ); + }); + + it("maps API-only package-less Next apps", async () => { + const root = await fixtureRoot("clawpatch-map-generic-next-api-only-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/app-api/app/api/hello/route.ts", + "export function GET() { return new Response('ok'); }\n", + ); + await writeFixture( + root, + "apps/pages-api/pages/api/hello.ts", + "export default function handler() {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect( + result.features.find((feature) => feature.title === "app-api route /api/hello") + ?.entrypoints[0]?.path, + ).toBe("apps/app-api/app/api/hello/route.ts"); + expect( + result.features.find((feature) => feature.title === "pages-api route /api/hello") + ?.entrypoints[0]?.path, + ).toBe("apps/pages-api/pages/api/hello.ts"); + }); + + it("maps package-less apps with nested server API sources", async () => { + const root = await fixtureRoot("clawpatch-map-generic-server-api-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2), + ); + await writeFixture(root, "apps/foo/server/api/index.ts", "export const api = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find( + (feature) => feature.title === "Node source apps/foo/server", + ); + + expect(source?.ownedFiles).toContainEqual({ + path: "apps/foo/server/api/index.ts", + reason: "source group apps/foo/server", + }); + expect(source?.tags).toEqual(expect.arrayContaining(["project:foo", "project-root:apps/foo"])); + }); + + it("does not let docs-only src folders suppress nested package-less projects", async () => { + const root = await fixtureRoot("clawpatch-map-generic-docs-only-src-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/**"] }, null, 2), + ); + await writeFixture(root, "apps/foo/src/README.md", "# notes\n"); + await writeFixture(root, "apps/foo/src/tsconfig.json", "{}\n"); + await writeFixture(root, "apps/foo/bar/src/index.ts", "export const bar = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find( + (feature) => feature.title === "Node source apps/foo/bar/src", + ); + + expect(source?.ownedFiles).toContainEqual({ + path: "apps/foo/bar/src/index.ts", + reason: "source group apps/foo/bar/src", + }); + expect(source?.tags).toEqual( + expect.arrayContaining(["project:bar", "project-root:apps/foo/bar"]), + ); + expect(result.features.some((feature) => feature.tags.includes("project-root:apps/foo"))).toBe( + false, + ); + }); + + it("does not let non-reviewable src files suppress nested package-less projects", async () => { + const root = await fixtureRoot("clawpatch-map-generic-non-reviewable-src-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/**"] }, null, 2), + ); + await writeFixture(root, "apps/foo/src/types.d.ts", "export type Config = {};\n"); + await writeFixture(root, "apps/foo/src/index.test.ts", "test('container', () => {});\n"); + await writeFixture( + root, + "apps/foo/src/generated/client.ts", + "export const generated = true;\n", + ); + await writeFixture(root, "apps/foo/src/fixtures/example.ts", "export const fixture = true;\n"); + await writeFixture(root, "apps/foo/bar/src/index.ts", "export const bar = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find( + (feature) => feature.title === "Node source apps/foo/bar/src", + ); + + expect(source?.ownedFiles).toContainEqual({ + path: "apps/foo/bar/src/index.ts", + reason: "source group apps/foo/bar/src", + }); + expect(source?.tags).toEqual( + expect.arrayContaining(["project:bar", "project-root:apps/foo/bar"]), + ); + expect(result.features.some((feature) => feature.tags.includes("project-root:apps/foo"))).toBe( + false, + ); + }); + + it("does not treat package-less React pages as Next routes without a Next signal", async () => { + const root = await fixtureRoot("clawpatch-map-generic-react-pages-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", dependencies: { react: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/web/src/pages/About.tsx", + "export default function About() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src"); + expect(result.features.some((feature) => feature.source === "next-pages-route")).toBe(false); + }); + + it("normalizes leading dot workspace globs for package-less Next apps", async () => { + const root = await fixtureRoot("clawpatch-map-generic-dot-workspace-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "workspace-root", workspaces: ["./services/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "services/web/src/app/about/page.tsx", + "export default function About() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "web route /about"); + const source = result.features.find( + (feature) => feature.title === "Node source services/web/src", + ); + + expect(route?.entrypoints[0]?.path).toBe("services/web/src/app/about/page.tsx"); + expect(route?.tags).toEqual( + expect.arrayContaining(["project:web", "project-root:services/web"]), + ); + expect(source?.tags).toEqual( + expect.arrayContaining(["project:web", "project-root:services/web"]), + ); + expect( + result.features.some((feature) => feature.tags.includes("project-root:./services/web")), + ).toBe(false); + }); + + it("maps deep package-less Next route trees", async () => { + const root = await fixtureRoot("clawpatch-map-generic-deep-next-route-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/web/src/app/(shop)/products/[slug]/reviews/page.tsx", + "export default function Reviews() { return null; }\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find( + (feature) => feature.title === "web route /products/:slug/reviews", + ); + + expect(route?.entrypoints[0]?.path).toBe( + "apps/web/src/app/(shop)/products/[slug]/reviews/page.tsx", + ); + expect(route?.tags).toEqual(expect.arrayContaining(["project:web", "project-root:apps/web"])); + }); + + it("does not duplicate nested Node source roots under project sourceRoot", async () => { + const root = await fixtureRoot("clawpatch-map-source-root-overlap-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2), + ); + await writeFixture( + root, + "apps/web/project.json", + JSON.stringify({ name: "web", sourceRoot: "apps/web", targets: {} }, null, 2), + ); + await writeFixture(root, "apps/web/src/index.ts", "export const web = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const sourceFeatures = result.features.filter((feature) => + feature.title.startsWith("Node source apps/web"), + ); + + expect(sourceFeatures.map((feature) => feature.title)).toEqual(["Node source apps/web"]); + expect(sourceFeatures[0]?.ownedFiles).toContainEqual({ + path: "apps/web/src/index.ts", + reason: "source group apps/web", + }); + }); + it("uses package-local commands when no task graph adapter is present", async () => { const root = await fixtureRoot("clawpatch-task-graph-fallback-"); await writeFixture( root, "package.json", - JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, null, 2), + JSON.stringify( + { name: "workspace-root", workspaces: ["apps/*"], dependencies: { next: "1.0.0" } }, + null, + 2, + ), ); await writeFixture(root, "pnpm-lock.yaml", ""); await writeFixture( @@ -390,7 +882,11 @@ describe("mapFeatures", () => { await writeFixture( root, "apps/web/package.json", - JSON.stringify({ name: "web", scripts: { test: "vitest run" } }, null, 2), + JSON.stringify( + { name: "web", scripts: { test: "vitest run" }, dependencies: { next: "1.0.0" } }, + null, + 2, + ), ); await writeFixture(root, "apps/web/package-lock.json", "{}\n"); await writeFixture( diff --git a/src/mappers/next.ts b/src/mappers/next.ts index 8137b54..100a51a 100644 --- a/src/mappers/next.ts +++ b/src/mappers/next.ts @@ -13,8 +13,12 @@ import type { WorkspaceTaskGraph } from "./task-graph.js"; import { FeatureSeed, MapperContext, suppressedTestCommandTag } from "./types.js"; export async function nextSeeds(root: string, context: MapperContext): Promise { + const rootProject = context.projects.find((project) => project.root === "."); + const rootHasNext = rootProject === undefined ? false : hasNextDependency(rootProject); const seedGroups = await Promise.all( - context.projects.map(async (project) => projectNextSeeds(root, project, context.taskGraph)), + context.projects.map(async (project) => + projectNextSeeds(root, project, context.taskGraph, rootHasNext), + ), ); return seedGroups.flat(); } @@ -23,8 +27,9 @@ async function projectNextSeeds( root: string, project: NodeProjectInfo, taskGraph: WorkspaceTaskGraph, + rootHasNext: boolean, ): Promise { - const prefixes = await nextPrefixes(root, project); + const prefixes = await nextPrefixes(root, project, rootHasNext); if (prefixes.length === 0) { return []; } @@ -68,13 +73,20 @@ async function projectNextSeeds( }); } -async function nextPrefixes(root: string, project: NodeProjectInfo): Promise { - const hasSignal = await isNextProject(root, project); +async function nextPrefixes( + root: string, + project: NodeProjectInfo, + rootHasNext: boolean, +): Promise { + const hasSignal = await isNextProject(root, project, rootHasNext); + const fallbackPrefixes = project.packageJson === null ? [] : ["app", "pages"]; const projectPrefixes = new Set( - hasSignal ? ["app", "pages", "src/app", "src/pages"] : ["app", "pages"], + hasSignal ? ["app", "pages", "src/app", "src/pages"] : fallbackPrefixes, ); - for (const prefix of sourceRootRoutePrefixes(project)) { - projectPrefixes.add(prefix); + if (hasSignal) { + for (const prefix of sourceRootRoutePrefixes(project)) { + projectPrefixes.add(prefix); + } } const existing: string[] = []; for (const prefix of [...projectPrefixes].map((path) => @@ -108,12 +120,12 @@ function sourceRootRoutePrefixes(project: NodeProjectInfo): string[] { ); } -async function isNextProject(root: string, project: NodeProjectInfo): Promise { - const pkg = project.packageJson; - if ( - dependencyFieldHas(pkg?.dependencies, "next") || - dependencyFieldHas(pkg?.devDependencies, "next") - ) { +async function isNextProject( + root: string, + project: NodeProjectInfo, + rootHasNext: boolean, +): Promise { + if (hasNextDependency(project)) { return true; } for (const file of ["next.config.js", "next.config.mjs", "next.config.ts"]) { @@ -121,9 +133,118 @@ async function isNextProject(root: string, project: NodeProjectInfo): Promise { + if (project.root === ".") { + return false; + } + if (hasNextCommandScript(project.packageJson?.scripts)) { + return hasLiteralNextRoutes(root, project); + } + if (project.packageJson === null || project.projectJsonPath !== null) { + return hasPackageLessNextRoutes(root, project); + } + return false; +} + +function hasNextCommandScript(scripts: unknown): boolean { + if (typeof scripts !== "object" || scripts === null) { + return false; + } + return Object.values(scripts).some( + (script) => typeof script === "string" && /(?:^|[\s;&|()])next(?:\s|$)/u.test(script), + ); +} + +async function hasLiteralNextRoutes(root: string, project: NodeProjectInfo): Promise { + for (const prefix of ["app", "pages", "src/app", "src/pages"]) { + if (await pathExists(join(root, packageRelativePath(project.root, prefix)))) { + return true; + } + } + for (const prefix of sourceRootRoutePrefixes(project)) { + if (await pathExists(join(root, packageRelativePath(project.root, prefix)))) { + return true; + } + } + return false; +} + +async function hasAppRouterRoutes(root: string, project: NodeProjectInfo): Promise { + for (const prefix of appRouterPrefixes(project)) { + const files = await walk(root, [packageRelativePath(project.root, prefix)]); + if ( + files.some((file) => { + const projectRelativePath = projectRelativeRoutePath(project, file); + return projectRelativePath !== null && nextRouteKind(projectRelativePath) === "app"; + }) + ) { + return true; + } + } + return false; +} + +async function hasPackageLessNextRoutes(root: string, project: NodeProjectInfo): Promise { + return (await hasAppRouterRoutes(root, project)) || (await hasPagesRouterSignal(root, project)); +} + +async function hasPagesRouterSignal(root: string, project: NodeProjectInfo): Promise { + for (const prefix of pagesRouterPrefixes(project)) { + const files = await walk(root, [packageRelativePath(project.root, prefix)]); + if ( + files.some((file) => { + const projectRelativePath = projectRelativeRoutePath(project, file); + return projectRelativePath !== null && isPagesRouterSignalFile(projectRelativePath); + }) + ) { + return true; + } + } return false; } +function appRouterPrefixes(project: NodeProjectInfo): string[] { + return [ + ...new Set(["app", "src/app", ...sourceRootRoutePrefixes(project).filter(isAppRouterPrefix)]), + ]; +} + +function pagesRouterPrefixes(project: NodeProjectInfo): string[] { + return [ + ...new Set([ + "pages", + "src/pages", + ...sourceRootRoutePrefixes(project).filter(isPagesRouterPrefix), + ]), + ]; +} + +function isAppRouterPrefix(prefix: string): boolean { + return prefix === "app" || prefix.endsWith("/app"); +} + +function isPagesRouterPrefix(prefix: string): boolean { + return prefix === "pages" || prefix.endsWith("/pages"); +} + +function isPagesRouterSignalFile(file: string): boolean { + return nextRouteKind(file) === "pages" && /^(?:src\/)?pages\/api\//u.test(file); +} + function projectRelativeRoutePath(project: NodeProjectInfo, file: string): string | null { if (project.root === ".") { return file; diff --git a/src/mappers/node.ts b/src/mappers/node.ts index ef25f9b..1e893c2 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -33,7 +33,7 @@ type PackageInfo = NodeProjectInfo & { packageJson: NodePackageJson; }; -const sourceDirectories = ["src", "lib", "app", "pages", "scripts"] as const; +const sourceDirectories = ["src", "lib", "app", "pages", "scripts", "server", "api"] as const; const testDirectories = ["test", "tests", "__tests__"] as const; const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; @@ -65,11 +65,12 @@ const semanticSourceSegments = [ ] as const; export async function nodeSeeds(root: string, context: MapperContext): Promise { - const packages = context.projects.filter(hasNodePackage); const seeds: FeatureSeed[] = []; - for (const info of packages) { - seeds.push(...(await packageSeeds(root, info, context.taskGraph))); + for (const info of context.projects) { + if (hasNodePackage(info)) { + seeds.push(...(await packageSeeds(root, info, context.taskGraph))); + } seeds.push(...(await sourceGroupSeeds(root, info, context.taskGraph))); } @@ -187,7 +188,7 @@ async function packageSeeds( async function sourceGroupSeeds( root: string, - info: PackageInfo, + info: NodeProjectInfo, taskGraph: WorkspaceTaskGraph, ): Promise { const packageName = projectDisplayName(info); @@ -210,6 +211,8 @@ async function sourceGroupSeeds( } for (const group of partitionNodeFileGroups(sourceRoot, files, sourceGroupMaxOwnedFiles)) { const tests = associatedTests(group.files, testFiles, testCommand ?? null); + const entryPath = + info.packageJsonPath ?? info.projectJsonPath ?? group.files[0] ?? sourceRoot; seeds.push({ title: `Node source ${group.label}`, summary: @@ -219,7 +222,7 @@ async function sourceGroupSeeds( kind: packageKind(`${packageName} ${group.label}`), source: "node-source-group", confidence: "medium", - entryPath: info.packageJsonPath, + entryPath, symbol: group.label, route: null, command: null, @@ -228,7 +231,9 @@ async function sourceGroupSeeds( reason: `source group ${group.label}`, })), contextFiles: uniqueFileRefs([ - { path: info.packageJsonPath, reason: "package manifest" }, + ...(info.packageJsonPath === null + ? await projectContextFiles(root, info) + : [{ path: info.packageJsonPath, reason: "package manifest" }]), ...tests.map((test) => ({ path: test.path, reason: "associated test" })), ]), tests, @@ -236,6 +241,7 @@ async function sourceGroupSeeds( "node", "typescript", "source-group", + ...(info.packageJsonPath === null ? ["generic-project"] : []), ...projectTags(info), ...(testCommand === null ? [suppressedTestCommandTag] : []), ], @@ -366,7 +372,7 @@ async function existingFileRefs(root: string, refs: SeedFileRef[]): Promise !pathMatchesPrefix(path, packageRelativePath(info.root, "app/assets"))); } - return sourceDirectories.map((dir) => packageRelativePath(info.root, dir)); + return shallowSourceRoots([ + ...new Set([ + ...(info.sourceRoot === null ? [] : [info.sourceRoot]), + ...sourceDirectories.map((dir) => packageRelativePath(info.root, dir)), + ]), + ]); +} + +function shallowSourceRoots(sourceRoots: string[]): string[] { + return sourceRoots.filter( + (sourceRoot) => + !sourceRoots.some( + (other) => other !== sourceRoot && (other === "." || pathMatchesPrefix(sourceRoot, other)), + ), + ); } function isRailsExcludedNodeSourcePath( - info: PackageInfo, + info: NodeProjectInfo, railsPackage: boolean, sourceRoot: string, path: string, @@ -399,7 +419,7 @@ function isRailsExcludedNodeSourcePath( ); } -async function packageTestFiles(root: string, info: PackageInfo): Promise { +async function packageTestFiles(root: string, info: NodeProjectInfo): Promise { const railsPackage = await isRailsPackage(root, info.root); const prefixes = [ ...packageSourceRoots(info, railsPackage), diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts index a845ac4..dcf60f2 100644 --- a/src/mappers/projects.ts +++ b/src/mappers/projects.ts @@ -100,6 +100,26 @@ export async function discoverNodeProjects(root: string): Promise left.root.localeCompare(right.root)); } @@ -329,6 +349,72 @@ async function packageRootsForPatterns(root: string, patterns: string[]): Promis return [...packageRoots].filter((path) => !isExcludedWorkspace(path, excludes)).toSorted(); } +async function discoverGenericProjectRoots( + root: string, + rootPackage: NodePackageJson | null, + existingProjects: Map, +): Promise { + const patterns = await workspacePatterns(root, rootPackage); + const excludes = patterns + .filter((pattern) => pattern.startsWith("!")) + .flatMap((pattern) => { + const normalized = normalizeWorkspacePattern(pattern.slice(1)); + return normalized === null ? [] : [normalized]; + }); + const projectRoots = new Set(); + for (const includePattern of patterns.filter((pattern) => !pattern.startsWith("!"))) { + for (const projectRoot of await expandGenericProjectPattern(root, includePattern)) { + projectRoots.add(projectRoot); + } + } + const roots: string[] = []; + const acceptedRoots: string[] = []; + for (const projectRoot of [...projectRoots].toSorted()) { + if ( + projectRoot !== "." && + !isExcludedWorkspace(projectRoot, excludes) && + !(await pathExists(join(root, projectRoot, "package.json"))) && + !(await pathExists(join(root, projectRoot, "project.json"))) && + !isNestedUnderRoots(projectRoot, existingProjects.keys()) && + !isNestedUnderRoots(projectRoot, acceptedRoots) && + (await hasGenericProjectSignal(root, rootPackage, projectRoot)) + ) { + roots.push(projectRoot); + acceptedRoots.push(projectRoot); + } + } + return roots; +} + +function isNestedUnderRoots(projectRoot: string, roots: Iterable): boolean { + return [...roots].some( + (existingRoot) => + existingRoot !== "." && + projectRoot !== existingRoot && + pathMatchesPrefix(projectRoot, existingRoot), + ); +} + +async function expandGenericProjectPattern(root: string, pattern: string): Promise { + const normalized = normalizeWorkspacePattern(pattern); + if (normalized === null || normalized === "." || normalized === "") { + return []; + } + if (normalized.endsWith("/**") && !hasWorkspaceGlob(normalized.slice(0, -3))) { + return discoverGenericProjectRootsUnder(root, normalized.slice(0, -3), 4); + } + const singleSegmentParent = normalized.endsWith("/*") ? normalized.slice(0, -2) : null; + if (singleSegmentParent !== null && !hasWorkspaceGlob(singleSegmentParent)) { + return (await safeDirectoryEntries(root, singleSegmentParent)).map( + (entry) => `${singleSegmentParent}/${entry}`, + ); + } + if (hasWorkspaceGlob(normalized)) { + return expandGenericProjectGlob(root, normalized); + } + return (await isSafeDirectory(root, join(root, normalized))) ? [normalized] : []; +} + function packageWorkspacePatterns(pkg: NodePackageJson): string[] { const workspaces = pkg.workspaces; if (Array.isArray(workspaces)) { @@ -400,7 +486,8 @@ async function expandWorkspacePattern(root: string, pattern: string): Promise { + const output: string[] = []; + await discoverGenericProjectRootsInto(root, prefix, maxDepth, output); + return output.filter((projectRoot) => projectRoot !== prefix).toSorted(); +} + async function discoverPackageRootsInto( root: string, prefix: string, @@ -531,6 +628,61 @@ async function discoverPackageRootsInto( } } +async function discoverGenericProjectRootsInto( + root: string, + prefix: string, + remainingDepth: number, + output: string[], +): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return; + } + if (prefix.length > 0) { + output.push(prefix); + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + await discoverGenericProjectRootsInto(root, `${prefix}/${entry}`, remainingDepth - 1, output); + } +} + +async function expandGenericProjectGlob(root: string, pattern: string): Promise { + const projects: string[] = []; + const segments = pattern.split("/"); + + async function visit(base: string, remaining: string[]): Promise { + const [segment, ...rest] = remaining; + if (segment === undefined) { + if (base.length > 0 && (await isSafeDirectory(root, join(root, base)))) { + projects.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)) { + await visit(base.length === 0 ? entry : `${base}/${entry}`, remaining); + } + return; + } + + const matcher = globSegmentRegExp(segment); + for (const entry of await safeDirectoryEntries(root, base)) { + if (matcher.test(entry)) { + await visit(base.length === 0 ? entry : `${base}/${entry}`, rest); + } + } + } + + await visit("", segments); + return projects.toSorted(); +} + async function discoverNxProjectJsonPaths(root: string): Promise { const output: string[] = []; await discoverNxProjectJsonPathsInto(root, "", 5, output); @@ -564,6 +716,179 @@ function shouldSkipProjectDir(path: string): boolean { return shouldSkip(path) || /(^|\/)(\.next|\.turbo|\.vercel)(\/|$)/u.test(path); } +async function hasGenericProjectSignal( + root: string, + rootPackage: NodePackageJson | null, + projectRoot: string, +): Promise { + for (const file of ["next.config.js", "next.config.mjs", "next.config.ts"]) { + if (await pathExists(join(root, packageRelativePath(projectRoot, file)))) { + return true; + } + } + if (await hasHoistedNextRouteSignal(root, rootPackage, projectRoot)) { + return true; + } + for (const sourceRoot of genericProjectSourceRoots(projectRoot)) { + const files = await sourceLikeFiles(root, sourceRoot); + if (isGenericProjectSignal(sourceRoot, files)) { + return true; + } + } + return false; +} + +async function genericProjectSourceRoot(root: string, projectRoot: string): Promise { + const src = packageRelativePath(projectRoot, "src"); + return (await pathExists(join(root, src))) ? src : null; +} + +function genericProjectSourceRoots(projectRoot: string): string[] { + return genericSourceRootNames.map((sourceRoot) => packageRelativePath(projectRoot, sourceRoot)); +} + +const genericSourceRootNames = ["src", "app", "pages", "lib", "server", "api"]; + +async function hasHoistedNextRouteSignal( + root: string, + rootPackage: NodePackageJson | null, + projectRoot: string, +): Promise { + if (!hasNextDependency(rootPackage)) { + return false; + } + for (const routeRoot of ["app", "src/app", "pages", "src/pages"]) { + const prefix = packageRelativePath(projectRoot, routeRoot); + const files = await sourceLikeFilesAtDepth(root, prefix, 12); + if (files.some((file) => isGenericNextRouteFile(projectRoot, file))) { + return true; + } + } + return false; +} + +function hasNextDependency(pkg: NodePackageJson | null): boolean { + return ( + dependencyFieldHas(pkg?.dependencies, "next") || + dependencyFieldHas(pkg?.devDependencies, "next") + ); +} + +function isGenericNextRouteFile(projectRoot: string, file: string): boolean { + const relativeFile = projectRoot === "." ? file : pathRelativeToPrefix(file, projectRoot); + return ( + /^src\/app\/.+\/(?:page|route)\.[cm]?[jt]sx?$/iu.test(relativeFile) || + /^app\/.+\/(?:page|route)\.[cm]?[jt]sx?$/iu.test(relativeFile) || + (/^(?:src\/)?pages\/api\/.+\.[cm]?[jt]sx?$/iu.test(relativeFile) && + !/^(?:src\/)?pages\/_(?:app|document|error)\.[cm]?[jt]sx?$/iu.test(relativeFile)) + ); +} + +function isGenericProjectSignal(sourceRoot: string, files: string[]): boolean { + if (files.length === 0) { + return false; + } + const sourceRootName = sourceRoot.split("/").at(-1); + if (sourceRootName === "src") { + return true; + } + return files.some((file) => { + const relativeFile = pathRelativeToPrefix(file, sourceRoot); + const [firstSegment, ...rest] = relativeFile.split("/"); + if (isNestedSourceSignal(sourceRootName, firstSegment)) { + return true; + } + return ( + firstSegment === undefined || + rest.length === 0 || + !genericSourceRootNames.includes(firstSegment) + ); + }); +} + +function isNestedSourceSignal( + sourceRootName: string | undefined, + firstSegment: string | undefined, +): boolean { + return ( + firstSegment === "api" && + (sourceRootName === "app" || sourceRootName === "pages" || sourceRootName === "server") + ); +} + +function pathRelativeToPrefix(path: string, prefix: string): string { + return path === prefix ? "" : path.slice(prefix.length + 1); +} + +async function sourceLikeFiles(root: string, sourceRoot: string): Promise { + return sourceLikeFilesAtDepth(root, sourceRoot, 4); +} + +async function sourceLikeFilesAtDepth( + root: string, + sourceRoot: string, + maxDepth: number, +): Promise { + const files: string[] = []; + await sourceLikeFilesInto(root, sourceRoot, maxDepth, files); + return files; +} + +async function sourceLikeFilesInto( + root: string, + prefix: string, + remainingDepth: number, + output: string[], +): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return; + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + await sourceLikeFilesInto(root, packageRelativePath(prefix, entry), remainingDepth - 1, output); + } + for (const file of await safeFileEntries(root, prefix)) { + const path = packageRelativePath(prefix, file); + if (isReviewableGenericSourceFile(path)) { + output.push(path); + } + } +} + +function isReviewableGenericSourceFile(path: string): boolean { + return ( + /\.(?:[cm]?[jt]sx?)$/iu.test(path) && + !/\.(?:test|spec)\.[cm]?[jt]sx?$/iu.test(path) && + !/\.d\.[cm]?ts$/iu.test(path) && + !/(^|\/)(?:__fixtures__|fixtures|testdata)(\/|$)/iu.test(path) && + !/(^|\/)(?:generated|__generated__)(\/|$)/iu.test(path) && + !/(^|\/)[^/]*(?:generated|\.gen)\.[^.]+$/iu.test(path) + ); +} + +async function safeFileEntries(root: string, prefix: string): Promise { + const dir = join(root, prefix); + if (!(await isSafeDirectory(root, dir))) { + return []; + } + const entries = await readdir(dir); + const output: string[] = []; + for (const entry of entries) { + const rel = normalize(join(prefix, entry)); + if (shouldSkipProjectDir(rel)) { + continue; + } + const childInfo = await lstat(join(dir, entry)); + if (childInfo.isFile() && !childInfo.isSymbolicLink()) { + output.push(entry); + } + } + return output.toSorted(); +} + +function isConventionalProjectRoot(projectRoot: string): boolean { + return /^(?:apps|packages|frontend|client|web|ui|extensions|plugins)(?:\/|$)/u.test(projectRoot); +} + async function safeDirectoryEntries(root: string, prefix: string): Promise { const dir = join(root, prefix); if (!(await isSafeDirectory(root, dir))) {