@lewishowles/lint-config 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0: 2026-09-11
4
+
5
+ ### Changes
6
+
7
+ - `comments/variable-declarations`: add a `rootOnly` option; `comments.json` enables it for test files so only root-level variables need comments there. If you override `comments.json` rules, concatenate the test-file override (see README).
8
+
9
+ ### Fixes
10
+
11
+ - Measure comment width in display columns, with tabs counted as four, so indented comments wrap and validate against the visible 80-column limit.
12
+
13
+ ## 0.4.0: 2026-08-28
14
+
15
+ ### New rules
16
+
17
+ - Added `comments/class-documentation`: requires a block comment on class declarations and const-assigned class expressions, JSDoc on constructors and ordinary methods, return-aware JSDoc on getters and setters, and line comments on instance and static fields. Ships in the opt-in `comments.json` layer.
18
+
19
+ ### Fixes
20
+
21
+ - A directive comment (`eslint-`, `oxlint-`, and similar) sitting between a doc comment and its code is now reported, instead of the doc being treated as attached to the code.
22
+ - Documentation placed before an exported declaration is now recognised.
23
+ - `using` and `await using` declarations now require a preceding line comment, like `const` and `let`.
24
+
25
+ Earlier versions predate this changelog; see the git history for their changes.
package/README.md CHANGED
@@ -92,6 +92,46 @@ The `comments/configured-api-calls` rule requires an immediately preceding line
92
92
  }
93
93
  ```
94
94
 
95
+ The `comments/variable-declarations` rule accepts a `rootOnly` option to require comments only before root-level `const`, `let`, and `using` declarations. The comments layer already enables `rootOnly` for test files matched by `**/*.test.*`, `**/*.spec.*`, and `**/test/**`; other files keep the default of `false`. Override it for another scope or to change the behaviour:
96
+
97
+ ```json
98
+ {
99
+ "rules": {
100
+ "comments/variable-declarations": ["error", { "rootOnly": true }]
101
+ }
102
+ }
103
+ ```
104
+
105
+ The `comments/class-documentation` rule requires an immediately preceding block comment before class declarations and const-assigned class expressions. Constructors, methods, getters, and setters require full JSDoc; constructors never need an `@returns` tag, and getters and setters need one only when they return a value. Instance and static fields require an immediately preceding line comment.
106
+
107
+ ### `vp check` configuration
108
+
109
+ The `vp check` and `vp lint` commands read Oxlint settings only from a `lint` block in `vite.config.js`; they do not read `.oxlintrc.json` directly. If `vite.config.js` is missing, they silently use an unrelated default configuration. You may still see plausible warnings and exit codes, but none of your rules are applied.
110
+
111
+ Each consuming repo needs a `vite.config.js` with a `lint` block built from the config layer(s) and the repo's `.oxlintrc.json`, imported as JSON. Concatenate the `overrides` arrays from every imported layer before the local overrides so shared file-specific rules still apply. Verify the setup with `vp lint --print-config <file>` and check that your real values, not defaults, are active.
112
+
113
+ ```js
114
+ import { defineConfig } from "vite-plus";
115
+ import lintConfigBase from "@lewishowles/lint-config/base.json" with { type: "json" };
116
+ import lintConfigComments from "@lewishowles/lint-config/comments.json" with { type: "json" };
117
+ import oxlintrc from "./.oxlintrc.json" with { type: "json" };
118
+
119
+ const lint = {
120
+ ...lintConfigBase,
121
+ env: oxlintrc.env,
122
+ ignorePatterns: oxlintrc.ignorePatterns,
123
+ jsPlugins: [...lintConfigBase.jsPlugins, ...lintConfigComments.jsPlugins],
124
+ overrides: [
125
+ ...(lintConfigBase.overrides ?? []),
126
+ ...(lintConfigComments.overrides ?? []),
127
+ ...(oxlintrc.overrides ?? []),
128
+ ],
129
+ rules: { ...lintConfigBase.rules, ...lintConfigComments.rules },
130
+ };
131
+
132
+ export default defineConfig({ lint });
133
+ ```
134
+
95
135
  ## Customising
96
136
 
97
137
  Your project's `.oxlintrc.json` can override rules, add ignore patterns, add overrides, or add plugins on top of the shared layer.
@@ -122,7 +162,7 @@ Ignore patterns are project-specific, so they always live in your project config
122
162
 
123
163
  ### Adding overrides
124
164
 
125
- Overrides are additive: shared overrides (if any) still apply, and your local ones are appended.
165
+ Overrides are additive in the `vite.config.js` lint block: shared overrides still apply, and your local ones are appended.
126
166
 
127
167
  ```json
128
168
  {
@@ -159,7 +199,7 @@ The base layer sorts named members within each import statement, but leaves decl
159
199
  ## What stays repo-local
160
200
 
161
201
  - `ignorePatterns`, since every project has different build output and tool directories
162
- - `overrides` for project-specific directories (e.g. `bin/**/*.js`, `src/cli/**/*.js`, `src/playwright/**/*.js`), since the file paths differ per project and can't be generalised
202
+ - `overrides` for project-specific directories (e.g. `bin/**/*.js`, `src/cli/**/*.js`, `src/playwright/**/*.js`), since the file paths differ per project and can't be generalised. The `vite.config.js` lint block appends these local entries after the shared layer overrides.
163
203
  - Rule relaxations for specific file patterns (e.g. turning off `vite-plus/prefer-vite-plus-imports` in generated `.d.ts` files)
164
204
  - Additional plugins, only for projects that need them
165
205
 
@@ -168,6 +208,6 @@ The base layer sorts named members within each import statement, but leaves decl
168
208
  When a project's `.oxlintrc.json` extends a shared layer:
169
209
 
170
210
  - **Rules** shallow-merge by key: your value wins for any rule defined in both
171
- - **Overrides** are additive: both shared and local `overrides` entries apply, including any `env` declared inside an override block
211
+ - **Overrides** are additive in the `vite.config.js` lint block: it concatenates shared layer and local `overrides` entries, including any `env` declared inside an override block
172
212
  - **Plugins** are additive: both shared and local `plugins`/`jsPlugins` load, deduplicated
173
213
  - **`env`, `globals`, and `ignorePatterns` don't merge through `extends` at all** (an open Oxlint bug), which is why the usage examples above redeclare `env`/`globals` directly. See [known limitations](docs/limitations.md) for the full detail, including the separate `vite-plus` caveat around resolving `extends` paths.
@@ -1,4 +1,5 @@
1
1
  import blockComments from "./rules/block-comments.js";
2
+ import classDocumentation from "./rules/class-documentation.js";
2
3
  import configuredApiCalls from "./rules/configured-api-calls.js";
3
4
  import functionDocumentation from "./rules/function-documentation.js";
4
5
  import jsdocTagFormatting from "./rules/jsdoc-tag-formatting.js";
@@ -15,6 +16,7 @@ export default {
15
16
  meta: { name: "comments" },
16
17
  rules: {
17
18
  "block-comments": blockComments,
19
+ "class-documentation": classDocumentation,
18
20
  "configured-api-calls": configuredApiCalls,
19
21
  "function-documentation": functionDocumentation,
20
22
  "jsdoc-tag-formatting": jsdocTagFormatting,
@@ -40,7 +40,8 @@ export default {
40
40
  continue;
41
41
  }
42
42
 
43
- // The comment, with its block structure and delimiters normalised.
43
+ // The comment, with its block structure and delimiters
44
+ // normalised.
44
45
  const formattedComment = formatJSDocBlockStructure(context.sourceCode, comment);
45
46
 
46
47
  if (formattedComment === commentText) {
@@ -0,0 +1,134 @@
1
+ import { getDocumentationNode, reportFunctionDocumentation } from "../utils/documentation.js";
2
+ import { hasImmediateBlockComment, hasImmediateLineComment } from "../utils/source.js";
3
+
4
+ /**
5
+ * Create the class-documentation rule.
6
+ *
7
+ * @returns {object}
8
+ * The Oxlint rule definition.
9
+ */
10
+ export default {
11
+ meta: {
12
+ docs: {
13
+ description:
14
+ "Require documentation for classes, their methods, getters, setters, and fields.",
15
+ },
16
+ type: "suggestion",
17
+ },
18
+ /**
19
+ * Create the rule's node visitors.
20
+ *
21
+ * @param {object} context
22
+ * The Oxlint rule context.
23
+ *
24
+ * @returns {object}
25
+ * The visitor functions for this rule.
26
+ */
27
+ createOnce(context) {
28
+ return {
29
+ /**
30
+ * Check a class declaration for a preceding block comment.
31
+ *
32
+ * @param {object} node
33
+ * The class declaration node.
34
+ */
35
+ ClassDeclaration(node) {
36
+ // Resolves any export wrapper before checking for
37
+ // documentation.
38
+ const documentationNode = getDocumentationNode(node);
39
+
40
+ if (!hasImmediateBlockComment(context.sourceCode, documentationNode)) {
41
+ context.report({
42
+ message: "Classes require an immediately preceding block comment.",
43
+ node: documentationNode,
44
+ });
45
+ }
46
+ },
47
+ /**
48
+ * Check class methods for required JSDoc blocks and tags.
49
+ * Constructors are exempt from the @returns requirement.
50
+ *
51
+ * @param {object} node
52
+ * The method-definition node.
53
+ */
54
+ MethodDefinition(node) {
55
+ if (node.kind === "constructor") {
56
+ reportFunctionDocumentation(context, node, node.value, {
57
+ requiresReturns: false,
58
+ subject: "Constructors",
59
+ });
60
+
61
+ return;
62
+ }
63
+
64
+ if (node.kind === "get") {
65
+ reportFunctionDocumentation(context, node, node.value, {
66
+ subject: "Getters",
67
+ });
68
+
69
+ return;
70
+ }
71
+
72
+ if (node.kind === "set") {
73
+ reportFunctionDocumentation(context, node, node.value, {
74
+ subject: "Setters",
75
+ });
76
+
77
+ return;
78
+ }
79
+
80
+ if (node.kind === "method") {
81
+ reportFunctionDocumentation(context, node, node.value, {
82
+ subject: "Methods",
83
+ });
84
+ }
85
+ },
86
+ /**
87
+ * Check instance and static fields for a preceding line comment.
88
+ *
89
+ * @param {object} node
90
+ * The property-definition node.
91
+ */
92
+ PropertyDefinition(node) {
93
+ if (hasImmediateLineComment(context.sourceCode, node)) {
94
+ return;
95
+ }
96
+
97
+ // Static and instance fields share the requirement; only the
98
+ // wording differs.
99
+ const message = node.static
100
+ ? "Static fields require an immediately preceding line comment."
101
+ : "Instance fields require an immediately preceding line comment.";
102
+
103
+ context.report({ message, node });
104
+ },
105
+ /**
106
+ * Check a const class expression for a preceding block comment.
107
+ *
108
+ * @param {object} node
109
+ * The variable declarator node.
110
+ */
111
+ VariableDeclarator(node) {
112
+ if (
113
+ node.parent?.kind !== "const" ||
114
+ node.parent.declarations.length !== 1 ||
115
+ node.id?.type !== "Identifier" ||
116
+ node.init?.type !== "ClassExpression"
117
+ ) {
118
+ return;
119
+ }
120
+
121
+ // Resolves any export wrapper before checking for
122
+ // documentation.
123
+ const documentationNode = getDocumentationNode(node.parent);
124
+
125
+ if (!hasImmediateBlockComment(context.sourceCode, documentationNode)) {
126
+ context.report({
127
+ message: "Classes require an immediately preceding block comment.",
128
+ node: documentationNode,
129
+ });
130
+ }
131
+ },
132
+ };
133
+ },
134
+ };
@@ -1,3 +1,4 @@
1
+ import { getDocumentationNode } from "../utils/documentation.js";
1
2
  import { hasImmediateLineComment } from "../utils/source.js";
2
3
 
3
4
  // The built-in APIs that require a preceding comment by default.
@@ -44,9 +45,14 @@ function hasDocumentedVariableDeclaration(sourceCode, node) {
44
45
  // The declarator's enclosing variable declaration.
45
46
  const declaration = declarator.parent;
46
47
 
47
- return (
48
- declaration?.type === "VariableDeclaration" && hasImmediateLineComment(sourceCode, declaration)
49
- );
48
+ if (declaration?.type !== "VariableDeclaration") {
49
+ return false;
50
+ }
51
+
52
+ // Resolves any export wrapper before checking for documentation.
53
+ const documentationNode = getDocumentationNode(declaration);
54
+
55
+ return hasImmediateLineComment(sourceCode, documentationNode);
50
56
  }
51
57
 
52
58
  /**
@@ -111,8 +117,10 @@ export default {
111
117
  * The call expression node.
112
118
  */
113
119
  CallExpression(node) {
114
- // Read fresh for every call: createOnce's visitor is shared across every file
115
- // in the run, so caching this at closure-creation time would freeze the first
120
+ // Read fresh for every call: createOnce's visitor is shared
121
+ // across every file
122
+ // in the run, so caching this at closure-creation time would
123
+ // freeze the first
116
124
  // file's options.
117
125
  const configuredApis = getConfiguredApis(context);
118
126
 
@@ -40,7 +40,8 @@ export default {
40
40
  continue;
41
41
  }
42
42
 
43
- // The comment, with its tag spacing, order, and grouping normalised.
43
+ // The comment, with its tag spacing, order, and grouping
44
+ // normalised.
44
45
  const formattedComment = formatJSDocTagFormatting(context.sourceCode, comment);
45
46
 
46
47
  if (formattedComment === commentText) {
@@ -2,6 +2,7 @@ import { formatJSDocWrapping, isJSDoc } from "../utils/jsdoc.js";
2
2
 
3
3
  import {
4
4
  getCommentText,
5
+ getDisplayWidth,
5
6
  getLineIndent,
6
7
  getNewline,
7
8
  isDirectiveComment,
@@ -33,7 +34,7 @@ function formatLineComment(sourceCode, comment) {
33
34
  }
34
35
 
35
36
  // The available width, allowing for the indent and "// " prefix.
36
- const width = maximumLineLength - indentation.length - 3;
37
+ const width = maximumLineLength - getDisplayWidth(indentation) - 3;
37
38
  // The comment's undecorated text.
38
39
  const text = comment.value.trim();
39
40
 
@@ -74,7 +75,7 @@ function formatBlockComment(sourceCode, comment) {
74
75
  // The comment body, sentence-formatted.
75
76
  const text = formatSentence(commentText.slice(2, -2).trim());
76
77
  // The available width, allowing for the indent and " * " prefix.
77
- const width = maximumLineLength - indentation.length - 3;
78
+ const width = maximumLineLength - getDisplayWidth(indentation) - 3;
78
79
  // The comment body, rewrapped to the available width.
79
80
  const lines = wrapWords(text, Math.max(1, width));
80
81
 
@@ -120,11 +121,20 @@ export default {
120
121
  // The comment's individual source lines.
121
122
  const lines = commentText.split(/\r\n|\n|\r/);
122
123
 
123
- if (!lines.some((line) => line.length > maximumLineLength)) {
124
+ // The whitespace before the comment, absent when code
125
+ // precedes it.
126
+ const indentation = getLineIndent(context.sourceCode, comment.range[0]) ?? "";
127
+ // The comment lines as they appear on screen. The raw text
128
+ // omits the first line's indentation, so it is restored
129
+ // before measuring.
130
+ const displayLines = [`${indentation}${lines[0]}`, ...lines.slice(1)];
131
+
132
+ if (!displayLines.some((line) => getDisplayWidth(line) > maximumLineLength)) {
124
133
  continue;
125
134
  }
126
135
 
127
- // The comment, rewrapped using the formatter matching its type.
136
+ // The comment, rewrapped using the formatter matching its
137
+ // type.
128
138
  const formattedComment =
129
139
  comment.type === "Line"
130
140
  ? formatLineComment(context.sourceCode, comment)
@@ -5,26 +5,10 @@ import {
5
5
  getLineIndent,
6
6
  getLineStart,
7
7
  getNewline,
8
+ isDirectiveComment,
8
9
  isLeadingComment,
9
10
  } from "../utils/source.js";
10
11
 
11
- /**
12
- * Return the end-to-token gap for a leading comment.
13
- *
14
- * @param {object} sourceCode
15
- * The Oxlint source code object.
16
- * @param {object} comment
17
- * The comment token.
18
- * @param {object} next
19
- * The next source token.
20
- *
21
- * @returns {string}
22
- * The source gap after the comment.
23
- */
24
- function getCommentToTokenGap(sourceCode, comment, next) {
25
- return sourceCode.text.slice(comment.range[1], next.range[0]);
26
- }
27
-
28
12
  /**
29
13
  * Return continuation comments indexed by their group leader.
30
14
  *
@@ -160,7 +144,7 @@ function getCommentIndentationFixes(
160
144
  }
161
145
 
162
146
  /**
163
- * Return the replacement that places a final leading comment against its code.
147
+ * Return the replacement that closes the gap after a final leading comment.
164
148
  *
165
149
  * @param {object} sourceCode
166
150
  * The Oxlint source code object.
@@ -169,7 +153,7 @@ function getCommentIndentationFixes(
169
153
  * @param {object} next
170
154
  * The documented source token.
171
155
  * @param {object|undefined} followingComment
172
- * The next comment token.
156
+ * The comment after this one in source order, when there is one.
173
157
  * @param {string} expectedIndent
174
158
  * The documented code's indentation.
175
159
  *
@@ -177,16 +161,23 @@ function getCommentIndentationFixes(
177
161
  * The gap replacement, or null when none is needed.
178
162
  */
179
163
  function getCommentGapFix(sourceCode, comment, next, followingComment, expectedIndent) {
180
- if (followingComment !== undefined && followingComment.range[0] <= next.range[0]) {
164
+ // Whether another comment sits between this one and its documented code.
165
+ const followingCommentIntervenes =
166
+ followingComment !== undefined && followingComment.range[0] <= next.range[0];
167
+
168
+ if (followingCommentIntervenes && !isDirectiveComment(followingComment)) {
181
169
  return null;
182
170
  }
183
171
 
184
- // The source text currently between the comment and its documented code.
185
- const gap = getCommentToTokenGap(sourceCode, comment, next);
172
+ // Stop at an intervening directive so the fix range never overlaps it.
173
+ const gapEnd = followingCommentIntervenes ? followingComment.range[0] : next.range[0];
174
+
175
+ // What currently follows the comment, up to the code or directive.
176
+ const gap = sourceCode.text.slice(comment.range[1], gapEnd);
186
177
  // The gap the documented code's indentation requires.
187
178
  const desiredGap = `${getNewline(sourceCode.text)}${expectedIndent}`;
188
179
 
189
- return gap === desiredGap ? null : { range: [comment.range[1], next.range[0]], text: desiredGap };
180
+ return gap === desiredGap ? null : { range: [comment.range[1], gapEnd], text: desiredGap };
190
181
  }
191
182
 
192
183
  /**
@@ -225,7 +216,11 @@ export default {
225
216
  );
226
217
 
227
218
  for (const [index, comment] of comments.entries()) {
228
- if (comment.type === "Shebang" || continuationComments.has(comment)) {
219
+ if (
220
+ comment.type === "Shebang" ||
221
+ isDirectiveComment(comment) ||
222
+ continuationComments.has(comment)
223
+ ) {
229
224
  continue;
230
225
  }
231
226
 
@@ -245,7 +240,8 @@ export default {
245
240
  continue;
246
241
  }
247
242
 
248
- // The reindentation fixes for the comment and its continuations.
243
+ // The reindentation fixes for the comment and its
244
+ // continuations.
249
245
  const fixes = getCommentIndentationFixes(
250
246
  context.sourceCode,
251
247
  comment,
@@ -254,10 +250,12 @@ export default {
254
250
  expectedIndent,
255
251
  );
256
252
 
257
- // The next comment token, used to avoid overlapping gap fixes.
253
+ // The next comment token, used to avoid overlapping gap
254
+ // fixes.
258
255
  const followingComment = comments[index + 1];
259
256
 
260
- // The fix that closes the gap between the comment and its code, when needed.
257
+ // The fix that closes the gap between the comment and its
258
+ // code, when needed.
261
259
  const gapFix = getCommentGapFix(
262
260
  context.sourceCode,
263
261
  comment,
@@ -136,7 +136,8 @@ function formatOrdinaryBlockComment(sourceCode, comment) {
136
136
  if (lines[0] === "/*" && lines.at(-1).trim() === "*/" && proseLineIndexes.length > 0) {
137
137
  // The comment lines, formatted in place.
138
138
  const formattedLines = [...lines];
139
- // The first and last prose line indexes, which start and end the sentence.
139
+ // The first and last prose line indexes, which start and end the
140
+ // sentence.
140
141
  const firstProseLine = proseLineIndexes[0];
141
142
  // The last prose line index, which ends the sentence.
142
143
  const lastProseLine = proseLineIndexes.at(-1);
@@ -193,13 +194,15 @@ export default {
193
194
  */
194
195
  Program() {
195
196
  for (const commentGroup of getLineCommentGroups(context.sourceCode)) {
196
- // The group's replacement text, or null when it already reads as a sentence.
197
+ // The group's replacement text, or null when it already
198
+ // reads as a sentence.
197
199
  const formattedGroup = formatLineCommentGroup(context.sourceCode, commentGroup);
198
200
 
199
201
  if (formattedGroup) {
200
202
  context.report({
201
203
  /**
202
- * Apply the formatted replacement to the comment group.
204
+ * Apply the formatted replacement to the comment
205
+ * group.
203
206
  *
204
207
  * @param {object} fixer
205
208
  * The Oxlint fixer.
@@ -208,7 +211,8 @@ export default {
208
211
  * The fixes to apply.
209
212
  */
210
213
  fix: (fixer) => {
211
- // The fixes to apply, starting with the first comment's replacement.
214
+ // The fixes to apply, starting with the first
215
+ // comment's replacement.
212
216
  const fixes = [
213
217
  replaceMinimalComment(
214
218
  fixer,
@@ -245,7 +249,8 @@ export default {
245
249
  // The comment's raw source text.
246
250
  const commentText = getCommentText(context.sourceCode, comment);
247
251
 
248
- // The comment, sentence-formatted using the JSDoc or ordinary-block formatter.
252
+ // The comment, sentence-formatted using the JSDoc or
253
+ // ordinary-block formatter.
249
254
  const formattedComment = isJSDoc(commentText)
250
255
  ? formatJSDocPunctuation(context.sourceCode, comment)
251
256
  : formatOrdinaryBlockComment(context.sourceCode, comment);
@@ -253,7 +258,8 @@ export default {
253
258
  if (formattedComment !== commentText) {
254
259
  context.report({
255
260
  /**
256
- * Apply the sentence-formatted replacement to the comment.
261
+ * Apply the sentence-formatted replacement to the
262
+ * comment.
257
263
  *
258
264
  * @param {object} fixer
259
265
  * The Oxlint fixer.
@@ -1,5 +1,9 @@
1
+ import { getDocumentationNode } from "../utils/documentation.js";
1
2
  import { hasImmediateLineComment } from "../utils/source.js";
2
3
 
4
+ // Declaration kinds that require an immediately preceding line comment.
5
+ const documentedKinds = new Set(["await using", "const", "let", "using"]);
6
+
3
7
  /**
4
8
  * Return whether a declaration is inside a loop header.
5
9
  *
@@ -29,6 +33,18 @@ export default {
29
33
  meta: {
30
34
  docs: { description: "Require comments before variable declarations." },
31
35
  type: "suggestion",
36
+ schema: [
37
+ {
38
+ type: "object",
39
+ properties: {
40
+ rootOnly: {
41
+ type: "boolean",
42
+ },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ ],
47
+ defaultOptions: [{ rootOnly: false }],
32
48
  },
33
49
  /**
34
50
  * Create the rule's node visitors.
@@ -42,20 +58,48 @@ export default {
42
58
  createOnce(context) {
43
59
  return {
44
60
  /**
45
- * Check a const or let declaration for a preceding comment.
61
+ * Check a variable declaration for a preceding line comment.
46
62
  *
47
63
  * @param {object} node
48
64
  * The variable declaration node.
49
65
  */
50
66
  VariableDeclaration(node) {
51
- if ((node.kind !== "const" && node.kind !== "let") || isLoopHeaderDeclaration(node)) {
67
+ if (!documentedKinds.has(node.kind) || isLoopHeaderDeclaration(node)) {
68
+ return;
69
+ }
70
+
71
+ // A lone const class expression is documented by
72
+ // class-documentation.
73
+ if (
74
+ node.kind === "const" &&
75
+ node.declarations.length === 1 &&
76
+ node.declarations[0].id?.type === "Identifier" &&
77
+ node.declarations[0].init?.type === "ClassExpression"
78
+ ) {
52
79
  return;
53
80
  }
54
81
 
55
- if (!hasImmediateLineComment(context.sourceCode, node)) {
82
+ // The rule's resolved options for the file currently being
83
+ // visited.
84
+ const options = context.options?.[0];
85
+
86
+ // Resolves any export wrapper before checking for
87
+ // documentation.
88
+ const documentationNode = getDocumentationNode(node);
89
+
90
+ // With rootOnly, only declarations directly under the
91
+ // Program need a comment, so a nested variable inside
92
+ // a function is left alone.
93
+ const shouldCheckDocumentation =
94
+ !options?.rootOnly || documentationNode.parent?.type === "Program";
95
+
96
+ if (
97
+ shouldCheckDocumentation &&
98
+ !hasImmediateLineComment(context.sourceCode, documentationNode)
99
+ ) {
56
100
  context.report({
57
101
  message: "Variable declarations require an immediately preceding line comment.",
58
- node,
102
+ node: documentationNode,
59
103
  });
60
104
  }
61
105
 
@@ -119,7 +119,8 @@ function hasComponentDocumentation(context, scriptBlock) {
119
119
  return false;
120
120
  }
121
121
 
122
- // A blank line would separate the component documentation from its entry point.
122
+ // A blank line would separate the component documentation from its entry
123
+ // point.
123
124
  const commentGap = context.sourceCode.text.slice(0, comment.range[0]);
124
125
 
125
126
  return immediateCommentGapPattern.test(commentGap);
@@ -142,7 +143,8 @@ export default {
142
143
  createOnce(context) {
143
144
  return {
144
145
  /**
145
- * Check the current component's script setup block for documentation.
146
+ * Check the current component's script setup block for
147
+ * documentation.
146
148
  *
147
149
  * @param {object} node
148
150
  * The entry point parsed from the current script block.
@@ -150,7 +152,8 @@ export default {
150
152
  Program(node) {
151
153
  // createOnce builds this visitor once for the whole run, and a
152
154
  // single file's script blocks are not necessarily visited
153
- // consecutively, so the matching block is looked up fresh on each
155
+ // consecutively, so the matching block is looked up fresh on
156
+ // each
154
157
  // call rather than tracked with shared state.
155
158
  const scriptBlock = findScriptBlock(context);
156
159
 
@@ -111,7 +111,8 @@ export default {
111
111
  return;
112
112
  }
113
113
 
114
- // Array and type-only forms have no runtime properties to document.
114
+ // Array and type-only forms have no runtime properties to
115
+ // document.
115
116
  const emitsObject = getObjectArgument(node, 0);
116
117
 
117
118
  if (emitsObject) {
@@ -8,6 +8,29 @@ import {
8
8
  isLeadingComment,
9
9
  } from "./source.js";
10
10
 
11
+ /**
12
+ * Return the declaration node that owns the documentation position.
13
+ *
14
+ * @param {object} node
15
+ * The declaration node.
16
+ *
17
+ * @returns {object}
18
+ * The node immediately following the documentation block.
19
+ */
20
+ export function getDocumentationNode(node) {
21
+ // Walks up through export wrappers to find the documented position.
22
+ let documentationNode = node;
23
+
24
+ while (
25
+ documentationNode.parent?.type === "ExportDefaultDeclaration" ||
26
+ documentationNode.parent?.type === "ExportNamedDeclaration"
27
+ ) {
28
+ documentationNode = documentationNode.parent;
29
+ }
30
+
31
+ return documentationNode;
32
+ }
33
+
11
34
  /**
12
35
  * Return whether a node is a function value, used to tell function-valued
13
36
  * options and properties apart from named function declarations.
@@ -264,14 +287,19 @@ function hasValueReturn(node) {
264
287
  * Optional reporting options.
265
288
  * @param {boolean} [options.requiresReturns=true]
266
289
  * Whether a returned value requires an @returns tag.
290
+ * @param {string} [options.subject="Functions"]
291
+ * The declaration kind named in report messages, such as "Constructors".
267
292
  */
268
293
  export function reportFunctionDocumentation(context, node, functionNode, options = {}) {
294
+ // Defaults to "Functions" when the caller names no declaration kind.
295
+ const subject = options.subject ?? "Functions";
296
+
269
297
  // Finds the JSDoc block documenting this function, when present.
270
298
  const comment = getDocumentationComment(context.sourceCode, node);
271
299
 
272
300
  if (!comment) {
273
301
  context.report({
274
- message: "Functions require an immediately preceding JSDoc block.",
302
+ message: `${subject} require an immediately preceding JSDoc block.`,
275
303
  node,
276
304
  });
277
305
 
@@ -292,7 +320,7 @@ export function reportFunctionDocumentation(context, node, functionNode, options
292
320
 
293
321
  if (!documentedParameters.has(path) && !documentedParameters.has(optionalPath)) {
294
322
  context.report({
295
- message: `Functions require an @param for ${path}.`,
323
+ message: `${subject} require an @param for ${path}.`,
296
324
  node,
297
325
  });
298
326
  }
@@ -307,14 +335,14 @@ export function reportFunctionDocumentation(context, node, functionNode, options
307
335
 
308
336
  if (options.requiresReturns !== false && hasValueReturn(functionNode) && !hasReturns) {
309
337
  context.report({
310
- message: "Functions that return a value require an @returns tag.",
338
+ message: `${subject} that return a value require an @returns tag.`,
311
339
  node,
312
340
  });
313
341
  }
314
342
 
315
343
  if (containsStatement(functionNode.body, "ThrowStatement") && !hasThrows) {
316
344
  context.report({
317
- message: "Functions that throw require an @throws tag.",
345
+ message: `${subject} that throw require an @throws tag.`,
318
346
  node,
319
347
  });
320
348
  }
@@ -1,4 +1,4 @@
1
- import { getCommentText, getLineIndent, getNewline } from "./source.js";
1
+ import { getCommentText, getDisplayWidth, getLineIndent, getNewline } from "./source.js";
2
2
  import { addTerminalPunctuation, capitaliseSentence, formatSentence, wrapWords } from "./wrap.js";
3
3
 
4
4
  // The JSDoc tags this package formats, in their required output order.
@@ -217,7 +217,8 @@ function formatUnwrappedProse(lines, addPunctuation) {
217
217
  // The formatted lines, built up in place.
218
218
  const result = lines.map((line) => line.trim());
219
219
 
220
- // The index of the current paragraph's first line, or null between paragraphs.
220
+ // The index of the current paragraph's first line, or null between
221
+ // paragraphs.
221
222
  let paragraphStart = null;
222
223
 
223
224
  for (let index = 0; index < result.length; index += 1) {
@@ -336,7 +337,8 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
336
337
  // The formatted lines, built up in place.
337
338
  const result = [];
338
339
 
339
- // The tag type of the previously written entry, used to detect group changes.
340
+ // The tag type of the previously written entry, used to detect group
341
+ // changes.
340
342
  let lastType = null;
341
343
 
342
344
  // The entries regrouped into the required tag order.
@@ -526,7 +528,7 @@ function getJSDocFormattingContext(sourceCode, comment) {
526
528
  // The newline style used by the surrounding source.
527
529
  const newline = getNewline(sourceCode.text);
528
530
  // The available content width, allowing for the indent and " * " prefix.
529
- const width = Math.max(1, 80 - indent.length - 3);
531
+ const width = Math.max(1, 80 - getDisplayWidth(indent) - 3);
530
532
  // The undecorated comment content lines.
531
533
  const content = getJSDocContent(commentText);
532
534
  // The content split into its prose and tag sections.
@@ -156,6 +156,21 @@ export function getLineIndent(sourceCode, offset) {
156
156
  return /^\s*$/.test(prefix) ? prefix : null;
157
157
  }
158
158
 
159
+ /**
160
+ * Return the number of columns a source string occupies on screen, so comment
161
+ * lines are measured and wrapped against the 80-column limit under tab
162
+ * indentation. A tab counts as four columns, the width this package assumes.
163
+ *
164
+ * @param {string} sourceText
165
+ * The source string to measure, such as a line or its indentation.
166
+ *
167
+ * @returns {number}
168
+ * The source string's width in display columns.
169
+ */
170
+ export function getDisplayWidth(sourceText) {
171
+ return sourceText.replaceAll("\t", " ").length;
172
+ }
173
+
159
174
  /**
160
175
  * Return the source items immediately around a comment.
161
176
  *
@@ -310,3 +325,37 @@ export function hasImmediateLineComment(sourceCode, node) {
310
325
  /^\r?\n[ \t]*$/.test(gap)
311
326
  );
312
327
  }
328
+
329
+ /**
330
+ * Return whether a block comment immediately documents a source node.
331
+ *
332
+ * @param {object} sourceCode
333
+ * The Oxlint source code object.
334
+ * @param {object} node
335
+ * The source node.
336
+ *
337
+ * @returns {boolean}
338
+ * Whether an ordinary block comment immediately precedes the node.
339
+ */
340
+ export function hasImmediateBlockComment(sourceCode, node) {
341
+ // Finds the closest preceding comment.
342
+ const comment = sourceCode
343
+ .getAllComments()
344
+ .findLast((candidate) => candidate.range[1] <= node.range[0]);
345
+
346
+ if (comment?.type !== "Block" || isDirectiveComment(comment)) {
347
+ return false;
348
+ }
349
+
350
+ // Checks the comments immediately around the node.
351
+ const { next, previous } = getCommentNeighbours(sourceCode, comment);
352
+
353
+ // Confirms there is no blank line before the node.
354
+ const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
355
+
356
+ return (
357
+ next?.range[0] === node.range[0] &&
358
+ isLeadingComment(sourceCode, comment, previous) &&
359
+ /^\r?\n[ \t]*$/.test(gap)
360
+ );
361
+ }
package/comments.json CHANGED
@@ -7,6 +7,7 @@
7
7
  ],
8
8
  "rules": {
9
9
  "comments/block-comments": "error",
10
+ "comments/class-documentation": "error",
10
11
  "comments/configured-api-calls": "error",
11
12
  "comments/function-documentation": "error",
12
13
  "comments/jsdoc-tag-formatting": "error",
@@ -18,5 +19,13 @@
18
19
  "comments/vue-component-documentation": "error",
19
20
  "comments/vue-emit-documentation": "error",
20
21
  "comments/vue-prop-documentation": "error"
21
- }
22
+ },
23
+ "overrides": [
24
+ {
25
+ "files": ["**/*.test.*", "**/*.spec.*", "**/test/**"],
26
+ "rules": {
27
+ "comments/variable-declarations": ["error", { "rootOnly": true }]
28
+ }
29
+ }
30
+ ]
22
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/lint-config",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared oxlint configuration for Lewis Howles projects",
5
5
  "keywords": [
6
6
  "config",
@@ -19,6 +19,7 @@
19
19
  "url": "git+https://github.com/lewishowles/lint-config.git"
20
20
  },
21
21
  "files": [
22
+ "CHANGELOG.md",
22
23
  "base.json",
23
24
  "comments",
24
25
  "comments.json",
@@ -39,7 +40,8 @@
39
40
  "lint": "vp check",
40
41
  "lint:fix": "vp check --fix",
41
42
  "prepare": "vp config --no-agent",
42
- "publint": "publint"
43
+ "publint": "publint",
44
+ "test:unit": "node --test test/comments/*.test.js"
43
45
  },
44
46
  "devDependencies": {
45
47
  "publint": "^0.3.22",