diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c6040..ffa001d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.5.1 - Unreleased +- Added `clawpatch review --feature-list ` for reviewing an explicit ordered, de-duplicated set of feature IDs, thanks @camwest. + ## 0.5.0 - 2026-05-31 - Added CUDA support to the C/C++ mapper, mapping `.cu` and `.cuh` sources as standalone `main()` files, CMake and autotools targets, legacy `FindCUDA` `cuda_add_executable` / `cuda_add_library` calls, and bounded loose source groups. diff --git a/docs/code-review.md b/docs/code-review.md index e87fb9b..9a5ebb5 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -37,6 +37,19 @@ Current behavior: ## Flags +### --feature-list + +Review exactly the feature ids listed in the file, in file order. The file must +contain one feature id per line. Blank lines are ignored and duplicate ids are +de-duplicated by first occurrence. + +This mode is explicit feature selection, so it cannot be combined with +`--feature`, `--project`, `--since`, or `--include-dirty`. + +```bash +clawpatch review --feature-list /tmp/features.txt +``` + ### --since Restrict review to features whose owned or context files have changed in diff --git a/docs/spec.md b/docs/spec.md index b33f9b2..e2bd83e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -206,7 +206,7 @@ Review feature slices and persist findings. Usage: ```bash -clawpatch review [--feature ] [--kind ] [--limit ] [--jobs ] [--rate-limit-per-minute ] [--since ] [--mode ] [--dry-run] [--provider ] [--model ] [--reasoning-effort ] [--resume ] +clawpatch review [--feature | --feature-list ] [--kind ] [--limit ] [--jobs ] [--rate-limit-per-minute ] [--since ] [--mode ] [--dry-run] [--provider ] [--model ] [--reasoning-effort ] [--resume ] ``` Behavior: @@ -228,6 +228,7 @@ Behavior: Selection: - Explicit `--feature` wins. +- Explicit `--feature-list` reviews exactly the listed feature ids in file order. - Else pending/errored features, filtered by `--kind`. - `--limit` caps claimed features. diff --git a/src/app.ts b/src/app.ts index 2ac258c..9ba4315 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,6 +42,7 @@ import { limitFeatures, nextFinding, selectReviewCandidates, + selectFeaturesByIdList, } from "./selection.js"; import { claimFeature, @@ -347,7 +348,9 @@ export async function reviewCommand( mode, customPrompt, limiter, - allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined, + allowNonPendingFeatureReview: + stringFlag(flags, "feature") !== undefined || + stringFlag(flags, "featureList") !== undefined, }); findingIds.push(...reviewed.findingIds); for (const dropped of reviewed.droppedFindings) { @@ -1989,7 +1992,31 @@ async function selectReviewFeatures( loaded: Awaited>, flags: Record, ): Promise { - const candidates = selectReviewCandidates(await readFeatures(loaded.paths), flags); + const featureListPath = stringFlag(flags, "featureList"); + const features = await readFeatures(loaded.paths); + if (featureListPath !== undefined) { + const featureIds = await loadFeatureIdList(featureListPath); + const selected = selectFeaturesByIdList(features, featureIds); + const missing = featureIds + .filter((featureId, index) => featureIds.indexOf(featureId) === index) + .filter((featureId) => !selected.some((feature) => feature.featureId === featureId)); + if (missing.length > 0) { + throw new ClawpatchError( + `unknown feature ids in --feature-list: ${missing.join(", ")}`, + 2, + "invalid-usage", + ); + } + if (selected.length === 0) { + throw new ClawpatchError( + "--feature-list did not include any feature ids", + 2, + "invalid-usage", + ); + } + return stringFlag(flags, "limit") === undefined ? selected : limitFeatures(selected, flags); + } + const candidates = selectReviewCandidates(features, flags); const sinceFiltered = await filterFeaturesByFilesSince(loaded.root, candidates, flags); return limitFeatures(sinceFiltered, flags); } @@ -2089,6 +2116,28 @@ async function loadCustomReviewPrompt( } } +async function loadFeatureIdList(path: string): Promise { + let contents: string; + try { + contents = await readFile(resolve(path), "utf8"); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new ClawpatchError( + `failed to read --feature-list ${path}: ${message}`, + 2, + "invalid-usage", + ); + } + const featureIds = contents + .split(/\r?\n/gu) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (featureIds.length === 0) { + throw new ClawpatchError("--feature-list did not include any feature ids", 2, "invalid-usage"); + } + return featureIds; +} + async function readStdinToString(): Promise { if (process.stdin.isTTY) { throw new ClawpatchError("--prompt-file=- requested but stdin is a TTY", 2, "invalid-usage"); diff --git a/src/cli.ts b/src/cli.ts index 0ea6eee..635d291 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -156,6 +156,7 @@ const commandFlags = { status: new Set(), review: new Set([ "feature", + "featureList", "project", "limit", "since", @@ -221,6 +222,7 @@ const valueFlagNames = new Set([ "state-dir", "config", "feature", + "feature-list", "finding", "limit", "since", @@ -315,6 +317,24 @@ function validateCommandRequirements( ) { throw new ClawpatchError("invalid --mode; expected default or deslopify", 2, "invalid-usage"); } + if (command === "review" && typeof flags["featureList"] === "string") { + for (const conflictingFlag of ["feature", "project", "since"] as const) { + if (typeof flags[conflictingFlag] === "string") { + throw new ClawpatchError( + `--feature-list cannot be combined with --${kebab(conflictingFlag)}`, + 2, + "invalid-usage", + ); + } + } + if (flags["includeDirty"] === true) { + throw new ClawpatchError( + "--feature-list cannot be combined with --include-dirty", + 2, + "invalid-usage", + ); + } + } } function isKnownCommand(command: string): command is keyof typeof commandFlags { @@ -414,6 +434,7 @@ Usage: Flags: --feature + --feature-list --project --limit --since diff --git a/src/selection.ts b/src/selection.ts index 8903349..5abf113 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -13,6 +13,26 @@ export function selectReviewCandidates(features: FeatureRecord[], flags: Flags): return projectFilter === undefined ? selected : selected.toSorted(featureReviewRank); } +export function selectFeaturesByIdList( + features: FeatureRecord[], + featureIds: readonly string[], +): FeatureRecord[] { + const featuresById = new Map(features.map((feature) => [feature.featureId, feature])); + const selected: FeatureRecord[] = []; + const seen = new Set(); + for (const featureId of featureIds) { + if (seen.has(featureId)) { + continue; + } + seen.add(featureId); + const feature = featuresById.get(featureId); + if (feature !== undefined) { + selected.push(feature); + } + } + return selected; +} + export function filterFeaturesByChangedFiles( features: FeatureRecord[], changed: Set, diff --git a/src/workflow.test.ts b/src/workflow.test.ts index e4114a1..1185493 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -3187,6 +3187,27 @@ describe("workflow", () => { }); }); + it("parses --feature-list as a review value flag", () => { + expect(parseArgs(["review", "--feature-list", "/tmp/features.txt"]).flags).toMatchObject({ + featureList: "/tmp/features.txt", + }); + }); + + it("rejects incompatible review flags when --feature-list is set", () => { + expect(() => + parseArgs(["review", "--feature-list", "/tmp/features.txt", "--feature", "feat_a"]), + ).toThrow("--feature-list cannot be combined with --feature"); + expect(() => + parseArgs(["review", "--feature-list", "/tmp/features.txt", "--project", "apps/web"]), + ).toThrow("--feature-list cannot be combined with --project"); + expect(() => + parseArgs(["review", "--feature-list", "/tmp/features.txt", "--since", "origin/main"]), + ).toThrow("--feature-list cannot be combined with --since"); + expect(() => + parseArgs(["review", "--feature-list", "/tmp/features.txt", "--include-dirty"]), + ).toThrow("--feature-list cannot be combined with --include-dirty"); + }); + it("runs review --prompt-file through the CLI entrypoint", async () => { const root = await fixtureRoot("clawpatch-prompt-file-cli-"); await writeFixture(root, "package.json", JSON.stringify({ name: "prompt-file-cli" })); @@ -3206,6 +3227,113 @@ describe("workflow", () => { ).rejects.toThrow("failed to read --prompt-file"); }); + it("runs review --feature-list through the CLI entrypoint", async () => { + const root = await fixtureRoot("clawpatch-feature-list-cli-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "feature-list-cli" })); + + await runCli(["--root", root, "--json", "--quiet", "init"]); + + await expect( + runCli([ + "--root", + root, + "--json", + "--quiet", + "review", + "--feature-list", + join(root, "missing.txt"), + ]), + ).rejects.toThrow("failed to read --feature-list"); + }); + + it("uses --feature-list order and de-duplicates repeated ids", async () => { + const root = await sinceFixture("clawpatch-feature-list-order-"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const features = await readFeatures(statePaths(join(root, ".clawpatch"))); + const selected = features + .filter((feature) => feature.title.includes("CLI command")) + .toSorted((left, right) => left.title.localeCompare(right.title)); + expect(selected).toHaveLength(3); + const featureListPath = join(root, "feature-list.txt"); + await writeFixture( + root, + "feature-list.txt", + `${selected[2]!.featureId}\n${selected[0]!.featureId}\n${selected[2]!.featureId}\n`, + ); + + const reviewed = (await reviewCommand(context, { + featureList: featureListPath, + dryRun: true, + })) as { featureIds: string[]; wouldReview: number }; + + expect(reviewed).toMatchObject({ + dryRun: true, + wouldReview: 2, + featureIds: [selected[2]!.featureId, selected[0]!.featureId], + }); + }); + + it("rejects unknown ids in --feature-list", async () => { + const root = await sinceFixture("clawpatch-feature-list-missing-"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const featureListPath = join(root, "feature-list.txt"); + await writeFixture(root, "feature-list.txt", "feat_missing\n"); + + await expect( + reviewCommand(context, { + featureList: featureListPath, + dryRun: true, + }), + ).rejects.toThrow("unknown feature ids in --feature-list: feat_missing"); + }); + + it("allows --feature-list to review a skipped feature explicitly", async () => { + const root = await fixtureRoot("clawpatch-feature-list-skipped-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "feature-list-skipped", + bin: { app: "src/index.ts" }, + }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + process.env["CLAWPATCH_PROVIDER"] = "mock"; + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const paths = statePaths(join(root, ".clawpatch")); + const feature = (await readFeatures(paths)).find((candidate) => + candidate.title.includes("CLI command"), + ); + expect(feature).toBeDefined(); + await writeFeature(paths, { + ...feature!, + status: "skipped", + }); + const featureListPath = join(root, "feature-list.txt"); + await writeFixture(root, "feature-list.txt", `${feature!.featureId}\n`); + + const reviewed = (await reviewCommand(context, { + featureList: featureListPath, + limit: "1", + })) as { reviewed: number; findings: number }; + const updated = await readFeatures(paths); + const reviewedFeature = updated.find((candidate) => candidate.featureId === feature!.featureId); + + expect(reviewed).toMatchObject({ reviewed: 1 }); + expect(reviewed.findings).toBeGreaterThan(0); + expect(reviewedFeature?.status).toBe("needs-fix"); + delete process.env["CLAWPATCH_PROVIDER"]; + }); + it("writes a tribunal-shaped JSONL ledger when --export-tribunal-ledger is set", async () => { const root = await fixtureRoot("clawpatch-export-tribunal-"); await writeFixture(