Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +114,7 @@ Useful flags:
- `--limit <n>`
- `--jobs <n>`
- `--feature <id>`
- `--project <name-or-root>`
- `--finding <id>`
- `--status <status>`
- `--severity <severity>`
Expand Down
20 changes: 19 additions & 1 deletion docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`.
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 111 additions & 7 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -255,7 +261,7 @@ export async function reportCommand(
return {
findings: filtered.length,
output: outputPath,
items: findingSummaries(filtered, features),
items: findingSummaries(filtered, scopedFeatures),
};
}
return {
Expand Down Expand Up @@ -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" };
}
Expand Down Expand Up @@ -918,9 +932,13 @@ function selectReviewCandidates(
flags: Record<string, string | boolean>,
): 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(
Expand Down Expand Up @@ -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<string, string | boolean>): number {
const parsed = Number(stringFlag(flags, "jobs") ?? "10");
if (!Number.isFinite(parsed) || parsed < 1) {
Expand Down
10 changes: 7 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,10 @@ const commandFlags = {
init: new Set(["force"]),
map: new Set(["dryRun"]),
status: new Set<string>(),
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([
Expand Down Expand Up @@ -193,6 +193,7 @@ const valueFlagNames = new Set([
"severity",
"category",
"triage",
"project",
"note",
]);

Expand Down Expand Up @@ -352,6 +353,7 @@ Usage:

Flags:
--feature <id>
--project <name-or-root>
--limit <n>
--since <ref>
--jobs <n> default: 10
Expand All @@ -373,6 +375,7 @@ Flags:
--status <status>
--severity <severity>
--feature <id>
--project <name-or-root>
--category <category>
--triage <triage>
--output <path>
Expand Down Expand Up @@ -400,6 +403,7 @@ Usage:

Flags:
--status <status> default: open
--project <name-or-root>
--json
`);
return;
Expand Down
Loading