A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it: a redundant comment still has to be maintained, will eventually contradict the code, and dilutes the comments that matter.
The operative rules are in AGENTS.md, kept short so they stay in an agent's
context. This document is the reasoning and the worked examples behind them, plus
how to run the linter.
Describe the code as it is. Not what it used to be, not what changed, not why it changed. A comment that narrates history is stale the moment the next change lands, and git already holds that record.
When you know the history and it explains the shape of the code, the useful half is the reason, not the sequence. State the reason:
// Don't:
// This used to reimplement the modal internals, which is how the procurement
// dialogs drifted from the billing ones.
// Do:
// Thin wrapper over the shared Modal: duplicating its portal and focus trap is
// how dialogs drift apart.Future state is the exception, and it belongs in a TODO with an issue.
Contract. What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects.
The bound is the surface, not the volume: document the contract of everything a caller outside the file can reach, and nothing else. Inside that surface say whatever a caller needs; outside it a comment earns its place on the same terms as any other.
/**
* Authority on which filesystem locations a policy may read or write. Fail-closed
* in order: denied entirely under the saas profile; Stirling's own config dir is
* always rejected; the path must resolve within policies.allowedFolderRoots.
*
* <p>Compared after normalisation so {@code ..} cannot escape a root. Symlink
* escape is not defended: an operator who roots an allowlist on a symlink to a
* sensitive location is trusted.
*/Why. The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
// whenComplete runs on the worker thread after the run finishes, so the
// terminal event never races the step events.
handle.completion()A reference is supplementary, never load-bearing: the comment must survive
deleting it. // See #1234 is a dead end.
// flatten() reads the annotation list that save() clears, so saving first loses
// every annotation (#6865).
document.flatten(annotations);Prefer a spec (RFC 3161, ISO 4217) or a CVE where one applies. Both are
immutable; a ticket can be closed, moved or made private.
Hazard. "Must stay in sync with X." "Order matters because Y." "Do not remove, it prevents Z."
Map. A short orientation at the head of a genuinely complex file: what it owns, and what it deliberately does not.
A comment earns its place when it sits at a different level of detail than the line below it: lower, stating a precise fact the code implies but does not say, or higher, giving intent a reader would otherwise assemble from ten lines. Same-altitude is the definition of redundant.
- Delete it. Is any information lost? If not, it stays deleted.
- Could a name carry it instead? A better identifier, an extracted function or a named constant beats a comment. Prefer the code change.
| Don't | Instead |
|---|---|
// Handle drag start above handleDragStart |
Nothing. The name already says it. |
// ─── Types ───, // Helpers, // ==== |
If a file needs internal signposting, split the file. |
// Step 1: narrating a function body |
Extract functions. If the steps need labels they need names. |
// No longer needed, // Previously this used X |
State why the code is as it is now, or nothing. |
| Commented-out code | Delete it. Git remembers. |
@param blob - The blob to download |
Omit the tag rather than pad it. |
| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. |
Step numbering is fine where it labels a genuinely numbered thing, such as a wizard step or a step in a written test procedure. It is narration when it numbers the lines of one function.
A trailing comment usually does a different job from one above the code: it decodes the line it sits on. Those are worth keeping, and the linter leaves them alone.
byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB
double buffer = 0.10; // 10% headroom
default -> toBytes(value, 2); // MBEach overlaps in words with the code and each adds the interpretation the code
leaves implicit, which is the lower-altitude case the test above asks for. So
CMT001 does not judge trailing comments; on this codebase it would have been
wrong about roughly six in seven of them.
What still applies is anything that does not depend on the code below: a trailing
// TODO fix this is as unowned as one on its own line, and a trailing
// this used to run before the flush narrates history wherever it sits.
A comment block over about 12 lines, outside a file or type header, is usually a sign the code needs restructuring. If it is genuinely product documentation, it belongs in the docs repo.
A TODO needs an issue, because an issue is the only part that will close it:
// TODO(#1234): re-enable the checkout gate once account syncing landsAn owner is not a substitute: a username goes stale when someone changes team and means nothing to an outside contributor. If the work is not worth an issue it is not worth a TODO, and the options are to do it now or leave the code alone. A question is not a TODO.
Java. Google Java Style, which this repo already formats to. Its §7.3.1 exception applies: omit Javadoc on a self-explanatory member where there is genuinely nothing to add, but do not cite it to skip something a reader needs. Summary fragments are noun or verb phrases, not sentences starting "This method returns".
TypeScript. JSDoc on the @app/* seams, exported hooks, and anything crossing
a layer boundary. No @param/@returns that restates a typed signature. JSX
comments follow the same rules as any other.
Python. Docstrings on modules, public functions and Pydantic models where the contract is not obvious from the type.
task comment-lint # what the working tree adds over HEAD
task comment-lint:branch # what the branch adds over origin/main (BASE=<ref> to change)
task pre-commit:comment-lint:ci # the fixture corpus, then the diffcomment-lint is the pre-commit question, so it reports nothing once you have
committed; on a CI pull request it compares against the target branch via
GITHUB_BASE_REF. comment-lint:branch is the review question. The corpus checks
the rules themselves rather than the code under review, so it runs on CI and before
a rule change, not on every local commit.
task comment-lint also runs inside task pre-commit, and as a Claude Code Stop
hook, so an agent is told before it finishes a turn and fixes the comment inside
that turn. Stop rather than per file write: a run costs the same for one file as for
twenty-five, and half of all writes in a turn go to a file already written in it.
Findings are scoped to comment text that is new, not to lines git calls new, so reindenting or moving code does not resurface comments you did not write.
The rules are the RULES object in
scripts/lint/comment-rules.mjs; the exact
condition for each is the predicate of the same name in that file, with the
readings it deliberately excludes beside it.
Every rule blocks. A rule that only warns is a rule nobody acts on. So a finding you believe is wrong is a bug in the rule, not something to live with: narrow the rule, or mark the line and say why.
Every comment form the repo writes is covered: // and /* */, Javadoc and JSDoc,
JSX comments, #, and Python docstrings. CMT007 reads all three parameter
conventions in use here, Javadoc/JSDoc @param, Sphinx :param name: and Google
name: description under Args:.
Two engines, one rule set. .ts/.tsx/.mjs go to an oxlint JS plugin, so
comments come from the parser: a // inside a string is not a comment, and JSX
{/* … */} is. .java/.py go to a line scanner. Neither reads the other's
files, so they cannot disagree about one file. scripts/lint/fixtures/ is the
corpus that keeps them meaning the same thing.
Name the rule on the line above:
// comment-lint-allow: CMT002
// ─── kept deliberately, because <reason> ───There is no form that disables every rule, and the directive has to earn its
place. CMT008 reports one that names something which is not a rule, and one that
silences nothing, so a typo does not read as a suppression and a stale
suppression does not sit there blinding the line. The whole comment must be the
directive; prose that mentions the syntax is just prose.
If you reach for this more than occasionally the rule is wrong: fix it in
comment-rules.mjs and update the fixture corpus in the same commit, so the diff
shows what moved.
task pre-commit:comment-lint:all reports the whole tree and never fails. There is
a standing backlog being cleared by directory; diff scoping is what keeps it off
whoever touches a file first.
To turn the editor hook off, put { "env": { "COMMENT_LINT_HOOK": "0" } } in
.claude/settings.local.json. The commit-time gate still applies, so you lose the
early warning rather than the check.