A complete record of design decisions, architectural patterns, data models, and operational guidance from the plugin design sessions.
Every Clean Code heuristic mapped to its detection tool and confidence level. High-confidence, tool-detectable heuristics are the primary investment. Low-confidence heuristics produce narrative-only sections in CLAUDE.md.
| Code | Heuristic | Tool | Confidence |
|---|---|---|---|
| Comments | |||
| C1 | Inappropriate Information | Manual / Narrative | LOW |
| C2 | Obsolete Comment | Manual / Semantic | LOW |
| C3 | Redundant Comment | OpenRewrite — comment mirrors signature | MEDIUM |
| C4 | Poorly Written Comment | Manual / NLP | LOW |
| C5 | Commented-Out Code | PMD CommentedOutCode |
HIGH |
| Environment | |||
| E1 | Build Requires More Than One Step | CI config inspection — manual | LOW |
| E2 | Tests Require More Than One Step | CI config inspection — manual | LOW |
| Functions | |||
| F1 | Too Many Arguments | Checkstyle ParameterNumber |
HIGH |
| F2 | Output Arguments | OpenRewrite — mutable parameter modification | MEDIUM |
| F3 | Flag Arguments | OpenRewrite — boolean params on non-private methods |
HIGH |
| F4 | Dead Function | PMD UnusedPrivateMethod + OpenRewrite package-scoped |
HIGH |
| General | |||
| G1 | Multiple Languages in One Source File | OpenRewrite — inline SQL/JS string literals | MEDIUM |
| G2 | Obvious Behavior Unimplemented | Manual / Semantic | LOW |
| G3 | Incorrect Behavior at Boundaries | Manual / Test coverage gaps | LOW |
| G4 | Overridden Safeties | SpotBugs — empty catch blocks, @SuppressWarnings |
HIGH |
| G5 | Duplication | CPD | HIGH |
| G6 | Code at Wrong Level of Abstraction | Manual / Semantic | LOW |
| G7 | Base Classes Depending on Derivatives | OpenRewrite — instanceof subclass in superclass |
HIGH |
| G8 | Too Much Information | PMD ExcessivePublicCount, GodClass |
MEDIUM |
| G9 | Dead Code | PMD UnusedLocalVariable, SpotBugs URF_UNREAD_FIELD |
HIGH |
| G10 | Vertical Separation | OpenRewrite — declaration distance from first use | MEDIUM |
| G11 | Inconsistency | Manual | LOW |
| G12 | Clutter | PMD UnusedImports, EmptyIfStmt |
HIGH |
| G13 | Artificial Coupling | Manual | LOW |
| G14 | Feature Envy | OpenRewrite — external calls > local field access | LOW |
| G15 | Selector Arguments | OpenRewrite — boolean/enum args selecting behaviour | MEDIUM |
| G16 | Obscured Intent | Manual | LOW |
| G17 | Misplaced Responsibility | PMD DataClass |
LOW |
| G18 | Inappropriate Static | OpenRewrite — instance methods not using this |
MEDIUM |
| G19 | Use Explanatory Variables | OpenRewrite — complex inline expressions | MEDIUM |
| G20 | Function Names Should Say What They Do | Manual | LOW |
| G21 | Understand the Algorithm | Manual | LOW |
| G22 | Make Logical Dependencies Physical | Manual | LOW |
| G23 | Prefer Polymorphism to If/Else or Switch/Case | OpenRewrite — type-switching chains | MEDIUM |
| G24 | Follow Standard Conventions | Checkstyle — formatting, brace style | HIGH |
| G25 | Replace Magic Numbers with Named Constants | Checkstyle MagicNumber |
HIGH |
| G26 | Be Precise | SpotBugs DM_BOXED_PRIMITIVE_FOR_COMPARE |
MEDIUM |
| G27 | Structure over Convention | Manual | LOW |
| G28 | Encapsulate Conditionals | OpenRewrite — complex inline boolean expressions | MEDIUM |
| G29 | Avoid Negative Conditionals | OpenRewrite — double-negation, !isNot...() |
HIGH |
| G30 | Functions Should Do One Thing | PMD CyclomaticComplexity, Checkstyle MethodLength |
MEDIUM |
| G31 | Hidden Temporal Couplings | Manual / Narrative | LOW |
| G32 | Don't Be Arbitrary | Manual | LOW |
| G33 | Encapsulate Boundary Conditions | OpenRewrite — inline offset arithmetic | MEDIUM |
| G34 | Functions Should Descend One Level of Abstraction | Manual / Narrative | LOW |
| G35 | Keep Configurable Data at High Levels | OpenRewrite — magic numbers at depth | MEDIUM |
| G36 | Avoid Transitive Navigation | OpenRewrite — Law of Demeter (chain depth ≥ 3) | HIGH |
| Naming | |||
| N1 | Choose Descriptive Names | Checkstyle LocalVariableName, MethodName |
MEDIUM |
| N2 | Choose Names at Appropriate Level of Abstraction | Manual | LOW |
| N3 | Use Standard Nomenclature | Manual / Domain-specific | LOW |
| N4 | Unambiguous Names | Manual | LOW |
| N5 | Use Long Names for Long Scopes | Checkstyle LocalVariableName + scope depth |
MEDIUM |
| N6 | Avoid Encodings | OpenRewrite — Hungarian notation, I-prefix interfaces |
HIGH |
| N7 | Names Should Describe Side-Effects | Manual | LOW |
| Tests | |||
| T1 | Insufficient Tests | JaCoCo — line/branch coverage thresholds | HIGH |
| T2 | Use a Coverage Tool | POM/Gradle inspection — JaCoCo plugin presence | HIGH |
| T3 | Don't Skip Trivial Tests | OpenRewrite — @Disabled/@Ignore without comment |
HIGH |
| T4 | An Ignored Test Is a Question about Ambiguity | Same as T3 | HIGH |
| T5 | Test Boundary Conditions | Manual / Coverage gap analysis | LOW |
| T6 | Exhaustively Test Near Bugs | Manual / Bug history | LOW |
| T7 | Patterns of Failure Are Revealing | Manual / Test results | LOW |
| T8 | Test Coverage Patterns Can Be Revealing | JaCoCo uncovered branch visualisation | MEDIUM |
| T9 | Tests Should Be Fast | Surefire timing — flag tests over threshold | HIGH |
Patterns clearly rooted in a chapter but without a canonical heuristic number
use chapter codes. These appear in the HeuristicCode enum alongside the
Martin codes:
| Code | Pattern | Confidence | Detection |
|---|---|---|---|
| Ch3.1 | Method length | HIGH | Recipe |
| Ch3.2 | Try-catch in loop | HIGH | Recipe |
| Ch3.3 | Data clumps | MEDIUM | Recipe (two-pass) |
| Ch6.1 | Primitive obsession | MEDIUM | Recipe |
| Ch7.1 | Catch-log-continue | MEDIUM | Recipe |
| Ch7.2 | Null density | LOW | Recipe |
| Ch10.1 | SRP candidates (composite) | LOW | Recipe |
| Ch10.2 | Large records without builder | HIGH | Recipe |
Chapter codes give genuine traceability without fabricating authority. They appear in reports and skill file footers only — never in invocation language or developer-facing documentation.
Everything converges on a single normalised Finding. Every adapter,
regardless of source tool, produces this type:
public record Finding(
HeuristicCode code, // G36, F3, Ch7.1, etc.
String sourceFile, // relative to project root; null = project-level
int startLine, // -1 if file-level
int endLine, // -1 if file-level
String message,
Severity severity, // ERROR, WARNING, INFO
Confidence confidence, // HIGH, MEDIUM, LOW
String tool, // "pmd", "checkstyle", "openrewrite" etc.
String ruleRef, // original rule ID for traceability
Map<String,String> metadata // tool-specific extras
) {
public static Finding at(HeuristicCode code, String sourceFile,
int startLine, int endLine, String message,
Severity severity, Confidence confidence,
String tool, String ruleRef) {
return new Finding(code, sourceFile, startLine, endLine,
message, severity, confidence, tool, ruleRef, Map.of());
}
public static Finding projectLevel(HeuristicCode code, String message,
Severity severity, Confidence confidence, String tool, String ruleRef) {
return new Finding(code, null, -1, -1, message,
severity, confidence, tool, ruleRef, Map.of());
}
}
public enum Severity { ERROR, WARNING, INFO }
public enum Confidence { HIGH, MEDIUM, LOW }public record AggregatedReport(
List<Finding> findings,
Set<HeuristicCode> coveredCodes,
Instant generatedAt,
String projectName,
String projectVersion
) {
public Map<HeuristicCode, List<Finding>> byCode() {
return findings.stream().collect(
Collectors.groupingBy(Finding::code, TreeMap::new, Collectors.toList()));
}
public Map<Severity, List<Finding>> bySeverity() {
return findings.stream().collect(
Collectors.groupingBy(Finding::severity));
}
}public interface FindingSource {
String id(); // "pmd", "checkstyle", "openrewrite" etc.
String displayName(); // human-readable, for reports
List<Finding> collectFindings(ProjectContext context)
throws FindingSourceException;
Set<HeuristicCode> coveredCodes(); // used to build CLAUDE.md scaffold
// even when no violations found
default boolean isAvailable(ProjectContext context) { return true; }
}public record ProjectContext(
Path projectRoot,
String projectName,
String projectVersion,
String javaVersion, // e.g. "21"
List<Path> sourceRoots, // [src/main/java]
List<Path> testSourceRoots, // [src/test/java]
Path buildDir, // build/
Path reportsDir // build/reports/
) {}FindingSource
├── PmdFindingSource // parses build/reports/pmd/main.xml
├── CheckstyleFindingSource // parses build/reports/checkstyle/main.xml
├── SpotBugsFindingSource // parses build/reports/spotbugs/main.xml
├── CpdFindingSource // parses build/reports/cpd/cpd.xml → G5
├── OpenRewriteFindingSource // runs ScanningRecipes inline, reads DataTable
├── JacocoFindingSource // parses build/reports/jacoco/test/jacocoTestReport.xml
└── SurefireFindingSource // parses build/reports/surefire-reports/*.xml
Rule-to-heuristic mapping lives entirely inside each adapter.
| PMD Rule | HeuristicCode | Severity | Confidence |
|---|---|---|---|
| CyclomaticComplexity | G30 | WARNING | MEDIUM |
| NPathComplexity | G30 | WARNING | MEDIUM |
| ExcessiveMethodLength | G30 | WARNING | MEDIUM |
| UnusedPrivateMethod | F4 | WARNING | HIGH |
| UnusedLocalVariable | G9 | INFO | HIGH |
| UnusedImports | G12 | INFO | HIGH |
| ExcessivePublicCount | G8 | WARNING | HIGH |
| CouplingBetweenObjects | G8 | WARNING | MEDIUM |
| AvoidConstantsInterface | J2 | WARNING | HIGH |
| CommentedOutCodeLine | C5 | WARNING | HIGH |
| EmptyCatchBlock | G4 | ERROR | HIGH |
| EmptyIfStmt | G12 | WARNING | HIGH |
| SwitchStmtsShouldHaveDefault | G23 | INFO | MEDIUM |
| TooManyFields | G8 | WARNING | MEDIUM |
| TooManyMethods | G8 | WARNING | MEDIUM |
| GodClass | G8 | ERROR | MEDIUM |
| DataClass | G17 | INFO | LOW |
PMD priority severity mapping: 1–2 → ERROR, 3 → WARNING, 4–5 → INFO. Override with HeuristicCode-level severity from table above where specified.
| Check (simple name) | HeuristicCode | Confidence |
|---|---|---|
| ParameterNumber | F1 | HIGH |
| MagicNumber | G25 | HIGH |
| LocalVariableName | N1 | MEDIUM |
| MethodLength | G30 | MEDIUM |
| AnonInnerLength | G30 | MEDIUM |
| AvoidStarImport | J1 | HIGH |
| IllegalImport | G12 | HIGH |
| InterfaceIsType | J2 | HIGH |
| VisibilityModifier | G8 | MEDIUM |
| HideUtilityClassConstructor | G18 | HIGH |
| OneTopLevelClass | G12 | HIGH |
| NeedBraces | G28 | MEDIUM |
| LeftCurly / RightCurly | G24 | HIGH |
| WhitespaceAround | G24 | HIGH |
| EmptyLineSeparator | G10 | MEDIUM |
| MethodName / TypeName | N1 | MEDIUM |
| Category / Bug type | HeuristicCode | Severity | Confidence |
|---|---|---|---|
| CORRECTNESS (any) | G4 | ERROR | HIGH |
| BAD_PRACTICE / DE_MIGHT_IGNORE | G4 | ERROR | HIGH |
| BAD_PRACTICE / ST_WRITE_TO_STATIC_FROM_INSTANCE | G18 | WARNING | HIGH |
| STYLE / URF_UNREAD_FIELD | G9 | INFO | HIGH |
| PERFORMANCE / DM_BOXED_PRIMITIVE_FOR_COMPARE | G26 | INFO | HIGH |
| MALICIOUS_CODE / MS_MUTABLE_ARRAY | G8 | WARNING | HIGH |
Rank 1–4 → ERROR, 5–9 → WARNING, 10–20 → INFO.
SpotBugs uses native @SuppressFBWarnings; the adapter skips findings already
suppressed in XML output rather than going through SuppressionIndex.
All CPD findings map to G5. One Finding per <file> element within each
<duplication> block, with otherFile in metadata. Severity by token count:
≥ 200 tokens → ERROR, 100–199 → WARNING, < 100 → INFO.
- T1: one project-level finding with overall line and branch coverage. < 50% → ERROR, 50–74% → WARNING, ≥ 75% → INFO.
- T8: per-class findings where line coverage < 50% and class ≥ 10 lines → WARNING.
- T2: if report file is absent and project has test sources → project-level ERROR.
- T9: per-test-method WARNING where execution time > threshold (configurable, default 5s). Project-level ERROR if any test exceeds 30s.
- T3/T4: per
<skipped/>element → INFO. Project-level WARNING if > 10% of tests are skipped.
All recipes extend ScanningRecipe<Accumulator> with a companion DataTable.
Parse once, run all via CompositeRecipe:
List<SourceFile> sourceFiles = parser.parse(sourcePaths, baseDir, ctx);
InMemoryLargeSourceSet lss = new InMemoryLargeSourceSet(sourceFiles);
Recipe composite = new CompositeRecipe(allRecipes);
RecipeRun result = composite.run(lss, ctx);ScanningRecipe is two-pass (accumulate then generate) but the parse is
performed once. DataTables from all recipes are harvested from the single
RecipeRun.
Classpath caching: classpath resolution is expensive in large
multi-module projects. Cache the resolved classpath at the
OpenRewriteFindingSource level across incremental builds — parsed
classpath entries do not change between runs unless dependencies change.
This is separate from LST caching and applies to the parser construction
step, not the parse itself.
JavaIsoVisitor on MethodDeclaration. For each boolean parameter on a
non-private method, emit a row. Exclude constructors.
DataTable: className, methodName, paramName, lineNumber.
JavaIsoVisitor on method invocation chains. Detect chains of depth ≥ 3
(e.g. a.getB().getC().getD()). Count depth by recursing into Select
expressions.
DataTable: className, methodName, chain, depth, lineNumber.
Detect double negation: !isNot...() — method name starts with isNot/hasNot
and the call is negated. Also detect !! patterns.
DataTable: className, methodName, expression, lineNumber.
Visit IfStatement and SwitchStatement. For if/else if chains of depth
≥ 3 where conditions involve instanceof, .getClass(), or a field named
type/kind/status, emit a row.
DataTable: className, methodName, depth, pattern, lineNumber.
Visit all comment nodes. Score comment body against heuristics: contains ;,
{, }, or Java keywords at line starts. Score ≥ 2 matches → emit row.
(PMD also detects C5 — this recipe provides the OpenRewrite alternative.)
DataTable: sourceFile, lineNumber, commentPreview.
Visit IntegerLiteralExpr and LongLiteralExpr. Exclude: 0, 1, -1, 2,
values in @interface, case labels, static final field initialisers.
Track call stack depth from class root — emit only when depth ≥ 3.
DataTable: className, methodName, value, lineNumber.
Visit IfStatement. If condition is a BinaryExpr tree with depth ≥ 2
(more than one logical operator) and is not a boolean-returning method call,
emit a row.
DataTable: className, methodName, conditionText, depth, lineNumber.
Visit ClassOrInterfaceDeclaration. Count static final int or
static final String fields. If ≥ 3 fields share a common prefix
(STATUS_ACTIVE, STATUS_INACTIVE, STATUS_PENDING), emit a row.
Use longest-common-prefix algorithm.
DataTable: className, prefix, fieldCount, lineNumber.
Visit J.Try. For each catch clause whose body contains only a logging
call (method call on a logger-named variable) or is empty, emit a row.
DataTable: className, methodName, exceptionType, lineNumber.
Visit methods. Count null-check expressions (x == null, x != null,
Objects.isNull(x)) per method. Emit when count ≥ threshold (configurable,
default 3).
DataTable: className, methodName, nullCheckCount, lineNumber.
Visit ClassDeclaration. Compute line span from getCoordinates().
For Ch10.1 composite: emit when line count > threshold (default 150) as one
proxy signal — requires ≥ 2 composite signals to raise finding.
For a standalone signal at > 300 lines: emit as MEDIUM without requiring
a second signal.
DataTable: className, lineCount, lineNumber.
Visit ClassDeclaration where getKind() == Kind.RECORD. Count record
components. If count > threshold (default 4) and no nested Builder class
exists, emit a row.
DataTable: className, componentCount, lineNumber.
Visit MethodDeclaration. For each parameter of a mutable reference type
(Collection, Map, array, or any non-final class), check whether the
method body contains assignments into that parameter's fields or mutating
method calls on it (add, put, set, remove). Emit when detected.
Exclude parameters that are clearly input+output by convention (e.g. StringBuilder).
DataTable: className, methodName, paramName, paramType, lineNumber.
Visit MethodDeclaration and ClassDeclaration annotated with
@Disabled (JUnit 5) or @Ignore (JUnit 4). Check whether a reason
string is present in the annotation value. If absent, emit a row.
Also emit when the annotation value is blank or a placeholder ("TODO", "").
DataTable: className, methodName, annotation, hasReason, lineNumber.
PMD UnusedPrivateMethod covers private methods. This recipe covers
package-scoped methods not reachable from the PMD scan. Visits all
non-private methods and checks for call sites within the module.
Important caveat: this recipe is approximate by design. Without a
full compile step there is no type resolution, so cross-module calls
and reflection-based invocations will produce false positives. Mark all
findings from this recipe Confidence.LOW unconditionally. Exclude
DeadFunctionRecipe findings from build gate eligibility by default —
they should appear in reports but never cause a build failure without
explicit opt-in in the thresholds config.
DataTable: className, methodName, visibility, lineNumber.
Visit ClassDeclaration. For each instanceof expression or direct
class reference in a superclass or interface body, check whether the
referenced type is a known subtype of the declaring class. Emit when
a parent class references a specific subclass by name.
DataTable: className, referencedSubclass, context, lineNumber.
Visit MethodDeclaration bodies. For each local variable declaration,
compute the distance in lines to the first use of that variable. Emit
when distance > threshold (configurable, default 5 lines). Excludes
variables declared at the top of a method that serve as accumulators.
DataTable: className, methodName, variableName, declarationLine,
firstUseLine, distance.
Visit complex expressions used directly as arguments or conditions
without being assigned to a named variable first. Proxy: detect method
call arguments or if conditions that are BinaryExpr trees of depth
≥ 3 and are not assigned to an intermediate variable. Emit as a
suggestion to extract to an explanatory variable.
DataTable: className, methodName, expressionPreview, lineNumber.
Variant of MagicNumberInDepthRecipe focused specifically on boundary
conditions. Detect arithmetic expressions of the form x + 1, x - 1,
length - 1, size + 1 used directly as array indices, loop bounds,
or substring arguments rather than assigned to a named variable. Emit
when the pattern appears inside a loop or index expression.
DataTable: className, methodName, expression, lineNumber.
Visit ClassOrInterfaceDeclaration that implements an interface.
Check whether the implemented interface contains only constants (static final fields) and no method signatures. Emit for each such interface —
this is the "constants interface" antipattern where a class inherits
constants by implementing the interface rather than using static imports.
DataTable: className, interfaceName, lineNumber.
Visit all named elements (fields, methods, parameters, local variables,
classes). Detect: Hungarian notation prefixes (strName, intCount,
bIsValid), I-prefix on interface names (IRepository, IService),
type suffixes (NameString, CountInt). Emit one row per violation.
DataTable: elementKind, className, elementName, violationType,
lineNumber.
Not a scanning recipe — a transformation recipe that runs during
./gradlew cleanCodeBaseline. Takes a list of Finding records already
produced by the scan pass and injects structured TODO comments at each
finding's source location. AST-aware, idempotent (does not duplicate
existing TODO comments for the same code at the same line).
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.SOURCE) // no runtime footprint
@Documented
@Repeatable(SuppressCleanCode.List.class)
public @interface SuppressCleanCode {
HeuristicCode[] value();
String reason(); // mandatory — no silent suppressions
String until() default ""; // ISO date — violation after deadline
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.SOURCE)
@Documented
@interface List {
SuppressCleanCode[] value();
}
}reason() is non-defaulted deliberately. A suppression with no rationale
is noise. The @Repeatable container allows multiple suppressions on the
same element without array syntax:
@SuppressCleanCode(value = HeuristicCode.F1, reason = "Legacy API", until = "2025-12-01")
@SuppressCleanCode(value = HeuristicCode.G28, reason = "Complex condition unavoidable here")
public Result generate(String a, String b, String c, String d, boolean paginated) { ... }RetentionPolicy.SOURCE means annotations aren't in bytecode. The index
is built by scanning source with JavaParser (no full compile needed).
This is a separate parse from the OpenRewrite LST — intentionally so.
JavaParser is lighter and faster than OpenRewrite's full LST parser for
annotation scanning only, and the two parsers serve distinct purposes.
Do not attempt to fold suppression scanning into the OpenRewrite LST
parse — it would work but is heavier than needed for this task.
public class SuppressionIndex {
public static SuppressionIndex build(Path sourceRoot) {
// Walk .java files, parse with JavaParser,
// collect @SuppressCleanCode annotations with target node spans.
// Handle both single @SuppressCleanCode and @SuppressCleanCode.List
// container — use ann.toNormalAnnotationExpr() /
// toSingleMemberAnnotationExpr() as needed.
}
public boolean isSuppressed(Finding f) {
// Check f.sourceFile + f.line falls within annotated span.
// Expired until= dates → emit META_SUPPRESSION_EXPIRED finding.
}
public List<Finding> metaFindings() {
// META_SUPPRESSION_EXPIRED (ERROR) — past until= date
// META_SUPPRESSION_NO_REASON (WARNING) — blank or "TODO" reason
return metaFindings;
}
}Known brittleness: JavaParser's getBegin()/getEnd() on declarations
is reliable for methods and top-level classes but gets tricky for inner
classes and lambdas. A type-level @SuppressCleanCode can be interpreted
two ways — applying to the whole class body, or only to the class
declaration line itself. The current design assumes the former (type-level
suppression covers all findings within the class body). Review this
decision before implementation and document the chosen behaviour in the
annotation Javadoc. Inner classes annotated separately are always treated
as their own suppression scope.
Post-filter approach — tools run unsuppressed, the filter removes
suppressed findings after parsing. Keeps the model consistent and tools
dumb. SpotBugs is the exception: retain native @SuppressFBWarnings and
skip already-suppressed findings in the XML.
clean-code/
├── annotations/ // SuppressCleanCode, HeuristicCode enum
├── core/ // Finding, FindingSource, SuppressionIndex,
│ // FindingFilter, AggregatedReport, ProjectContext
├── adapters/ // One FindingSource per tool
└── plugin/ // Gradle plugin, tasks, extension
Build bottom-up with tests at each layer before proceeding upward. The temptation is to wire the Gradle plugin first to see something working — resist it. The adapter layer is where most bugs will live and it must be tested in isolation before the plugin wires it together.
Recommended order:
annotationsmodule —HeuristicCodeenum,SuppressCleanCodecoremodule —Finding,FindingSource,AggregatedReportcoresuppression —SuppressionIndex,FindingFilteradaptersmodule — one adapter at a time, each with its own testspluginmodule — Gradle wiring last, once adapters are verified
cleanCode {
failOnViolation = true
reportFormats = listOf("html", "json")
thresholds {
// Build gate
error (G36, maxCount = 0)
error (T1, maxCount = 0)
warning(G5, maxCount = 5)
warning(G30, maxCount = 10)
// Recipe thresholds
classLineCount = 150
classLineCountAlone = 300
publicMethodCount = 15
fieldCount = 10
injectedDependencies = 6
importFanOut = 8
methodParameterCount = 4
recordComponentCount = 4
nullCheckDensity = 3
}
sources {
pmd { reportsDir = layout.buildDirectory.dir("reports/pmd") }
checkstyle { reportsDir = layout.buildDirectory.dir("reports/checkstyle") }
cpd { reportsDir = layout.buildDirectory.dir("reports/cpd") }
spotbugs { reportsDir = layout.buildDirectory.dir("reports/spotbugs") }
jacoco { reportsDir = layout.buildDirectory.dir("reports/jacoco") }
openrewrite { /* runs inline */ }
}
skillsDir = project.file(".claude/skills") // optional, defaults to this
}class CleanCodePlugin : Plugin<Project> {
override fun apply(project: Project) {
val ext = project.extensions.create("cleanCode", CleanCodeExtension::class.java)
val analyse = project.tasks.register("analyseCleanCode", AnalyseTask::class.java) {
it.extension.set(ext)
it.mustRunAfter("check")
}
project.tasks.register("generateClaudeMd", GenerateClaudeMdTask::class.java) {
it.reportFile.set(
project.layout.buildDirectory.file("reports/clean-code/findings.json"))
it.claudeMdFile.set(project.file("CLAUDE.md"))
it.dependsOn(analyse)
}
project.tasks.register("cleanCodeBaseline", BaselineTask::class.java) {
it.dependsOn(analyse)
}
project.tasks.register("cleanCodeExplain", ExplainTask::class.java)
project.tasks.register("cleanCodeCompare", CompareTask::class.java) {
it.dependsOn(analyse)
}
project.tasks.named("build") {
it.finalizedBy(analyse)
}
}
}AnalyseTask normalises all tool XML reports into Finding records.
OpenRewriteFindingSource is the exception — it runs recipes inline
against the same parsed LST rather than reading a pre-existing report.
The plugin bundles conservative PMD, Checkstyle, and SpotBugs rule sets
as JAR resources. Only rules that map to a HeuristicCode are included —
no unmapped rules that produce unfilterable findings:
resources/
├── cleancode-pmd-rules.xml
├── cleancode-checkstyle-rules.xml
└── cleancode-spotbugs-filter.xml
project-root/
├── CLAUDE.md # generated on every build
├── SKILLS.md # human-readable catalogue (hand-maintained)
├── clean-code-baseline.json # baseline snapshot (source-controlled)
└── .claude/
└── skills/
├── ch3-functions.md
├── ch7-exception-handling.md
├── ch7-null-handling.md
├── ch10-classes.md
├── openrewrite-recipes.md
└── project-conventions.md # team-specific stub on first run
GenerateClaudeMdTask distinguishes two section types using HTML comment
markers:
<!-- GENERATED: Ch7.1 -->
...findings content...
<!-- /GENERATED -->
<!-- ANNOTATE: G31 -->
...human-written narrative preserved across regenerations...
<!-- /ANNOTATE -->On regeneration: only GENERATED sections are replaced. ANNOTATE
sections are preserved verbatim. Use a line-based parser to detect
section boundaries — do not use regex for multi-line section matching.
Scaffold behaviour: coveredCodes() from each FindingSource is
used to generate empty stub sections for all heuristic codes even when
no violations are found. This ensures CLAUDE.md always has a complete
structure, and human narrative can be added to any section even before
violations appear. Low-confidence and narrative-only codes get ANNOTATE
stubs by default; detectable codes get GENERATED sections.
Scaffold stub shape for narrative-only codes:
<!-- ANNOTATE: G31 -->
## Hidden Temporal Couplings
No automated detection — add project-specific notes here.
<!-- /ANNOTATE -->This is the mechanism that makes CLAUDE.md useful for patterns that cannot be detected statically — the stub section prompts a human to fill it in, and the content is preserved across every subsequent build.
CLAUDE.md has two kinds of section:
- Generated — produced entirely by the plugin, overwritten on every run. Finding sections, delta table, framework summary.
- ANNOTATE — preserved by the plugin across regenerations. Team-authored content that enriches the generated sections with human context.
ANNOTATE sections are delimited by <!-- ANNOTATE --> markers:
## Error Handling Issues [19 findings + 4 TODOs]
> Read `.claude/skills/ch7-exception-handling.md` before addressing these.
<!-- ANNOTATE -->
Note: the pipeline entry point is `AnalysisRunner.run()` — all exceptions
should surface there. The batch loop in `ReportRenderer` is intentional and
uses BatchErrorCollector correctly; do not flag it.
<!-- /ANNOTATE -->
**From analysis:**
- ReportOrchestrator.java:847GenerateClaudeMdTask uses a line-based parser (not regex) to detect
<!-- ANNOTATE --> blocks and preserve them when regenerating the file.
If the corresponding finding section disappears (all findings resolved),
the ANNOTATE block is removed automatically.
ProjectCharacterisationTask runs during generateClaudeMd and emits a
framework summary into CLAUDE.md. It detects which frameworks are in use
via a FrameworkRegistry that maps group:artifact coordinates to display
names:
// Known patterns (extend as needed):
// org.springframework.boot:spring-boot-starter → "Spring Boot"
// org.jooq:jooq → "jOOQ"
// org.duckdb:duckdb_jdbc → "DuckDB"
// io.pf4j:pf4j → "PF4J"
// org.junit.jupiter:junit-jupiter → "JUnit 5"
// com.fasterxml.jackson.core:jackson-databind → "Jackson"The emitted section tells Claude Code which frameworks are present without requiring it to infer from imports:
## Frameworks in use
Spring Boot 3.2, jOOQ 3.19, DuckDB 0.10, JUnit 5, Jackson 2.17This section is Generated and overwritten on every build.
## Before you start any work in this codebase
1. Read `.claude/skills/SKILLS.md` now, before reading anything else.
This is mandatory, not optional.
2. When a finding section below points to a skill file, read it before
acting on that finding.
3. When working on anything not covered by a finding, check SKILLS.md
for a matching skill before proceeding.
## Frameworks in use
[generated by ProjectCharacterisationTask]
## Current standing vs baseline
| Category | Baseline | Current | Delta |
|---|---|---|---|
| Ch7.1 Catch-log-continue | 23 | 19 | -4 ✓ |
| Ch10.1 SRP candidates | 8 | 9 | +1 ⚠ |
...
## Error Handling Issues [19 findings + 4 TODOs]
> Read `.claude/skills/ch7-exception-handling.md` before addressing these.
<!-- ANNOTATE -->
[optional team-authored context, preserved across regenerations]
<!-- /ANNOTATE -->
**From analysis:**
- ReportOrchestrator.java:847
- UserSessionManager.java:203
**From TODOs [autofix]:**
- ProfilerEngine.java:134 — catch swallows IOExceptionThe skill pointer is the first line of every finding section, adjacent to the findings it governs. Never centralised in a header.
The delta table is the most important section for human developers.
./gradlew cleanCodeBaseline
Three things in a single pass:
- Snapshot finding counts into
clean-code-baseline.json - Inject structured TODO at every finding site via
InjectBaselineTodoRecipe - Subsequent builds report only findings above baseline
// TODO Ch7.1 [autofix] (.claude/skills/clean-code-exception-handling/SKILL.md): catch swallows ProfilerException — wrap and propagate with context| Field | Source |
|---|---|
| Code | Finding's HeuristicCode |
| Action tag | Derived from Confidence |
| Skill path | Plugin code→skill registry |
| Reason | Generated from finding metadata |
Confidence → action tag:
| Confidence | Tag | Meaning |
|---|---|---|
| HIGH | [autofix] |
Claude Code may act directly |
| MEDIUM | [review] |
Claude Code assesses, does not act |
| LOW | [human] |
Requires judgment — Claude Code must not act |
[claude] author tag for Claude Code-generated TODOs:
// TODO Ch7.1 [claude] [human-review]: unable to determine if fallback is genuine recoveryPrevents another Claude Code instance acting on uncertainty that was explicitly flagged for human judgment.
Duplication (G5): inject at both sites with cross-reference:
// TODO G5 [review] (.claude/skills/ch3-functions.md): duplicated block — see also UserService.java:203Placement: immediately before the offending construct — class declaration for class-level, method signature for method-level, catch block for Ch7.1. Consistent placement means Claude Code finds the relevant construct immediately after the TODO.
After baseline, the codebase contains a complete tagged inventory:
grep -r "TODO Ch7" src/ # all exception handling debt
grep -r "TODO.*autofix" src/ # Claude Code's work queue
grep -r "TODO.*human" src/ # items requiring human judgment
Static registry in the plugin maps every code to its skill file. Multiple codes mapping to the same file is expected:
Map<HeuristicCode, String> SKILL_PATHS = Map.of(
Ch7_1, ".claude/skills/clean-code-exception-handling/SKILL.md",
Ch7_2, ".claude/skills/clean-code-null-handling/SKILL.md",
Ch10_1, ".claude/skills/clean-code-classes/SKILL.md",
Ch10_2, ".claude/skills/clean-code-classes/SKILL.md",
Ch3_1, ".claude/skills/clean-code-functions/SKILL.md",
Ch3_2, ".claude/skills/clean-code-functions/SKILL.md",
G5, ".claude/skills/clean-code-functions/SKILL.md"
);Write for Claude Code first — prescriptive, explicit, with negative constraints. Ensure content is readable prose a developer can learn from. Both audiences use the same files.
Scope section first. State what the skill applies to and what it excludes. Include new code as well as fixing — Claude Code reads scope literally.
Split fixing vs new code paths. Caller search and AutoCloseable checks on existing blocks only make sense for existing code.
Inline skill pointers at findings, not centralised index. Pointer must be adjacent to the content that triggers it. Long-range associations across a large document are unreliable.
Pattern tables beat prose for Claude Code. Mutually exclusive patterns with explicit tiebreaker rules are more reliably followed.
Negative constraints are as important as positive patterns. Without do-not rules Claude Code does the nearest plausible thing, which is often subtly wrong.
Flag-for-human-review must be concrete:
// TODO: Ch7.1 — requires human review: [reason]One finding per task. Batching makes review harder and a bad fix affects multiple commits if reverted.
Test classes need explicit exclusion. Without it Claude Code applies production patterns to test code.
Examples must be labelled theoretical when they use class names that don't exist in the codebase:
// Illustrative only — class names are theoretical
// DataAccessException is real (jOOQ)One level of indirection works reliably. Skill files referencing other skill files is unreliable. Keep the hierarchy flat. Cross-references are fine for humans; for Claude Code use them sparingly.
Human-readable catalogue — not a router for Claude Code. Claude Code uses inline CLAUDE.md pointers. SKILLS.md is for onboarding and maintenance:
| Concern | File | When to read |
|---|---|---|
| Error handling & exceptions | ch7-exception-handling.md | Catch blocks, propagation |
| Null handling | ch7-null-handling.md | Null returns, Optional |
| Class design & SRP | ch10-classes.md | Splitting, sizing |
| Function design | ch3-functions.md | Length, parameters, loops |
| Project conventions | project-conventions.md | Naming, approved libraries |
| Adding new recipes | openrewrite-recipes.md | Extending the plugin || Tier | Format | Content |
|---|---|---|
| 1 | Build output | One sentence: what went wrong and where |
| 2 | CLAUDE.md | Paragraph: what the pattern is and the remedy |
| 3 | Skill file | Full treatment: patterns, examples, constraints |
New violations introduced (vs baseline):
ReportOrchestrator.java:847 — catches ProfilerException without propagating
UserSessionManager.java:203 — catches ConfigException without propagating
Run ./gradlew cleanCodeExplain --finding=error-handling for guidance.
Prints skill content directly to the terminal. Invoked by concern, not by code — developers never need to know chapter codes or file paths:
./gradlew cleanCodeExplain --finding=error-handling
./gradlew cleanCodeExplain --finding=class-structure
Zero mandatory configuration:
plugins {
id("com.citi.cleancode") version "1.0.0"
}./gradlew generateClaudeMd produces a useful CLAUDE.md immediately.
On first run the plugin scaffolds the skills directory — chapter skill
files copied from plugin resources, project-conventions.md stub added.
| Concern | Claude.ai | Claude Code |
|---|---|---|
| Correction loop | Conversational, iterative | Autonomous — acts then stops |
| Stakes of ambiguity | Slightly wrong answer | Wrong file committed |
| Instruction following | Suggestive language works | Imperative language required |
| Scope | Naturally bounded by conversation | Must be explicitly constrained |
| Tool calls | Implicit reasoning | Explicit, literal execution |
Write skill files for Claude Code. They will also work for Claude.ai — additional precision does not hurt a conversational interaction. The reverse is not true.
Invoke by intent, not by code:
fix the error handling in ProfilerEngine
improve the class structure in ReportOrchestrator
add a builder to QueryDefinition
Chapter codes appear only in:
HeuristicCodeenum- Skill file traceability footers
clean-code-baseline.json- TODO comments injected by the baseline pass
They never appear in build output, cleanCodeExplain invocation syntax,
SKILLS.md descriptions, or verbal communication about the plugin.
OpenRewrite recipes detect patterns structurally — deterministic, fast, consistent, but limited to static AST analysis. Claude reading source files directly using skills detects patterns semantically — contextual, capable of reasoning about intent, without structural guarantees.
Running both and comparing produces a calibrated finding set where they agree, and a gap analysis where they diverge. The divergences are the most valuable output.
| Recipe finds it | Recipe misses it | |
|---|---|---|
| Claude finds it | High confidence — both agree | Semantic-only — recipe gap |
| Claude misses it | Possible false positive | Clean — neither sees a problem |
Quadrant 1 (both agree): Highest confidence. Safe for [autofix]
regardless of individual confidence level.
Quadrant 2 (Claude only): Most valuable. Things semantic reasoning sees that structural analysis cannot — intent violations, abstraction mismatches, feature envy. Recurring Quadrant 2 findings are candidates for new recipes.
Quadrant 3 (recipe only): Worth reviewing. Either the recipe is triggering on something Claude reads as contextually acceptable, or Claude is missing something. Feeds threshold tuning.
Quadrant 4 (neither): Clean by both measures.
Read the following source files. Using the patterns described in
.claude/skills/ch7-exception-handling.md, identify every location
that violates the exception handling rules. For each finding produce
a structured result in this exact JSON format:
{
"file": "relative/path/to/File.java",
"line": 123,
"code": "Ch7.1",
"description": "brief description of the specific violation",
"confidence": "HIGH|MEDIUM|LOW",
"reasoning": "one sentence explaining why this is a violation"
}
Do not fix anything. Do not output anything other than the JSON array.
The reasoning field is what makes Quadrant 3 analysis possible —
without it you know Claude disagreed but not why.
build/reports/clean-code/scan-comparison.json:
{
"file": "src/main/java/ProfilerEngine.java",
"line": 203,
"code": "Ch7.1",
"recipe": { "found": true, "confidence": "MEDIUM" },
"claude": {
"found": true,
"confidence": "HIGH",
"reasoning": "Catch block logs ProfilerException and returns void — caller cannot distinguish success from failure"
},
"quadrant": 1,
"recommended_action": "autofix"
}recommended_action derivation:
| Quadrant | Recommended action |
|---|---|
| 1 — both agree | autofix if either HIGH, review otherwise |
| 2 — Claude only | review — potential recipe gap |
| 3 — recipe only | review — potential threshold issue |
| 4 — neither | omitted |
./gradlew cleanCodeCompare
Produces scan-comparison.json and a human-readable HTML report with
quadrant breakdown as the headline figure.
cleanCodeCompare must declare the OpenRewrite LST as a task input
dependency rather than triggering a fresh parse. The LST produced by
analyseCleanCode is serialised to build/cleancode/lst/ and consumed
by both cleanCodeBaseline and cleanCodeCompare. A second call to
OpenRewriteFindingSource.collectFindings() in the compare task must
reuse this serialised LST, not re-parse source files. Re-parsing on
every compare run would double the most expensive step in the pipeline.
Quadrant 2 → new recipes: When ≥ 3 instances share a structural
pattern in Claude's reasoning, write an OpenRewrite recipe for it.
Run cleanCodeCompare again — findings should move to Quadrant 1.
Quadrant 3 → threshold tuning: Claude's reasoning explains why
it considers the finding benign — the input needed to decide whether
to raise the threshold, add an exclusion condition, or lower confidence.
Claude's skill-based scan is a calibration layer, not a build-time check. Run it:
- At baseline establishment — to populate Quadrant 2 findings recipes will never see
- When introducing a new recipe — to validate findings before committing to thresholds
- Periodically on hotspot files — high-churn classes where semantic drift is most likely
Recipe scanning runs on every build. Claude scanning runs on demand.
These go in the standing instructions block of CLAUDE.md. They cannot be detected statically but are often the most important patterns for Claude Code — the ones a human architect would brief a new team member on:
- G31 Hidden temporal couplings — methods must be called in order; nothing in the type system enforces it
- G34 Functions should descend only one level of abstraction — mixing orchestration and implementation in the same method
- G26 Be precise — returning
Listwhen one result expected, float for money,Dateinstead of typed temporal - G14 Feature envy — deeper than static analysis can see reliably
These are placed in CLAUDE.md as standing instructions rather than finding sections, since there are no recipe findings to attach them to.
Beyond the Clean Code heuristics there is a fourth tier: project-specific
antipatterns that don't exist in the book but follow the same detection
architecture. These use the same ScanningRecipe + DataTable shape and
feed into the same CLAUDE.md pipeline.
Examples for the BI platform:
- Raw
DSLContext.fetch()calls outside the approved profiling query path — should always go through the profiler wrapper - ECharts option objects constructed inline rather than via the
EMapfluent builder ColumnProfileinstances created outside the staged pipeline
These use a project-specific HeuristicCode namespace (e.g. PROJ1,
PROJ2) rather than Martin codes or chapter codes, and point to
project-conventions.md as their skill file.
The domain recipe tier is where the plugin pays back its investment most directly — generic tools can never catch project-specific misuse of approved APIs, but a custom recipe can. These recipes are also the lowest false-positive risk because the detection is exact (specific class and method names) rather than heuristic.