@third-option/comment-budget
v0.1.0
Published
A gate on comment volume: how much prose must a reader wade through to reach the code.
Maintainers
Readme
comment-budget
A gate on comment volume. One question: how much prose 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 prose" — not comments-per-code-line. Two other
signals sit beside the ratio: an over-long unbroken run of comment, and an
over-long .md.
Linters flag what a comment says, not how much of it there is; comment volume goes unmeasured. This measures only that.
Install
cargo install comment-budget # or, for a prebuilt binary:
bun add -d @third-option/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 # surface table and worst files, no finding list
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 prose 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 of it 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.
exemptcomment prefixes are invisible to every signal, neither comment nor code, so a Rust//!header can be as long as the decision it records.countedis the bloat being measured. A comment matching neither 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 the directive
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, never capped
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.
