@lewishowles/lint-config 0.4.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
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
+
3
13
  ## 0.4.0: 2026-08-28
4
14
 
5
15
  ### New rules
package/README.md CHANGED
@@ -92,13 +92,45 @@ 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
+
95
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.
96
106
 
97
107
  ### `vp check` configuration
98
108
 
99
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.
100
110
 
101
- 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. Verify the setup with `vp lint --print-config <file>` and check that your real values, not defaults, are active.
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
+ ```
102
134
 
103
135
  ## Customising
104
136
 
@@ -130,7 +162,7 @@ Ignore patterns are project-specific, so they always live in your project config
130
162
 
131
163
  ### Adding overrides
132
164
 
133
- 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.
134
166
 
135
167
  ```json
136
168
  {
@@ -167,7 +199,7 @@ The base layer sorts named members within each import statement, but leaves decl
167
199
  ## What stays repo-local
168
200
 
169
201
  - `ignorePatterns`, since every project has different build output and tool directories
170
- - `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.
171
203
  - Rule relaxations for specific file patterns (e.g. turning off `vite-plus/prefer-vite-plus-imports` in generated `.d.ts` files)
172
204
  - Additional plugins, only for projects that need them
173
205
 
@@ -176,6 +208,6 @@ The base layer sorts named members within each import statement, but leaves decl
176
208
  When a project's `.oxlintrc.json` extends a shared layer:
177
209
 
178
210
  - **Rules** shallow-merge by key: your value wins for any rule defined in both
179
- - **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
180
212
  - **Plugins** are additive: both shared and local `plugins`/`jsPlugins` load, deduplicated
181
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.
@@ -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) {
@@ -33,7 +33,8 @@ export default {
33
33
  * The class declaration node.
34
34
  */
35
35
  ClassDeclaration(node) {
36
- // Resolves any export wrapper before checking for documentation.
36
+ // Resolves any export wrapper before checking for
37
+ // documentation.
37
38
  const documentationNode = getDocumentationNode(node);
38
39
 
39
40
  if (!hasImmediateBlockComment(context.sourceCode, documentationNode)) {
@@ -44,8 +45,8 @@ export default {
44
45
  }
45
46
  },
46
47
  /**
47
- * Check class methods for required JSDoc blocks and tags. Constructors are
48
- * exempt from the @returns requirement.
48
+ * Check class methods for required JSDoc blocks and tags.
49
+ * Constructors are exempt from the @returns requirement.
49
50
  *
50
51
  * @param {object} node
51
52
  * The method-definition node.
@@ -93,7 +94,8 @@ export default {
93
94
  return;
94
95
  }
95
96
 
96
- // Static and instance fields share the requirement; only the wording differs.
97
+ // Static and instance fields share the requirement; only the
98
+ // wording differs.
97
99
  const message = node.static
98
100
  ? "Static fields require an immediately preceding line comment."
99
101
  : "Instance fields require an immediately preceding line comment.";
@@ -116,7 +118,8 @@ export default {
116
118
  return;
117
119
  }
118
120
 
119
- // Resolves any export wrapper before checking for documentation.
121
+ // Resolves any export wrapper before checking for
122
+ // documentation.
120
123
  const documentationNode = getDocumentationNode(node.parent);
121
124
 
122
125
  if (!hasImmediateBlockComment(context.sourceCode, documentationNode)) {
@@ -117,8 +117,10 @@ export default {
117
117
  * The call expression node.
118
118
  */
119
119
  CallExpression(node) {
120
- // Read fresh for every call: createOnce's visitor is shared across every file
121
- // 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
122
124
  // file's options.
123
125
  const configuredApis = getConfiguredApis(context);
124
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)
@@ -240,7 +240,8 @@ export default {
240
240
  continue;
241
241
  }
242
242
 
243
- // The reindentation fixes for the comment and its continuations.
243
+ // The reindentation fixes for the comment and its
244
+ // continuations.
244
245
  const fixes = getCommentIndentationFixes(
245
246
  context.sourceCode,
246
247
  comment,
@@ -249,10 +250,12 @@ export default {
249
250
  expectedIndent,
250
251
  );
251
252
 
252
- // The next comment token, used to avoid overlapping gap fixes.
253
+ // The next comment token, used to avoid overlapping gap
254
+ // fixes.
253
255
  const followingComment = comments[index + 1];
254
256
 
255
- // 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.
256
259
  const gapFix = getCommentGapFix(
257
260
  context.sourceCode,
258
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.
@@ -33,6 +33,18 @@ export default {
33
33
  meta: {
34
34
  docs: { description: "Require comments before variable declarations." },
35
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 }],
36
48
  },
37
49
  /**
38
50
  * Create the rule's node visitors.
@@ -56,7 +68,8 @@ export default {
56
68
  return;
57
69
  }
58
70
 
59
- // A lone const class expression is documented by class-documentation.
71
+ // A lone const class expression is documented by
72
+ // class-documentation.
60
73
  if (
61
74
  node.kind === "const" &&
62
75
  node.declarations.length === 1 &&
@@ -66,10 +79,24 @@ export default {
66
79
  return;
67
80
  }
68
81
 
69
- // Resolves any export wrapper before checking for documentation.
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.
70
88
  const documentationNode = getDocumentationNode(node);
71
89
 
72
- if (!hasImmediateLineComment(context.sourceCode, documentationNode)) {
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
+ ) {
73
100
  context.report({
74
101
  message: "Variable declarations require an immediately preceding line comment.",
75
102
  node: documentationNode,
@@ -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) {
@@ -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
  *
package/comments.json CHANGED
@@ -19,5 +19,13 @@
19
19
  "comments/vue-component-documentation": "error",
20
20
  "comments/vue-emit-documentation": "error",
21
21
  "comments/vue-prop-documentation": "error"
22
- }
22
+ },
23
+ "overrides": [
24
+ {
25
+ "files": ["**/*.test.*", "**/*.spec.*", "**/test/**"],
26
+ "rules": {
27
+ "comments/variable-declarations": ["error", { "rootOnly": true }]
28
+ }
29
+ }
30
+ ]
23
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/lint-config",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared oxlint configuration for Lewis Howles projects",
5
5
  "keywords": [
6
6
  "config",
@@ -40,7 +40,8 @@
40
40
  "lint": "vp check",
41
41
  "lint:fix": "vp check --fix",
42
42
  "prepare": "vp config --no-agent",
43
- "publint": "publint"
43
+ "publint": "publint",
44
+ "test:unit": "node --test test/comments/*.test.js"
44
45
  },
45
46
  "devDependencies": {
46
47
  "publint": "^0.3.22",