diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e9457..64d5715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.2.1 - Unreleased +- Added deslopify review mode and ranked maintainability/performance report clusters for repeated cleanup patterns, thanks @mbelinky. - Added a `pi` provider for routing review, fix, revalidate, and agent map through the [pi coding agent](https://pi.dev) in non-interactive print mode, thanks @danielmarbach. - Added explicit Codex reasoning effort selection via `--reasoning-effort`, `CLAWPATCH_REASONING_EFFORT`, and provider config, with `doctor` reporting the active setting. - Added deterministic Express, Fastify, and Hono route mapping for Node projects, thanks @rohitjavvadi. diff --git a/README.md b/README.md index ded2307..3eb29d9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ pnpm link --global clawpatch init clawpatch map clawpatch review --limit 3 --jobs 3 +clawpatch review --mode deslopify --limit 3 clawpatch report clawpatch next clawpatch show --finding @@ -109,6 +110,7 @@ Supported provider names today: - `clawpatch map`: write feature records - `clawpatch status`: show project, dirty state, feature/finding counts - `clawpatch review`: review pending or selected features +- `clawpatch review --mode deslopify`: review only for locally provable slop cleanup - `clawpatch report`: print or write a Markdown findings report - `clawpatch next`: print the next actionable finding - `clawpatch show --finding `: inspect one finding with evidence and suggested validation diff --git a/docs/code-review.md b/docs/code-review.md index 7013972..c5c3c70 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -12,6 +12,7 @@ clawpatch review --limit 3 clawpatch review --limit 12 --jobs 4 clawpatch review --feature clawpatch review --since origin/main +clawpatch review --mode deslopify --limit 3 clawpatch review --provider codex --model ``` @@ -56,6 +57,37 @@ count as `lockFiles`. There is no multi-provider panel yet. +### --mode deslopify + +Use deslopify mode when you want one narrow lane for simplifying code and +improving performance by removing code slop. It restricts findings to +maintainability or performance issues caused by accidental complexity, +inefficient indirection, semantic duplication, needless wrappers, dead code, or +avoidable repeated work. It should not report unrelated correctness, security, +API contract, data-loss, or build-release issues; provider findings outside +maintainability and performance are discarded in this mode. + +The deslopify rubric is intentionally narrow. It asks the provider to prioritize +locally provable slop patterns where the likely fix is deletion, consolidation, +or reuse of an existing local pattern: + +- semantic duplication across files, tests, CLIs, SQL queries, adapters, wrappers, + or generated-looking utilities +- shadow modules and thin pass-through wrappers +- concrete code bloat: generated-looking mass, production-included test/debug/demo + artifacts, wrapper swarms, duplicated boilerplate, or manual registries that + duplicate a source of truth +- dead legacy paths kept alive by tests +- cargo-cult defensive code that does not match a real trust boundary +- tautological or coupled tests that preserve implementation internals instead of + behavior +- type/build silencing and band-aid hacks such as broad disables, `any`, + `type-ignore`, sleeps/timeouts, path mutation, fake success returns, or removed + checks, when simplification is the fix + +It should not report file size, explicit generated files, normal framework +boilerplate, or domain modules that merely look large. + Categories requested from the provider: - `bug` diff --git a/docs/reporting.md b/docs/reporting.md index a9928cf..5d40ae0 100644 --- a/docs/reporting.md +++ b/docs/reporting.md @@ -17,6 +17,8 @@ clawpatch report --feature Markdown output includes: +- ranked action clusters when related maintainability/performance findings share + a slop pattern and evidence area - finding ID - severity, category, confidence, triage, and status - feature ID and title when available @@ -27,6 +29,12 @@ Markdown output includes: - recommendation and reproduction text when available - next inspection command for status-filtered queues +Action clusters are report-only. They do not change finding IDs, status, triage, +or fix commands. They are intended to make deslopify-style reports easier to +scan by grouping repeated root causes such as duplication, dead code, wrapper +bloat, test coupling, defensive bloat, band-aid fixes, and concrete code bloat. +The full finding details remain in the report beneath the cluster summary. + `review` also writes a Markdown report for each run under: ```text diff --git a/src/app.ts b/src/app.ts index 2e923c5..93416e1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import { mapFeatures } from "./mapper.js"; import { emitProgress } from "./progress.js"; import { providerByName } from "./provider.js"; import { buildFixPrompt, buildReviewPrompt, buildRevalidatePrompt } from "./prompt.js"; +import type { ReviewMode } from "./prompt.js"; import { evidenceLabel, findingSummaries, @@ -66,6 +67,7 @@ import { FixPlanOutput, FindingRecord, PatchAttempt, + ReviewOutput, RunRecord, reasoningEffortSchema, reasoningEfforts, @@ -231,6 +233,7 @@ export async function reviewCommand( const loaded = await loadProjectState(context); const config = applyProviderFlags(loaded.config, flags); const provider = providerByName(config.provider.name); + const mode = reviewMode(flags); const features = await selectReviewFeatures(loaded, flags); if (features.length === 0 && typeof flags["since"] === "string") { return { next: "no features touched by diff" }; @@ -239,6 +242,7 @@ export async function reviewCommand( return { dryRun: true, wouldReview: features.length, + mode, jobs: reviewJobs(flags), featureIds: features.map((feature) => feature.featureId), }; @@ -276,6 +280,7 @@ export async function reviewCommand( currentRunId, index, total: features.length, + mode, allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined, }); findingIds.push(...reviewed.findingIds); @@ -483,6 +488,7 @@ type ReviewFeatureOptions = { currentRunId: string; index: number; total: number; + mode: ReviewMode; allowNonPendingFeatureReview: boolean; }; @@ -496,6 +502,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId currentRunId, index, total, + mode, allowNonPendingFeatureReview, } = options; const started = Date.now(); @@ -516,9 +523,16 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId }, ); locked = lockedFeature; - const prompt = await buildReviewPrompt(loaded.root, loaded.project, lockedFeature, config); + const prompt = await buildReviewPrompt( + loaded.root, + loaded.project, + lockedFeature, + config, + mode, + ); const output = await provider.review(loaded.root, prompt, providerOptions(config)); - const records = output.findings + const modeFindings = reviewFindingsForMode(output.findings, mode); + const records = modeFindings .slice(0, config.review.maxFindingsPerFeature) .map((finding) => findingFromOutput(finding, lockedFeature.featureId, currentRunId)); const findingIds: string[] = []; @@ -1054,6 +1068,25 @@ function reviewJobs(flags: Record): number { return Math.min(Math.floor(parsed), 32); } +function reviewMode(flags: Record): ReviewMode { + const mode = stringFlag(flags, "mode") ?? "default"; + if (mode === "default" || mode === "deslopify") { + return mode; + } + throw new ClawpatchError("invalid --mode; expected default or deslopify", 2, "invalid-usage"); +} + +function reviewFindingsForMode( + findings: ReviewOutput["findings"], + mode: ReviewMode, +): ReviewOutput["findings"] { + if (mode !== "deslopify") { + return findings; + } + return findings.filter( + (finding) => finding.category === "maintainability" || finding.category === "performance", + ); +} function featureLock(currentRunId: string): NonNullable { return { lockedByRunId: currentRunId, diff --git a/src/cli.ts b/src/cli.ts index 289d1b0..a8f363e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -154,6 +154,7 @@ const commandFlags = { "limit", "since", "jobs", + "mode", "provider", "model", "reasoningEffort", @@ -197,6 +198,7 @@ const valueFlagNames = new Set([ "limit", "since", "jobs", + "mode", "source", "provider", "model", @@ -267,6 +269,14 @@ function validateCommandRequirements( ) { throw new ClawpatchError("missing --finding or --all", 2, "invalid-usage"); } + if ( + command === "review" && + typeof flags["mode"] === "string" && + flags["mode"] !== "default" && + flags["mode"] !== "deslopify" + ) { + throw new ClawpatchError("invalid --mode; expected default or deslopify", 2, "invalid-usage"); + } } function isKnownCommand(command: string): command is keyof typeof commandFlags { @@ -370,6 +380,7 @@ Flags: --limit --since --jobs default: 10 + --mode --provider --model --reasoning-effort diff --git a/src/prompt.ts b/src/prompt.ts index df592ee..6c8b68d 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -2,6 +2,8 @@ import { readFile, realpath } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; import { ClawpatchConfig, FeatureRecord, FindingRecord, ProjectRecord } from "./types.js"; +export type ReviewMode = "default" | "deslopify"; + export function buildAgentMapPrompt(project: ProjectRecord, inventory: unknown): string { return `You are mapping a repository into semantic clawpatch review slices. @@ -57,6 +59,7 @@ export async function buildReviewPrompt( project: ProjectRecord, feature: FeatureRecord, config: ClawpatchConfig, + mode: ReviewMode = "default", ): Promise { const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles); const context = feature.contextFiles.slice(0, config.review.maxContextFiles); @@ -87,6 +90,8 @@ Review categories: - release/build hazards - maintainability risks with concrete impact +${reviewModeInstructions(mode)} + Inspect owned files, context files, and linked tests. Treat included tests as first-class evidence of intended behavior. If tests contradict a suspected bug, either skip it or downgrade confidence and explain the uncertainty. Avoid reporting behavior as a bug @@ -120,6 +125,31 @@ Files: ${fileBlocks.join("\n\n")}`; } +function reviewModeInstructions(mode: ReviewMode): string { + if (mode === "default") { + return ""; + } + if (mode === "deslopify") { + return `Deslopify mode: +- report only simplification findings in category "maintainability" or "performance" +- stay separate from normal review: do not look for general bugs, security issues, API contract problems, or missing edge-case handling +- focus on locally provable AI-slop patterns whose likely fix is deletion, consolidation, or reuse of an existing local pattern +- prioritize semantic duplication: repeated behavior across files, tests, CLIs, SQL queries, adapters, wrappers, or generated-looking utilities +- prioritize shadow modules and useless wrappers: thin layers that pass through to another path without hiding real complexity +- prioritize concrete code bloat: generated-looking mass, production-included test/debug/demo artifacts, wrapper swarms, duplicated boilerplate, or manual registries that duplicate a source of truth +- prioritize dead legacy paths kept alive by tests: obsolete validators, schemas, adapters, compatibility branches, feature flags, or helpers +- prioritize cargo-cult defensive code: broad try/catch, fallback, logging, null guard, or "safe" wrapper code that does not match a real trust boundary +- prioritize tautological or coupled tests: tests that mirror implementation internals, repeat giant fake harnesses, or preserve accidental private structure instead of behavior +- prioritize type/build silencing and band-aid hacks: broad disables, any/type-ignore casts, sleeps/timeouts, path mutation, fake success returns, or removed checks when simplification is the fix +- every finding must have a concrete maintenance or runtime cost in the included files +- prefer deletion, consolidation, or existing local patterns over new abstractions +- do not report file size, explicit generated files, normal framework boilerplate, or domain modules that merely look large +- do not report style taste, naming preference, broad architecture opinions, or speculative cleanup +- do not report correctness, security, API contract, data-loss, or build-release issues unless the root cause is accidental complexity and the minimum fix is simplification`; + } + throw new Error(`Unsupported review mode: ${mode}`); +} + export async function buildRevalidatePrompt(root: string, findingJson: string): Promise { return `Revalidate this clawpatch finding against the current repository at ${root}. diff --git a/src/provider.ts b/src/provider.ts index adfb102..a6ca4fd 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -204,7 +204,7 @@ const piProvider: Provider = { if (result.exitCode !== 0) { throw new ClawpatchError("pi CLI not available", 4, "provider-auth"); } - return (result.stdout.trim() || result.stderr.trim()); + return result.stdout.trim() || result.stderr.trim(); }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runPiJson(root, prompt, options, agentMapJsonSchema, true); diff --git a/src/reporting.test.ts b/src/reporting.test.ts new file mode 100644 index 0000000..5523520 --- /dev/null +++ b/src/reporting.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { findingClusters, renderReport } from "./reporting.js"; +import type { FindingRecord } from "./types.js"; + +describe("reporting", () => { + it("clusters and ranks related slop findings without hiding detail", () => { + const duplicateMedium = finding({ + id: "fnd_duplicate_medium", + title: "Alert CLIs duplicate the same position loader", + severity: "medium", + confidence: "high", + path: "scripts/cli/news_signal_search.py", + reasoning: "Two CLI paths duplicate the same loader and can drift.", + recommendation: "Consolidate the duplicate loader.", + }); + const duplicateLow = finding({ + id: "fnd_duplicate_low", + title: "Alert renderer repeats position scope filtering", + path: "scripts/cli/portfolio_event_alert.py", + reasoning: "Repeated filtering creates a parallel alert scope.", + recommendation: "Reuse the shared loader.", + }); + const deadLow = finding({ + id: "fnd_dead_low", + title: "Dead strategy helper should be deleted", + path: "core/services/strategy.py", + reasoning: "Unused helper is dead code.", + recommendation: "Delete the unused helper.", + }); + + const report = renderReport([deadLow, duplicateLow, duplicateMedium]); + + expect(findingClusters([deadLow, duplicateLow, duplicateMedium])).toHaveLength(1); + expect(report).toContain("clusters: 1"); + expect(report).toContain("cluster 1: scripts/cli duplication (2 findings)"); + expect(report).toContain("- medium/high fnd_duplicate_medium: Alert CLIs duplicate"); + expect(report).toContain("- low/high fnd_duplicate_low: Alert renderer repeats"); + expect(report).toContain("## low: Dead strategy helper should be deleted"); + expect(report.indexOf("## medium: Alert CLIs duplicate")).toBeLessThan( + report.indexOf("## low: Alert renderer repeats"), + ); + }); + + it("does not cluster unrelated single findings", () => { + const report = renderReport([ + finding({ + id: "fnd_single", + title: "Delete unused wrapper", + path: "core/services/wrapper.py", + reasoning: "One unused wrapper exists.", + recommendation: "Delete it.", + }), + ]); + + expect(report).toContain("findings: 1"); + expect(report).not.toContain("clusters:"); + expect(report).not.toContain("## action clusters"); + }); + + it("prefers concrete wrapper and bloat labels over generic legacy wording", () => { + const report = renderReport([ + finding({ + id: "fnd_wrapper_one", + title: "Legacy pass-through wrapper hides event rendering", + path: "scripts/cli/events.py", + reasoning: "The deprecated no-op wrapper only forwards to the real renderer.", + recommendation: "Delete the pass-through layer.", + }), + finding({ + id: "fnd_wrapper_two", + title: "Legacy alias wrapper keeps an obsolete path alive", + path: "scripts/cli/alerts.py", + reasoning: "The alias shim forwards every call without owning behavior.", + recommendation: "Call the renderer directly.", + }), + ]); + + expect(report).toContain("cluster 1: scripts/cli wrapper bloat (2 findings)"); + expect(report).not.toContain("cluster 1: scripts/cli dead code"); + }); + + it("ranks severe clusters before larger low-severity clusters", () => { + const report = renderReport([ + finding({ + id: "fnd_low_one", + title: "CLI one duplicates a helper", + path: "scripts/cli/one.py", + reasoning: "This duplicate helper is low risk.", + recommendation: "Share the helper.", + }), + finding({ + id: "fnd_low_two", + title: "CLI two duplicates a helper", + path: "scripts/cli/two.py", + reasoning: "This duplicate helper is low risk.", + recommendation: "Share the helper.", + }), + finding({ + id: "fnd_low_three", + title: "CLI three duplicates a helper", + path: "scripts/cli/three.py", + reasoning: "This duplicate helper is low risk.", + recommendation: "Share the helper.", + }), + finding({ + id: "fnd_high_one", + title: "Core worker hides errors behind wrappers", + severity: "high", + path: "core/services/worker.py", + reasoning: "The wrapper hides production errors.", + recommendation: "Remove the wrapper.", + }), + finding({ + id: "fnd_high_two", + title: "Core runner hides errors behind wrappers", + severity: "high", + path: "core/services/runner.py", + reasoning: "The wrapper hides production errors.", + recommendation: "Remove the wrapper.", + }), + ]); + + expect(report.indexOf("cluster 1: core wrapper bloat")).toBeLessThan( + report.indexOf("cluster 2: scripts/cli duplication"), + ); + }); + + it("keeps unclustered critical findings ahead of low-severity cluster details", () => { + const report = renderReport([ + finding({ + id: "fnd_low_one", + title: "CLI one duplicates a helper", + path: "scripts/cli/one.py", + reasoning: "This duplicate helper is low risk.", + recommendation: "Share the helper.", + }), + finding({ + id: "fnd_low_two", + title: "CLI two duplicates a helper", + path: "scripts/cli/two.py", + reasoning: "This duplicate helper is low risk.", + recommendation: "Share the helper.", + }), + finding({ + id: "fnd_critical", + title: "Data export deletes unrelated files", + category: "data-loss", + severity: "critical", + path: "core/export.ts", + reasoning: "Cleanup can delete files outside the export directory.", + recommendation: "Constrain deletion to the export directory.", + }), + ]); + + expect(report.indexOf("## critical: Data export deletes")).toBeLessThan( + report.indexOf("## low: CLI one duplicates"), + ); + }); + + it("does not treat ordinary words containing any as type-silencing slop", () => { + const report = renderReport([ + finding({ + id: "fnd_company_one", + title: "Company loader keeps legacy branches", + path: "core/company.py", + reasoning: "Company metadata keeps obsolete branches.", + recommendation: "Delete the dead branch.", + }), + finding({ + id: "fnd_company_two", + title: "Company parser keeps unused branches", + path: "core/company_parser.py", + reasoning: "Company parser keeps unused branches.", + recommendation: "Delete the dead branch.", + }), + ]); + + expect(report).toContain("cluster 1: core dead code (2 findings)"); + expect(report).not.toContain("band-aid"); + }); +}); + +function finding(overrides: { + id: string; + title: string; + path: string; + reasoning: string; + recommendation: string; + severity?: FindingRecord["severity"]; + confidence?: FindingRecord["confidence"]; + category?: FindingRecord["category"]; +}): FindingRecord { + const now = "2026-05-17T00:00:00.000Z"; + return { + schemaVersion: 1, + findingId: overrides.id, + featureId: "feat_test", + title: overrides.title, + category: overrides.category ?? "maintainability", + severity: overrides.severity ?? "low", + confidence: overrides.confidence ?? "high", + triage: "risk", + evidence: [ + { + path: overrides.path, + startLine: 1, + endLine: 2, + symbol: null, + quote: null, + }, + ], + reasoning: overrides.reasoning, + reproduction: null, + recommendation: overrides.recommendation, + whyTestsDoNotAlreadyCoverThis: "", + suggestedRegressionTest: null, + minimumFixScope: "", + status: "open", + history: [], + signature: overrides.id, + linkedPatchAttemptIds: [], + createdByRunId: "run_test", + createdAt: now, + updatedAt: now, + }; +} diff --git a/src/reporting.ts b/src/reporting.ts index 6a0f752..36e791b 100644 --- a/src/reporting.ts +++ b/src/reporting.ts @@ -28,9 +28,31 @@ export function renderReport( features: FeatureRecord[] = [], options: { includeNext?: boolean } = {}, ): string { - const lines = ["# clawpatch report", "", `findings: ${findings.length}`, ""]; const featureById = new Map(features.map((feature) => [feature.featureId, feature])); - for (const finding of findings) { + const clusters = findingClusters(findings); + const orderedFindings = findings.toSorted(compareFindings); + const lines = ["# clawpatch report", "", `findings: ${findings.length}`]; + if (clusters.length > 0) { + lines.push(`clusters: ${clusters.length}`); + } + lines.push(""); + if (clusters.length > 0) { + lines.push("## action clusters"); + lines.push(""); + for (const [index, cluster] of clusters.entries()) { + lines.push( + `### cluster ${index + 1}: ${cluster.area} ${cluster.patternLabel} (${cluster.findings.length} findings)`, + ); + lines.push(""); + for (const finding of cluster.findings) { + lines.push( + `- ${finding.severity}/${finding.confidence} ${finding.findingId}: ${finding.title}`, + ); + } + lines.push(""); + } + } + for (const finding of orderedFindings) { lines.push(`## ${finding.severity}: ${finding.title}`); lines.push(""); lines.push(`id: ${finding.findingId}`); @@ -81,6 +103,46 @@ export function renderReport( return `${lines.join("\n")}\n`; } +type FindingCluster = { + area: string; + pattern: string; + patternLabel: string; + findings: FindingRecord[]; +}; + +export function findingClusters(findings: FindingRecord[]): FindingCluster[] { + const clusterable = findings.filter(isClusterableFinding); + const groups = new Map(); + for (const finding of clusterable) { + const pattern = slopPattern(finding); + const area = evidenceArea(finding); + const key = `${pattern.id}:${area}`; + const group = groups.get(key); + if (group === undefined) { + groups.set(key, { + area, + pattern: pattern.id, + patternLabel: pattern.label, + findings: [finding], + }); + } else { + group.findings.push(finding); + } + } + return [...groups.values()] + .filter((cluster) => cluster.findings.length > 1) + .map((cluster) => ({ + ...cluster, + findings: cluster.findings.toSorted(compareFindings), + })) + .toSorted( + (a, b) => + clusterRank(a) - clusterRank(b) || + a.area.localeCompare(b.area) || + a.pattern.localeCompare(b.pattern), + ); +} + export function renderFindingDetail( finding: FindingRecord, feature: FeatureRecord | null, @@ -222,3 +284,88 @@ export function evidenceLabel(evidence: FindingRecord["evidence"][number]): stri export function featureLabel(featureId: string, feature: FeatureRecord | undefined): string { return feature === undefined ? featureId : `${feature.title} (${featureId})`; } + +function clusterRank(cluster: FindingCluster): number { + const bestFindingRank = Math.min(...cluster.findings.map(findingReportRank)); + return bestFindingRank * 1000 - cluster.findings.length; +} + +function isClusterableFinding(finding: FindingRecord): boolean { + return finding.category === "maintainability" || finding.category === "performance"; +} + +function findingReportRank(finding: FindingRecord): number { + const severityRank = { critical: 0, high: 1, medium: 2, low: 3 }[finding.severity]; + const confidenceRank = { high: 0, medium: 1, low: 2 }[finding.confidence]; + return severityRank * 100 + confidenceRank * 10; +} + +function compareFindings(a: FindingRecord, b: FindingRecord): number { + return ( + findingReportRank(a) - findingReportRank(b) || + a.title.localeCompare(b.title) || + a.findingId.localeCompare(b.findingId) + ); +} + +function evidenceArea(finding: FindingRecord): string { + const path = finding.evidence[0]?.path ?? finding.featureId; + const parts = path.split("/").filter((part) => part.length > 0); + if (parts.length <= 1) { + return parts[0] ?? "unknown"; + } + if (parts.length === 2) { + return parts[0] ?? "unknown"; + } + return `${parts[0]}/${parts[1]}`; +} + +function slopPattern(finding: FindingRecord): { id: string; label: string } { + const text = `${finding.title} ${finding.reasoning} ${finding.recommendation}`.toLowerCase(); + if (hasAny(text, ["duplicate", "duplicated", "copy", "copied", "repeated", "parallel"])) { + return { id: "duplication", label: "duplication" }; + } + if (hasAny(text, ["wrapper", "pass-through", "forward", "alias", "shim"])) { + return { id: "wrapper", label: "wrapper bloat" }; + } + if (hasAny(text, ["boilerplate", "generated", "registry", "manual", "bloat", "mass"])) { + return { id: "bloat", label: "code bloat" }; + } + if (hasAny(text, ["test", "fixture", "fake", "mock", "harness"])) { + return { id: "test", label: "test coupling" }; + } + if (isBandAidPattern(text)) { + return { id: "band-aid", label: "band-aid" }; + } + if (hasAny(text, ["try/catch", "fallback", "warning", "suppress", "swallow", "defensive"])) { + return { id: "defensive", label: "defensive bloat" }; + } + if (hasAny(text, ["dead", "unused", "obsolete", "legacy", "deprecated", "no-op"])) { + return { id: "dead", label: "dead code" }; + } + if (finding.category === "performance") { + return { id: "performance", label: "performance waste" }; + } + return { id: "slop", label: "slop cleanup" }; +} + +function hasAny(text: string, terms: string[]): boolean { + return terms.some((term) => text.includes(term)); +} + +function isBandAidPattern(text: string): boolean { + return ( + hasAny(text, [ + "type-ignore", + "timeout", + "sleep", + "sys.path", + "silenc", + " as any", + ": any", + "", + "any[]", + "array { expect(parseArgs(["review", "--since", "HEAD~5"]).flags).toMatchObject({ since: "HEAD~5", }); + expect(parseArgs(["review", "--mode", "deslopify"]).flags).toMatchObject({ + mode: "deslopify", + }); + expect(() => parseArgs(["review", "--mode", "simplify"])).toThrow( + "invalid --mode; expected default or deslopify", + ); + expect(() => parseArgs(["review", "--mode", "slop"])).toThrow( + "invalid --mode; expected default or deslopify", + ); expect(parseArgs(["revalidate", "--since", "origin/main"]).flags).toMatchObject({ since: "origin/main", }); @@ -2164,6 +2173,79 @@ describe("workflow", () => { delete process.env["CLAWPATCH_PROVIDER"]; }); + it("adds deslopify-only review instructions when requested", async () => { + const root = await fixtureRoot("clawpatch-deslopify-prompt-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "deslopify-prompt" })); + await writeFixture(root, "src/index.ts", "export function main() { return 1; }\n"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + const project = await readProject(statePaths(join(root, ".clawpatch"))); + expect(project).toBeDefined(); + const prompt = await buildReviewPrompt( + root, + project!, + { + schemaVersion: 1, + featureId: "feat_deslopify", + title: "deslopify", + summary: "deslopify", + kind: "library", + source: "test", + confidence: "high", + entrypoints: [{ path: "src/index.ts", symbol: null, route: null, command: null }], + ownedFiles: [{ path: "src/index.ts", reason: "test" }], + contextFiles: [], + tests: [], + tags: [], + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + await loadConfig(root, testOptions(root)), + "deslopify", + ); + + expect(prompt).toContain("Deslopify mode:"); + expect(prompt).toContain( + 'only simplification findings in category "maintainability" or "performance"', + ); + expect(prompt).toContain("stay separate from normal review"); + expect(prompt).toContain("locally provable AI-slop patterns"); + expect(prompt).toContain("semantic duplication"); + expect(prompt).toContain("shadow modules and useless wrappers"); + expect(prompt).toContain("concrete code bloat"); + expect(prompt).toContain("dead legacy paths kept alive by tests"); + expect(prompt).toContain("cargo-cult defensive code"); + expect(prompt).toContain("tautological or coupled tests"); + expect(prompt).toContain("type/build silencing and band-aid hacks"); + expect(prompt).toContain("do not report file size"); + expect(prompt).toContain("do not report correctness, security, API contract"); + }); + + it("filters non-simplification findings in deslopify mode", async () => { + const root = await fixtureRoot("clawpatch-deslopify-filter-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "deslopify-filter" })); + 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 reviewed = await reviewCommand(context, { limit: "1", mode: "deslopify" }); + const paths = statePaths(join(root, ".clawpatch")); + const findings = await readFindings(paths); + + expect(reviewed).toMatchObject({ findings: 0 }); + expect(findings).toHaveLength(0); + delete process.env["CLAWPATCH_PROVIDER"]; + }); + it("does not include escaped feature paths in prompts", async () => { const root = await fixtureRoot("clawpatch-path-escape-"); const siblingSecret = join(root, "..", "secret.txt");