From 06b6950f24064833f2073eb92f8215ad626e2f28 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Sat, 16 May 2026 22:09:16 +0530 Subject: [PATCH 1/2] fix: make review feature locks atomic --- src/app.ts | 88 ++++++++++++++++----------- src/fs.ts | 3 +- src/state.ts | 105 +++++++++++++++++++++++++++++++- src/workflow.test.ts | 141 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 289 insertions(+), 48 deletions(-) diff --git a/src/app.ts b/src/app.ts index 5f957c3..4308dec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,8 @@ import { mapFeatures } from "./mapper.js"; import { providerByName } from "./provider.js"; import { buildFixPrompt, buildReviewPrompt, buildRevalidatePrompt } from "./prompt.js"; import { + claimFeature, + clearFeatureLockFiles, ensureStateDirs, readFeatures, readFinding, @@ -25,6 +27,7 @@ import { writePatchAttempt, writeProject, writeRun, + releaseFeatureLock, } from "./state.js"; import { CommandResult, @@ -180,6 +183,7 @@ export async function reviewCommand( currentRunId, index, total: features.length, + allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined, }); findingIds.push(...reviewed.findingIds); } catch (error: unknown) { @@ -372,10 +376,21 @@ type ReviewFeatureOptions = { currentRunId: string; index: number; total: number; + allowNonPendingFeatureReview: boolean; }; async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingIds: string[] }> { - const { context, loaded, config, provider, feature, currentRunId, index, total } = options; + const { + context, + loaded, + config, + provider, + feature, + currentRunId, + index, + total, + allowNonPendingFeatureReview, + } = options; const started = Date.now(); let locked: FeatureRecord | null = null; emitReviewProgress(context, "feature-start", { @@ -385,9 +400,15 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId title: feature.title, }); try { - const lockedFeature = lockFeature(feature, currentRunId); + const lockedFeature = await claimFeature( + loaded.paths, + feature.featureId, + featureLock(currentRunId), + { + allowNonPending: allowNonPendingFeatureReview, + }, + ); locked = lockedFeature; - await writeFeature(loaded.paths, lockedFeature); const prompt = await buildReviewPrompt(loaded.root, loaded.project, lockedFeature, config); const output = await provider.review(loaded.root, prompt, config.provider.model); const records = output.findings @@ -421,6 +442,8 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId updatedAt: nowIso(), }; await writeFeature(loaded.paths, updated); + await releaseFeatureLock(loaded.paths, lockedFeature.featureId); + locked = null; emitReviewProgress(context, "feature-done", { index: index + 1, total, @@ -432,23 +455,27 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); if (locked !== null) { - await writeFeature(loaded.paths, { - ...locked, - status: "error", - lock: null, - analysisHistory: [ - ...locked.analysisHistory, - { - runId: currentRunId, - kind: "review-error", - summary: message, - provider: provider.name, - model: config.provider.model, - createdAt: nowIso(), - }, - ], - updatedAt: nowIso(), - }); + try { + await writeFeature(loaded.paths, { + ...locked, + status: "error", + lock: null, + analysisHistory: [ + ...locked.analysisHistory, + { + runId: currentRunId, + kind: "review-error", + summary: message, + provider: provider.name, + model: config.provider.model, + createdAt: nowIso(), + }, + ], + updatedAt: nowIso(), + }); + } finally { + await releaseFeatureLock(loaded.paths, locked.featureId); + } } emitReviewProgress(context, "feature-error", { index: index + 1, @@ -755,7 +782,8 @@ export async function cleanLocksCommand(context: AppContext): Promise { }); cleared += 1; } - return { cleared }; + const lockFilesCleared = await clearFeatureLockFiles(loaded.paths); + return { cleared, lockFilesCleared }; } async function loadProjectState(context: AppContext) { @@ -949,20 +977,12 @@ function emitRevalidateProgress( process.stderr.write(`clawpatch revalidate ${event}${values.length > 0 ? ` ${values}` : ""}\n`); } -function lockFeature(feature: FeatureRecord, currentRunId: string): FeatureRecord { - if (feature.lock !== null) { - throw new ClawpatchError(`feature locked: ${feature.featureId}`, 7, "lock-conflict"); - } +function featureLock(currentRunId: string): NonNullable { return { - ...feature, - status: "claimed", - lock: { - lockedByRunId: currentRunId, - lockedAt: nowIso(), - hostname: hostname(), - pid: process.pid, - }, - updatedAt: nowIso(), + lockedByRunId: currentRunId, + lockedAt: nowIso(), + hostname: hostname(), + pid: process.pid, }; } diff --git a/src/fs.ts b/src/fs.ts index 9f34cae..04d2642 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { z } from "zod"; @@ -23,7 +24,7 @@ export async function readJson(path: string, schema: z.ZodType): Promise { await ensureDir(dirname(path)); - const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`; await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8"); await rename(tmp, path); } diff --git a/src/state.ts b/src/state.ts index fdb0e45..6fc1f04 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,7 +1,8 @@ -import { readdir } from "node:fs/promises"; +import { open, readdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { z } from "zod"; -import { ensureDir, pathExists, readJson, writeJson } from "./fs.js"; +import { ClawpatchError } from "./errors.js"; +import { ensureDir, nowIso, pathExists, readJson, writeJson } from "./fs.js"; import { FeatureRecord, FindingRecord, @@ -68,8 +69,94 @@ export async function readFeatures(paths: StatePaths): Promise return readRecords(paths.features, featureRecordSchema); } +export async function readFeature(paths: StatePaths, id: string): Promise { + const path = featurePath(paths, id); + if (!(await pathExists(path))) { + return null; + } + return readJson(path, featureRecordSchema); +} + export async function writeFeature(paths: StatePaths, feature: FeatureRecord): Promise { - await writeJson(join(paths.features, `${feature.featureId}.json`), feature); + await writeJson(featurePath(paths, feature.featureId), feature); +} + +export async function claimFeature( + paths: StatePaths, + featureId: string, + lock: NonNullable, + options: { allowNonPending?: boolean } = {}, +): Promise { + await ensureDir(paths.locks); + const lockPath = featureLockPath(paths, featureId); + let handle; + try { + handle = await open(lockPath, "wx"); + await handle.writeFile(`${JSON.stringify(lock, null, 2)}\n`, "utf8"); + } catch (error: unknown) { + if (isNodeError(error, "EEXIST")) { + throw new ClawpatchError(`feature locked: ${featureId}`, 7, "lock-conflict"); + } + if (handle !== undefined) { + await handle.close(); + handle = undefined; + await releaseFeatureLock(paths, featureId); + } + throw error; + } finally { + await handle?.close(); + } + + try { + const feature = await readFeature(paths, featureId); + if (feature === null) { + throw new ClawpatchError(`feature not found: ${featureId}`, 2, "feature-not-found"); + } + if (feature.lock !== null) { + throw new ClawpatchError(`feature locked: ${featureId}`, 7, "lock-conflict"); + } + if (options.allowNonPending !== true && !["pending", "error"].includes(feature.status)) { + throw new ClawpatchError(`feature not reviewable: ${featureId}`, 7, "lock-conflict"); + } + const claimed: FeatureRecord = { + ...feature, + status: "claimed", + lock, + updatedAt: nowIso(), + }; + await writeFeature(paths, claimed); + return claimed; + } catch (error: unknown) { + await releaseFeatureLock(paths, featureId); + throw error; + } +} + +export async function releaseFeatureLock(paths: StatePaths, featureId: string): Promise { + await unlink(featureLockPath(paths, featureId)).catch((error: unknown) => { + if (!isNodeError(error, "ENOENT")) { + throw error; + } + }); +} + +export async function clearFeatureLockFiles(paths: StatePaths): Promise { + if (!(await pathExists(paths.locks))) { + return 0; + } + let cleared = 0; + for (const name of await readdir(paths.locks)) { + if (!name.endsWith(".json")) { + continue; + } + await unlink(join(paths.locks, name)).catch((error: unknown) => { + if (!isNodeError(error, "ENOENT")) { + throw error; + } + }); + cleared += 1; + } + return cleared; } export async function readFindings(paths: StatePaths): Promise { @@ -118,3 +205,15 @@ async function readRecords(dir: string, schema: z.ZodType): Promise { } return records; } + +function featurePath(paths: StatePaths, featureId: string): string { + return join(paths.features, `${featureId}.json`); +} + +function featureLockPath(paths: StatePaths, featureId: string): string { + return join(paths.locks, `${featureId}.json`); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 489d982..0d18b61 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { access, mkdir, readFile, rm, symlink, unlink } from "node:fs/promises"; +import { access, mkdir, open, readFile, readdir, rm, symlink, unlink } from "node:fs/promises"; import { join } from "node:path"; import { fixCommand, @@ -19,6 +19,8 @@ import { packageVersion, parseArgs } from "./cli.js"; import { loadConfig } from "./config.js"; import { runCommand } from "./exec.js"; import { + claimFeature, + releaseFeatureLock, readFeatures, readFinding, readFindings, @@ -364,6 +366,126 @@ describe("workflow", () => { delete process.env["CLAWPATCH_PROVIDER"]; }); + it("claims feature locks atomically", async () => { + const root = await fixtureRoot("clawpatch-atomic-lock-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "atomic-lock", bin: { atomic: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + + await initCommand(context, {}); + await mapCommand(context); + const feature = (await readFeatures(paths)).find((candidate) => + candidate.title.includes("CLI command"), + ); + expect(feature).toBeDefined(); + + const first = { + lockedByRunId: "run-one", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, + }; + const second = { + lockedByRunId: "run-two", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 2, + }; + const results = await Promise.allSettled([ + claimFeature(paths, feature!.featureId, first), + claimFeature(paths, feature!.featureId, second), + ]); + const fulfilled = results.filter((result) => result.status === "fulfilled"); + const rejected = results.filter((result) => result.status === "rejected"); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: { code: "lock-conflict" }, + }); + expect(await readdir(paths.locks)).toEqual([`${feature!.featureId}.json`]); + + await releaseFeatureLock(paths, feature!.featureId); + expect(await readdir(paths.locks)).toEqual([]); + }); + + it("cleans up lock files when claim lock payload writes fail", async () => { + const root = await fixtureRoot("clawpatch-lock-write-fail-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "lock-write-fail", bin: { lock: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + + await initCommand(context, {}); + await mapCommand(context); + const feature = (await readFeatures(paths)).find((candidate) => + candidate.title.includes("CLI command"), + ); + expect(feature).toBeDefined(); + const probe = await open(join(paths.locks, "probe.json"), "w"); + const writeFileSpy = vi + .spyOn(Object.getPrototypeOf(probe) as { writeFile: typeof probe.writeFile }, "writeFile") + .mockRejectedValueOnce(new Error("simulated lock write failure")); + await probe.close(); + await unlink(join(paths.locks, "probe.json")); + + await expect( + claimFeature(paths, feature!.featureId, { + lockedByRunId: "run", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, + }), + ).rejects.toThrow("simulated lock write failure"); + expect(await readdir(paths.locks)).toEqual([]); + + writeFileSpy.mockRestore(); + }); + + it("does not claim a stale feature after another run finishes it", async () => { + const root = await fixtureRoot("clawpatch-stale-lock-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "stale-lock", bin: { stale: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + + await initCommand(context, {}); + await mapCommand(context); + const feature = (await readFeatures(paths)).find((candidate) => + candidate.title.includes("CLI command"), + ); + expect(feature).toBeDefined(); + await writeFeature(paths, { + ...feature!, + status: "reviewed", + lock: null, + updatedAt: new Date().toISOString(), + }); + + await expect( + claimFeature(paths, feature!.featureId, { + lockedByRunId: "run", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, + }), + ).rejects.toMatchObject({ code: "lock-conflict" }); + expect(await readdir(paths.locks)).toEqual([]); + }); + it("does not consume features on dry-run review", async () => { const root = await fixtureRoot("clawpatch-dry-run-"); await writeFixture( @@ -575,6 +697,7 @@ describe("workflow", () => { expect(features[0]?.status).toBe("error"); expect(features[0]?.lock).toBeNull(); + expect(await readdir(join(root, ".clawpatch/locks"))).toEqual([]); await rm(join(root, ".clawpatch"), { recursive: true, force: true }); }); @@ -622,21 +745,19 @@ describe("workflow", () => { await mapCommand(context); const feature = (await readFeatures(paths))[0]; expect(feature).toBeDefined(); - await writeFeature(paths, { - ...feature!, - status: "claimed", - lock: { - lockedByRunId: "run", - lockedAt: new Date().toISOString(), - hostname: "test", - pid: 1, - }, + await claimFeature(paths, feature!.featureId, { + lockedByRunId: "run", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, }); + expect(await readdir(paths.locks)).toEqual([`${feature!.featureId}.json`]); await cleanLocksCommand(context); const cleaned = (await readFeatures(paths))[0]; expect(cleaned?.status).toBe("pending"); expect(cleaned?.lock).toBeNull(); + expect(await readdir(paths.locks)).toEqual([]); }); it("filters state files from successful fix results", async () => { From ce4cdba0956ef1add5a68b0f99dd24f67549da9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 19:02:12 +0100 Subject: [PATCH 2/2] fix: surface atomic review lock files --- CHANGELOG.md | 1 + docs/code-review.md | 10 ++++-- docs/safety.md | 4 +-- src/app.ts | 13 ++++++-- src/cli.ts | 2 +- src/state.ts | 28 ++++++++-------- src/workflow.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 113 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6c86a..ad7bac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Added Next.js route mapping for `src/app` and `src/pages` layouts, thanks @obatried. - Added first-pass Python mapping for project metadata, console scripts, source groups, pytest suites, and conservative validation defaults, thanks @xiamx. - Added progress output for `clawpatch revalidate`, thanks @twidtwid. +- Fixed overlapping `clawpatch review` runs so feature claims use atomic lock files and can be recovered with `clean-locks`, thanks @rohitjavvadi. - Improved Node/TypeScript mapping for large workspaces by splitting package source trees into bounded review groups with package-local tests. - Added generic nested SwiftPM, Apple/Xcode, and Gradle/Android app mapping. diff --git a/docs/code-review.md b/docs/code-review.md index e9ed7c9..7c0094c 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -17,7 +17,7 @@ clawpatch review --provider codex --model Current behavior: - selects pending features unless `--feature` is set -- claims each feature with a run lock +- claims each feature with an atomic lock file plus the feature run lock - reviews with a bounded worker pool; default `--jobs` is `10` - emits progress to stderr unless `--quiet` is set - builds bounded prompt context from owned files, context files, and tests @@ -28,8 +28,12 @@ Current behavior: - releases the feature lock Progress uses stderr so `--json` stdout remains machine-readable. The worker -pool is per-process and still uses feature locks, so overlapping runs should not -claim the same feature. +pool is per-process, and lock files under `.clawpatch/locks/` prevent +overlapping review processes from claiming the same feature. Interrupted runs +can leave recoverable lock files; clear them with `clawpatch clean-locks` after +confirming no review process is still active. `clawpatch status` includes both +feature-record locks and lock files in `activeLocks`, and reports the lock-file +count as `lockFiles`. There is no multi-provider panel yet. diff --git a/docs/safety.md b/docs/safety.md index 5c78e91..d974a9f 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -14,8 +14,8 @@ Current safety rules: - `.clawpatch/` state changes are allowed during runs. - review and revalidate provider calls use a read-only sandbox. - provider output must pass runtime schema validation. -- feature locks are stored in feature records and can be cleared with - `clawpatch clean-locks`. +- feature locks are stored in feature records and `.clawpatch/locks/`; `status` + surfaces both, and `clean-locks` clears both. - the mapper skips symlinked directories and common generated directories. Not implemented today: diff --git a/src/app.ts b/src/app.ts index 4308dec..0c7a601 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import { clearFeatureLockFiles, ensureStateDirs, readFeatures, + readFeatureLockIds, readFinding, readFindings, readPatchAttempts, @@ -116,12 +117,19 @@ export async function mapCommand( export async function statusCommand(context: AppContext): Promise { const loaded = await loadProjectState(context); - const [features, findings, runs, git] = await Promise.all([ + const [features, findings, runs, git, lockFileIds] = await Promise.all([ readFeatures(loaded.paths), readFindings(loaded.paths), readRuns(loaded.paths), discoverGit(loaded.root), + readFeatureLockIds(loaded.paths), ]); + const activeLockIds = new Set( + features.flatMap((feature) => (feature.lock === null ? [] : [feature.featureId])), + ); + for (const id of lockFileIds) { + activeLockIds.add(id); + } return { project: loaded.project.name, branch: git.currentBranch, @@ -129,7 +137,8 @@ export async function statusCommand(context: AppContext): Promise { features: features.length, findings: findings.length, openFindings: findings.filter((finding) => finding.status === "open").length, - activeLocks: features.filter((feature) => feature.lock !== null).length, + activeLocks: activeLockIds.size, + lockFiles: lockFileIds.length, lastRun: runs.at(-1)?.runId ?? null, }; } diff --git a/src/cli.ts b/src/cli.ts index 0e78c39..e2ee294 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,7 @@ import { GlobalOptions } from "./config.js"; const moduleRequire = createRequire(import.meta.url); -async function main(argv: string[]): Promise { +export async function main(argv: string[]): Promise { const parsed = parseArgs(argv); if (parsed.help) { printHelp(parsed.command); diff --git a/src/state.ts b/src/state.ts index 6fc1f04..b5c99b4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -141,22 +141,22 @@ export async function releaseFeatureLock(paths: StatePaths, featureId: string): } export async function clearFeatureLockFiles(paths: StatePaths): Promise { - if (!(await pathExists(paths.locks))) { - return 0; + const lockIds = await readFeatureLockIds(paths); + for (const id of lockIds) { + await releaseFeatureLock(paths, id); } - let cleared = 0; - for (const name of await readdir(paths.locks)) { - if (!name.endsWith(".json")) { - continue; - } - await unlink(join(paths.locks, name)).catch((error: unknown) => { - if (!isNodeError(error, "ENOENT")) { - throw error; - } - }); - cleared += 1; + return lockIds.length; +} + +export async function readFeatureLockIds(paths: StatePaths): Promise { + if (!(await pathExists(paths.locks))) { + return []; } - return cleared; + const names = await readdir(paths.locks); + return names + .filter((name) => name.endsWith(".json")) + .map((name) => name.slice(0, -".json".length)) + .toSorted(); } export async function readFindings(paths: StatePaths): Promise { diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 0d18b61..c61a1e1 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -15,7 +15,7 @@ import { statusCommand, triageCommand, } from "./app.js"; -import { packageVersion, parseArgs } from "./cli.js"; +import { main, packageVersion, parseArgs } from "./cli.js"; import { loadConfig } from "./config.js"; import { runCommand } from "./exec.js"; import { @@ -760,6 +760,66 @@ describe("workflow", () => { expect(await readdir(paths.locks)).toEqual([]); }); + it("surfaces crash-window lock files in status", async () => { + const root = await fixtureRoot("clawpatch-file-lock-status-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "file-lock-status", bin: { clean: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const context = await makeContext(testOptions(root)); + const paths = statePaths(join(root, ".clawpatch")); + + await initCommand(context, {}); + await mapCommand(context); + const feature = (await readFeatures(paths))[0]; + expect(feature).toBeDefined(); + await writeFixture( + root, + `.clawpatch/locks/${feature!.featureId}.json`, + `${JSON.stringify({ + lockedByRunId: "interrupted", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, + })}\n`, + ); + + expect(await statusCommand(context)).toMatchObject({ activeLocks: 1, lockFiles: 1 }); + }); + + it("cleans interrupted review locks through the CLI entrypoint", async () => { + const root = await fixtureRoot("clawpatch-clean-locks-cli-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "clean-locks-cli", bin: { clean: "src/index.ts" } }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + + await runCli(["--root", root, "init", "--json"]); + await runCli(["--root", root, "map", "--json"]); + + const paths = statePaths(join(root, ".clawpatch")); + const feature = (await readFeatures(paths))[0]; + expect(feature).toBeDefined(); + await claimFeature(paths, feature!.featureId, { + lockedByRunId: "interrupted", + lockedAt: new Date().toISOString(), + hostname: "test", + pid: 1, + }); + + const output = await runCli(["--root", root, "clean-locks", "--json"]); + const cleaned = (await readFeatures(paths))[0]; + + expect(JSON.parse(output)).toMatchObject({ cleared: 1, lockFilesCleared: 1 }); + expect(cleaned?.status).toBe("pending"); + expect(cleaned?.lock).toBeNull(); + expect(await readdir(paths.locks)).toEqual([]); + }); + it("filters state files from successful fix results", async () => { const root = await fixtureRoot("clawpatch-filter-state-"); await runCommand( @@ -1048,3 +1108,19 @@ describe("workflow", () => { delete process.env["CLAWPATCH_PROVIDER"]; }); }); + +async function runCli(argv: string[]): Promise { + let output = ""; + const stdout = vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + output += chunk.toString(); + return true; + }) as typeof process.stdout.write); + try { + await main(argv); + return output; + } finally { + stdout.mockRestore(); + } +}