Skip to content

Commit 8135fbb

Browse files
committed
Address review: pre-existing comments, AAA markers, TODOs
Three changes from the review at 09e6b2b. A reindent or a code move no longer resurfaces comments nobody wrote. git marks a reformatted line as added, so line membership alone reported pre-existing findings: a whitespace-only reindent of PageImageLocator.java turned a banner at line 88 into a blocking error, and moving a block does the same. Findings are now matched against the comment text present at the base, so only genuinely new comment content reports. Costs one `git show` per changed file, memoised. Verified on both engines, and a genuinely new banner in the same file still blocks. Arrange/Act/Assert and Given/When/Then are exempt from CMT001. 85 of 537 findings were bare `// Assert` markers, all in test files. They restate the code by the rule's letter and carry real structure, and this is a blocking rule, so leaving it to be relitigated in every test PR was the wrong trade. Kept narrow: the marker first and at most four words, so `// Assert the cap is clamped ...` is still judged on its merits. CMT009 advises on a TODO that names no issue. 21 of the 25 in the tree name neither issue nor owner, which makes them the one comment category demonstrably rotting. Advisory, not blocking, because unlike CMT001/002/005 its false-positive rate here is unmeasured. Anchored at the start of the comment, after the unanchored version flagged the paragraph describing it. Doc changes: the contract bound now sits next to the instruction it bounds rather than 40 lines away in the per-language notes; references are split by durability with the supplementary-not-load-bearing rule demonstrated rather than only stated; a TODO section; and an explicit statement that this is not a push for fewer comments.
1 parent 3eb293b commit 8135fbb

7 files changed

Lines changed: 266 additions & 34 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ Two tests before keeping a comment:
4949

5050
A comment block over ~12 lines outside a file or type header is either prose that belongs in `devGuide/`, or a sign the code needs restructuring.
5151

52+
A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. A username goes stale and means nothing to an outside contributor. If it is not worth an issue, it is not worth a TODO.
53+
54+
A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Specs (`RFC 3161`) and CVEs outlast tickets, so prefer them when either applies.
55+
56+
This is not a push for fewer comments. The linter only ever asks you to delete, because deletion is the half it can judge. Contract docs on anything a caller outside the file can reach are the half it cannot, and the half this codebase is short of.
57+
5258
`task comment-lint` enforces the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Full guidance, worked examples, and the rule list: @devGuide/CODE_COMMENTS.md
5359

5460
## Common Development Commands

devGuide/CODE_COMMENTS.md

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,26 @@ eventually contradict the code, and it dilutes the comments that matter.
77
That single rule is the whole standard. The rest of this document is how to apply
88
it, and what the linter checks.
99

10+
This is not a campaign to have fewer comments. Every rule the linter enforces is a
11+
deletion, because deletion is the half a pattern can judge; the half that matters
12+
more is the writing, and no linter will ask you for it. The contract docs below are
13+
the part this codebase is short of.
14+
1015
## The four jobs a comment can do
1116

1217
**Contract.** What a caller must know that the signature cannot say:
1318
preconditions, invariants, units, ownership and lifetime, thread-safety, error
14-
semantics, side effects. Be generous here. This is the one category the codebase
15-
is short of, not long on. Goes on the type, method, or module as Javadoc, JSDoc,
16-
or a docstring.
19+
semantics, side effects. Goes on the type, method, or module as Javadoc, JSDoc, or
20+
a docstring.
21+
22+
This is the one category the codebase is short of rather than long on, but "be
23+
generous" is not a checkable instruction, and taken alone it produces the padding
24+
`CMT007` flags. The bound is the surface, not the volume: **document the contract
25+
of everything a caller outside the file can reach, and nothing else.** Concretely,
26+
visible types and members in Java, the `@app/*` seams and exported hooks in
27+
TypeScript, modules and public functions in Python. Inside that surface, say
28+
whatever a caller genuinely needs. Outside it, a comment has to earn its place on
29+
the same terms as any other.
1730

1831
```java
1932
/**
@@ -31,8 +44,26 @@ Nothing there is recoverable from reading the method bodies. It states the
3144
ordering, the failure mode, and a deliberate gap in the threat model.
3245

3346
**Why.** The non-obvious reason the code is shaped this way: the constraint it
34-
satisfies, the bug it avoids, the alternative that was rejected. Name the ticket,
35-
CVE, or spec when there is one.
47+
satisfies, the bug it avoids, the alternative that was rejected.
48+
49+
Point at a source when there is one, and know how long each kind lasts. A **spec**
50+
is the best reference available and never moves: `RFC 3161`, `ISO 4217`, `RFC 9728
51+
section 3.1`. A **CVE or GHSA** is immutable too. A **ticket** is the weakest of
52+
the three, because it can be closed, moved or made private, so it must not be the
53+
only thing holding the comment up.
54+
55+
That is the rule for all three, not just tickets: **a reference is supplementary,
56+
never load-bearing.** The comment has to survive deleting it. `// See #1234` is a
57+
dead end; the same fact with the reason first is not:
58+
59+
```java
60+
// flatten() reads the annotation list that save() clears, so saving first loses
61+
// every annotation (#6865).
62+
document.flatten(annotations);
63+
```
64+
65+
Delete the `(#6865)` and the comment still tells you everything you need. That is
66+
the test.
3667

3768
```java
3869
// whenComplete runs on the worker thread after the run finishes, so the
@@ -84,13 +115,32 @@ Two of these have a legitimate form worth knowing:
84115
A block longer than about 12 lines outside a file or type header is usually prose
85116
that belongs in `devGuide/`, or a sign the code needs restructuring.
86117

118+
## TODOs
119+
120+
A TODO needs something that will eventually close it, which means an issue:
121+
122+
```java
123+
// TODO(#1234): re-enable the checkout gate once account syncing lands
124+
```
125+
126+
An owner is not a substitute. A username goes stale the moment someone changes
127+
team and means nothing to an outside contributor, while an issue outlives both. If
128+
the work is not worth an issue, it is not worth a TODO, and the honest options are
129+
to do it now or leave the code as it is.
130+
131+
This is the one comment category demonstrably rotting here today: of 25 TODO,
132+
FIXME and HACK comments in the tree, 21 name neither an issue nor an owner. Some
133+
are questions rather than tasks (`// TODO: why do this server side not client?`),
134+
which is a note to nobody. `CMT009` advises on new ones, at the point where
135+
opening the issue is cheapest.
136+
87137
## Per-language notes
88138

89-
**Java.** Javadoc on visible types and members, per the Google Java Style guide
90-
this repo already formats to. Its §7.3.1 exception applies: omit Javadoc on a
91-
self-explanatory member where there is genuinely nothing to add, but do not cite
92-
that to skip something a reader needs. Summary fragments are noun or verb phrases,
93-
not sentences beginning "This method returns".
139+
**Java.** Per the Google Java Style guide this repo already formats to. Its
140+
§7.3.1 exception applies: omit Javadoc on a self-explanatory member where there
141+
is genuinely nothing to add, but do not cite that to skip something a reader needs.
142+
Summary fragments are noun or verb phrases, not sentences beginning "This method
143+
returns".
94144

95145
**TypeScript.** JSDoc where a caller needs the contract, particularly on the
96146
`@app/*` seams, exported hooks, and anything crossing a layer boundary. Do not
@@ -149,17 +199,25 @@ thing; `task pre-commit:comment-lint:selftest` checks it.
149199
| --- | --- | --- |
150200
| `CMT001` | A comment whose words are all already in the code below it | blocks |
151201
| `CMT002` | Section banners and position markers | blocks |
152-
| `CMT005` | Three or more commented-out lines of code | blocks |
202+
| `CMT005` | Three or more consecutive commented-out lines of code | blocks |
153203
| `CMT003` | `Step N:` and `Then,` narration | advises |
154204
| `CMT004` | Comments about the change rather than the code | advises |
155205
| `CMT006` | A comment block over 12 lines outside a header | advises |
156206
| `CMT007` | Doc tags that restate the signature | advises |
157207
| `CMT008` | `IMPORTANT:` / `CRITICAL:` with nothing to point at | advises |
208+
| `CMT009` | A `TODO` / `FIXME` / `HACK` naming no issue or link | advises |
209+
210+
Only three rules block, and which three was decided by running them over this
211+
repo. The others each have a legitimate form that no pattern can distinguish from
212+
the bad one, and blocking those would teach people to delete good comments to get a
213+
build green.
214+
215+
`CMT005` catches runs of three or more, so a single commented-out line passes. That
216+
is a deliberate trade against false positives on prose, and it means its low count
217+
is not evidence that the tree is free of dead code.
158218

159-
Only three rules block, and which three was decided by running all eight over
160-
this repo. The other five each have a legitimate form that no pattern can
161-
distinguish from the bad one, and blocking those would teach people to delete good
162-
comments to get a build green.
219+
Findings are scoped to comment *text* that is new, not just to lines git calls new.
220+
Reindenting or moving code does not resurface comments you did not write.
163221

164222
Advisory findings print and never fail anything. Fix them when they are right.
165223

scripts/lint/comment-lint.mjs

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2626
import { dirname, join, relative, resolve } from "node:path";
2727
import { fileURLToPath } from "node:url";
2828

29-
import { analyse, isExcludedPath, isGenerated, isTestPath, ruleLabel, RULES, SEVERITY } from "./comment-rules.mjs";
29+
import {
30+
analyse,
31+
commentBodiesOf,
32+
normaliseComment,
33+
isExcludedPath,
34+
isGenerated,
35+
isTestPath,
36+
ruleLabel,
37+
RULES,
38+
SEVERITY,
39+
} from "./comment-rules.mjs";
3040

3141
const HERE = dirname(fileURLToPath(import.meta.url));
3242
const REPO = resolve(HERE, "..", "..");
@@ -56,6 +66,12 @@ const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts";
5666
// vanished. Batching keeps each invocation well under the cap.
5767
const ARGV_BUDGET = 24_000;
5868

69+
// Memoised base-version comment text, keyed by ref:path. Declared up here with
70+
// the other module constants because the top-level run starts before the
71+
// function bodies below it are reached, and a `const` further down would
72+
// still be in its temporal dead zone.
73+
const baseComments = new Map();
74+
5975
const argv = process.argv.slice(2);
6076
const flags = new Set(argv.filter((a) => a.startsWith("--")));
6177
const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a));
@@ -88,18 +104,23 @@ function resolveScope() {
88104
// Paths plus --since is how the editor hook asks about one file: lint it, but
89105
// only the lines this session actually wrote.
90106
if (paths.length > 0 && flags.has("--since")) {
91-
return narrow(diffScope(["diff", "--unified=0", "--no-color", mergeBase(flagValue("--since"))]), paths);
107+
const ref = mergeBase(flagValue("--since"));
108+
return narrow(diffScope(["diff", "--unified=0", "--no-color", ref], ref), paths);
92109
}
93110
if (paths.length > 0) return { mode: "paths", files: paths, added: null };
94111

95-
if (flags.has("--since")) return diffScope(["diff", "--unified=0", "--no-color", mergeBase(flagValue("--since"))]);
112+
if (flags.has("--since")) {
113+
const ref = mergeBase(flagValue("--since"));
114+
return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
115+
}
96116

97117
// Always a working-tree comparison, never `--cached`. Findings are read from
98118
// the file on disk, so diffing the index instead would pair index line numbers
99119
// with working-tree content and silently mismatch once the two differ.
100120
// CI knows the target branch; a developer running this before a commit does not.
101121
const base = process.env.GITHUB_BASE_REF;
102-
return diffScope(["diff", "--unified=0", "--no-color", mergeBase(base ? `origin/${base}` : "HEAD")]);
122+
const ref = mergeBase(base ? `origin/${base}` : "HEAD");
123+
return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
103124
}
104125

105126
function mergeBase(ref) {
@@ -111,7 +132,7 @@ function mergeBase(ref) {
111132
}
112133
}
113134

114-
function diffScope(args) {
135+
function diffScope(args, base) {
115136
let diff;
116137
try {
117138
diff = git(args);
@@ -146,7 +167,7 @@ function diffScope(args) {
146167
added.set(file, allLinesOf(file));
147168
}
148169

149-
return { mode: "diff", files: [...added.keys()], added };
170+
return { mode: "diff", files: [...added.keys()], added, base };
150171
}
151172

152173
function untrackedFiles() {
@@ -163,7 +184,7 @@ function allLinesOf(file) {
163184
function narrow(scope, paths) {
164185
const wanted = new Set(paths);
165186
const added = new Map([...scope.added].filter(([file]) => wanted.has(file)));
166-
return { mode: "diff", files: [...added.keys()], added };
187+
return { mode: "diff", files: [...added.keys()], added, base: scope.base };
167188
}
168189

169190
function trackedFiles() {
@@ -186,11 +207,50 @@ function collect(scope) {
186207
if (ts.length > 0) results.push(...lintTypeScript(ts));
187208

188209
if (scope.added) {
189-
return results.filter((r) => scope.added.get(r.file)?.has(r.line));
210+
const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line));
211+
return onAddedLine.filter((r) => !existedAtBase(r, scope.base));
190212
}
191213
return results;
192214
}
193215

216+
// git marks a reindented or moved line as added, so line membership alone reports
217+
// comments nobody wrote: a whitespace-only reformat of PageImageLocator.java
218+
// turned a pre-existing banner into a blocking error. A finding only counts if
219+
// its comment text is not already in the file at the base.
220+
//
221+
// Cost is one `git show` per file, memoised. The one thing it gets wrong is
222+
// adding a further copy of an already-duplicated comment, which it treats as
223+
// pre-existing. That is the right way round for a blocking rule.
224+
225+
function existedAtBase(finding, base) {
226+
if (!base) return false;
227+
// Findings from the oxlint plugin arrive without their comment text, because
228+
// they cross a process boundary as a message string. Recover it from the file
229+
// on disk at the reported line, which is the same text the rule judged.
230+
const body = finding.body ?? currentLineBody(finding);
231+
if (!body) return false;
232+
const key = `${base}:${finding.file}`;
233+
if (!baseComments.has(key)) {
234+
let source = "";
235+
try {
236+
source = git(["show", key]);
237+
} catch {
238+
// Not in the base at all, so the whole file is new.
239+
}
240+
baseComments.set(key, commentBodiesOf(source));
241+
}
242+
return baseComments.get(key).has(body);
243+
}
244+
245+
function currentLineBody(finding) {
246+
try {
247+
const line = readFileSync(join(REPO, finding.file), "utf8").split(/\r?\n/)[finding.line - 1];
248+
return line === undefined ? "" : normaliseComment(line);
249+
} catch {
250+
return "";
251+
}
252+
}
253+
194254
function isLintable(file) {
195255
if (isExcludedPath(file)) return false;
196256

0 commit comments

Comments
 (0)