commentdiet — a linter that stops AI from commenting every line
AI agents comment everything: "check if empty", "return the error". Writing "don't comment the obvious" in CLAUDE.md works for about half an hour. So I built a small linter: it measures the ratio of comment characters to code characters per file, directory, and project, and fails the pre-commit hook when a limit is exceeded. Go and C/C++, one docker run, image just over a megabyte.
The problem looks like this. You give an agent a task, it does it, and does it reasonably well. Then you open the diff:
// processItems processes the items in the slice.
// It iterates over each item and processes it.
func processItems(items []Item) error {
// Check if items is empty
if len(items) == 0 {
// Return nil if there are no items to process
return nil
}
// Iterate over all items
for _, item := range items {
// Process the current item
if err := process(item); err != nil {
// Return the error if processing failed
return err
}
}
// Return nil on success
return nil
}
That snippet has 221 characters of comments for 146 characters of code. 151%. Not one comment says anything the code doesn't already say. There is even // Return nil on success right above return nil.
You write in CLAUDE.md: "Don't comment the obvious. Comment only what the code cannot say itself." It works. For half an hour. Then the context gets compacted, or the task runs long, or it is just in that mood, and it starts again. A human reviewer gets tired of this and starts letting it through. A linter doesn't get tired.
What it measures
One number: comment characters divided by code characters, whitespace excluded. Characters, not lines, because one 120-character line of explanation and one // nolint are very different things, and by line count they are identical.
The number is measured at three levels, each with its own limit:
- File. Catches the specific file the agent just buried in comments.
- Directory. Sum over the files directly in it. Catches a package where every file is slightly under the limit and together they are way over.
- Project. Sum over everything. The strictest limit, because legitimate exceptions get diluted at that scale.
Files with less code than min_code_chars (200 by default) are not checked on their own, but their characters still count toward the totals. Otherwise a three-line file with one comment would fail everything.
Not counted as comments: license headers, cgo preambles, directives like //go:build or //go:generate. Generated files (the ones with DO NOT EDIT) are skipped entirely, as is .git.
What real code looks like
To pick sensible limits, I measured whatever was on my disk:
| What | Comments / code |
|---|---|
| commentdiet itself | 8.2% |
| This blog (Go) | 10.3% |
| A small Go service from this same site | 13.2% |
| golangci-lint without testdata | 11.7% |
| testify | 24.0% |
| yaml/v3 | 28.1% |
| Go stdlib: strings | 28.9% |
| Go stdlib: encoding/json | 29.0% |
| Go stdlib: net/http | 32.3% |
| Go stdlib: sync | 41.3% |
| golangci-lint with testdata | 109.6% |
The picture is simple. Application code lives at 8–13%. Libraries where every exported symbol has a doc comment sit at 25–30%. sync is 41%, but every comment there explains the memory model, and it should. The 110% in golangci-lint is a single testdata file with 845 kilobytes of comments for a long-line test. That is exactly what exclude and overrides are for.
Then I ran it with default limits over everything I have access to: 24 work repositories in C++ and Go, and 10 of my own from this site.
| 34 repositories, default limits | |
|---|---|
| Passed on the first run | 2 |
| Violations per repository | 2 to 917 |
| Own code above 20% at the project level | 6 |
| Own code: at least one file above 25% | 28 |
| Own code: at least one file with more comment than code | 16 |
| File limit needed to pass with contrib/ and build/ included | up to 3027% |
Three takeaways. First: on average everything is fine. Own code fits under 20% at the project level almost everywhere, so developers as a whole are not abusing comments. Second: the average means nothing. 28 of 34 repositories have a file above 25%, and 16 have a file with more comment than code. Those are the files the linter names, and those are the files it exists for. Third: without exclude on contrib/, build/, vendor/ the numbers are absurd. To pass with third-party code inside, the file limit would have to be 285%, and in one case 3027%.
So on an existing repository the first run is almost guaranteed to fail. That is not a bug, that is the report. The order of business: exclude what isn't yours, look at the files it names, and add overrides for the legitimate exceptions such as tests with large fixtures.
Default limits
File 25%, directory 20%, project 15%. At the project level this is lenient: application code passes with a wide margin, the stdlib would not, and an agent at 150% certainly not. At the file level it is strict, and that is the point. If you disagree, drop a .commentdiet.yaml in the project root:
file_max_ratio: 0.25
dir_max_ratio: 0.20
project_max_ratio: 0.15
min_code_chars: 200
exclude:
- "vendor/**"
overrides:
- path: "**/testdata/**"
disable: true
- path: "**/*_test.go"
file_max_ratio: 0.50
Any limit can be turned off with null. Glob patterns are relative to the config file, ** matches any number of path segments. An unknown key in the config is an error, not something silently ignored.
How to run it
From the project root:
docker run --rm -v "$PWD:/src:ro" serguacom/commentdiet:latest
The image is scratch with a single static binary, just over a megabyte, available for amd64 and arm64. The project is mounted read-only; the tool writes nothing and talks to no one. Exit code 0 means clean, 1 means too many comments, 2 means a configuration or runtime error.
As a pre-commit hook, in .git/hooks/pre-commit:
#!/bin/sh
docker run --rm -v "$(git rev-parse --show-toplevel):/src:ro" serguacom/commentdiet:latest || exit $?
Or without Docker:
go install github.com/serguacom/commentdiet@latest
The output is in the format every editor and CI already understands:
verbose.go:1:1: comment ratio 45.3% exceeds file limit 25.0% (comments 136, code 300 chars)
lib: comment ratio 23.1% exceeds dir limit 20.0% (comments 231, code 1000 chars)
project: comment ratio 17.0% exceeds project limit 15.0% (comments 340, code 2000 chars)
What it does not do
It does not judge comment quality. It has no idea that // Return nil on success is garbage and // WalkDir lstats its root: a symlinked cwd would be skipped whole is the most useful line in the file. All it knows is that useful comments are short and garbage is long and plentiful. In practice that is enough: an agent that is not allowed to write a hundred comments writes five, and those five suddenly start meaning something.
It is not a replacement for the rule in CLAUDE.md. The rule tells the agent what to do. The linter tells it what happens if it doesn't.
Dogfooding
commentdiet's own limits are 18% per file, 14% per directory, 8% per project, and it sits at 8.2%. Right at the edge, so every new comment has to earn its place. I like it that way.
Code: github.com/serguacom/commentdiet, BSD-2-Clause. Image: serguacom/commentdiet. Go and C/C++ only for now, because those are what I write. If you need another language, say which one in the comments.
Comments and questions · Google / GitHub / anonymous