Commit df5f8dd
feat(linter): Add short descriptions to most lint rules. (#23365)
This adds short description fields using a codemod script and then
reviewing the result (by myself and with Claude to find any
possibly-weird ones and flag them for me) and then cleaning up some
overly-long or weirdly-formatted rule short descriptions (by myself).
This _does not_ cover all rules, as the codemod was not built to handle
the shared jest/vitest rules or "What it does" sections that were any
more complex than a single sentence. But it handles >75% of them. I'll
work on the remainder in the next few days after we get this part
merged.
I've also made modifications/edits to various rule descriptions and
short descriptions when I noticed as part of this work that they were
overly verbose, inconsistent with the description format of most other
rules, or grammatically incorrect.
<details>
<summary>Codemod script for reference</summary>
```js
#!/usr/bin/env node
// Insert a `short_description = "..."` argument into the
// `declare_oxc_lint!` macro for every rule whose inline-doc "What it does"
// section is a single sentence.
//
// Notes:
// - Only handles rules with INLINE `///` doc comments. Shared-docs rules
// (vitest/jest rules pulling from rules/shared) are intentionally skipped.
// - Markdown links `[text](url)` are flattened to `text`.
// - The insertion goes right after `version = "...",` so `short_description`
// sits at the end of the macro arguments. Indentation mirrors `version`.
// - `declare_oxc_lint!` must accept the `short_description = "..."` argument
// (see crates/oxc_macros/src/declare_oxc_lint.rs); otherwise the generated
// code will not compile.
//
// Usage: node insert_short_descriptions.mjs # dry-run, prints stats + sample diffs
// node insert_short_descriptions.mjs --write # actually modify files
// node insert_short_descriptions.mjs --show <substring> # preview generated insertions
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const RULES_DIR = "~/code/oxc/crates/oxc_linter/src/rules";
const SHARED_DIR = join(RULES_DIR, "shared");
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) out.push(...walk(p));
else if (entry.endsWith(".rs")) out.push(p);
}
return out;
}
// --- extraction (inline-only) --------------------------------------------
function ruleDocLines(source) {
const out = [];
for (const line of source.split("\n")) {
const m = line.match(/^\s*\/\/\/ ?(.*)$/);
if (m) out.push(m[1]);
}
return out;
}
function extractWhatItDoesFromMarkdown(lines) {
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*###\s+What it does\s*$/i.test(lines[i])) {
start = i + 1;
break;
}
}
if (start === -1) return null;
const body = [];
for (let i = start; i < lines.length; i++) {
if (/^\s*###\s+/.test(lines[i])) break;
body.push(lines[i]);
}
while (body.length && body[0].trim() === "") body.shift();
while (body.length && body[body.length - 1].trim() === "") body.pop();
return body;
}
// A "What it does" body that contains a markdown list or a URL can't be
// faithfully reduced to a one-line sentence — flattening it produces a run-on
// string (the whole list inlined) or a description that leads with a raw URL.
// Skip these rather than emit garbage.
function hasListOrUrl(bodyLines) {
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) return true; // markdown list item
if (/https?:\/\//.test(line)) return true; // bare/inline URL
}
return false;
}
function countSentences(bodyLines) {
const kept = [];
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (!inFence) kept.push(line);
}
let text = kept.join(" ").trim();
if (!text) return 0;
text = text.replace(/`[^`]*`/g, "X");
text = text.replace(/\b(e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|Jr|Sr)\./gi, "$1");
text = text.replace(/(\d)\.(\d)/g, "$1$2");
const matches = text.match(/[.!?]+(?=\s|$)/g);
return matches ? matches.length : 0;
}
// --- build short_description ---------------------------------------------
function bodyToShortDescription(bodyLines) {
const kept = [];
let inFence = false;
for (const line of bodyLines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (!inFence) kept.push(line);
}
let text = kept.join(" ").replace(/\s+/g, " ").trim();
let prev;
do {
prev = text;
text = text.replace(/\[([^\]]*)\]\(([^)]*)\)/g, "$1");
} while (text !== prev);
return text;
}
function escapeForRustString(s) {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
// --- macro surgery -------------------------------------------------------
function findMacroSpan(source) {
const idx = source.indexOf("declare_oxc_lint!");
if (idx === -1) return null;
let i = idx + "declare_oxc_lint!".length;
while (i < source.length && /\s/.test(source[i])) i++;
if (source[i] !== "(") return null;
const open = i;
let depth = 1;
i++;
while (i < source.length && depth > 0) {
const c = source[i];
if (c === '"') {
i++;
while (i < source.length && source[i] !== '"') {
if (source[i] === "\\") i++;
i++;
}
} else if (c === "/" && source[i + 1] === "/") {
while (i < source.length && source[i] !== "\n") i++;
} else if (c === "(") {
depth++;
} else if (c === ")") {
depth--;
if (depth === 0) return { open, close: i };
}
i++;
}
return null;
}
function insertShortDescription(source, sentence) {
const span = findMacroSpan(source);
if (!span) return null;
const inner = source.slice(span.open + 1, span.close);
// Don't touch rules that already have the field.
if (/\bshort_description\s*=/.test(inner)) return source;
// Match the last `version = "..."` assignment. Last-match wins so we skip
// any stray mentions in doc comments. The trailing comma (if any) is
// detected separately below — folding `\s*,?` into this regex would let
// `\s*` swallow the following newline when there is no comma, misplacing the
// insertion point.
const versionRe = /\bversion\s*=\s*"(?:[^"\\]|\\.)*"/g;
let lastMatch = null;
let vm;
while ((vm = versionRe.exec(inner)) !== null) lastMatch = vm;
if (!lastMatch) return null;
const matchStartAbs = span.open + 1 + lastMatch.index;
let matchEndAbs = matchStartAbs + lastMatch[0].length;
// Consume an optional trailing comma that sits on the same line (after only
// horizontal whitespace), leaving any newline untouched.
let j = matchEndAbs;
while (source[j] === " " || source[j] === "\t") j++;
const hasTrailingComma = source[j] === ",";
if (hasTrailingComma) matchEndAbs = j + 1;
let lineStart = matchStartAbs;
while (lineStart > 0 && source[lineStart - 1] !== "\n") lineStart--;
const indent = source.slice(lineStart, matchStartAbs);
const isOnOwnLine = /^\s*$/.test(indent);
const escaped = escapeForRustString(sentence);
let insertion;
if (isOnOwnLine) {
const prefix = hasTrailingComma ? "" : ",";
insertion = `${prefix}\n${indent}short_description = "${escaped}",`;
} else {
const prefix = hasTrailingComma ? " " : ", ";
insertion = `${prefix}short_description = "${escaped}",`;
}
return source.slice(0, matchEndAbs) + insertion + source.slice(matchEndAbs);
}
// --- main ----------------------------------------------------------------
const args = process.argv.slice(2);
const flag = (n) => args.includes(n);
const showIdx = args.indexOf("--show");
const showNeedle = showIdx >= 0 ? args[showIdx + 1] : null;
const writeMode = flag("--write");
const files = walk(RULES_DIR).sort();
let processed = 0;
let skippedShared = 0;
let skippedMulti = 0;
let skippedListOrUrl = 0;
let skippedMissing = 0;
let skippedAlreadyHas = 0;
let skippedNoVersion = 0;
let changed = 0;
const samples = [];
for (const file of files) {
if (file.startsWith(SHARED_DIR + "/")) continue;
const src = readFileSync(file, "utf8");
if (!/declare_oxc_lint!/.test(src)) continue;
processed++;
const docLines = ruleDocLines(src);
const body = extractWhatItDoesFromMarkdown(docLines);
if (body === null) { skippedShared++; continue; }
if (body.length === 0) { skippedMissing++; continue; }
if (hasListOrUrl(body)) { skippedListOrUrl++; continue; }
const n = countSentences(body);
if (n !== 1) { skippedMulti++; continue; }
const sentence = bodyToShortDescription(body);
if (!sentence) { skippedMissing++; continue; }
if (/\bshort_description\s*=/.test(src)) { skippedAlreadyHas++; continue; }
const updated = insertShortDescription(src, sentence);
if (updated === null) { skippedNoVersion++; continue; }
if (updated === src) { skippedAlreadyHas++; continue; }
if (showNeedle && file.includes(showNeedle)) {
samples.push({ file, sentence, updated });
} else if (samples.length < 3 && !showNeedle) {
samples.push({ file, sentence, updated });
}
if (writeMode) writeFileSync(file, updated);
changed++;
}
console.log(`Rule files scanned: ${processed}`);
console.log(`Would insert short_description: ${changed}`);
console.log(`Skipped (shared-docs / no inline): ${skippedShared}`);
console.log(`Skipped (multi-sentence / none): ${skippedMulti}`);
console.log(`Skipped (list or URL body): ${skippedListOrUrl}`);
console.log(`Skipped (empty/missing body): ${skippedMissing}`);
console.log(`Skipped (already has field): ${skippedAlreadyHas}`);
console.log(`Skipped (no version= found): ${skippedNoVersion}`);
console.log(writeMode ? `\nWROTE ${changed} files.` : `\nDry run. Pass --write to apply.`);
if (samples.length) {
console.log("\nSamples:\n");
for (const s of samples) {
console.log(`--- ${relative(RULES_DIR, s.file)} ---`);
console.log(` short_description = "${escapeForRustString(s.sentence)}"`);
const span = findMacroSpan(s.updated);
if (span) {
const chunk = s.updated.slice(span.open - 20, span.close + 2);
console.log("");
for (const line of chunk.split("\n")) console.log(" " + line);
}
console.log("");
}
}
```
</details>
---------
Co-authored-by: Cameron Clark <[email protected]>1 parent 40556ad commit df5f8dd
593 files changed
Lines changed: 845 additions & 261 deletions
File tree
- crates/oxc_linter/src
- rules
- eslint
- array_callback_return
- no_shadow
- no_unused_vars
- import
- jest
- jsdoc
- jsx_a11y
- nextjs
- node
- oxc
- promise
- react_perf
- react
- typescript
- unicorn
- vitest
- vue
- snapshots
- npm/oxlint
- tasks/website_linter/src/snapshots
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
| 122 | + | |
122 | 123 | | |
123 | 124 | | |
124 | 125 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
167 | 168 | | |
168 | 169 | | |
169 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
155 | 155 | | |
156 | 156 | | |
157 | 157 | | |
| 158 | + | |
158 | 159 | | |
159 | 160 | | |
160 | 161 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
130 | 130 | | |
131 | 131 | | |
132 | 132 | | |
| 133 | + | |
133 | 134 | | |
134 | 135 | | |
135 | 136 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| 151 | + | |
151 | 152 | | |
152 | 153 | | |
153 | 154 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
| 72 | + | |
72 | 73 | | |
73 | 74 | | |
74 | 75 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
176 | 176 | | |
177 | 177 | | |
178 | 178 | | |
| 179 | + | |
179 | 180 | | |
180 | 181 | | |
181 | 182 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| 89 | + | |
89 | 90 | | |
90 | 91 | | |
91 | 92 | | |
| |||
0 commit comments