1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const TS_FILE_PATTERN = /\.[cm]?tsx?$/u;
4const DIRECTIVE_PATTERN =
5 /^\s*(?:(?:oxlint|eslint)-|@ts-|@effect-diagnostics|prettier-ignore|biome-ignore|c8 |istanbul |#(?:end)?region)/u;
6const DIVIDER_LINE_PATTERN = /^(?:[-=*#~_/+]{3,}.*|.*[-=*#~_]{3,})$/u;
7const TEST_STEP_PATTERN = /^(?:arrange|act|assert|given|when|then)\s*[:.]?$/iu;
8const TASK_MARKER_PATTERN = /\b(?:TODO|FIXME|XXX|HACK)\b/u;
9// `regression: #123` and `quarantine: #123` test markers exist to carry the number.
10const TEST_MARKER_PATTERN = /^\s*(?:regression|quarantine):/u;
11const TASK_LINK_PATTERN = /#\d+|https?:\/\//u;
12// `owner/repo#123` names an external tracker; a bare `#123` is our own history.
13const BARE_ISSUE_REF_PATTERN = /(?<![\w/&#-])#[1-9]\d{2,4}(?![\w-])/u;
14const CODE_SPAN_PATTERN = /`[^`]*`/gu;
15const PUBLIC_BOILERPLATE_PATTERN = /^@public\s+Service construction is part of the canonical/u;
16// Comments grew back as long why-blocks; past this, the reason needs cutting, not more context.
17const MAX_COMMENT_LINES = 4;
18const LICENSE_PATTERN = /^!|@license|\bcopyright\b|\bSPDX-License-Identifier\b/iu;
19const JSDOC_TYPE_PATTERN = /^@(?:param|returns?|type|typedef)\s+\{/u;
20const EMPTY_JSDOC_TAG_PATTERN = /^@(?:param(?:\s+\[?[\w$.]+\]?)?|returns?)$/u;
21const COMMENTED_CODE_PATTERN =
22 /^(?:(?:(?:const|let|var)\s+[\w${}[\], ]+=.*|import\s.+\sfrom\s+["'].+["']|(?:await\s+|return\s+)?[\w$.]+\(.*\)|[\w$.[\]]+\s*=\s*.+);|(?:if|for|while|switch)\s*\(.*\)\s*\{|\}(?:\s*else\s*\{)?)$/u;
23const DECLARATION_PATTERN =
24 /^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:static\s+)?(?:readonly\s+)?(?:(?:const|let|var|function\*?|class|interface|type|enum)\s+)?([A-Za-z_$][\w$]*)\s*[?!]?\s*[:=(<{]/u;
25const FILLER_WORDS = new Set(
26 "a an the of for to in on at by with from and or is are be this that it its as get set return create new current value given whether".split(
27 " ",
28 ),
29);
30
31const stem = (word: string) => (word.length > 3 ? word.replace(/([^s])s$/u, "$1") : word);
32const words = (text: string) =>
33 (text.toLowerCase().match(/[a-z0-9]+/gu) ?? [])
34 .filter((word) => !FILLER_WORDS.has(word))
35 .map(stem)
36 .filter((word) => !FILLER_WORDS.has(word));
37const identifierWords = (name: string) =>
38 words(name.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2"));
39
40const commentLines = (comment: ESTree.Comment) =>
41 comment.value
42 .split("\n")
43 .map((line) => line.replace(/^\s*\*?\s?/u, "").trim())
44 .filter((line) => line !== "");
45
46export default defineRule({
47 meta: {
48 type: "suggestion",
49 docs: {
50 description: "Reject comments that only repeat the code, divide sections, or narrate tests.",
51 },
52 },
53 create(context) {
54 const { sourceCode } = context;
55 const isTypeScript = TS_FILE_PATTERN.test(context.filename);
56
57 // Consecutive `//` lines at one indent read as one comment.
58 const groups: Array<{ comments: Array<ESTree.Comment>; lines: Array<string> }> = [];
59 for (const comment of sourceCode.getAllComments()) {
60 const previous = groups.at(-1)?.comments.at(-1);
61 const continues =
62 comment.type === "Line" &&
63 previous?.type === "Line" &&
64 previous.loc.end.line === comment.loc.start.line - 1 &&
65 previous.loc.start.column === comment.loc.start.column;
66 if (continues) {
67 groups.at(-1)!.comments.push(comment);
68 groups.at(-1)!.lines.push(...commentLines(comment));
69 } else {
70 groups.push({ comments: [comment], lines: commentLines(comment) });
71 }
72 }
73
74 const restatesNextName = (lastLine: number, lines: ReadonlyArray<string>) => {
75 if (lines.some((line) => line.startsWith("@"))) return false;
76 const name = DECLARATION_PATTERN.exec(sourceCode.lines[lastLine] ?? "")?.[1];
77 if (name === undefined) return false;
78 const content = words(lines.join(" "));
79 if (content.length === 0 || content.length > 6) return false;
80 const nameWords = new Set(identifierWords(name));
81 return content.every((word) => nameWords.has(word));
82 };
83
84 const problem = (comments: ReadonlyArray<ESTree.Comment>, lines: ReadonlyArray<string>) => {
85 const text = comments.map((comment) => comment.value).join("\n");
86 if (lines.length === 0 || comments.some((comment) => DIRECTIVE_PATTERN.test(comment.value))) {
87 return undefined;
88 }
89 const isLine = comments[0]!.type === "Line";
90 if (lines.some((line) => DIVIDER_LINE_PATTERN.test(line))) {
91 return "Drop the section divider; split the file or let the code's structure show the grouping.";
92 }
93 if (isLine && lines.length === 1 && TEST_STEP_PATTERN.test(lines[0]!)) {
94 return "Drop the test step label.";
95 }
96 if (TASK_MARKER_PATTERN.test(text) && !TASK_LINK_PATTERN.test(text)) {
97 return "Link the issue that tracks this, or drop the marker.";
98 }
99 if (
100 !TASK_MARKER_PATTERN.test(text) &&
101 !TEST_MARKER_PATTERN.test(text) &&
102 BARE_ISSUE_REF_PATTERN.test(text.replace(CODE_SPAN_PATTERN, ""))
103 ) {
104 return "Drop the PR or issue number; git history records it. Keep the reason.";
105 }
106 if (isTypeScript && lines.some((line) => JSDOC_TYPE_PATTERN.test(line))) {
107 return "Drop the JSDoc type; TypeScript already has it.";
108 }
109 if (lines.some((line) => PUBLIC_BOILERPLATE_PATTERN.test(line))) {
110 return "Keep `@public` alone; the boilerplate after it says nothing about this export.";
111 }
112 if (lines.some((line) => EMPTY_JSDOC_TAG_PATTERN.test(line))) {
113 return "Drop the JSDoc tag that has no description.";
114 }
115 if (isLine && lines.every((line) => COMMENTED_CODE_PATTERN.test(line))) {
116 return "Delete commented-out code; git keeps the history.";
117 }
118 if (restatesNextName(comments.at(-1)!.loc.end.line, lines)) {
119 return "Drop the comment; it only repeats the name below it. Say why, or say nothing.";
120 }
121 if (lines.length > MAX_COMMENT_LINES && !LICENSE_PATTERN.test(text)) {
122 return `Cut this to ${MAX_COMMENT_LINES} lines or fewer: keep the constraint, drop the background.`;
123 }
124 return undefined;
125 };
126
127 return {
128 Program() {
129 for (const { comments, lines } of groups) {
130 const message = problem(comments, lines);
131 if (message === undefined) continue;
132 context.report({
133 loc: { start: comments[0]!.loc.start, end: comments.at(-1)!.loc.end },
134 message,
135 });
136 }
137 },
138 };
139 },
140});