Skip to content

Latest commit

 

History

History
457 lines (352 loc) · 31.5 KB

File metadata and controls

457 lines (352 loc) · 31.5 KB

Clean Code Plugin

CI Java Gradle License

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)

Heuristic Coverage

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

Sample Output

═══════════════════════════════════════════════════════════════════════════
  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>
═══════════════════════════════════════════════════════════════════════════

Configuration

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
    }
}

Suppressions

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; @SuppressCleanCode cannot suppress them. Use disabledRecipes = listOf("E1") if noise from dependency reports is unwanted.
  • When a suppression expires (until date in the past), the index emits a meta-finding pointing at the annotation so it surfaces in the next report.

Architecture

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

Usage

plugins {
    id("io.github.fiftieshousewife.cleancode") version "0.1.3"
}

Tasks

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.

Interactive triage — cleanCodeServe

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.1 only; the apply endpoint mutates files on disk so off-host requests are refused.
  • If you reopen findings.html cold (no server running), the triage buttons are disabled with a tooltip pointing back to ./gradlew cleanCodeServe.
  • Existing @SuppressWarnings annotations 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. Press Ctrl-C in 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.

Opt-in formatter enforcement

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.

Apply to another project

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 publishToMavenLocal

In 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"
}

Build

./gradlew build                 # compile + run all tests
./gradlew publishToMavenLocal   # publish all modules to ~/.m2

Consumers 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).

Testing

./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 only

The 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.

Refactoring Recipes

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>

Dependencies

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

References

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

This Project's Code Cleanliness Index

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 in experiment/baseline/*.json; recipes/ and refactoring/ 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-pilot branch has agent-fix commits on top of an older tip and is not merged into main by 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.sh

The 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.

Experiment: Manual vs Recipe-Assisted Fix

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

Skills

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.

Running a single experiment (scripted, one command)

export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
scripts/run-experiment.sh manual 1

The 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.

Live feedback

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
done

Stop the run with Ctrl-C in the main terminal.

Running a single experiment (interactive)

# 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 main

Scheduling overnight runs

Any 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

Analysis

After all 6 runs, invoke /experiment-analyse to generate a comparison report at experiment/analysis.md.