diff --git a/README.md b/README.md index 2868167..7f7989b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ validation commands and records a patch attempt under `.clawpatch/`. - npm package bins - selected root and workspace package scripts: `start`, `build`, `test`, `lint`, `typecheck`, `format` -- Next.js `app/` and `pages/` routes +- Node/TypeScript workspace packages under `apps/*`, `packages/*`, and package + workspace patterns +- Nx project metadata from `project.json`, including project-scoped validation + targets +- Next.js `app/` and `pages/` routes, including routes inside monorepo apps - 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 @@ -110,6 +114,7 @@ Useful flags: - `--limit ` - `--jobs ` - `--feature ` +- `--project ` - `--finding ` - `--status ` - `--severity ` diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 937aecf..706c01b 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -29,8 +29,9 @@ 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 +- 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/` -- Next.js `app/` and `pages/` routes +- Next.js `app/` and `pages/` routes at the repo root or inside discovered monorepo projects - Go `cmd/*/main.go` - Go `internal/*` packages - Python project metadata, console scripts, root app files, bounded source groups, @@ -55,6 +56,22 @@ be found cheaply. Selected `package.json` scripts are mapped for the root package and discovered workspace packages, with workspace script titles including the package name. +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: + +```bash +clawpatch review --project apps/web --limit 10 +clawpatch review --project web --limit 10 +clawpatch report --project web --status open +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. + 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`. @@ -83,4 +100,5 @@ Known gaps: - no Express/Fastify/Hono route mapper yet - no Django route mapper yet - no import graph expansion beyond nearby tests yet +- no Turborepo task metadata mapper yet - no agent enrichment yet diff --git a/docs/spec.md b/docs/spec.md index 499003c..34c0c5b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -698,8 +698,9 @@ Mappers: - Node package bins from `package.json`. - Node scripts from `package.json`. +- Node workspace and Nx project metadata as project roots for framework mappers. - TypeScript/JavaScript CLI command registries when cheap to detect. -- Next.js `app/**/page.*`, `app/**/route.*`, `pages/**`. +- Next.js `app/**/page.*`, `app/**/route.*`, `pages/**` at the repo root or inside discovered project roots. - Express/Fastify/Hono route registrations. - Go `cmd/*` commands and `internal/*` packages. - Rust Cargo commands, libraries, workspace crates, and integration tests. diff --git a/src/app.ts b/src/app.ts index e4409cc..9a6f682 100644 --- a/src/app.ts +++ b/src/app.ts @@ -243,8 +243,14 @@ export async function reportCommand( readFindings(loaded.paths), readFeatures(loaded.paths), ]); - const filtered = filterFindings(findings, flags); - const output = renderReport(filtered, features, { + const projectFilter = stringFlag(flags, "project"); + const scopedFeatures = filterFeaturesByProject(features, projectFilter); + const filtered = filterFindingsByFeatures( + filterFindings(findings, flags), + scopedFeatures, + projectFilter, + ); + const output = renderReport(filtered, scopedFeatures, { includeNext: stringFlag(flags, "status") !== undefined, }); const outputPath = typeof flags["output"] === "string" ? resolve(flags["output"]) : null; @@ -255,7 +261,7 @@ export async function reportCommand( return { findings: filtered.length, output: outputPath, - items: findingSummaries(filtered, features), + items: findingSummaries(filtered, scopedFeatures), }; } return { @@ -305,7 +311,15 @@ export async function nextCommand( readFeatures(loaded.paths), ]); const status = stringFlag(flags, "status") ?? "open"; - const selected = nextFinding(findings.filter((finding) => finding.status === status)); + const projectFilter = stringFlag(flags, "project"); + const scopedFeatures = filterFeaturesByProject(features, projectFilter); + const selected = nextFinding( + filterFindingsByFeatures( + findings.filter((finding) => finding.status === status), + scopedFeatures, + projectFilter, + ), + ); if (selected === null) { return { finding: null, status, next: "clawpatch report --status open" }; } @@ -918,9 +932,13 @@ function selectReviewCandidates( flags: Record, ): FeatureRecord[] { const featureId = stringFlag(flags, "feature"); - return featureId === undefined - ? features.filter((feature) => ["pending", "error"].includes(feature.status)) - : features.filter((feature) => feature.featureId === featureId); + const projectFilter = stringFlag(flags, "project"); + const projectFeatures = filterFeaturesByProject(features, projectFilter); + const selected = + featureId === undefined + ? projectFeatures.filter((feature) => ["pending", "error"].includes(feature.status)) + : projectFeatures.filter((feature) => feature.featureId === featureId); + return projectFilter === undefined ? selected : selected.toSorted(featureReviewRank); } async function filterFeaturesByFilesSince( @@ -979,6 +997,92 @@ function limitFeatures( return features.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 1); } +function filterFeaturesByProject( + features: FeatureRecord[], + project: string | undefined, +): FeatureRecord[] { + if (project === undefined) { + return features; + } + const normalized = normalizeProjectFilter(project); + return features.filter((feature) => featureMatchesProject(feature, project, normalized)); +} + +function filterFindingsByFeatures( + findings: FindingRecord[], + features: FeatureRecord[], + project: string | undefined, +): FindingRecord[] { + if (project === undefined) { + return findings; + } + const featureIds = new Set(features.map((feature) => feature.featureId)); + return findings.filter((finding) => featureIds.has(finding.featureId)); +} + +function featureMatchesProject( + feature: FeatureRecord, + rawProject: string, + normalizedProject: string, +): boolean { + if ( + feature.tags.includes(`project:${rawProject}`) || + feature.tags.includes(`project:${normalizedProject}`) || + feature.tags.includes(`project-root:${normalizedProject}`) + ) { + return true; + } + if (normalizedProject === ".") { + return feature.tags.includes("project-root:."); + } + return featurePaths(feature).some( + (path) => path === normalizedProject || path.startsWith(`${normalizedProject}/`), + ); +} + +function featurePaths(feature: FeatureRecord): string[] { + return [ + ...feature.entrypoints.map((entrypoint) => entrypoint.path), + ...feature.ownedFiles.map((file) => file.path), + ...feature.contextFiles.map((file) => file.path), + ...feature.tests.map((test) => test.path), + ].map(normalizePath); +} + +function normalizeProjectFilter(project: string): string { + const normalized = normalizePath(project).replace(/^\.\//u, ""); + return normalized.length === 0 ? "." : normalized; +} + +function featureReviewRank(left: FeatureRecord, right: FeatureRecord): number { + return ( + featureStatusRank(left) - featureStatusRank(right) || + featureSourceRank(left) - featureSourceRank(right) || + left.title.localeCompare(right.title) || + left.featureId.localeCompare(right.featureId) + ); +} + +function featureStatusRank(feature: FeatureRecord): number { + return feature.status === "error" ? 0 : 1; +} + +function featureSourceRank(feature: FeatureRecord): number { + if (feature.source.startsWith("next-")) { + return 0; + } + if (feature.source === "package-json-bin") { + return 1; + } + if (feature.source === "node-source-group") { + return 2; + } + if (feature.source === "node-package") { + return 3; + } + return 4; +} + function reviewJobs(flags: Record): number { const parsed = Number(stringFlag(flags, "jobs") ?? "10"); if (!Number.isFinite(parsed) || parsed < 1) { diff --git a/src/cli.ts b/src/cli.ts index ee3e06d..c64a67e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -148,10 +148,10 @@ const commandFlags = { init: new Set(["force"]), map: new Set(["dryRun"]), status: new Set(), - review: new Set(["feature", "limit", "since", "jobs", "provider", "model", "dryRun"]), - report: new Set(["status", "severity", "feature", "category", "triage", "output"]), + review: new Set(["feature", "project", "limit", "since", "jobs", "provider", "model", "dryRun"]), + report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]), show: new Set(["finding"]), - next: new Set(["status"]), + next: new Set(["status", "project"]), triage: new Set(["finding", "status", "note"]), fix: new Set(["finding", "provider", "model", "dryRun"]), revalidate: new Set([ @@ -193,6 +193,7 @@ const valueFlagNames = new Set([ "severity", "category", "triage", + "project", "note", ]); @@ -352,6 +353,7 @@ Usage: Flags: --feature + --project --limit --since --jobs default: 10 @@ -373,6 +375,7 @@ Flags: --status --severity --feature + --project --category --triage --output @@ -400,6 +403,7 @@ Usage: Flags: --status default: open + --project --json `); return; diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 273670c..7872b55 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -138,6 +138,137 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Route /_error"); }); + it("maps Next routes inside Nx workspace projects", async () => { + const root = await fixtureRoot("clawpatch-map-next-nx-workspace-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "workspace-root", workspaces: ["apps/*"] }, 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), + ); + await writeFixture( + root, + "apps/web/project.json", + JSON.stringify( + { + name: "web", + sourceRoot: "apps/web/src", + projectType: "application", + targets: { test: {}, lint: {} }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/web/src/app/(dashboard)/users/[id]/page.tsx", + "export default function Page() { return null; }\n", + ); + await writeFixture( + root, + "apps/web/src/app/(dashboard)/users/[id]/page.test.tsx", + "test('route', () => {});\n", + ); + await writeFixture( + root, + "apps/web/src/app/api/things/route.ts", + "export function GET() { return new Response('ok'); }\n", + ); + await writeFixture( + root, + "apps/admin/package.json", + JSON.stringify({ name: "admin", dependencies: { next: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/admin/project.json", + JSON.stringify({ name: "admin", targets: { test: {} } }, 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, []); + const titles = result.features.map((feature) => feature.title); + const webRoute = result.features.find((feature) => feature.title === "web route /users/:id"); + const adminRoute = result.features.find((feature) => feature.title === "admin route /settings"); + + expect(titles).toContain("web route /users/:id"); + expect(titles).toContain("web route /api/things"); + expect(titles).toContain("admin route /settings"); + expect(webRoute?.entrypoints[0]?.path).toBe("apps/web/src/app/(dashboard)/users/[id]/page.tsx"); + expect(webRoute?.entrypoints[0]?.route).toBe("/users/:id"); + expect(webRoute?.tags).toEqual( + expect.arrayContaining(["project:web", "project-root:apps/web", "project-type:application"]), + ); + expect(webRoute?.tests).toEqual([ + { + path: "apps/web/src/app/(dashboard)/users/[id]/page.test.tsx", + command: "yarn nx test web", + }, + ]); + expect(webRoute?.contextFiles).toContainEqual({ + path: "apps/web/project.json", + reason: "project context", + }); + expect(adminRoute?.tests.every((test) => test.command === "yarn nx test admin")).toBe(true); + }); + + 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, "pnpm-lock.yaml", ""); + await writeFixture( + root, + "apps/portal/project.json", + JSON.stringify( + { + name: "portal", + sourceRoot: "apps/portal/src", + projectType: "application", + targets: { test: {} }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/portal/src/app/account/page.tsx", + "export default function Account() { return null; }\n", + ); + await writeFixture( + root, + "apps/portal/src/app/account/page.test.tsx", + "test('route', () => {});\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "portal route /account"); + + expect(route?.entrypoints[0]?.path).toBe("apps/portal/src/app/account/page.tsx"); + expect(route?.tests).toEqual([ + { path: "apps/portal/src/app/account/page.test.tsx", command: "pnpm nx test portal" }, + ]); + expect(route?.tags).toEqual( + expect.arrayContaining([ + "project:portal", + "project-root:apps/portal", + "project-type:application", + ]), + ); + }); + 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)); diff --git a/src/mapper.ts b/src/mapper.ts index 677be7e..6f8f836 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -7,11 +7,12 @@ 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 { discoverNodeProjects } from "./mappers/projects.js"; import { rubySeeds } from "./mappers/ruby.js"; import { rustSeeds } from "./mappers/rust.js"; import { nearbyTests } from "./mappers/shared.js"; import { swiftSeeds } from "./mappers/swift.js"; -import { FeatureMapper, FeatureSeed } from "./mappers/types.js"; +import { FeatureMapper, FeatureSeed, MapperContext } from "./mappers/types.js"; import { FeatureRecord, ProjectRecord } from "./types.js"; export type MapResult = { @@ -154,7 +155,10 @@ function uniqueTests(tests: Array<{ path: string; command: string | null }>): Ar } async function collectSeeds(root: string): Promise { - const groups = await Promise.all(featureMappers.map((mapper) => mapper.map(root))); + const context: MapperContext = { + projects: await discoverNodeProjects(root), + }; + const groups = await Promise.all(featureMappers.map((mapper) => mapper.map(root, context))); return dedupeSeeds(groups.flat()); } diff --git a/src/mappers/next.ts b/src/mappers/next.ts index d05088c..2db2a6b 100644 --- a/src/mappers/next.ts +++ b/src/mappers/next.ts @@ -1,44 +1,105 @@ import { join } from "node:path"; -import { readPackageJson } from "../detect.js"; import { pathExists } from "../fs.js"; +import { + dependencyFieldHas, + packageRelativePath, + projectContextFiles, + projectTags, + projectTargetCommand, +} from "./projects.js"; import { walk } from "./shared.js"; -import { FeatureSeed } from "./types.js"; +import type { NodeProjectInfo } from "./projects.js"; +import { FeatureSeed, MapperContext } from "./types.js"; -export async function nextSeeds(root: string): Promise { - const prefixes = (await isNextProject(root)) - ? ["app", "pages", "src/app", "src/pages"] - : ["app", "pages"]; - const files = await walk(root, prefixes); - const routeFiles = files.filter( - (file) => - /(^|\/)(page|route)\.(tsx|ts|jsx|js)$/u.test(file) || - (/^(src\/)?pages\/.+\.(tsx|ts|jsx|js)$/u.test(file) && !isPagesFrameworkFile(file)), +export async function nextSeeds(root: string, context: MapperContext): Promise { + const seedGroups = await Promise.all( + context.projects.map(async (project) => projectNextSeeds(root, project)), ); - return routeFiles.map((file) => ({ - title: `Route ${routeFromFile(file)}`, - summary: `Web route implemented by ${file}.`, - kind: "route", - source: isAppRoute(file) ? "next-app-route" : "next-pages-route", - confidence: "high", - entryPath: file, - symbol: null, - route: routeFromFile(file), - command: null, - tags: ["next", "web"], - trustBoundaries: ["user-input", "network", "serialization"], - })); + return seedGroups.flat(); } -function isAppRoute(file: string): boolean { - return file.startsWith("app/") || file.startsWith("src/app/"); +async function projectNextSeeds(root: string, project: NodeProjectInfo): Promise { + const prefixes = await nextPrefixes(root, project); + if (prefixes.length === 0) { + return []; + } + const files = await walk(root, prefixes); + const routeFiles = files.flatMap((file) => { + const projectRelativePath = projectRelativeRoutePath(project, file); + if (projectRelativePath === null) { + return []; + } + const kind = nextRouteKind(projectRelativePath); + return kind === null ? [] : [{ file, projectRelativePath, kind }]; + }); + const testCommand = projectTargetCommand(project, "test"); + const contextFiles = await projectContextFiles(root, project); + + return routeFiles.map(({ file, projectRelativePath, kind }) => { + const route = routeFromProjectFile(projectRelativePath, kind); + return { + title: project.root === "." ? `Route ${route}` : `${project.name} route ${route}`, + summary: + project.root === "." + ? `Web route implemented by ${file}.` + : `Web route implemented by ${file} in project ${project.name}.`, + kind: "route", + source: kind === "app" ? "next-app-route" : "next-pages-route", + confidence: "high", + entryPath: file, + symbol: null, + route, + command: null, + contextFiles, + tags: ["next", "web", ...projectTags(project)], + trustBoundaries: ["user-input", "network", "serialization"], + ...(testCommand === null ? {} : { testCommand }), + }; + }); } -function isPagesFrameworkFile(file: string): boolean { - return /^(src\/)?pages\/_(app|document|error)\.(tsx|ts|jsx|js)$/u.test(file); +async function nextPrefixes(root: string, project: NodeProjectInfo): Promise { + const hasSignal = await isNextProject(root, project); + const projectPrefixes = new Set( + hasSignal ? ["app", "pages", "src/app", "src/pages"] : ["app", "pages"], + ); + for (const prefix of sourceRootRoutePrefixes(project)) { + projectPrefixes.add(prefix); + } + const existing: string[] = []; + for (const prefix of [...projectPrefixes].map((path) => + packageRelativePath(project.root, path), + )) { + if (await pathExists(join(root, prefix))) { + existing.push(prefix); + } + } + return existing; } -async function isNextProject(root: string): Promise { - const pkg = await readPackageJson(root); +function sourceRootRoutePrefixes(project: NodeProjectInfo): string[] { + const sourceRoot = project.sourceRoot; + if (sourceRoot === null) { + return []; + } + const relativeSourceRoot = + project.root === "." + ? sourceRoot + : sourceRoot === project.root + ? "" + : sourceRoot.startsWith(`${project.root}/`) + ? sourceRoot.slice(project.root.length + 1) + : null; + if (relativeSourceRoot === null) { + return []; + } + return ["app", "pages"].map((path) => + relativeSourceRoot.length === 0 ? path : `${relativeSourceRoot}/${path}`, + ); +} + +async function isNextProject(root: string, project: NodeProjectInfo): Promise { + const pkg = project.packageJson; if ( dependencyFieldHas(pkg?.dependencies, "next") || dependencyFieldHas(pkg?.devDependencies, "next") @@ -46,19 +107,39 @@ async function isNextProject(root: string): Promise { return true; } for (const file of ["next.config.js", "next.config.mjs", "next.config.ts"]) { - if (await pathExists(join(root, file))) { + if (await pathExists(join(root, packageRelativePath(project.root, file)))) { return true; } } return false; } -function dependencyFieldHas(field: unknown, name: string): boolean { - return typeof field === "object" && field !== null && Object.hasOwn(field, name); +function projectRelativeRoutePath(project: NodeProjectInfo, file: string): string | null { + if (project.root === ".") { + return file; + } + return file.startsWith(`${project.root}/`) ? file.slice(project.root.length + 1) : null; } -function routeFromFile(file: string): string { - let route = isAppRoute(file) ? appRouteFromFile(file) : pagesRouteFromFile(file); +function nextRouteKind(file: string): "app" | "pages" | null { + if ( + (file.startsWith("app/") || file.startsWith("src/app/")) && + /\/(page|route)\.(tsx|ts|jsx|js)$/u.test(file) + ) { + return "app"; + } + if (/^(src\/)?pages\/.+\.(tsx|ts|jsx|js)$/u.test(file) && !isPagesFrameworkFile(file)) { + return "pages"; + } + return null; +} + +function isPagesFrameworkFile(file: string): boolean { + return /^(src\/)?pages\/_(app|document|error)\.(tsx|ts|jsx|js)$/u.test(file); +} + +function routeFromProjectFile(file: string, kind: "app" | "pages"): string { + let route = kind === "app" ? appRouteFromFile(file) : pagesRouteFromFile(file); if (route === "") { route = "/"; } @@ -66,18 +147,45 @@ function routeFromFile(file: string): string { } function appRouteFromFile(file: string): string { - return file + const normalized = file .replace(/^src\//u, "") .replace(/^app\//u, "/") - .replace(/\/(page|route)\.[^.]+$/u, "") - .replace(/\[(.+?)\]/gu, ":$1"); + .replace(/\/(page|route)\.[^.]+$/u, ""); + return normalizeRouteSegments(normalized); } function pagesRouteFromFile(file: string): string { - return file + const normalized = file .replace(/^src\//u, "") .replace(/^pages\//u, "/") .replace(/\.[^.]+$/u, "") - .replace(/\/index$/u, "") - .replace(/\[(.+?)\]/gu, ":$1"); + .replace(/\/index$/u, ""); + return normalizeRouteSegments(normalized); +} + +function normalizeRouteSegments(route: string): string { + const segments = route + .split("/") + .filter((segment) => segment.length > 0) + .filter((segment) => !isRouteGroupSegment(segment)) + .filter((segment) => !segment.startsWith("@")) + .map(dynamicSegment); + return `/${segments.join("/")}`.replace(/\/$/u, ""); +} + +function isRouteGroupSegment(segment: string): boolean { + return segment.startsWith("(") && segment.endsWith(")") && !segment.startsWith("(."); +} + +function dynamicSegment(segment: string): string { + const optionalCatchAll = /^\[\[\.\.\.(.+)\]\]$/u.exec(segment); + if (optionalCatchAll?.[1] !== undefined) { + return `:${optionalCatchAll[1]}*`; + } + const catchAll = /^\[\.\.\.(.+)\]$/u.exec(segment); + if (catchAll?.[1] !== undefined) { + return `:${catchAll[1]}*`; + } + const dynamic = /^\[(.+)\]$/u.exec(segment); + return dynamic?.[1] === undefined ? segment : `:${dynamic[1]}`; } diff --git a/src/mappers/node.ts b/src/mappers/node.ts index 443c95b..85e6f73 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -1,29 +1,24 @@ -import { lstat, readFile, readdir, realpath } from "node:fs/promises"; import { basename, dirname, extname, join } from "node:path"; -import { packageBins, packageScripts, readPackageJson } from "../detect.js"; +import { packageBins, packageScripts } from "../detect.js"; import { pathExists } from "../fs.js"; import { normalize, - isSafeDirectory, packageKind, packageTrustBoundaries, pathMatchesPrefix, - shouldSkip, walk, } from "./shared.js"; -import { FeatureSeed, SeedFileRef, SeedTestRef } from "./types.js"; - -type NodePackageJson = { - name?: unknown; - scripts?: unknown; - dependencies?: unknown; - devDependencies?: unknown; - bin?: unknown; - workspaces?: unknown; -}; - -type PackageInfo = { - root: string; +import { + packageRelativePath, + projectContextFiles, + projectDisplayName, + projectTags, + projectTargetCommand, +} from "./projects.js"; +import type { NodePackageJson, NodeProjectInfo } from "./projects.js"; +import { FeatureSeed, MapperContext, SeedFileRef, SeedTestRef } from "./types.js"; + +type PackageInfo = NodeProjectInfo & { packageJsonPath: string; packageJson: NodePackageJson; }; @@ -38,31 +33,30 @@ const testDirectories = ["test", "tests", "__tests__"] as const; const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; -export async function nodeSeeds(root: string): Promise { - const rootPackage = await readPackageJson(root); - const packages = await discoverPackages(root, rootPackage); - const packageManager = await detectNodePackageManager(root); +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, packageManager))); - seeds.push(...(await sourceGroupSeeds(root, info, packageManager))); + seeds.push(...(await packageSeeds(root, info))); + seeds.push(...(await sourceGroupSeeds(root, info))); } return seeds; } -async function packageSeeds( - root: string, - info: PackageInfo, - packageManager: string, -): Promise { +function hasNodePackage(project: NodeProjectInfo): project is PackageInfo { + return project.packageJsonPath !== null && project.packageJson !== null; +} + +async function packageSeeds(root: string, info: PackageInfo): Promise { const seeds: FeatureSeed[] = []; - const packageName = packageDisplayName(info); - const packageTags = ["node", "package"]; + const packageName = projectDisplayName(info); + const packageTags = ["node", "package", ...projectTags(info)]; if (info.root !== ".") { packageTags.push("workspace"); } + const testCommand = projectTargetCommand(info, "test"); const manifestSeed: FeatureSeed = { title: `Node package ${packageName}`, @@ -75,7 +69,9 @@ async function packageSeeds( route: null, command: null, ownedFiles: [{ path: info.packageJsonPath, reason: "package manifest" }], - contextFiles: await packageContextFiles(root, info), + contextFiles: (await projectContextFiles(root, info)).filter( + (ref) => ref.path !== info.packageJsonPath, + ), tags: packageTags, trustBoundaries: packageTrustBoundaries(`${packageName} ${info.root}`), skipNearbyTests: true, @@ -98,9 +94,7 @@ async function packageSeeds( command, tags: ["node", "cli"], trustBoundaries: ["user-input", "filesystem", "process-exec"], - ...(packageScripts(info.packageJson)["test"] - ? { testCommand: scriptCommand(packageManager, info.root, "test") } - : {}), + ...(testCommand === null ? {} : { testCommand }), }); } @@ -124,7 +118,7 @@ async function packageSeeds( symbol: script, route: null, command: script, - tags: ["node", "package-script"], + tags: ["node", "package-script", ...projectTags(info)], trustBoundaries: script === "test" ? [] : ["process-exec", "filesystem"], skipNearbyTests: true, }); @@ -134,15 +128,9 @@ async function packageSeeds( return seeds; } -async function sourceGroupSeeds( - root: string, - info: PackageInfo, - packageManager: string, -): Promise { - const packageName = packageDisplayName(info); - const testCommand = packageScripts(info.packageJson)["test"] - ? scriptCommand(packageManager, info.root, "test") - : null; +async function sourceGroupSeeds(root: string, info: PackageInfo): Promise { + const packageName = projectDisplayName(info); + const testCommand = projectTargetCommand(info, "test"); const testFiles = await packageTestFiles(root, info); const seeds: FeatureSeed[] = []; @@ -180,7 +168,7 @@ async function sourceGroupSeeds( ...tests.map((test) => ({ path: test.path, reason: "associated test" })), ]), tests, - tags: ["node", "typescript", "source-group"], + tags: ["node", "typescript", "source-group", ...projectTags(info)], trustBoundaries: packageTrustBoundaries(`${packageName} ${group.label}`), testCommand, skipNearbyTests: true, @@ -191,310 +179,6 @@ async function sourceGroupSeeds( return seeds; } -async function discoverPackages( - root: string, - rootPackage: NodePackageJson | null, -): Promise { - const packageRoots = new Set(); - if (rootPackage !== null) { - packageRoots.add("."); - } - 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]; - }); - for (const includePattern of patterns.filter((pattern) => !pattern.startsWith("!"))) { - for (const packageRoot of await expandWorkspacePattern(root, includePattern)) { - packageRoots.add(packageRoot); - } - } - - const packages: PackageInfo[] = []; - for (const packageRoot of [...packageRoots] - .filter((path) => !isExcludedWorkspace(path, excludes)) - .toSorted()) { - const packageJsonPath = packageRelativePath(packageRoot, "package.json"); - const packageJson = await readPackageJsonAt(root, packageJsonPath); - if (packageJson !== null) { - packages.push({ root: packageRoot, packageJsonPath, packageJson }); - } - } - return packages; -} - -async function workspacePatterns(root: string, pkg: NodePackageJson | null): Promise { - const patterns = new Set(); - if (pkg !== null) { - for (const pattern of packageWorkspacePatterns(pkg)) { - 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); - } - } - for (const fallback of ["packages/*", "apps/*", "extensions/*", "plugins/*"]) { - if (await pathExists(join(root, fallback.slice(0, -2)))) { - patterns.add(fallback); - } - } - return [...patterns]; -} - -function packageWorkspacePatterns(pkg: NodePackageJson): string[] { - const workspaces = pkg.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("/**") && !hasWorkspaceGlob(normalized.slice(0, -3))) { - return discoverPackageRoots(root, normalized.slice(0, -3), 4); - } - const singleSegmentParent = normalized.endsWith("/*") ? normalized.slice(0, -2) : null; - if (singleSegmentParent !== null && !hasWorkspaceGlob(singleSegmentParent)) { - const parent = singleSegmentParent; - const entries = await safeDirectoryEntries(root, parent); - const packageRoots: string[] = []; - for (const entry of entries) { - const candidate = `${parent}/${entry}`; - if (await pathExists(join(root, candidate, "package.json"))) { - packageRoots.push(candidate); - } - } - return packageRoots; - } - if (hasWorkspaceGlob(normalized)) { - return expandWorkspaceGlob(root, normalized); - } - return (await isSafeDirectory(root, join(root, normalized))) && - (await pathExists(join(root, normalized, "package.json"))) - ? [normalized] - : []; -} - -function normalizeWorkspacePattern(pattern: string): string | null { - const normalized = normalize(pattern) - .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; -} - -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); -} - -async function expandWorkspaceGlob(root: string, pattern: string): Promise { - const packages: 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))) && - (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)) { - 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 packages.toSorted(); -} - -function hasWorkspaceGlob(pattern: string): boolean { - return /[*?]/u.test(pattern); -} - -function globSegmentRegExp(segment: string): RegExp { - const escaped = segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&"); - return new RegExp(`^${escaped.replace(/\*/gu, "[^/]*").replace(/\?/gu, "[^/]")}$`, "u"); -} - -async function discoverPackageRoots( - root: string, - prefix: string, - maxDepth: number, -): Promise { - const output: string[] = []; - await discoverPackageRootsInto(root, prefix, maxDepth, output); - return output.toSorted(); -} - -async function discoverPackageRootsInto( - root: string, - prefix: string, - remainingDepth: number, - output: string[], -): Promise { - if (remainingDepth < 0 || shouldSkip(prefix)) { - return; - } - if (await pathExists(join(root, prefix, "package.json"))) { - output.push(prefix); - } - for (const entry of await safeDirectoryEntries(root, prefix)) { - await discoverPackageRootsInto(root, `${prefix}/${entry}`, remainingDepth - 1, output); - } -} - -async function safeDirectoryEntries(root: string, prefix: string): Promise { - const dir = join(root, prefix); - if (!(await isSafeDirectory(root, dir))) { - return []; - } - const [realRoot, realDir] = await Promise.all([realpath(root), realpath(dir)]); - if (!pathMatchesPrefix(normalize(realDir), normalize(realRoot))) { - return []; - } - const entries = await readdir(dir); - const output: string[] = []; - for (const entry of entries) { - const rel = normalize(join(prefix, entry)); - if (shouldSkip(rel)) { - continue; - } - const childInfo = await lstat(join(dir, entry)); - if (childInfo.isDirectory() && !childInfo.isSymbolicLink()) { - output.push(entry); - } - } - return output.toSorted(); -} - -async function readPackageJsonAt(root: string, path: string): Promise { - if (!(await pathExists(join(root, path)))) { - return null; - } - const parsed: unknown = JSON.parse(await readFile(join(root, path), "utf8")); - return typeof parsed === "object" && parsed !== null ? (parsed as NodePackageJson) : null; -} - -async function packageContextFiles(root: string, info: PackageInfo): Promise { - const candidates = ["README.md", "AGENTS.md", "tsconfig.json"].map((path) => - packageRelativePath(info.root, path), - ); - const refs: SeedFileRef[] = []; - for (const candidate of candidates) { - if (candidate !== info.packageJsonPath && (await pathExists(join(root, candidate)))) { - refs.push({ path: candidate, reason: "package context" }); - } - } - return refs; -} - async function packageSourceRoots(root: string, info: PackageInfo): Promise { if (await isRailsPackage(root, info.root)) { const railsSourceDirectories = sourceDirectories.filter((dir) => dir !== "app"); @@ -697,49 +381,6 @@ function normalizePackagePath(path: string): string { return normalize(path).replace(/^\.\//u, ""); } -function packageRelativePath(packageRoot: string, path: string): string { - return packageRoot === "." ? normalize(path) : normalize(join(packageRoot, path)); -} - -function packageDisplayName(info: PackageInfo): string { - if (typeof info.packageJson.name === "string" && info.packageJson.name.length > 0) { - return info.packageJson.name; - } - return info.root === "." ? basename(dirname(join(info.packageJsonPath))) : basename(info.root); -} - -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"; -} - -function scriptCommand(packageManager: string, packageRoot: string, script: string): string { - if (packageRoot === ".") { - 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 isReviewableNodeSourceFile(path: string): boolean { return ( /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u.test(path) && diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts new file mode 100644 index 0000000..8db7b2f --- /dev/null +++ b/src/mappers/projects.ts @@ -0,0 +1,592 @@ +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { packageScripts, readPackageJson } from "../detect.js"; +import { pathExists } from "../fs.js"; +import { isSafeDirectory, normalize, pathMatchesPrefix, shouldSkip } from "./shared.js"; +import type { SeedFileRef } from "./types.js"; + +export type NodePackageJson = { + name?: unknown; + scripts?: unknown; + dependencies?: unknown; + devDependencies?: unknown; + bin?: unknown; + workspaces?: unknown; +}; + +export type NodeProjectTarget = { + name: string; +}; + +export type NodeProjectInfo = { + root: string; + name: string; + packageJsonPath: string | null; + packageJson: NodePackageJson | null; + projectJsonPath: string | null; + sourceRoot: string | null; + projectType: string | null; + targets: Record; + packageManager: string; +}; + +type CandidateContextFile = { + path: string | null; + reason: string; +}; + +export async function discoverNodeProjects(root: string): Promise { + const rootPackage = await readPackageJson(root); + const packageManager = await detectNodePackageManager(root); + const byRoot = new Map(); + + for (const packageRoot of await discoverPackageRoots(root, rootPackage)) { + const packageJsonPath = packageRelativePath(packageRoot, "package.json"); + const packageJson = await readPackageJsonAt(root, packageJsonPath); + if (packageJson === null) { + continue; + } + byRoot.set(packageRoot, { + root: packageRoot, + name: packageDisplayName(packageRoot, packageJsonPath, packageJson), + packageJsonPath, + packageJson, + projectJsonPath: null, + sourceRoot: null, + projectType: null, + targets: {}, + packageManager, + }); + } + + for (const projectJsonPath of await discoverNxProjectJsonPaths(root)) { + const nxProject = await readNxProjectJson(root, projectJsonPath); + if (nxProject === null) { + continue; + } + const projectRoot = dirname(projectJsonPath); + const packageJsonPath = packageRelativePath(projectRoot, "package.json"); + const packageJson = + byRoot.get(projectRoot)?.packageJson ?? (await readPackageJsonAt(root, packageJsonPath)); + const previous = byRoot.get(projectRoot); + byRoot.set(projectRoot, { + root: projectRoot, + name: nxProjectName({ + projectRoot, + packageJsonPath, + packageJson, + previousName: previous?.name, + nxName: nxProject.name, + }), + packageJsonPath: packageJson === null ? null : packageJsonPath, + packageJson, + projectJsonPath, + sourceRoot: nxProject.sourceRoot, + projectType: nxProject.projectType, + targets: nxProject.targets, + packageManager, + }); + } + + return [...byRoot.values()].toSorted((left, right) => left.root.localeCompare(right.root)); +} + +export function projectTags(project: NodeProjectInfo): string[] { + const tags = [`project:${project.name}`, `project-root:${project.root}`]; + if (project.projectType !== null) { + tags.push(`project-type:${project.projectType}`); + } + return tags; +} + +export function projectContextFiles( + root: string, + project: NodeProjectInfo, +): Promise { + return existingProjectContextFiles(root, project); +} + +export function projectTargetCommand(project: NodeProjectInfo, target: string): string | null { + if (project.targets[target] !== undefined) { + return nxCommand(project.packageManager, target, project.name); + } + if (project.packageJson !== null && packageScripts(project.packageJson)[target] !== undefined) { + return scriptCommand(project.packageManager, project.root, target); + } + return null; +} + +export function packageRelativePath(packageRoot: string, path: string): string { + return packageRoot === "." ? normalize(path) : normalize(join(packageRoot, path)); +} + +export function scriptCommand(packageManager: string, packageRoot: string, script: string): string { + if (packageRoot === ".") { + 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}`; +} + +export function projectDisplayName(info: NodeProjectInfo): string { + return info.name; +} + +export function dependencyFieldHas(field: unknown, name: string): boolean { + return typeof field === "object" && field !== null && Object.hasOwn(field, name); +} + +async function existingProjectContextFiles( + root: string, + project: NodeProjectInfo, +): Promise { + const candidates: CandidateContextFile[] = [ + { path: project.packageJsonPath, reason: "package manifest" }, + { path: project.projectJsonPath, reason: "project context" }, + { path: packageRelativePath(project.root, "README.md"), reason: "package context" }, + { path: packageRelativePath(project.root, "AGENTS.md"), reason: "package context" }, + { path: packageRelativePath(project.root, "tsconfig.json"), reason: "package context" }, + { path: packageRelativePath(project.root, "next.config.js"), reason: "project context" }, + { path: packageRelativePath(project.root, "next.config.mjs"), reason: "project context" }, + { path: packageRelativePath(project.root, "next.config.ts"), reason: "project context" }, + ]; + const refs: SeedFileRef[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const candidatePath = candidate.path; + if (candidatePath === null) { + continue; + } + if (seen.has(candidatePath) || !(await pathExists(join(root, candidatePath)))) { + continue; + } + seen.add(candidatePath); + refs.push({ path: candidatePath, reason: candidate.reason }); + } + return refs; +} + +function nxProjectName(options: { + projectRoot: string; + packageJsonPath: string; + packageJson: NodePackageJson | null; + previousName: string | undefined; + nxName: string | null; +}): string { + if (options.nxName !== null) { + return options.nxName; + } + if (options.previousName !== undefined) { + return options.previousName; + } + if (options.packageJson !== null) { + return packageDisplayName(options.projectRoot, options.packageJsonPath, options.packageJson); + } + if (options.projectRoot === ".") { + return "root"; + } + return basename(options.projectRoot); +} + +async function discoverPackageRoots( + root: string, + rootPackage: NodePackageJson | null, +): Promise { + const packageRoots = new Set(); + if (rootPackage !== null) { + packageRoots.add("."); + } + 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]; + }); + for (const includePattern of patterns.filter((pattern) => !pattern.startsWith("!"))) { + for (const packageRoot of await expandWorkspacePattern(root, includePattern)) { + packageRoots.add(packageRoot); + } + } + return [...packageRoots].filter((path) => !isExcludedWorkspace(path, excludes)).toSorted(); +} + +async function workspacePatterns(root: string, pkg: NodePackageJson | null): Promise { + const patterns = new Set(); + if (pkg !== null) { + for (const pattern of packageWorkspacePatterns(pkg)) { + 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); + } + } + for (const fallback of ["packages/*", "apps/*", "extensions/*", "plugins/*"]) { + if (await pathExists(join(root, fallback.slice(0, -2)))) { + patterns.add(fallback); + } + } + return [...patterns]; +} + +function packageWorkspacePatterns(pkg: NodePackageJson): string[] { + const workspaces = pkg.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("/**") && !hasWorkspaceGlob(normalized.slice(0, -3))) { + return discoverPackageRootsUnder(root, normalized.slice(0, -3), 4); + } + const singleSegmentParent = normalized.endsWith("/*") ? normalized.slice(0, -2) : null; + if (singleSegmentParent !== null && !hasWorkspaceGlob(singleSegmentParent)) { + const entries = await safeDirectoryEntries(root, singleSegmentParent); + const packageRoots: string[] = []; + for (const entry of entries) { + const candidate = `${singleSegmentParent}/${entry}`; + if (await pathExists(join(root, candidate, "package.json"))) { + packageRoots.push(candidate); + } + } + return packageRoots; + } + if (hasWorkspaceGlob(normalized)) { + return expandWorkspaceGlob(root, normalized); + } + return (await isSafeDirectory(root, join(root, normalized))) && + (await pathExists(join(root, normalized, "package.json"))) + ? [normalized] + : []; +} + +function normalizeWorkspacePattern(pattern: string): string | null { + const normalized = normalize(pattern) + .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; +} + +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); +} + +async function expandWorkspaceGlob(root: string, pattern: string): Promise { + const packages: 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))) && + (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)) { + 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 packages.toSorted(); +} + +function hasWorkspaceGlob(pattern: string): boolean { + return /[*?]/u.test(pattern); +} + +function globSegmentRegExp(segment: string): RegExp { + const escaped = segment.replace(/[.+^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`^${escaped.replace(/\*/gu, "[^/]*").replace(/\?/gu, "[^/]")}$`, "u"); +} + +async function discoverPackageRootsUnder( + root: string, + prefix: string, + maxDepth: number, +): Promise { + const output: string[] = []; + await discoverPackageRootsInto(root, prefix, maxDepth, output); + return output.toSorted(); +} + +async function discoverPackageRootsInto( + root: string, + prefix: string, + remainingDepth: number, + output: string[], +): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return; + } + if (await pathExists(join(root, prefix, "package.json"))) { + output.push(prefix); + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + await discoverPackageRootsInto(root, `${prefix}/${entry}`, remainingDepth - 1, output); + } +} + +async function discoverNxProjectJsonPaths(root: string): Promise { + const output: string[] = []; + await discoverNxProjectJsonPathsInto(root, "", 5, output); + return output.toSorted(); +} + +async function discoverNxProjectJsonPathsInto( + root: string, + prefix: string, + remainingDepth: number, + output: string[], +): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return; + } + const projectJsonPath = packageRelativePath(prefix === "" ? "." : prefix, "project.json"); + if (projectJsonPath !== "project.json" && (await pathExists(join(root, projectJsonPath)))) { + output.push(projectJsonPath); + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + await discoverNxProjectJsonPathsInto( + root, + prefix.length === 0 ? entry : `${prefix}/${entry}`, + remainingDepth - 1, + output, + ); + } +} + +function shouldSkipProjectDir(path: string): boolean { + return shouldSkip(path) || /(^|\/)(\.next|\.turbo|\.vercel)(\/|$)/u.test(path); +} + +async function safeDirectoryEntries(root: string, prefix: string): Promise { + const dir = join(root, prefix); + if (!(await isSafeDirectory(root, dir))) { + return []; + } + const [realRoot, realDir] = await Promise.all([realpath(root), realpath(dir)]); + if (!pathMatchesPrefix(normalize(realDir), normalize(realRoot))) { + 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.isDirectory() && !childInfo.isSymbolicLink()) { + output.push(entry); + } + } + return output.toSorted(); +} + +async function readPackageJsonAt(root: string, path: string): Promise { + if (!(await pathExists(join(root, path)))) { + return null; + } + const parsed: unknown = JSON.parse(await readFile(join(root, path), "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as NodePackageJson) : null; +} + +type NxProjectJson = { + name: string | null; + sourceRoot: string | null; + projectType: string | null; + targets: Record; +}; + +async function readNxProjectJson(root: string, path: string): Promise { + if (!(await pathExists(join(root, path)))) { + return null; + } + const parsed: unknown = JSON.parse(await readFile(join(root, path), "utf8")); + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const record = parsed as { + name?: unknown; + sourceRoot?: unknown; + projectType?: unknown; + targets?: unknown; + }; + return { + name: typeof record.name === "string" && record.name.length > 0 ? record.name : null, + sourceRoot: + typeof record.sourceRoot === "string" && record.sourceRoot.length > 0 + ? normalize(record.sourceRoot) + : null, + projectType: + typeof record.projectType === "string" && record.projectType.length > 0 + ? record.projectType + : null, + targets: nxTargets(record.targets), + }; +} + +function nxTargets(targets: unknown): Record { + if (typeof targets !== "object" || targets === null) { + return {}; + } + const output: Record = {}; + for (const name of Object.keys(targets).toSorted()) { + output[name] = { name }; + } + return output; +} + +function packageDisplayName( + packageRoot: string, + packageJsonPath: string, + packageJson: NodePackageJson, +): string { + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + return packageJson.name; + } + return packageRoot === "." ? basename(dirname(join(packageJsonPath))) : basename(packageRoot); +} + +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"; +} + +function nxCommand(packageManager: string, target: string, projectName: string): string { + if (packageManager === "npm") { + return `npx nx ${target} ${projectName}`; + } + if (packageManager === "bun") { + return `bunx nx ${target} ${projectName}`; + } + return `${packageManager} nx ${target} ${projectName}`; +} diff --git a/src/mappers/types.ts b/src/mappers/types.ts index b3c8ef9..7199bc3 100644 --- a/src/mappers/types.ts +++ b/src/mappers/types.ts @@ -1,4 +1,5 @@ import { FeatureRecord, TrustBoundary } from "../types.js"; +import type { NodeProjectInfo } from "./projects.js"; export type SeedFileRef = { path: string; @@ -32,5 +33,9 @@ export type FeatureSeed = { export type FeatureMapper = { name: string; - map(root: string): Promise; + map(root: string, context: MapperContext): Promise; +}; + +export type MapperContext = { + projects: NodeProjectInfo[]; }; diff --git a/src/workflow.test.ts b/src/workflow.test.ts index e724594..07bc7ef 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -176,9 +176,12 @@ describe("workflow", () => { }); it("parses review jobs and report filters", () => { - expect(parseArgs(["review", "--limit", "4", "--jobs", "3"]).flags).toMatchObject({ + expect( + parseArgs(["review", "--limit", "4", "--jobs", "3", "--project", "apps/web"]).flags, + ).toMatchObject({ limit: "4", jobs: "3", + project: "apps/web", }); expect(parseArgs(["review", "--since", "HEAD~5"]).flags).toMatchObject({ since: "HEAD~5", @@ -186,9 +189,12 @@ describe("workflow", () => { expect(parseArgs(["revalidate", "--since", "origin/main"]).flags).toMatchObject({ since: "origin/main", }); - expect(parseArgs(["report", "--status", "open", "--severity", "high"]).flags).toMatchObject({ + expect( + parseArgs(["report", "--status", "open", "--severity", "high", "--project", "web"]).flags, + ).toMatchObject({ status: "open", severity: "high", + project: "web", }); expect( parseArgs(["triage", "--finding", "f", "--status", "wont-fix", "--note", "ok"]).flags, @@ -706,6 +712,71 @@ describe("workflow", () => { expect(features[0]?.status).toBe("pending"); }); + it("filters review dry-runs by project name or root", async () => { + const root = await fixtureRoot("clawpatch-project-filter-"); + 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", dependencies: { next: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/web/project.json", + JSON.stringify({ name: "web", targets: { test: {} } }, null, 2), + ); + await writeFixture( + root, + "apps/web/src/app/dashboard/page.tsx", + "export default function Page() { return null; }\n", + ); + await writeFixture( + root, + "apps/admin/package.json", + JSON.stringify({ name: "admin", dependencies: { next: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "apps/admin/project.json", + JSON.stringify({ name: "admin", targets: { test: {} } }, null, 2), + ); + await writeFixture( + root, + "apps/admin/src/app/dashboard/page.tsx", + "export default function Page() { return null; }\n", + ); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const byRoot = (await reviewCommand(context, { + dryRun: true, + project: "apps/web", + limit: "20", + })) as { featureIds: string[]; wouldReview: number }; + const byName = (await reviewCommand(context, { + dryRun: true, + project: "web", + limit: "20", + })) as { featureIds: string[]; wouldReview: number }; + const features = await readFeatures(statePaths(join(root, ".clawpatch"))); + const titleById = new Map(features.map((feature) => [feature.featureId, feature.title])); + + expect(byRoot.wouldReview).toBeGreaterThan(0); + expect(byRoot.featureIds).toEqual(byName.featureIds); + expect(byRoot.featureIds.map((id) => titleById.get(id))).toEqual( + expect.arrayContaining(["Node package web", "web route /dashboard"]), + ); + expect(byRoot.featureIds.map((id) => titleById.get(id))).not.toContain("Node package admin"); + expect(byRoot.featureIds.map((id) => titleById.get(id))).not.toContain( + "admin route /dashboard", + ); + }); + it("does not mutate features on dry-run map", async () => { const root = await fixtureRoot("clawpatch-map-dry-run-"); await writeFixture(