forked from Stirling-Tools/Stirling-PDF
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcomment-rules.mjs
More file actions
500 lines (434 loc) · 20.8 KB
/
Copy pathcomment-rules.mjs
File metadata and controls
500 lines (434 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
// The comment-quality rule set, shared by both engines so a rule means the same
// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real
// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and
// has only lines). Neither engine ever scans the other's files, so the two can
// differ in precision without producing contradictory findings on one file.
//
// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule
// here without changing that document leaves the repo with two answers.
//
// Between them the engines read every comment form the repo writes: // and /* */,
// Javadoc and JSDoc, JSX comments, # and Python docstrings.
export const SEVERITY = { ERROR: "error", WARN: "warn" };
// Every rule blocks. A rule that only warns is a rule nobody acts on, so a
// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the
// line with comment-lint-allow and say why. Each rule below carries the readings
// it deliberately excludes, which is where to start when one misfires.
export const RULES = {
CMT001: { name: "restates-code", severity: SEVERITY.ERROR },
CMT002: { name: "banner", severity: SEVERITY.ERROR },
CMT003: { name: "step-narration", severity: SEVERITY.ERROR },
CMT004: { name: "diff-narration", severity: SEVERITY.ERROR },
CMT005: { name: "dead-code", severity: SEVERITY.ERROR },
CMT006: { name: "block-too-long", severity: SEVERITY.ERROR },
CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR },
CMT008: { name: "bad-allow", severity: SEVERITY.ERROR },
CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR },
};
export const MAX_BLOCK_LINES = 12;
// CMT001 compares a comment against the code it introduces. Both sides are
// reduced to the same shape first: lowercased, camel/snake/kebab split into
// words, stop words and short words dropped. What survives is the information
// each side actually carries, so "Handle drag start" and `handleDragStart` land
// on the same set and the comment is shown to add nothing.
const STOP_WORDS = new Set(
(
"a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" +
" does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" +
" most other another same when while where which what who how why here there now next finally first second third let const var function" +
" return set get"
).split(" "),
);
const WORD_RE = /[a-z][a-z0-9]*/g;
export function contentWords(text) {
return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
}
export function identWords(text) {
const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " ");
return contentWords(split);
}
// Sentence punctuation marks prose, which is usually saying something the code
// does not. A single trailing full stop does not count.
const PROSE_PUNCT = /[.;:?!]/;
const MAX_RESTATE_WORDS = 6;
// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than
// describe the line beneath. Exempt only as a bare marker, so
// `// Assert the cap is clamped to the tier maximum` is prose and judged on its
// merits.
const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i;
const MAX_MARKER_WORDS = 4;
export function restatesCode(body, codeText) {
if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false;
if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false;
const comment = contentWords(body);
if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false;
const code = identWords(codeText);
if (code.length === 0) return false;
// Prefix matching either way, so "config" covers "configuration" and vice versa.
return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k)));
}
const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/;
const SECTION_LABEL = new RegExp(
"^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" +
"|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" +
"|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" +
"\\s*(section|area|block)?\\s*$",
"i",
);
export function isBanner(body) {
if (RULE_CHARS.test(body.trim())) return true;
// A label wrapped in decoration is still a label: strip the decoration first.
const bare = body
.replace(/[=~_*#+\-─-╿]/g, " ")
.replace(/\s+/g, " ")
.trim();
return bare.length > 0 && SECTION_LABEL.test(bare);
}
// A bare "1." is not narration: numbered lists are how a doc block enumerates
// conditions or alternatives, and matching them buries the rule in false
// positives. Only the explicit step form and sequencing adverbs qualify, and the
// number needs a separator after it, so a wrapped line beginning "step 2 unmounts
// + remounts the panel" reads as the prose it is.
const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i;
export function isStepNarration(body) {
return STEP.test(body.trim());
}
// Only phrases that can be talking about the code's own past. Excluded because
// each has an innocent reading that fires constantly:
// "used to" alone - "Used to clamp the live line" means "is used to"
// "previously" alone - "re-show even if previously dismissed" is runtime state
// "was called" - collides with "verify getSession was called"
// "left over from" - "no cards left over from the unfiltered grid"
const DIFF_NARRATION = new RegExp(
"\\b((this|it|we|they|that) used to|used to (be|live|sit)" +
"|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" +
"|renamed from|was (previously|formerly) (called|named|known)" +
"|instead of the old|has been (removed|replaced) )",
"i",
);
const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i;
export function isDiffNarration(body) {
const t = body.trim();
return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t);
}
const CODE_KEYWORD = new RegExp(
"^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" +
"|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" +
"|throw|new|super|this|@[A-Za-z])\\b",
);
const STATEMENT_TAIL = /[;{}]\s*$/;
const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/;
const ASSIGNMENT = /\S\s*=\s*\S/;
export function looksLikeCode(line) {
const t = line.trim();
if (t.length === 0) return false;
if (CODE_KEYWORD.test(t)) return true;
if (CALL_ONLY.test(t)) return true;
return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t);
}
export const MIN_DEAD_CODE_RUN = 3;
const DEAD_CODE_SHARE = 2 / 3;
export function isDeadCodeRun(bodies) {
if (bodies.length < MIN_DEAD_CODE_RUN) return false;
const codeish = bodies.filter(looksLikeCode).length;
return codeish / bodies.length >= DEAD_CODE_SHARE;
}
// Every documented-parameter form this repo writes, so the rule is not quietly
// Javadoc-only:
// Javadoc / JSDoc @param blob The blob to download
// Sphinx :param blob: The blob to download
// Google docstring blob: The blob to download (under an Args: heading)
// NumPy style is deliberately absent: it splits the name and the description
// across two lines, and there is one instance of it in the tree.
const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/;
const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/;
const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/;
const RETURN_TAG = /^@returns?\s+(.+)$/;
const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/;
// Description adds nothing when every word in it already appears in the thing
// being described. `@param blob - The blob to download` is the canonical case.
//
// No native linter covers this. eslint-plugin-jsdoc's require-param-description,
// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a
// description exists, not whether it says anything.
export function docRestatesSignature(body, ownerName = "") {
const t = body.trim();
for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) {
const match = pattern.exec(t);
if (!match) continue;
const [, name, description] = match;
// Google form is just `name: description`, which also matches ordinary prose
// containing a colon. Require the description to be short and unpunctuated so
// "Note: the cap is clamped" is not read as a parameter called "note".
if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false;
return addsNothing(description, name, 5);
}
const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t);
if (returns && ownerName) return addsNothing(returns[1], ownerName, 4);
return false;
}
function addsNothing(description, subject, limit) {
const words = contentWords(description);
if (words.length === 0 || words.length > limit) return false;
const known = identWords(subject);
return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k)));
}
// A TODO with no reference has nothing that will ever close it. An owner is not
// accepted in its place: a username goes stale when someone leaves and means
// nothing to an outside contributor, while an issue outlives both.
//
// Anchored at the start, so this catches a comment that *is* a TODO rather than
// prose that mentions the word.
const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/;
// What counts as something that will close it: an issue, a link, or a security
// advisory. Checked across the whole comment run, so the reference can sit on a
// continuation line.
const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/;
export function isUnownedTodo(body, runText = body) {
return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText);
}
// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or
// on the line above it. There is deliberately no form that disables every rule.
//
// The whole comment must be the directive. Matching it anywhere in the text meant
// prose that merely mentions the syntax silenced a rule, which this file's own
// paragraph above did.
const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i;
export function isDirective(body) {
return DIRECTIVE.test(body.trim());
}
// A directive that names nothing real, or that suppresses nothing, is dead
// configuration: it reads as a silenced rule while silencing nothing, and it
// blinds the line for whoever inherits it. Reported for the same reason ESLint
// has --report-unused-disable-directives and ruff has RUF100.
class Allowance {
constructor(directives) {
this.entries = [];
for (const directive of directives) {
for (const token of directiveTokens(directive.body)) {
this.entries.push({ token, directive, known: token in RULES, used: false });
}
}
}
// Called only once a rule has decided it would report, so a directive counts
// as used when it actually silenced something. Asking before the rule decided
// marked every consulted directive as used, which hid the unused ones.
suppresses(rule) {
let allowed = false;
for (const entry of this.entries) {
if (entry.token !== rule) continue;
entry.used = true;
allowed = true;
}
return allowed;
}
reportUnused(report) {
for (const entry of this.entries) {
if (entry.used) continue;
const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`;
report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body);
}
}
}
// Every token a directive names, valid or not, so an unknown one is reported
// rather than quietly ignored. Matching only real ids would let `CMT999` through
// as a silent no-op: it looks like a rule and silences nothing.
export function directiveTokens(body) {
const match = DIRECTIVE.exec(body.trim());
if (!match) return [];
return match[1]
.split(",")
.map((token) => token.trim().toUpperCase())
.filter(Boolean);
}
// Generated files carry whatever the generator emits, and editing them to
// satisfy a lint rule would be undone on the next regeneration.
const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i;
const GENERATED_HEADER_LINES = 10;
export function isGenerated(source) {
return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n"));
}
export const EXCLUDED_PATHS = [
/(^|\/)node_modules\//,
/(^|\/)dist(-\w+)?\//,
/(^|\/)build\//,
/(^|\/)target\//,
/(^|\/)vendor\//,
/pdfjs/i,
/thirdParty/i,
/\.min\./,
/src-tauri\/gen\//,
/public\/locales\//,
/\.d\.ts$/,
/(^|\/)storybook-static\//,
/(^|\/)playwright-report\//,
/(^|\/)org\/apache\//,
];
export function isExcludedPath(file) {
const normalised = file.replace(/\\/g, "/");
return EXCLUDED_PATHS.some((re) => re.test(normalised));
}
// Comment text reduced to what a reader would call "the same comment": trimmed,
// whitespace collapsed, comment markers and decoration stripped. Both sides of
// the pre-existing check normalise through here so indentation and marker style
// cannot make an unchanged comment look new.
export function normaliseComment(text) {
return String(text)
.replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ")
.replace(/\*+\/[\s}]*$/gm, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
// Every comment in a source file, normalised. Deliberately permissive and
// language-agnostic: it only ever decides whether a finding is pre-existing, so
// over-matching suppresses a duplicate comment and under-matching just reports
// something the author can look at.
export function commentBodiesOf(source) {
const bodies = new Set();
for (const raw of source.split(/\r?\n/)) {
const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw);
if (!marker) continue;
const body = normaliseComment(raw.slice(marker.index));
if (body.length > 0) bodies.add(body);
}
return bodies;
}
export function ruleLabel(id) {
return `${id} ${RULES[id].name}`;
}
// Both engines funnel into this. They differ only in how they build `runs`: the
// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping
// the rule application here is what stops the two drifting apart.
//
// A "run" is a group of comment lines with no code between them, which is the
// unit CMT005 and CMT006 judge. Shape:
// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] }
// `line` is 1-based to match every editor and every diff.
export function analyse({ lines, runs, isTestFile = false }) {
const findings = [];
// `body` is the comment's own text, kept alongside the formatted detail so the
// caller can ask whether this exact comment already existed before the change.
// That is what stops a reindent or a code move reporting comments nobody wrote.
const report = (rule, line, column, detail, body) => {
if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return;
findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity });
};
for (const run of runs) {
// A directive is scaffolding, not content. Leaving it in the run made it two
// lines long, and CMT001 only judges a one-line run, so any directive
// silenced CMT001 whatever rule it named.
const directives = run.lines.filter((l) => isDirective(l.body));
const content = run.lines.filter((l) => !isDirective(l.body));
const allowed = new Allowance(directives);
if (content.length === 0) {
allowed.reportUnused(report);
continue;
}
const bodies = content.map((l) => l.body);
const runText = bodies.join("\n");
const first = content[0];
// Only CMT004 and CMT009 judge a trailing comment. The others depend on the
// comment introducing the code below it, and a trailing comment sits beside
// it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is
// the kind of lower-altitude fact the standard asks for.
if (run.trailing) {
for (const entry of content) {
const body = entry.body.trim();
if (body.length === 0) continue;
if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
report("CMT004", entry.line, entry.column, truncate(body), body);
continue;
}
if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
report("CMT009", entry.line, entry.column, truncate(body), body);
}
}
allowed.reportUnused(report);
continue;
}
if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) {
report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText);
allowed.reportUnused(report);
continue; // Every other rule would pile onto the same block of dead code.
}
// A doc block is exempt: the standard asks for thorough contracts, so capping
// their length would argue with itself. This judges runs of implementation
// comment, where an essay means the code needs restructuring.
const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES;
if (essay && !allowed.suppresses("CMT006")) {
report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText);
}
const owner = run.kind === "line" ? "" : nextCodeLine(lines, run);
for (const entry of content) {
const body = entry.body.trim();
if (body.length === 0) continue;
if (isBanner(body) && !allowed.suppresses("CMT002")) {
report("CMT002", entry.line, entry.column, truncate(body), body);
continue;
}
if (isStepNarration(body) && !allowed.suppresses("CMT003")) {
report("CMT003", entry.line, entry.column, truncate(body), body);
continue;
}
if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
report("CMT004", entry.line, entry.column, truncate(body), body);
continue;
}
if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) {
report("CMT007", entry.line, entry.column, truncate(body), body);
continue;
}
if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
report("CMT009", entry.line, entry.column, truncate(body), body);
continue;
}
}
// CMT001 judges a whole single-line run against the code it introduces, so
// a two-line comment that happens to echo one identifier is left alone.
// A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above
// `<CapEditor …>` is caught. A doc block does not: it is a contract, and
// CMT007 is the rule that judges those.
if (run.kind !== "doc" && content.length === 1) {
const entry = first;
const body = entry.body.trim();
const code = nextCodeLine(lines, run);
if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) {
report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body);
}
}
allowed.reportUnused(report);
}
return findings.sort((a, b) => a.line - b.line || a.column - b.column);
}
// A file header is allowed to be as long as it needs to be.
const FILE_HEADER_LINES = 5;
const DETAIL_WIDTH = 58;
// Both of these say something real in a test and nothing anywhere else. A
// regression test explains itself by describing the old behaviour, and the e2e
// specs number their comments to match a written manual test procedure.
const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]);
function precedingLine(lines, startLine) {
return lines[startLine - 2] ?? "";
}
function nextCodeLine(lines, run) {
const commentLines = new Set(run.lines.map((l) => l.line));
for (let i = run.startLine; i < lines.length; i++) {
const lineNumber = i + 1;
if (commentLines.has(lineNumber)) continue;
const text = lines[i]?.trim() ?? "";
if (text.length === 0) continue;
if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue;
if (text === "}" || text === "};" || text === ")" || text === ");") return "";
return text;
}
return "";
}
function truncate(text) {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat;
}
export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/;
export function isTestPath(file) {
return TEST_FILE.test(file.replace(/\\/g, "/"));
}