Skip to content

feat(lint): a comment budget rule, and what calibrating it against our code showed - #1625

Open
dawsontoth wants to merge 2 commits into
stagefrom
claude/eslint-comment-limits-plugin-ef72b4
Open

feat(lint): a comment budget rule, and what calibrating it against our code showed#1625
dawsontoth wants to merge 2 commits into
stagefrom
claude/eslint-comment-limits-plugin-ef72b4

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Adds a lint rule that caps comment volume, to push toward code that explains itself rather than prose
that drifts from it. Discussed as a draft; the team is on board, so this is now proposed for merge.

Scope: this repo only. Not extracting a standalone package for now — the rule file is written to
the ESLint v9 API and would need no changes to become one later, but nothing here depends on that.

Nothing on npm does this

Worth recording, because "surely this exists" was the first assumption:

Package Last release Verdict
eslint-plugin-comment-density 0.0.1, Jun 2022 Unfinished yeoman scaffold — README still says "Fill in provided rules here". Its one rule enforces a minimum density (Code comment coverage should be greater than…), the exact opposite.
eslint-plugin-max-comments-per-function 0.0.2, 2022 (repo archived Dec 2020) Right idea, forked from core max-lines-per-function. requires eslint/lib/util/ast-utils, an internal path removed in ESLint 6 — cannot load.
eslint-plugin-no-comments 1.2.1, Apr 2026 Maintained, but all-or-nothing: ban comments, with a prefix allowlist. No counting.
eslint-plugin-complexity 1.0.2, 2023 complexity/comment requires a comment above a complexity threshold. Inverse of this.
eslint-plugin-comment-length, eslint-plugin-eslint-comments, @stylistic/* current Comment length, style, and directives. Never count.

What this adds

  • tools/oxlint-plugins/comment-budget.js — the rule, written to the ESLint v9 rule API so the same
    file runs under oxlint's jsPlugins and under ESLint unchanged. Ships nothing to the bundle;
    pnpm lint over the whole repo still finishes in 0.24s with it loaded.
  • .oxlintrc.jsonwarn at 12 comments per file and 4 per block.
  • tools/oxlint-plugins/comment-budget.test.js — 13 cases, driven through the real oxlint binary
    rather than by calling create() directly, so they catch oxlint API drift rather than re-testing
    our own assumptions.
  • AGENTS.md — how to read a warning, and the escape hatch.

It counts comment sites, not comments or lines

A run of own-line comments on consecutive lines is one site however long it runs. A five-line
paragraph explaining a race costs exactly what a lone // increment i costs.

That is the central design decision. Budgeting per line would price the considered explanation
above the throwaway, inverting the incentive we want. Trailing comments never merge with
neighbours (a = 1; // why then b = 2; // why is two asides, not a paragraph). Directives
(eslint-*, oxlint-*, @ts-*, dprint-*, c8 ignore, #region) and JSDoc/TSDoc are exempt — a
budget that eats the escape hatches just teaches people to disable the budget. A /*** banner ***/
is not exempt.

Sites are charged to their innermost enclosing block, so splitting a long function genuinely
clears a warning instead of relocating it.

What calibration showed

Budgets are measured, not chosen by taste. Across src (1,239 files, 596 with comments): median 3
sites per file, p90 = 9, p95 = 12, max 40.

maxPerFile files flagged maxPerBlock blocks flagged
8 70 (5.6%) 2 134
10 41 (3.3%) 3 72
12 29 (2.3%) 4 50
15 17 (1.4%) 5 29

At 12/4 that is 82 warnings repo-wide (30 file, 52 block). warn severity, and oxlint exits 0 on
warnings, so neither CI nor the pre-commit hook starts failing.

Read the flagged set carefully, because it complicates the premise. The densest scopes in the
repo are the best-commented code we have:

  • useResizableDialog.ts (31 sites) — Radix invoking a ref more than once per render and looping
    forever; why size is persisted but position deliberately is not.
  • DatabaseTableView.tsx (16 in one block) — why describe_all and describe_table race and which
    copy wins; a #1199 reference for the primary-key-changed-after-rows-existed case.

None of that survives being renamed into the code. It is exactly the load-bearing "why" no identifier
can carry. Those files trip the budget because the functions are large, not because the comments
drifted.

So, honestly: this is closer to a scope-size signal than a comment-quality signal. Its advice
("extract a named function") is right for the code it flags, but for the reason
max-lines-per-function would give. The rule cannot distinguish a drifting comment from an
irreplaceable one — it only measures volume.

That is why it is a warn with a generous budget, and why AGENTS.md states plainly that answering
a warning by deleting prose leaves us worse off than before the rule existed. The failure mode to
guard against is someone trimming the #1199 note to get under a number.

Still open, and fine to settle after merge

  1. Budgets. 12/4 keeps the flagged set at 82. Tightening to 10/2 would flag 41 files and 134 blocks.
  2. Ratchet to error? Our lint baseline was previously spotless, so 82 permanent warnings do
    erode that signal. Suggest revisiting once the flagged set is worked down.
  3. A separate budget for test files? Test prose is often scenario description that no naming
    replaces, and tests hold the extreme tail (220 of the 596 commented files; median 3, max 40).
    Leaving it single-budget for now — a second knob is easier to add on evidence than to remove.

Verification

Full suite 292 files / 2,268 tests green, tsc -b clean, dprint check clean, pnpm lint exits 0.
The 13 rule tests cover budget arithmetic, paragraph grouping, the blank-line break, trailing
comments, CR line endings, directive and JSDoc exemption, banner comments, innermost-block
attribution, switch-case scoping, one-report-per-scope, and the inline disable.

Two things checked while preparing this, both fine: jsPlugins exists in the oxlint config schema
back to our declared ^1.32.0 floor (lockfile pins 1.75.0), and a broken plugin path fails the whole
lint run with exit 1 rather than silently dropping the rule.

Known gap: tools/ sits outside both tsconfig projects and the plugin is plain JS, so tsc -b does
not check it. The tests are its only safety net.

🤖 Generated with Claude Code

Nothing on npm does this. `eslint-plugin-comment-density` is an unfinished
scaffold that enforces a *minimum* density, and `eslint-plugin-max-comments-per-function`
(the one plugin with the right idea) requires `eslint/lib/util/ast-utils`, an
internal path removed back in ESLint 6, so it cannot load at all. So: a local
oxlint JS plugin, written to the ESLint v9 rule API so it also runs under ESLint.

It budgets comment *sites* rather than comments or lines — a run of own-line
comments is one site however long — because per-line budgeting prices a considered
paragraph above the `// increment i` it should be displacing. Trailing comments
never merge; directives and JSDoc are exempt; each site is charged to its
innermost block, so extraction actually clears a warning.

Budgets are calibrated against this repo rather than guessed: at 12/file and
4/block it flags 30 files and 52 blocks (2.3% of src). Severity is `warn`, and
oxlint exits 0 on warnings, so neither CI nor the pre-commit hook starts failing.

Worth knowing before tightening it: the densest scopes here are commented *well*
(Radix ref-loop hazards in useResizableDialog, the describe_all/describe_table
race in DatabaseTableView). They trip the budget because the functions are large,
which makes this a scope-size signal more than a comment-quality one — recorded
in AGENTS.md so nobody answers a warning by deleting the load-bearing prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a custom oxlint/ESLint plugin, comment-budget, which enforces a configurable limit on the number of comment sites per file and per block. It includes documentation in AGENTS.md, configuration in .oxlintrc.json, and a comprehensive test suite. The review feedback highlights three key improvement opportunities: making the line-boundary check in isOwnLine robust against carriage returns (\r), optimizing the O(N^2) array spreading inside the block-grouping loop to O(N), and appending .cmd to the oxlint binary path on Windows to ensure cross-platform test execution.

Comment thread tools/oxlint-plugins/comment-budget.js
Comment thread tools/oxlint-plugins/comment-budget.js
Comment thread tools/oxlint-plugins/comment-budget.test.js Outdated
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 55.32% 6678 / 12071
🔵 Statements 55.93% 7188 / 12850
🔵 Functions 47.52% 1653 / 3478
🔵 Branches 49.3% 4661 / 9453
File CoverageNo changed files found.
Generated in workflow #1719 for commit 283564e by the Vitest Coverage Report Action

Three review comments from gemini-code-assist, all real:

- `isOwnLine` only stopped its leftward indentation scan at LF, so with bare-CR
  line endings it ran on into the previous line, found that line's `;`, and
  classified an own-line comment as a trailing aside — which also stopped it
  merging with its neighbours. Now stops at CR too, which drops the `\r`
  special-case from the loop body. Covered by a new test that fails with a
  count of 2 against the old code and 1 against the new.

- Accumulating each block's sites with a spread copied the whole array per
  site, O(N^2) in a block's comment count. Push into the existing array
  instead. Behaviour is unchanged: the repo still reports exactly 82 warnings.

- The test harness spawned `node_modules/.bin/oxlint`, which is POSIX-only.
  The suggested `.cmd` suffix would not have fixed Windows on its own — Node
  refuses to spawn `.cmd` without `shell: true` (CVE-2024-27980 hardening).
  Instead run oxlint's declared `bin`, which is a plain Node script, through
  `process.execPath`, resolved via `oxlint/package.json` since pnpm puts the
  real package in the store outside this worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review August 14, 2026 17:29
@dawsontoth
dawsontoth requested a review from a team as a code owner August 14, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant