Skip to content

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

Closed
dawsontoth wants to merge 8 commits into
stagefrom
claude/eslint-comment-limits-plugin-ef72b4
Closed

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

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Adds comment-budget, a custom oxlint rule that warns when a file carries more than 12 comment "sites" or a single block more than 4, to push toward code that explains itself rather than prose that drifts from it. It is a warn on an experiment footing — CI and the pre-commit hook stay green — and nothing off the shelf does this (the two npm packages that come close are abandoned 2022 stubs, one of which enforces a minimum comment density).

Scoped to this repo only; the rule is written to the ESLint v9 API and would need no changes to become a package later, but nothing here depends on that.

For the human reviewer

  1. Is this worth having at all? kriszyp's review makes the strongest counter-argument and I largely agree with it: for newly written code the skills updates apply comment discipline at write time, which beats flagging it afterwards. What is left for a lint rule is pre-existing code, human edits made outside the harness, and a trackable number. Sampling my own flagged set (6 of 52 blocks, chosen before reading them) put the precision at 2 clear hits — it flags load-bearing prose about as often as narration. Reversibility is the argument for merging anyway: 140 lines and one config entry to delete, and the calibration data is the durable artifact either way. A "no" costs nothing already spent. Approved by kriszyp on the experiment framing; ruling again here is fair game.
  2. Sites, not lines or comments. A run of adjacent own-line comments is one site however long. Budgeting per line would price a considered six-line explanation above six scattered // increment is, which inverts the incentive. The cost is that verbosity within one comment is unpriced. Reversible in ~10 lines of toSites.
  3. 12 per file / 4 per block, from measurement not taste. Across src: median 3 sites per file, p90 9, p95 12. At 12/4 that is 78 warnings; 10/2 would be 175. I picked the looser end because the baseline was previously spotless and I did not want to spend that signal. Tightening is a one-line config change.
  4. warn, not error. oxlint exits 0 on warnings, so this cannot fail CI or the hook. The cost is 78 permanent warnings eroding a previously clean baseline. Ratcheting later is the intended path, not shipping strict now.
  5. The block signal is really a scope-size signal. The densest scopes here (useResizableDialog.ts, DatabaseTableView.tsx) are the best-commented code we have — Radix ref-loop hazards, the describe_all/describe_table race, a #1199 reference. They trip the budget because the functions are large. max-lines-per-function measures that more directly with no custom plugin, and choosing this over that is a real call I could be wrong about. AGENTS.md states plainly that answering a warning by deleting prose leaves us worse off.
  6. Test files share the one budget. Tests hold the extreme tail (220 of 596 commented files; max 40). An oxlint overrides block giving them their own budget is a few lines; I left it out because a second knob is easier to add on evidence than to remove. Watch for a few weeks.

Verification

Route: new unit tests plus a repo-wide run of the rule against real code — a lint rule is fully observable at the linter, so there is no integration surface beyond that.

  • 20 rule tests, driven through the real oxlint binary rather than by calling create() directly, so they exercise oxlint's own AST and SourceCode rather than my assumptions about them. Covers budget arithmetic, paragraph grouping, the blank-line break, trailing comments, CR line endings, directive and JSDoc exemption including TS triple-slash, banner comments, annotated array/object literals with both the own-line and nested-block carve-outs, prose that merely opens with a tool name, sibling literals charged separately, innermost-block attribution, switch-case scoping, one-report-per-scope, and the inline disable.
  • Every fix was checked against its own counterfactual rather than asserted — reverting the fix must turn its test red. CR: 2 sites instead of 1. Triple-slash: 1 instead of 0. Both exemption holes: [] instead of a 4-comment block warning. A regression test that passes either way proves nothing.
  • The two exemption holes were reproduced as live defects before being fixed: a five-comment object method body and five prose comments opening with tool names each produced zero warnings where five were due.
  • Repo-wide: pnpm lint reports 78 warnings (29 file, 49 block) and exits 0 — unchanged by the exemption fixes, so neither hole was being exploited here; both were latent. pnpm test 292 files / 2,275 tests green, tsc -b clean, dprint check clean. Studio has no test:unit:main / test:integration:all scripts; those are the repo's full-gate equivalents.
  • Two boundary conditions checked rather than assumed: jsPlugins exists in the oxlint config schema back to our declared ^1.32.0 floor, and a broken plugin path fails the whole lint run with exit 1 rather than silently dropping the rule.

Review coverage

Authored by Opus 5. Cross-model review: codex (gpt-5.6-sol) ✓, gemini via agy (default model) ✓. Failed or skipped, named rather than omitted: cursor-composer ✗ — cursor-review structurally refuses any diff that changes agent instructions, and this one edits AGENTS.md; cursor-grok ✗ pruned by policy; Harper domain adjudication ✗ (exit 1 twice, then pruned on the narrow deltas). So outside coverage here is two lenses, not four, and no adjudication ran. Receipt @ 6e068c79.

Five rounds. Rounds 1–4 found five real defects — each reproduced as a live failure before being fixed, each with a counterfactual proving its regression test goes red without the fix. Round 5 returned no findings.

Worth stating plainly: this review should have run before the first push. It did not. The four findings that reached this PR from gemini and kriszyp were all the same class the pre-push leg catches in minutes, and running it late turned up five more that no PR reviewer had seen.

Human-Review-Need: 4 @ 6e068c7

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.45% 6701 / 12083
🔵 Statements 56.05% 7212 / 12865
🔵 Functions 47.66% 1661 / 3485
🔵 Branches 49.64% 4715 / 9497
File CoverageNo changed files found.
Generated in workflow #1750 for commit 6e068c7 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

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Kinda wanted to see some examples, since I am skeptical. Although I guess if the point is to try this on studio as the "experiment", that's fine, go for it.
(And I as mentioned, I feel like the problem has been completely solved with the latest skills updates)
🤖 Reviewed with Codex

Comment thread tools/oxlint-plugins/comment-budget.js Outdated
`DIRECTIVE` is matched against `comment.value`, which has the opening `//`
already stripped — so `/// <reference types="vite/client" />` reaches the rule as
`/ <reference types="vite/client" />`, and the `<reference` alternative never
matched past that leading slash. Compiler directives meant to be exempt were
being charged against the budget.

Accept the remaining slash and any whitespace, and cover `<amd-module>` /
`<amd-dependency>` while there. `src/vite-env.d.ts` drops from 3 counted sites to
2 (the two that remain are real prose and should count); the repo total is
unchanged at 82, since that file was never near the budget — which is exactly why
calibration did not surface this.

Triple-slash fixtures added to the directive test. Verified against the old regex:
the test reports a count of 1 before the fix and 0 after.

Reported by kriszyp (GPT-5) in review.

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

Copy link
Copy Markdown
Contributor Author

Fair ask, and the honest answer is less flattering to the rule than I'd like. I read 6 of the 52
flagged blocks rather than picking the ones that look good. 2 of the 6 are cases where the rule is
clearly right.

Where it earns its keepsrc/lib/string/collapseKebabsToMaxLength.ts, 8 sites in one block:

// If the string is already within the max length, return it as is
if (str.length <= maxLength) { return str; }

// Split the string by hyphens to get individual segments
const segments = str.split('-');

// If there's only one segment, just truncate it
if (segments.length === 1) { return segments[0].slice(0, maxLength); }

Every comment restates the line under it in English. Same shape in to-kebab-case.ts, which has
// Convert to lowercase sitting on top of .toLowerCase(). This is the drift risk you'd want
caught: nothing verifies those sentences, and they're pure cost.

Where it is wrongsrc/integrations/datadog/shouldKeepEvent.ts:109, 10 sites, all like this:

// Errors thrown entirely inside an embedded third-party script are that vendor's bugs, not
// Studio's: we can't reproduce them, fix them, or act on them... Reo.dev alone contributed two
// distinct "issues" within a day of first appearing (2026-07-28) ... Attribute on the stack
// rather than the message, so a genuine Studio error that happens to share a message is kept.

No identifier carries that. Same for components/ui/table.tsx (why relative and not
overflow-hidden, why select-none).

And a failure mode I hadn't anticipatedsrc/lib/string/entityId.test.ts:32, 9 sites:

'org',        // no body
'org-',       // empty body
'Org-1',      // uppercase prefix is a title, not an id
'org 1',      // space, not a hyphen

An annotated test-case table. These are good comments, and my own "trailing comments never merge"
rule — added to fix a different bug — is exactly what makes each one cost a full site. So a
well-documented fixture table is the single most expensive thing you can write under this rule.

That suggests a concrete refinement before anyone takes the numbers seriously: exempt (or merge)
trailing comments on elements of an array/object literal, since annotating data is not the same act
as narrating control flow. Happy to add it — it would clear entityId.test.ts and probably several
of the 52.

On skills having solved this

I think you're mostly right, and I'd rather say so than argue the rule up. For newly written code the
skill applies at write time, which is strictly better than flagging it afterwards. What a lint rule
adds is narrower than I implied when I opened this: it covers code that predates the skills and
human edits made outside the harness, and it produces a number that can be tracked over time.

But the sample above says its precision as a comment-quality signal is poor — it flags
load-bearing prose at least as often as narration. Its defensible value is the scope-size signal, and
max-lines-per-function measures that more directly with no custom plugin.

So the experiment framing is the right one. A fair test over the next few weeks is simply: does
anyone actually change code because of a warning, and is what they change better afterwards? If the
answer is no, the rule is 140 lines and one config entry to delete, and the calibration data in this
PR is the useful artifact either way.

🤖 Generated with Claude Code

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Be the guinea pig!

Sampling the flagged blocks for review turned up a false positive I had built
myself. `entityId.test.ts` and `hostNameRegex.test.ts` are tables of test cases
where each row carries a trailing note:

    'org-',   // empty body
    'Org-1',  // uppercase prefix is a title, not an id

Those are good comments — they describe the row they sit on, and no renaming can
absorb a note about a string literal. But "trailing comments never merge", added
two commits ago to fix a different bug, made each one cost a full site. Under the
rule as it stood, documenting fixture data was the most expensive thing you could
write, which is the opposite of the intent.

A trailing comment inside an array, object, tuple, type literal, or enum body now
charges that literal once, however many rows carry a note. Own-line prose inside a
literal is deliberately NOT covered and still follows the paragraph rules, so the
exemption can't be used to park commentary inside an array.

Effect: 82 warnings -> 78. `hostNameRegex.test.ts` (the second-densest block in the
repo at 15 sites) and `entityId.test.ts` (9) are now clean, along with a block in
`csvExport.test.ts`; every one was an annotated table. Four new tests cover array
and object literals, sibling literals charged separately, and the own-line
carve-out. Verified `TSEnumBody` really does match in oxlint's AST rather than
being a dead selector.

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

Copy link
Copy Markdown
Contributor Author

Implemented the data-literal refinement I floated above — f7764f08.

A trailing comment inside an array, object, tuple, type literal, or enum body now charges that
literal once, however many rows carry a note. Own-line prose inside a literal is deliberately
not covered and still follows the paragraph rules, so this can't be used to park commentary inside
an array.

82 warnings → 78, and the three that cleared were all annotated tables, which is exactly the
class I wanted gone:

File Before After
lib/string/regex/hostNameRegex.test.ts block:15, file:15 clean
lib/string/entityId.test.ts block:9 clean
analytics/lib/csvExport.test.ts block:5 clean

hostNameRegex.test.ts was the second-densest block in the repo, and it was 15 rows of
'-bad.com', // starts with hyphen in label. Under the old rule that was the single most expensive
thing in the codebase; now it costs one site. Nothing outside test fixtures changed, which is the
right blast radius.

Four new tests (17 total): array literals, object literals, sibling literals charged separately, and
the own-line carve-out that keeps the exemption honest. Also confirmed TSEnumBody genuinely matches
in oxlint's AST rather than being a dead selector I'd added on faith.

Re-running the earlier sample with this in place, the 2-of-6 hit rate becomes 2-of-5 — the
entityId.test.ts false positive is simply gone rather than reclassified. The two genuine misses
(shouldKeepEvent.ts, components/ui/table.tsx) are still misses, and I don't think any amount of
heuristic fixes those: they are long functions full of irreplaceable prose, which remains the honest
limit of this rule.

🤖 Addressed by Claude Code

dawsontoth and others added 4 commits August 18, 2026 12:32
…del review

Both were verified as live before fixing — each repro produced zero warnings where
five were due.

The data-literal exemption reached into executable code. `innermostEnclosing` found
the enclosing ObjectExpression for a comment sitting inside an object *method body*,
so five trailing comments in `{ run() { … } }` collapsed to one site and the method's
block budget stopped applying entirely. A literal now only absorbs a comment when no
block nested inside that literal encloses it.

The directive exemption matched bare tool names, so `// eslint has different behavior
here` and `// webpack injects this value` were read as directives and cost nothing.
Alternatives now require real directive syntax (`eslint-`, the `eslint rule: value`
inline form, `webpack<Name>:`, `#region`). `global` and `jshint` are dropped rather
than tightened: their only valid forms are indistinguishable from an English sentence,
and neither appears in this repo.

Also removed three comments that narrate their own function signatures
(`innermostEnclosing`, `isOwnLine`, `annotatedLiteralFor`) — flagged by the reviewer
and independently by the step-11 audit, which is a fair verdict on a rule whose whole
premise is that such comments should not exist.

Repo-wide count is unchanged at 78, so neither hole was being exploited here; both were
latent. Three regression tests added (20 total), each confirmed to fail with its fix
reverted.

Reviewed-by: codex (cross-model pre-push review)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 review found the tightened prefixes still leaked: `// eslint-based
behavior differs here` matched `eslint-`, and `// webpack: this build injects the
value` matched `webpack\w*\s*:`. Checking the whole class rather than the two
reported cases, ALL TEN remaining prefixes had a near-miss — `prettier-style`,
`dprint-formatted`, `biome-like`, `@ts-experts`, `@vite-plugins`,
`type-coverage: not great`, `jscs:`, `#region-ish`. Prefix matching is structurally
wrong for this: a tool name is also an ordinary English word.

Replaced with an explicit allowlist of the recognised forms — the eslint/oxlint
disable-enable family and inline `rule: config`, `eslint-env`, `prettier-ignore`,
the four `dprint-ignore` variants, `biome-ignore`, the four `@ts-` pragmas,
`@vite-ignore`, `type-coverage:ignore-`, coverage markers, the nine webpack magic
comments by name, `#__PURE__`/`#__NO_SIDE_EFFECTS__`, `#region`, and TS
triple-slash. `jscs` joins `global` and `jshint` in being dropped rather than
tightened.

Positive coverage widened so the allowlist's real forms stay pinned, and a
five-comment near-miss test added that goes red under the old prefix regex.
Repo-wide count is unchanged at 78 — no near-miss prose exists here today, so this
too was latent.

Also records the repo-local oxlint plugin convention in DESIGN.md.

Reviewed-by: codex + gemini (cross-model pre-push review)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-3 review: `\b` also succeeds before a hyphen, so `prettier-ignore\b`
matched `// prettier-ignore-this explanation`. Checking the class rather than the
three reported cases, all ten `\b`-anchored forms leaked the same way —
`eslint-env-specific`, `@vite-ignore-me`, `eslint-disable-ish`,
`dprint-ignore-everything`, `biome-ignore-all`, `@ts-ignore-this-please`,
`c8 ignore-ish`, `istanbul ignore-not-really`, and `type-coverage:ignore-this`.

Every fixed form now ends `(?=\s|$)`. `type-coverage:ignore-` was an open prefix
and is now enumerated (`line`, `next-line`, `file`).

This is the third round of the same defect shape: a pattern that looks anchored
but is not. Prefix -> allowlist fixed the tool-name half; this fixes the
end-of-token half.

Repo-wide count still 78; latent like the others. Suffix near-miss test added
(22 rule tests), red with `\b` restored.

Reviewed-by: codex + gemini (cross-model pre-push review)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-4 review caught the inverse of the last three rounds: the end-of-token
anchoring introduced a false negative. `// @ts-expect-error: the vendor types are
wrong` is the form typescript-eslint's `descriptionFormat` asks for, and
`(?=\s|$)` rejected it, so a real compiler directive started costing budget. A
five-comment block containing two of them counted 5 where 3 was right.

The `@ts-` family now accepts `:` as a delimiter. The enumeration still does the
work of keeping prose out — `// @ts-experts: disagree` is not one of the four
listed pragmas, so it is still charged.

Positive fixture added to the directive test; it goes red with the colon removed.

Reviewed-by: codex + gemini (cross-model pre-push review)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth dawsontoth closed this Aug 19, 2026
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.

2 participants