@towles-tool/comment-budget
v0.2.1
Published
A budget for comment volume: AI writes comments faster than anyone can review them, so cap how much commentary a reader wades through to reach the code.
Maintainers
Readme
comment-budget
A budget for comment volume, written for a codebase an AI writes most of.
A model can emit more commentary in one pass than a human could ever accurately review, and it keeps adding — narrating each step, restating the line below it, heading every block, layering the next pass over the last — until there is too much of it to read. Commentary nobody reads is not documentation; it is what the code is hiding behind.
So the budget is a cap on what review can actually absorb. One question: how much commentary must a reader wade through to reach the code?
comment_lines / (comment_lines + code_lines) blank lines ignoredSo 0.15 means "1 line in 7 is comment" — not comments-per-code-line. Two other
signals sit beside the ratio: an over-long unbroken run of comment, and an
over-long .md. It never reads what a comment says, so it can't tell you one
is stale or wrong — that is not the check. Volume is, and nothing else measures
it.
Install
cargo install comment-budget # or, for a prebuilt binary:
bun add -d @towles-tool/comment-budget # npm/pnpm/yarn work tooUse
comment-budget # the gate: only the lines this branch adds over `main`
comment-budget --all # the standing backlog, repo-wide
comment-budget --report # thresholds in effect, surface table, worst files
comment-budget --format json # findings as an array, for CI to consume
comment-budget --surface web # one surface, for a session spent fixing it
comment-budget --new-from-merge-base release # gate against a branch other than main
comment-budget --new-from-rev HEAD~3 # gate against a revision itselfExit status is 0 when nothing errored, 1 when something did, 2 on a bad
invocation. Warnings never fail the run — they are the standing hit list.
Why the default is a diff
Repo-wide, an established codebase reports hundreds of errors, and a gate that fails every run is one nobody reads. So the default judges only what a branch adds, and the ratio is the added lines' own — a branch can neither add commentary nor inherit the file's existing debt. An over-long run is the exception: it is measured whole and merely has to touch an added line to be reported, because a reader wades through all of it however much you wrote.
The comparison is against the working tree, so a local run judges what you
are about to push, not only what you have committed. --whole-files opts back
into judging every touched file whole. The --new-from-* flag names are
golangci-lint's, which is where the idea is best known from.
Configure
Everything measured — and how hard, and why — lives in comment-budget.toml at
the root of the tree, found by searching upward from the working directory.
- kinds bind file extensions to a grammar —
rust,typescript,tsx,hcl, orprose.exemptprefixes are invisible to every signal, neither comment nor code, so a Rust//!header can hold the decision it records — but only for a file's firstexempt_freelines, past which they count normally. That cap is what stopsexemptbeing a hiding place: without it the cheapest way to pass is to move prose from///into//!, shortening nothing for a reader.countedis the bloat being measured; a comment matching neither list counts, so a new syntax can't slip through unmeasured. - surfaces claim paths by glob and set the thresholds. First match wins, and a readable file no surface claims is a hard error — under first-match-wins the failure mode of this design is a tree nobody noticed was exempt, and that reads exactly like passing.
surface.filefires only when ratio and line count both exceed a tier. Mass alone flags a big well-commented file; ratio alone flags a tiny stub whose three doc lines are 40% of nothing. Neither is the thing being hunted.
A file may opt out with a top-of-file comment-budget: allow(<reason>). The
reason is required — an unexplained opt-out is the failure mode it exists to
prevent. A minimal config:
skip = ["node_modules", "target", "dist"]
[kinds.rust]
grammar = "rust" # the tree-sitter grammar whose comment nodes are read
extensions = ["rs"]
exempt = ["//!"] # module docs: where hard-won "why" lives
exempt_free = 12 # ...but only this many lines of it are free
counted = ["///", "//"] # item docs and narration: the bloat being measured
[kinds.markdown]
grammar = "prose" # parses nothing; measures length instead
extensions = ["md"]
[[surface]]
name = "crates"
paths = ["crates/*/src/**/*.rs"]
goal = "Document the module and the crossing points; not every pub item."
target = 0.15 # reported per surface, not enforced
[surface.file]
warn = { ratio = 0.20, lines = 50 }
error = { ratio = 0.30, lines = 100 }
[surface.run]
warn = 8
error = 14
[[surface]]
name = "docs"
paths = ["**/*.md"]
goal = "Prose has no code to sit against, so length is the only signal it offers."
target = { lines = 150 }
warn = { lines = 150 }
error = { lines = 250 }
[escape]
directive = "comment-budget: allow(<reason>)"Library
The binary is a thin shell over the crate; Finding keeps its fields rather
than only a rendered line, so a consumer can emit GitHub annotations or editor
diagnostics without parsing text back out.
let (cfg, root) = Config::discover(&std::env::current_dir()?)?;
let diff = Diff::open(&root, &Since::MergeBase("main".into()), false)?;
let analysis = comment_budget::analyze(&root, &cfg, Some(&diff))?;
for finding in comment_budget::judge(&cfg, &analysis.stats) {
println!("{finding}");
}Fixing what it reports
Delete, don't reflow. Cut history — git already holds it — and keep only what looks forward: the why, and the how where the code leaves it unclear. Squeezing under a threshold just moves an error onto the warning list, and the budgets are not the thing to lower.
License
MIT OR Apache-2.0, at your option.
