What?
A build configuration that makes generated code readable at a glance, and the handful of principles behind it — short enough to paste into AGENTS.md so the model isn’t surprised by the build.
Six tools, each doing one job nothing else can:
| Category | Tool | Owns |
|---|---|---|
| Compiler | javac -Werror -Xlint:all | Nothing gets through unnoticed |
| Formatter | Spotless + google-java-format | Layout, pinned to a version |
| Linter | Error Prone | Correctness you didn’t think to check |
| Linter | Checkstyle | The counting a formatter can’t do |
| Architecture | ArchUnit | Boundaries, asserted rather than described |
| Coverage | JaCoCo | What the tests never ran |
Why?
We have always read more than we wrote. Now we barely write at all. The ratio was already lopsided when you typed every line. In modern, AI-assisted and agentic coding and you are almost purely a reader. Every minute you save writing is spent reading something you did not write, which is actually the more expensive of the two.
Even when you stop reading, you still glance. A snippet. An article. A diff. You’re lying to ourselves if we believe you will always review every generated line. You won’t. What you will do is glance — and a glance is exactly where a defect hides. The glance has to be enough, which means the shape has to carry the meaning.
Formatting is for you. Names and structure are for both you and the model.
Strip the formatting and models do just as well on fewer tokens,1 but scramble the names and structure and they get worse.2 So formatting is a kindness to yourself, and naming and structure help you both. The real reason for a config, though, is that it ends the
conversation. An agent that doesn’t know your conventions invents plausible ones, and you correct them every session. A rule in the build is enforced; a rule in AGENTS.md is anticipated. You want both, and you want them to agree.
How?
Compiler: Make every warning fatal
options.compilerArgs.addAll(
listOf(
"-Werror",
"-Xlint:all",
"-Xlint:-processing", // the annotation processor is noise, not signal
),
)A warning nobody acts on is noise that hides the next one. The only sustainable number of ignored warnings is zero, and the only way to hold it is to make them fail.
This pays off in unglamorous ways. A stray /** */ left dangling above an import is a javac
warning most builds swallow. Here it stops the build, which is what you want, because a doc comment attached to nothing is a doc comment that lies.
Formatter: One formatter, pinned to an exact version
spotless {
java {
googleJavaFormat("1.36.1") // Google style: 2-space indent, 100 columns
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
}
}Not “a formatter” — that formatter, at that version. Formatting is not a matter of taste worth anyone’s afternoon, and a floating version means your diff depends on who ran it last.
Most of the settings are implicit. google-java-format applies the Google Java Style Guide:
- 2-space blocks, continuation lines indented by at least 4;
- a 100-column limit, one statement per line;
- Javadoc reformatted.
The only real option is AOSP style (.aosp(), 4-space indent - which doesn’t look as compact in code snippets). Link the style guide in AGENTS.md, since it’s the spec the model is held to.
The pin earns its keep more often than you’d think. Java 23 introduced Markdown doc comments (///), and google-java-format 1.35.0 wraps a long one by continuing it with // — silently turning a doc comment into a dangling one, which -Werror then rejects. Fixed in 1.36.x. The next release, 1.37.0, doesn’t run at all under current Spotless. Without a pin you’d meet both of those by accident, on a Tuesday.
Linter: Checks you didn’t write, kept only if they find something
options.errorprone {
enable(
"ImmutableSetForContains",
"ConstantPatternCompile",
"IdentifierName",
"StatementSwitchToExpressionSwitch",
"MissingDefault",
"RedundantOverride",
"UnnecessaryStaticImport",
)
}Error Prone ships a lot switched off. The discipline that matters is not which you enable, it’s the procedure: turn one on, run it against the whole tree, and keep it only if what it found was worth fixing. A rule kept because it sounded sensible is a rule you will suppress later.
Two of these were tried and thrown out, and the reasons generalise:
- One wanted a method to return
Listwhere it returnedSequencedCollection. The narrower type was the point — those values are ordered, not indexed. A rule that fights deliberate vocabulary loses. - One flagged fully-qualified names. It found a single accident and five deliberate uses, in a method quoted in articles where
java.security.MessageDigesttells a reader whichMessageDigestwithout imports they cannot see. A rule that fights a constraint it cannot see loses.
Write those reasons beside the rule, in the config, not in a suppressions file. The next person to wonder “why isn’t this on?” is standing right there.
Linter: Count the three things a formatter can’t see
<module name="TreeWalker">
<module name="MethodLength">
<property name="max" value="25"/>
<property name="countEmpty" value="false"/>
</module>
<module name="ParameterNumber">
<property name="max" value="4"/>
</module>
<module name="CyclomaticComplexity">
<property name="max" value="10"/>
</module>
</module>Layout belongs to the formatter and correctness to Error Prone. What neither can see is how long a method is, how many things it is handed, and how many ways it can go. Those are the three numbers that decide whether a glance is enough.
Set the limit where the code already is, then hold it. Drive the count to zero first and only then let it fail the build. A limit you pick above the code is a limit you have already failed, and it will drift upward every time someone is in a hurry.
Style: Write lines that read as sentences
Most of this no tool can judge, so it goes in AGENTS.md. Where a tool can check part of it, turn
the check on:
| Convention | Checked by | What’s left to judgement |
|---|---|---|
Name locals for what they are: var matchingSpecs, not Matcher s | Checkstyle LocalVariableName & co: no single letters | Whether the name is right |
var when the right-hand side names the type | Error Prone Varifier, with an auto-fix | Keep the type when it’s the only place it’s said |
A repeated call (m.group() three times) becomes a named local | Nothing | All of it |
| A stream of three or more stages inside a bigger method becomes a named method | Nothing off the shelf (MethodLength helps) | The name: sortedHtmlFilesIn(files) |
contains then add means you wanted a Set | Nothing off the shelf | Use LinkedHashSet, declare SequencedSet |
| Name every regex group, even ones nothing reads | ArchUnit: ban Matcher.group(int) | Unnamed groups in the pattern itself |
A second concern is its own type: X and CachingX | Nothing reliable | All of it |
<!-- No single-letter locals, except a loop index. Same format for final, lambda and pattern variables. -->
<module name="TreeWalker">
<module name="LocalVariableName">
<property name="format" value="^([a-z][a-zA-Z0-9]+|_)$"/>
<property name="allowOneCharVarInForLoop" value="true"/>
</module>
</module>options.errorprone { error("Varifier") } // fires only when the right-hand side names the type@ArchTest
static final ArchRule groupsAreReadByName =
noClasses().should().callMethod(Matcher.class, "group", int.class)
.because("group(2) says nothing, and shifts when the pattern grows");- The checks find the shape, not the meaning.
- Avoid the rules that fight these conventions:
- A name is worth the time. Give a pipeline a name that says what it returns, give a predicate
a name that reads in an
if, and give a local the name of the thing it holds. - AI review is advisory, not a gate.
Architecture: Assert the boundaries instead of describing them
noClasses().that().resideInAPackage("..kitchen..")
.should().dependOnClassesThat().resideInAPackage("..shopfront..")
.because("the kitchen bakes for any counter; a new shop needs a new counter, not a new kitchen")An architecture diagram is a wish. An ArchUnit test is a fact. It also catches what Checkstyle structurally can’t. E.g. Checkstyle’s parameter rule can’t see a record at all; ArchUnit reads bytecode, where a record is just a class. So a constructor limit lives there, and the tighter rule for anything public — three parameters, not four — lives there too, because ArchUnit can read modifiers and Checkstyle can’t distinguish a private helper from your API.
Coverage: Measure what the tests never ran
jacoco { toolVersion = "0.8.14" } // the first release that reads Java 25 bytecode
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
counter = "BRANCH" // lines flatter; a branch is a decision someone made
minimum = "0.80".toBigDecimal() // where the code already is
}
}
}
}
tasks.check { dependsOn(tasks.jacocoTestCoverageVerification) }- Tests are the contract, written as examples. A name says what a method is for; its tests say what it promises. They’re the one documentation that fails when it’s wrong.
- Coverage shows what isn’t written down. It can’t say what is tested, only what isn’t. An untested branch is an unstated promise.
- A floor, not a grade. An assertion-free test covers every line and documents none, and generated tests are often that. Set the minimum where the code is; only raise it.
- Note: Coverage says a test ran, not that it would notice. For that, see mutation testing in the companion.
The principles, for AGENTS.md
Short enough to paste, general enough to survive a change of language:
## Code conventions
1. Every warning fails the build. A warning nobody acts on hides the next one.
2. One formatter, pinned to an exact version. Layout is not a conversation.
3. A snippet must stand alone. Code is read without its imports or its file — in a review, a chat, an article. Prefer the form that survives being torn out.
4. Limits are set where the code already is, then held. A limit above the code is
one you have already failed.
5. When something exceeds a limit, split it and name the pieces. Do not raise the bar.
6. A check earns its place by finding something worth fixing. Turn it on, run it over everything, delete it if it found nothing.
7. When you reject a rule, write the reason beside the rule — not in a suppressions file.
8. Name and structure carefully; let the formatter handle whitespace. Identifiers and shape change what a reader and a model understand. Indentation changes neither.
9. Boundaries are asserted in tests, not described in comments.
10. A test that skips when its inputs are missing has checked nothing. Let the everyday task skip; make CI run one that fails.
11. A line should read as a sentence. Name locals for what they hold. Use `var` when the right-hand side names the type, and keep the type when the declaration is the only place it's said.
12. Name what you'd otherwise repeat or imply. A call made three times becomes a local. A stream of three or more stages inside a bigger method becomes a private method named for what it returns.
13. Say what the collection is. `contains` followed by `add` means a `Set`: use `LinkedHashSet` for order, and declare `SequencedSet` when callers only iterate.
14. Name every regex group, including the ones nothing reads back. Never read a group by number.
15. One concern per type. Caching, prefixing or retrying wraps the thing it adds to (`X` and `CachingX`) instead of being folded into it.The point of giving the model these is not obedience. It is that a rule it knows about is a rule it designs around, and a rule it meets only as a build failure is a rule it works around.
What this actually buys
A method you can take in at a glance, a name that says what the thing is, a boundary that holds because something checks it, and a snippet that still makes sense when it arrives somewhere else with none of its context.
That’s the joy. It’s quieter than the word suggests: it’s the absence of the small, constant friction of reading code that was written as though nobody would.
Companions: for Builders, on what a glance can’t see, and for Operators, on the running system.
Sources
- google-java-format and the Google Java Style Guide it implements: the settings behind
googleJavaFormat()that the config never states. - AI code review comments in the wild: 31,073 CodeRabbit comments across 239 repositories, 36.4% accepted. Also Error Prone
Varifierand CheckstyleLocalVariableName. - The Hidden Cost of Readability: How Code Formatting Silently Consumes Your LLM Budget — 10 models, 4 languages, 24.5% input-token reduction with performance maintained.
- Improving Code Style for Accurate Code Generation — NeurIPS 2023; restructured training data, up to 30% improvement.
Footnotes
-
The Hidden Cost of Readability stripped formatting from code across 10 models and 4 languages: input tokens fell 24.5% on average and performance was maintained. Indentation and newlines are a visual aid for you; the model is unmoved. ↩
-
Obfuscating identifiers and injecting dead branches degrades models. And Improving Code Style for Accurate Code Generation (NeurIPS 2023) found that restructuring programs to be more readable improved a fine-tuned CodeLLaMa-7B by up to 30%, though that is about training data, not the code sitting in your repository. ↩