A Gradle plugin that detects violations of Robert C. Martin's Clean Code heuristics across a Java codebase. It combines static analysis tools (PMD, Checkstyle, SpotBugs, JaCoCo) with 53 custom OpenRewrite detection recipes and 11 refactoring recipes, normalises all findings into Martin's taxonomy, and produces linked HTML reports with book references and prescriptive guidance.
"Clean code reads like well-written prose." -- Robert C. Martin, Clean Code (2008)
Every finding is mapped to a specific heuristic from Clean Code Chapter 17 ("Smells and Heuristics") or to a chapter-specific pattern. The plugin currently detects 60 heuristic codes across 9 finding sources.
For the full Robert Martin text, detection details, and skill file links for every heuristic, see HEURISTICS.md. For per-tool rule mappings with documentation links, see FINDING-SOURCES.md.
| Code | Name | Reference | Detection |
|---|---|---|---|
| C2 | Obsolete Comment | Ch.17 p.286 | ObsoleteCommentRecipe |
| C3 | Redundant Comment | Ch.17 p.286 | MumblingCommentRecipe |
| C5 | Commented-Out Code | Ch.17 p.287 | CommentedCodeRecipe |
| E1 | Build Requires More Than One Step | Ch.17 p.287 | DependencyUpdatesFindingSource |
| F1 | Too Many Arguments | Ch.17 p.288 | Checkstyle ParameterNumber, LargeConstructorRecipe |
| F2 | Output Arguments | Ch.17 p.288 | OutputArgumentRecipe, InconsistentReturnRecipe |
| F3 | Flag Arguments | Ch.17 p.288 | FlagArgumentRecipe |
| F4 | Dead Function | Ch.17 p.288 | PMD UnusedPrivateMethod |
| G1 | Multiple Languages in One Source File | Ch.17 p.288 | EmbeddedLanguageRecipe |
| G4 | Overridden Safeties | Ch.17 p.289 | UncheckedCastRecipe, SystemOutRecipe, SwallowedExceptionRecipe, SuppressedWarningRecipe, PMD, SpotBugs |
| G5 | Duplication | Ch.17 p.289 | CPD token-based detection |
| G7 | Base Classes Depending on Derivatives | Ch.17 p.291 | BaseClassDependencyRecipe |
| G8 | Too Much Information | Ch.17 p.291 | VisibilityReductionRecipe, PMD, SpotBugs |
| G9 | Dead Code | Ch.17 p.292 | PMD, SpotBugs |
| G10 | Vertical Separation | Ch.17 p.292 | VerticalSeparationRecipe |
| G11 | Inconsistency | Ch.17 p.292 | InconsistentNamingRecipe |
| G12 | Clutter | Ch.17 p.293 | PMD/Checkstyle |
| G13 | Artificial Coupling | Ch.17 p.293 | ArtificialCouplingRecipe |
| G14 | Feature Envy | Ch.17 p.293 | FeatureEnvyRecipe |
| G15 | Selector Arguments | Ch.17 p.294 | SelectorArgumentRecipe |
| G16 | Obscured Intent | Ch.17 p.295 | NestedTernaryRecipe |
| G18 | Inappropriate Static | Ch.17 p.296 | InappropriateStaticRecipe, Checkstyle, SpotBugs |
| G19 | Use Explanatory Variables | Ch.17 p.296 | MissingExplanatoryVariableRecipe |
| G22 | Make Logical Dependencies Physical | Ch.17 p.298 | Checkstyle FinalLocalVariable |
| G23 | Prefer Polymorphism to If/Else or Switch | Ch.17 p.299 | SwitchOnTypeRecipe, StringSwitchRecipe, StringlyTypedDispatchRecipe |
| G24 | Follow Standard Conventions | Ch.17 p.299 | Checkstyle |
| G25 | Replace Magic Numbers with Named Constants | Ch.17 p.300 | MagicStringRecipe |
| G26 | Be Precise | Ch.17 p.300 | LegacyFileApiRecipe, RawGenericRecipe, PMD |
| G28 | Encapsulate Conditionals | Ch.17 p.301 | EncapsulateConditionalRecipe |
| G29 | Avoid Negative Conditionals | Ch.17 p.302 | NegativeConditionalRecipe, GuardClauseRecipe |
| G30 | Functions Should Do One Thing | Ch.17 p.302 | WhitespaceSplitMethodRecipe, ImperativeLoopRecipe |
| G31 | Hidden Temporal Couplings | Ch.17 p.304 | TemporalCouplingRecipe |
| G33 | Encapsulate Boundary Conditions | Ch.17 p.304 | BoundaryConditionRecipe |
| G34 | Functions Should Descend Only One Level | Ch.17 p.304 | SectionCommentRecipe |
| G35 | Keep Configurable Data at High Levels | Ch.17 p.306 | ConfigurableDataRecipe, HardcodedListRecipe |
| G36 | Avoid Transitive Navigation | Ch.17 p.306 | LawOfDemeterRecipe (fluent APIs excluded) |
| J1 | Avoid Long Import Lists | Ch.17 p.307 | Checkstyle AvoidStarImport |
| J2 | Don't Inherit Constants | Ch.17 p.307 | InheritConstantsRecipe |
| J3 | Constants versus Enums | Ch.17 p.308 | EnumForConstantsRecipe |
| N1 | Choose Descriptive Names | Ch.17 p.309 | BadClassNameRecipe, Checkstyle |
| N5 | Use Long Names for Long Scopes | Ch.17 p.312 | ShortVariableNameRecipe |
| N6 | Avoid Encodings | Ch.17 p.312 | EncodingNamingRecipe |
| N7 | Names Should Describe Side-Effects | Ch.17 p.313 | SideEffectNamingRecipe |
| T1 | Insufficient Tests | Ch.17 p.313 | JaCoCo line coverage, MultipleAssertRecipe, PrivateMethodTestabilityRecipe |
| T2 | Use a Coverage Tool | Ch.17 p.313 | JaCoCo report presence |
| T3 | Don't Skip Trivial Tests | Ch.17 p.313 | DisabledTestRecipe |
| T4 | An Ignored Test Is a Question | Ch.17 p.313 | DisabledTestRecipe |
| T8 | Test Coverage Patterns | Ch.17 p.314 | JaCoCo per-class analysis |
| T9 | Tests Should Be Fast | Ch.17 p.314 | Surefire timing |
| Ch7.1 | Use Exceptions Rather Than Return Codes | Ch.7 p.103 | CatchLogContinueRecipe, BroadCatchRecipe |
| Ch7.2 | Don't Return Null | Ch.7 p.110 | NullDensityRecipe, SpotBugs |
| Ch10.1 | Classes Should Be Small | Ch.10 p.136 | ClassLineLengthRecipe |
═══════════════════════════════════════════════════════════════════════════
CLEAN CODE ANALYSIS — my-project
═══════════════════════════════════════════════════════════════════════════
1 errors · 18 warnings · 2 info
───────────────────────────────────────────────────────────────────────────
Ch7_1: Use Exceptions Rather Than Return Codes (1)
Clean Code Ch.7 'Error Handling' p.103
! UserService.java Catch block in 'save' only logs or is empty
───────────────────────────────────────────────────────────────────────────
Sources:
openrewrite: 14
checkstyle: 4
spotbugs: 3
pmd: 1
jacoco: 1
═══════════════════════════════════════════════════════════════════════════
23 findings — ./gradlew cleanCodeExplain --finding=<code>
═══════════════════════════════════════════════════════════════════════════
cleanCode {
skillsDir = ".claude/skills"
repositoryUrl = "https://github.com/your-org/your-repo" // enables linked HTML reports
thresholds {
classLineCount = 200 // default 150
recordComponentCount = 8 // default 6
nullCheckDensity = 4 // default 3
chainDepthThreshold = 4 // default 3
verticalSeparationDistance = 15 // default 10
methodBlankLineSections = 8 // default 6
privateMethodMinLines = 15 // default 12
magicStringMinOccurrences = 3 // default 2
stringSwitchMinCases = 4 // default 3
shortNameMinLength = 2 // default 2
cpdMinimumTokens = 100 // default 50
magicNumberMinValue = 1 // default 1
sectionCommentThreshold = 1 // default 1
hardcodedListMinLiterals = 5 // default 5
temporalCouplingMinCalls = 3 // default 3
}
disabledRecipes = listOf("G36", "G10")
// Opt-in rules — disabled by default because they're noisy or only valuable
// to teams that have already committed to the convention. Run the build
// once and the report's "Optional rules" panel lists what's available.
enabledOptionalRules = listOf(
"checkstyle:FinalLocalVariable", // G22 — require all locals/params to be final
"pmd:UseLocaleWithCaseConversions" // G26 — require an explicit Locale on toLowerCase/toUpperCase
)
servePort = 7070 // port for ./gradlew cleanCodeServe (default 7070)
packageSuppressions = mapOf(
"io.github.fiftieshousewife.cleancode.recipes" to listOf("G5", "Ch7_2")
)
claudeReview { // opt-in LLM assessment (requires ANTHROPIC_API_KEY)
enabled.set(true) // default: false
model.set("claude-sonnet-4-6") // default
maxFilesPerRun.set(50) // default
codes.set(listOf("G6", "G20", "N4")) // default — the 3 codes needing semantic judgement
}
}Three complementary mechanisms, from narrowest to broadest:
| Mechanism | Where it lives | Scope |
|---|---|---|
@SuppressCleanCode({...}, reason="...") |
Method, type, constructor | Exact block, source-anchored |
@SuppressCleanCode({...}, reason="...") on package-info.java |
Package | Every file in that package (CPD cross-file pairs too) |
cleanCode.packageSuppressions = mapOf(...) |
Gradle build script | Package, config-driven — fallback for findings without a source anchor |
cleanCode.disabledRecipes = listOf(...) |
Gradle build script | Heuristic code, project-wide |
Prefer the annotation. Reasons live next to the code, get reviewed in PRs, and can carry until="YYYY-MM-DD" so the suppression expires and reappears as a finding.
Example — suppress CPD duplication and null-density across a whole package:
@SuppressCleanCode(
value = { HeuristicCode.G5, HeuristicCode.Ch7_2 },
reason = "OpenRewrite visitor pattern produces structurally similar scanners and "
+ "relies on null returns to signal no-change — API-imposed, not design flaws"
)
package io.github.fiftieshousewife.cleancode.recipes;
import io.github.fiftieshousewife.cleancode.annotations.HeuristicCode;
import io.github.fiftieshousewife.cleancode.annotations.SuppressCleanCode;This repo applies exactly that to recipes/ and refactoring/, together with JSpecify @NullMarked at the package level so IDE nullability inspections match the OpenRewrite API contract.
Gaps worth knowing:
E1(outdated-dependency findings) have no source anchor;@SuppressCleanCodecannot suppress them. UsedisabledRecipes = listOf("E1")if noise from dependency reports is unwanted.- When a suppression expires (
untildate in the past), the index emits a meta-finding pointing at the annotation so it surfaces in the next report.
CleanClaude/
├── annotations/ HeuristicCode enum, @SuppressCleanCode annotation
├── core/ Finding, AggregatedReport, BuildOutputFormatter,
│ HeuristicDescriptions, SuppressionIndex, BaselineManager,
│ ClaudeMdGenerator, HtmlReportWriter, JSON report I/O
├── recipes/ 53 custom OpenRewrite ScanningRecipes (detection)
├── refactoring/ 11 OpenRewrite Recipes (code transformation)
├── adapters/ 9 FindingSource implementations (PMD, Checkstyle, SpotBugs,
│ CPD, JaCoCo, Surefire, Dependency Updates, OpenRewrite, Claude Review)
├── claude-review/ Claude API FindingSource for G6/G20/N4 (opt-in)
├── plugin/ Gradle plugin, tasks, extension DSL
└── build-logic/ Convention plugins
plugins {
id("io.github.fiftieshousewife.cleancode") version "0.1.3"
}| Task | Group | What it does |
|---|---|---|
analyseCleanCode |
verification | Run all detectors against the module; emit build/reports/clean-code/findings.{html,json} plus the boxed text summary shown under Sample Output. |
cleanCodeSummary |
verification | (root only) Aggregate every module's findings.json into docs/reports/index.html and the deterministic docs/reports/SUMMARY.md. |
generateClaudeMd |
verification | Regenerate CLAUDE.md from the latest findings + skill registry. Run after upgrading the plugin so newly added skills appear. |
cleanCodeFixPlan |
verification | Group findings by file into per-class fix briefs for agent handoff. |
cleanCodeBaseline |
verification | Snapshot current findings as clean-code-baseline.json. |
cleanCodeExplain |
help | Print skill guidance for a finding code, e.g. cleanCodeExplain --finding=error-handling. |
cleanCodeServe |
clean code | Long-running interactive triage UI at http://localhost:7070 (see below). |
cleanCodeStop |
clean code | Stop a running cleanCodeServe daemon. |
reworkClass |
clean code | Rework a single class via the ReworkOrchestrator Java API. |
reworkCompare |
clean code | Run rework with vs without recipe tools on a sandbox fixture; produces a side-by-side comparison. |
updateVersionCatalog |
verification | (root only) Rewrite gradle/libs.versions.toml with non-major updates from the dependencyUpdates report. |
The analyseCleanCode text summary (per-module banner, sources breakdown, total) is what the Sample Output section shows. Run ./gradlew analyseCleanCode on any module to reproduce it; on this repo run ./scripts/dogfood.sh to apply the plugin to every module and aggregate.
Long-running task that runs the analysis, opens the report at http://localhost:7070, and accepts in-page batched changes:
| Action | Where | What it does |
|---|---|---|
| 🔇 Suppress | per finding row | inserts // CleanCode-suppress CODE: <reason> + @SuppressWarnings("CleanCode:CODE") above the enclosing method/class |
| ❌ Disable | per code section | appends the code to cleanCode.disabledRecipes in build.gradle.kts |
| ⚙️ Tune | per code section (when configurable) | updates the matching threshold in cleanCode.thresholds { ... } |
Click any action → reason modal (≥ 5 chars, required) → entry is staged in the browser's localStorage. The staging bar at the top shows pending count and offers Confirm & apply (POSTs the batch) or Discard. On confirm the server applies all edits, re-runs the analysis, and the page reloads with fresh state.
Notes:
- Server binds to
127.0.0.1only; the apply endpoint mutates files on disk so off-host requests are refused. - If you reopen
findings.htmlcold (no server running), the triage buttons are disabled with a tooltip pointing back to./gradlew cleanCodeServe. - Existing
@SuppressWarningsannotations are merged into, not duplicated. The single-string form is upgraded to array form when a new code is added. - Configurable port via
cleanCode.servePort = 8080. PressCtrl-Cin the terminal to stop the server.
The plugin automatically applies java, pmd, checkstyle, jacoco, and com.github.spotbugs. It provides a bundled Checkstyle configuration if the project has none, and wires analyseCleanCode to depend on all tool report tasks.
Set cleanCode.enforceFormatting = true to apply the Spotless plugin with Google Java Format (AOSP) to every src/**/*.java source set. Intended for projects ready to commit to a single formatter — running it on an older codebase will reformat many files at once. Once enabled, ./gradlew check fails if any file drifts from the style; ./gradlew spotlessApply fixes it.
The SpotBugs Gradle plugin is bundled into the Clean Code plugin jar, so consumers
do not need gradlePluginPortal() in their pluginManagement.repositories.
mavenLocal() plus mavenCentral() is enough.
# From this repo: publish the plugin to your local Maven repo
./gradlew publishToMavenLocalIn the target project's settings.gradle.kts:
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
}
}In the target project's build.gradle.kts:
plugins {
id("io.github.fiftieshousewife.cleancode") version "0.1.3"
}./gradlew build # compile + run all tests
./gradlew publishToMavenLocal # publish all modules to ~/.m2Consumers need JDK 21+. Plugin classes are emitted with --release 21 so any Gradle build on JDK 21 or newer can apply the plugin. Building this repo uses the JDK 25 toolchain (auto-provisioned by Gradle). Uses Gradle 9.4 with version catalog (gradle/libs.versions.toml).
./gradlew test # run all unit tests
./gradlew :plugin:test # run plugin TestKit tests only
./gradlew :recipes:test # run recipe tests only
./gradlew :refactoring:test # run refactoring recipe tests onlyThe plugin module includes Gradle TestKit tests that verify plugin application, task registration, CPD end-to-end detection, skill file scaffolding, and threshold refresh behaviour.
Recipe tests use OpenRewrite's RewriteTest harness to verify detection and transformation accuracy against inline Java source fixtures.
The refactoring module contains OpenRewrite recipes that transform code, not just detect problems:
| Recipe | Fixes | What it does |
|---|---|---|
| AddFinalRecipe | G22 | Adds final to non-reassigned local variables |
| DeleteUnusedImportRecipe | G12/J1 | Removes unused imports, expands star imports |
| ShortenFullyQualifiedReferencesRecipe | G12 | Replaces inline org.pkg.Foo with Foo + an import statement |
| ExtractConstantRecipe | G25 | Adds private static final for repeated string literals |
| ReduceVisibilityRecipe | T1/Ch3.1 | Changes private to package-private for testability |
| RecordToLombokValueRecipe | F1 | Converts large records to @Value @Builder classes |
| ExtractExplanatoryVariableRecipe | G19/G28 | Extracts complex if-conditions to named variables |
| EncapsulateBoundaryRecipe | G33 | Adds named variable for .length - 1 / .size() - 1 |
| MoveDeclarationRecipe | G10 | Moves local variable declarations closer to first use |
| RemoveNestedTernaryRecipe | G16 | Converts nested ternary to if/else chains |
| WrapAssertAllRecipe | T1 | Wraps consecutive assertions in assertAll |
| AddLocaleRecipe | G26 | Adds Locale.ROOT to toLowerCase()/toUpperCase() |
| ExtractClassConstantRecipe | G35 | Promotes repeated numeric literals to private static final fields |
| InvertNegativeConditionalRecipe | G29 | Rewrites if (!cond) A else B as if (cond) B else A |
| SplitFlagArgumentRecipe | F3 | Emits <name>When<Flag>() / <name>When<Flag>IsFalse() helpers next to a private method whose sole boolean parameter drives a single if/else |
| RenameShortNameRecipe | N5 | Renames short non-loop variable names using a user-supplied Map<String, String> |
| Library | Version | Used by |
|---|---|---|
| JUnit Jupiter | 5.14.4 | All modules (test) |
| JavaParser | 3.28.0 | core (SuppressionIndex) |
| Gson | 2.14.0 | core, adapters (JSON I/O) |
| OpenRewrite | 8.81.3 | recipes, refactoring, adapters |
| Anthropic Java SDK | 2.27.0 | claude-review |
| SpotBugs Gradle Plugin | 6.5.4 | plugin |
| PMD | 7.24.0 | plugin (analyzer) |
| Checkstyle | 10.26.1 | plugin (analyzer) |
| JaCoCo | 0.8.14 | plugin (analyzer) |
| Ben-Manes Versions | 0.53.0 | build-logic |
Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship, Prentice Hall, 2008.
| Chapter | Pages | Topics |
|---|---|---|
| Ch.3 | p.31-52 | Function size, arguments, flag arguments |
| Ch.7 | p.103-112 | Exceptions vs return codes, null handling |
| Ch.10 | p.135-151 | Class size, single responsibility principle |
| Ch.17 | p.285-314 | The complete taxonomy of 66 code smells |
The plugin analyses its own codebase. Each module report includes clickable links to the source at the exact line of each finding.
Self-analysis summary (auto-generated): docs/reports/SUMMARY.md — total + per-module + per-code counts. Deterministic (no paths, no timestamps), so local and CI runs produce byte-identical output. Regenerated by ./gradlew cleanCodeSummary (root project only); CI fails if it drifts. The browser-friendly counterpart is docs/reports/index.html. Per-module HTML reports are gitignored — uploaded as the dogfood-report CI artifact and regenerated locally for browser viewing.
Key context when reading the numbers:
- Counts are post-
@SuppressCleanCode. The raw pre-suppression baseline (1,313) lives inexperiment/baseline/*.json;recipes/andrefactoring/packages are annotated, which hides most of their findings. - E1 findings (outdated dependencies) are emitted only at the Gradle root — sub-modules skip them once the catalog is anchored at
gradle/libs.versions.toml. - The
experiment/manual-pilotbranch has agent-fix commits on top of an older tip and is not merged intomainby design — it's a side-by-side data point for the fix-cost experiment, not a shipping branch.
Regenerate locally with (self-applied via init script, no changes to committed build files):
./scripts/dogfood.shThe script publishes all modules to mavenLocal, runs analyseCleanCode against every Java module via the init script, copies each module's findings.html into docs/reports/, and regenerates docs/reports/index.html via cleanCodeSummary. CI runs the same script and fails if docs/reports/ drifts from the committed state, so the summary on this README always matches main.
The project includes token monitoring hooks and a structured experiment plan to compare the cost of fixing all findings manually vs using the refactoring recipes first.
Protocol: 6 runs total — 3 manual fix sessions, 3 recipe-assisted sessions. Each starts from the same commit, uses a clean Claude Code session, and saves a git patch + token logs.
Metrics compared:
- Total tokens consumed
- Number of tool calls and turns
- Cache hit ratio
- Patch size and findings remaining
Slash-command skills that drive the workflow:
| Skill | Usage | Purpose |
|---|---|---|
/clean-code |
/clean-code |
Apply the plugin to any project, generate briefs, delegate per-file fixes to agents |
/experiment |
/experiment manual 1 |
Create branch, clear logs, print the fix prompt |
/experiment-save |
/experiment-save |
Save patch + token logs after a run |
/experiment-analyse |
/experiment-analyse |
Compare all runs and write experiment/analysis.md |
The plugin also ships ten domain skills (clean-code-functions, clean-code-classes, clean-code-naming, clean-code-comments-and-clutter, clean-code-conditionals-and-expressions, clean-code-exception-handling, clean-code-null-handling, clean-code-java-idioms, clean-code-test-quality, clean-code-project-conventions). Claude Code auto-discovers them from .claude/skills/ and the cleanCodeFixPlan briefs route findings to the correct one via SkillPathRegistry.
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
scripts/run-experiment.sh manual 1The script creates the branch, regenerates per-file fix briefs, invokes claude -p non-interactively with the fix prompt, and saves the patch + token usage JSON to experiment/. Suitable for cron or the schedule skill — no interactive input required.
The main terminal shows the gradle output and Claude's streamed responses. For a skimmable view of tool activity, open a second terminal:
tail -f .claude/tool-log.jsonl | jq -r '"\(.tool) \(.detail // "")"'To track commit accumulation on the experiment branch, a third terminal:
while sleep 60; do
git -C /path/to/CleanClaude log --oneline experiment/manual-1 ^main | wc -l
doneStop the run with Ctrl-C in the main terminal.
# 1. In a Claude Code session, set up the run:
/experiment manual 1
# 2. Exit, then start a fresh session with the task label:
CLAUDE_TASK_LABEL="manual-fix-1" claude
# 3. Paste the fix prompt (printed by /experiment) and let it run
# 4. When done, save outputs:
/experiment-save
# 5. Return to main and repeat for the next run
git checkout mainAny scheduler that can invoke a shell command works. With the schedule skill inside Claude Code:
/schedule create --cron "30 23 * * *" --command "scripts/run-experiment.sh manual 1"
Or from cron:
30 23 * * * cd /path/to/CleanClaude && scripts/run-experiment.sh manual 1 > experiment/manual-1.log 2>&1
After all 6 runs, invoke /experiment-analyse to generate a comparison report at experiment/analysis.md.