feat(lint): a comment budget rule, and what calibrating it against our code showed - #1625
feat(lint): a comment budget rule, and what calibrating it against our code showed#1625dawsontoth wants to merge 8 commits into
Conversation
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>
There was a problem hiding this comment.
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.
Coverage Report
File CoverageNo changed files found. |
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>
kriszyp
left a comment
There was a problem hiding this comment.
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
`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>
|
Fair ask, and the honest answer is less flattering to the rule than I'd like. I read 6 of the 52 Where it earns its keep — // 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 Where it is wrong — // 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 And a failure mode I hadn't anticipated — 'org', // no body
'org-', // empty body
'Org-1', // uppercase prefix is a title, not an id
'org 1', // space, not a hyphenAn annotated test-case table. These are good comments, and my own "trailing comments never merge" That suggests a concrete refinement before anyone takes the numbers seriously: exempt (or merge) On skills having solved thisI think you're mostly right, and I'd rather say so than argue the rule up. For newly written code the But the sample above says its precision as a comment-quality signal is poor — it flags So the experiment framing is the right one. A fair test over the next few weeks is simply: does 🤖 Generated with Claude Code |
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>
|
Implemented the data-literal refinement I floated above — A trailing comment inside an array, object, tuple, type literal, or enum body now charges that 82 warnings → 78, and the three that cleared were all annotated tables, which is exactly the
Four new tests (17 total): array literals, object literals, sibling literals charged separately, and Re-running the earlier sample with this in place, the 2-of-6 hit rate becomes 2-of-5 — the 🤖 Addressed by Claude Code |
…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>
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 awarnon 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
// increment is, which inverts the incentive. The cost is that verbosity within one comment is unpriced. Reversible in ~10 lines oftoSites.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.warn, noterror. 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.useResizableDialog.ts,DatabaseTableView.tsx) are the best-commented code we have — Radix ref-loop hazards, thedescribe_all/describe_tablerace, a#1199reference. They trip the budget because the functions are large.max-lines-per-functionmeasures that more directly with no custom plugin, and choosing this over that is a real call I could be wrong about.AGENTS.mdstates plainly that answering a warning by deleting prose leaves us worse off.overridesblock 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.
create()directly, so they exercise oxlint's own AST andSourceCoderather 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.[]instead of a 4-comment block warning. A regression test that passes either way proves nothing.pnpm lintreports 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 test292 files / 2,275 tests green,tsc -bclean,dprint checkclean. Studio has notest:unit:main/test:integration:allscripts; those are the repo's full-gate equivalents.jsPluginsexists in the oxlint config schema back to our declared^1.32.0floor, 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 viaagy(default model) ✓. Failed or skipped, named rather than omitted:cursor-composer✗ —cursor-reviewstructurally refuses any diff that changes agent instructions, and this one editsAGENTS.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