@sister.software/oxlint-config 10.0.0 → 12.0.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.
Files changed (82) hide show
  1. package/README.md +169 -0
  2. package/out/acronym-case-plugin.d.ts +80 -0
  3. package/out/acronym-case-plugin.d.ts.map +1 -0
  4. package/out/acronym-case-plugin.js +272 -0
  5. package/out/acronym-case-plugin.js.map +1 -0
  6. package/out/browser-globals.d.ts +15 -0
  7. package/out/browser-globals.d.ts.map +1 -0
  8. package/out/browser-globals.js +76 -0
  9. package/out/browser-globals.js.map +1 -0
  10. package/out/console-padding-plugin.d.ts +16 -0
  11. package/out/console-padding-plugin.d.ts.map +1 -0
  12. package/out/console-padding-plugin.js +68 -0
  13. package/out/console-padding-plugin.js.map +1 -0
  14. package/out/constant-doc-plugin.d.ts +9 -3
  15. package/out/constant-doc-plugin.d.ts.map +1 -1
  16. package/out/constant-doc-plugin.js +6 -2
  17. package/out/constant-doc-plugin.js.map +1 -1
  18. package/out/headers-plugin.d.ts +6 -2
  19. package/out/headers-plugin.d.ts.map +1 -1
  20. package/out/headers-plugin.js +3 -1
  21. package/out/headers-plugin.js.map +1 -1
  22. package/out/index.d.ts +123 -27
  23. package/out/index.d.ts.map +1 -1
  24. package/out/index.js +63 -5
  25. package/out/index.js.map +1 -1
  26. package/out/jsdoc-plugin.d.ts +16 -0
  27. package/out/jsdoc-plugin.d.ts.map +1 -0
  28. package/out/jsdoc-plugin.js +68 -0
  29. package/out/jsdoc-plugin.js.map +1 -0
  30. package/out/length-truthiness-plugin.d.ts.map +1 -1
  31. package/out/length-truthiness-plugin.js +15 -5
  32. package/out/length-truthiness-plugin.js.map +1 -1
  33. package/out/multiline-statement-plugin.d.ts +18 -0
  34. package/out/multiline-statement-plugin.d.ts.map +1 -0
  35. package/out/multiline-statement-plugin.js +93 -0
  36. package/out/multiline-statement-plugin.js.map +1 -0
  37. package/out/padding-plugin.d.ts +4 -3
  38. package/out/padding-plugin.d.ts.map +1 -1
  39. package/out/padding-plugin.js +44 -27
  40. package/out/padding-plugin.js.map +1 -1
  41. package/out/padding-utils.d.ts +34 -0
  42. package/out/padding-utils.d.ts.map +1 -0
  43. package/out/padding-utils.js +50 -0
  44. package/out/padding-utils.js.map +1 -0
  45. package/out/plugin-types.d.ts +75 -13
  46. package/out/plugin-types.d.ts.map +1 -1
  47. package/out/plugin.d.ts.map +1 -1
  48. package/out/plugin.js +13 -0
  49. package/out/plugin.js.map +1 -1
  50. package/out/process-globals-plugin.d.ts.map +1 -1
  51. package/out/process-globals-plugin.js +6 -2
  52. package/out/process-globals-plugin.js.map +1 -1
  53. package/out/restrictions.d.ts +9 -3
  54. package/out/restrictions.d.ts.map +1 -1
  55. package/out/restrictions.js +4 -70
  56. package/out/restrictions.js.map +1 -1
  57. package/out/section-marker-plugin.d.ts +25 -0
  58. package/out/section-marker-plugin.d.ts.map +1 -0
  59. package/out/section-marker-plugin.js +234 -0
  60. package/out/section-marker-plugin.js.map +1 -0
  61. package/out/threshold-plugin.d.ts +9 -3
  62. package/out/threshold-plugin.d.ts.map +1 -1
  63. package/out/threshold-plugin.js +9 -3
  64. package/out/threshold-plugin.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/acronym-case-plugin.ts +370 -0
  67. package/src/browser-globals.ts +77 -0
  68. package/src/console-padding-plugin.ts +76 -0
  69. package/src/constant-doc-plugin.ts +15 -5
  70. package/src/headers-plugin.ts +6 -2
  71. package/src/index.ts +183 -29
  72. package/src/jsdoc-plugin.ts +75 -0
  73. package/src/length-truthiness-plugin.ts +15 -5
  74. package/src/multiline-statement-plugin.ts +104 -0
  75. package/src/padding-plugin.ts +44 -29
  76. package/src/padding-utils.ts +70 -0
  77. package/src/plugin-types.ts +75 -13
  78. package/src/plugin.ts +18 -0
  79. package/src/process-globals-plugin.ts +6 -2
  80. package/src/restrictions.ts +16 -77
  81. package/src/section-marker-plugin.ts +306 -0
  82. package/src/threshold-plugin.ts +18 -6
package/src/index.ts CHANGED
@@ -9,26 +9,46 @@ import { createRuntimeOverrides } from "./restrictions.js"
9
9
 
10
10
  export * from "./restrictions.js"
11
11
 
12
- /** An oxlint configuration object, as consumed by `oxlint.config.ts` / `.oxlintrc.json`. */
12
+ /**
13
+ * An oxlint configuration object, as consumed by `oxlint.config.ts` / `.oxlintrc.json`.
14
+ */
13
15
  export type OxlintConfig = Record<string, unknown>
14
16
 
15
- /** Numeric ceilings for the legibility-guardrail rules. Each is a hard ceiling, not a target. */
17
+ /**
18
+ * Numeric ceilings for the legibility-guardrail rules. Each is a hard ceiling, not a target.
19
+ */
16
20
  export interface OxlintConfigLimits {
17
- /** Maximum block nesting depth. */
21
+ /**
22
+ * Maximum block nesting depth.
23
+ */
18
24
  maxDepth: number
19
- /** Maximum parameters on a single function. */
25
+ /**
26
+ * Maximum parameters on a single function.
27
+ */
20
28
  maxParams: number
21
- /** Maximum statements in a single function body. */
29
+ /**
30
+ * Maximum statements in a single function body.
31
+ */
22
32
  maxStatements: number
23
- /** Maximum lines in a single function body, blank lines and comments excluded. */
33
+ /**
34
+ * Maximum lines in a single function body, blank lines and comments excluded.
35
+ */
24
36
  maxLinesPerFunction: number
25
- /** Maximum lines in a single file, blank lines and comments excluded. */
37
+ /**
38
+ * Maximum lines in a single file, blank lines and comments excluded.
39
+ */
26
40
  maxLines: number
27
- /** Maximum nested callback depth. */
41
+ /**
42
+ * Maximum nested callback depth.
43
+ */
28
44
  maxNestedCallbacks: number
29
- /** Maximum nested call-expression depth, e.g. `a(b(c(d())))`. */
45
+ /**
46
+ * Maximum nested call-expression depth, e.g. `a(b(c(d())))`.
47
+ */
30
48
  maxNestedCalls: number
31
- /** Maximum cyclomatic complexity of a single function. */
49
+ /**
50
+ * Maximum cyclomatic complexity of a single function.
51
+ */
32
52
  complexity: number
33
53
  }
34
54
 
@@ -61,7 +81,9 @@ export const DefaultLimits: OxlintConfigLimits = {
61
81
  complexity: 85,
62
82
  }
63
83
 
64
- /** Globs treated as test files, where the size and named-constant rules are switched off. */
84
+ /**
85
+ * Globs treated as test files, where the size and named-constant rules are switched off.
86
+ */
65
87
  export const DefaultTestFilePatterns = [
66
88
  "**/*.test.ts",
67
89
  "**/*.test.tsx",
@@ -82,23 +104,56 @@ export const DefaultTestFilePatterns = [
82
104
  */
83
105
  export const DefaultGeneratedFilePatterns = ["**/*.gen.ts", "**/*.gen.tsx", "**/*.generated.ts", "**/generated/**"]
84
106
 
85
- /** Options for {@link createOxlintConfig}. */
107
+ /**
108
+ * Globs for files JavaScript checks at runtime rather than TypeScript checking ahead of it. A JSDoc block there often
109
+ * IS the type annotation, and a one-line `@type` cast is the idiomatic form — so the multi-line requirement, which
110
+ * exists to make prose read as documentation, does not apply.
111
+ */
112
+ export const DefaultUntypedFilePatterns = ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx"]
113
+
114
+ /**
115
+ * Options for {@link createOxlintConfig}.
116
+ */
86
117
  export interface OxlintConfigOptions {
87
- /** The package namespace whose runtime boundaries are enforced, e.g. `@sister.software`. */
118
+ /**
119
+ * The package namespace whose runtime boundaries are enforced, e.g. `@sister.software`.
120
+ */
88
121
  packageNamespace?: string
89
- /** The copyright holder stamped into file headers. */
122
+ /**
123
+ * The copyright holder stamped into file headers.
124
+ */
90
125
  copyrightHolder?: string
91
- /** The SPDX license identifier stamped into file headers. */
126
+ /**
127
+ * The SPDX license identifier stamped into file headers.
128
+ */
92
129
  spdxLicenseIdentifier?: string
93
- /** The author stamped into file headers. */
130
+ /**
131
+ * The author stamped into file headers.
132
+ */
94
133
  author?: string
95
- /** Enable oxlint's React plugin (off by default). */
134
+ /**
135
+ * Enable oxlint's React plugin (off by default).
136
+ */
96
137
  react?: boolean
97
- /** Enforce file headers via the bundled JS plugin (on by default). */
138
+ /**
139
+ * Enforce file headers via the bundled JS plugin (on by default).
140
+ */
98
141
  headers?: boolean
99
- /** Require a blank line before `return`/block-like statements (on by default). */
142
+ /**
143
+ * Require a blank line before `return`/block-like statements (on by default).
144
+ */
100
145
  padding?: boolean
101
- /** Require braces around single-statement work bodies (bare `return` exempt; on by default). */
146
+ /**
147
+ * Blank line on each side of a `console.*` call, with runs of them grouped (on by default).
148
+ */
149
+ consolePadding?: boolean
150
+ /**
151
+ * Blank line on each side of a statement that spans lines (on by default).
152
+ */
153
+ multilineStatementPadding?: boolean
154
+ /**
155
+ * Require braces around single-statement work bodies (bare `return` exempt; on by default).
156
+ */
102
157
  braces?: boolean
103
158
  /**
104
159
  * Forbid direct `process.env` / `process.argv` access (off by default). Turn on once the project funnels those reads
@@ -115,21 +170,57 @@ export interface OxlintConfigOptions {
115
170
  * `"screaming"`, or the default `"exported-or-screaming"`.
116
171
  */
117
172
  constantDocs?: boolean | { scope?: "exported" | "screaming" | "exported-or-screaming" }
118
- /** Rewrite explicit length comparisons to truthiness in boolean positions (on by default). */
173
+ /**
174
+ * Flag an acronym title-cased inside an identifier — `parseJson` where the house form is `parseJSON` (off by
175
+ * default). `acronyms` replaces the shipped list, `extraAcronyms` layers a project's own vocabulary on top of it,
176
+ * `ignoreNames` exempts identifiers whose casing is not ours to choose, and `scope` widens the rule past exported
177
+ * declarations.
178
+ */
179
+ acronymCasing?:
180
+ | boolean
181
+ | { acronyms?: string[]; extraAcronyms?: string[]; ignoreNames?: string[]; scope?: "exported" | "all" }
182
+ /**
183
+ * Rewrite explicit length comparisons to truthiness in boolean positions (on by default).
184
+ */
119
185
  lengthTruthiness?: boolean
120
- /** Override individual legibility ceilings. Unspecified keys keep their calibrated default. */
186
+ /**
187
+ * Require JSDoc blocks to span multiple lines (on by default). Off for untyped files.
188
+ */
189
+ multilineJSDoc?: boolean
190
+ /**
191
+ * Enforce the section-marker ladder — `----` banners become `// MARK:`, long labels are flagged, and a file with many
192
+ * markers is nudged toward regions and then toward being several files (on by default).
193
+ */
194
+ sectionMarkers?: boolean | { maxBodyLength?: number; maxRegions?: number }
195
+ /**
196
+ * Override individual legibility ceilings. Unspecified keys keep their calibrated default.
197
+ */
121
198
  limits?: Partial<OxlintConfigLimits>
122
- /** Replace the globs treated as test files. */
199
+ /**
200
+ * Replace the globs treated as test files.
201
+ */
123
202
  testFilePatterns?: string[]
124
- /** Replace the globs treated as generated files, where only the size ceilings are switched off. */
203
+ /**
204
+ * Replace the globs treated as generated files, where only the size ceilings are switched off.
205
+ */
125
206
  generatedFilePatterns?: string[]
126
- /** Override the default ignore patterns. */
207
+ /**
208
+ * Replace the globs treated as untyped, where the multi-line JSDoc requirement switches off.
209
+ */
210
+ untypedFilePatterns?: string[]
211
+ /**
212
+ * Override the default ignore patterns.
213
+ */
127
214
  ignorePatterns?: string[]
128
- /** Extra config deep-merged last; an escape hatch for per-repo tweaks. */
215
+ /**
216
+ * Extra config deep-merged last; an escape hatch for per-repo tweaks.
217
+ */
129
218
  overrides?: OxlintConfig
130
219
  }
131
220
 
132
- /** Default ignore patterns for generated/build output and vendored tooling. */
221
+ /**
222
+ * Default ignore patterns for generated/build output and vendored tooling.
223
+ */
133
224
  export const DefaultIgnorePatterns = [
134
225
  "**/out",
135
226
  "**/dist",
@@ -165,14 +256,20 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
165
256
  react = false,
166
257
  headers = true,
167
258
  padding = true,
259
+ consolePadding = true,
260
+ multilineStatementPadding = true,
168
261
  braces = true,
169
262
  restrictProcessGlobals = false,
170
263
  unnamedThresholds = false,
171
264
  constantDocs = false,
265
+ acronymCasing = false,
172
266
  lengthTruthiness = true,
267
+ multilineJSDoc = true,
268
+ sectionMarkers = true,
173
269
  limits: limitOverrides = {},
174
270
  testFilePatterns = DefaultTestFilePatterns,
175
271
  generatedFilePatterns = DefaultGeneratedFilePatterns,
272
+ untypedFilePatterns = DefaultUntypedFilePatterns,
176
273
  ignorePatterns = DefaultIgnorePatterns,
177
274
  overrides = {},
178
275
  } = options
@@ -432,10 +529,48 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
432
529
  rules["sister-software/require-constant-doc"] = ["error", typeof constantDocs === "object" ? constantDocs : {}]
433
530
  }
434
531
 
532
+ if (acronymCasing) {
533
+ // Error severity: on exported surface a title-cased acronym costs a consumer a breaking rename
534
+ // later, and the convention it drifts from is one a reviewer cannot enforce by reading.
535
+ rules["sister-software/no-title-case-acronym"] = ["error", typeof acronymCasing === "object" ? acronymCasing : {}]
536
+ }
537
+
435
538
  if (lengthTruthiness) {
436
539
  rules["sister-software/prefer-length-truthiness"] = "error"
437
540
  }
438
541
 
542
+ if (consolePadding) {
543
+ rules["sister-software/console-padding"] = "warn"
544
+ }
545
+
546
+ if (multilineStatementPadding) {
547
+ rules["sister-software/multiline-statement-padding"] = "warn"
548
+ }
549
+
550
+ if (multilineJSDoc) {
551
+ rules["sister-software/multiline-jsdoc"] = "error"
552
+ }
553
+
554
+ if (sectionMarkers) {
555
+ const markerOptions = typeof sectionMarkers === "object" ? sectionMarkers : {}
556
+ // The two mechanical rungs are errors: a banner has exactly one correct rewrite, and a label
557
+ // length is measurable. The two judgment rungs warn — where a section ends, and whether it
558
+ // should become its own file, are not decisions a linter gets to make.
559
+ rules["sister-software/prefer-mark-comment"] = "error"
560
+
561
+ rules["sister-software/concise-section-marker"] = [
562
+ "error",
563
+ markerOptions.maxBodyLength === undefined ? {} : { maxBodyLength: markerOptions.maxBodyLength },
564
+ ]
565
+
566
+ rules["sister-software/prefer-region-over-marks"] = "warn"
567
+
568
+ rules["sister-software/max-regions"] = [
569
+ "warn",
570
+ markerOptions.maxRegions === undefined ? {} : { max: markerOptions.maxRegions },
571
+ ]
572
+ }
573
+
439
574
  // Rules switched off inside test files. Table-driven test bodies are legitimately long, and
440
575
  // expected values are legitimately unnamed numbers. oxlint validates override entries against the
441
576
  // registered rule set, so an entry may only name a rule this config actually turned on.
@@ -460,7 +595,16 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
460
595
  testFileRules["sister-software/require-constant-doc"] = "off"
461
596
  }
462
597
 
463
- /** Generated files: size ceilings only. Everything else still applies — generated code ships. */
598
+ if (sectionMarkers) {
599
+ // A test file's sections track the suite's shape, which the code under test dictates — a big
600
+ // table of cases legitimately wants many markers, and splitting it would scatter the suite.
601
+ testFileRules["sister-software/prefer-region-over-marks"] = "off"
602
+ testFileRules["sister-software/max-regions"] = "off"
603
+ }
604
+
605
+ /**
606
+ * Generated files: size ceilings only. Everything else still applies — generated code ships.
607
+ */
464
608
  const generatedFileRules: Record<string, unknown> = {
465
609
  "max-lines": "off",
466
610
  "max-lines-per-function": "off",
@@ -470,7 +614,16 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
470
614
 
471
615
  return {
472
616
  plugins,
473
- ...(headers || padding || braces || restrictProcessGlobals || unnamedThresholds || constantDocs || lengthTruthiness
617
+ ...(headers ||
618
+ padding ||
619
+ braces ||
620
+ restrictProcessGlobals ||
621
+ unnamedThresholds ||
622
+ constantDocs ||
623
+ acronymCasing ||
624
+ lengthTruthiness ||
625
+ multilineJSDoc ||
626
+ sectionMarkers
474
627
  ? { jsPlugins: ["@sister.software/oxlint-config/plugin"] }
475
628
  : {}),
476
629
  categories: { correctness: "error" },
@@ -480,6 +633,7 @@ export function createOxlintConfig(options: OxlintConfigOptions = {}): OxlintCon
480
633
  ...createRuntimeOverrides(packageNamespace),
481
634
  { files: testFilePatterns, rules: testFileRules },
482
635
  { files: generatedFilePatterns, rules: generatedFileRules },
636
+ ...(multilineJSDoc ? [{ files: untypedFilePatterns, rules: { "sister-software/multiline-jsdoc": "off" } }] : []),
483
637
  ],
484
638
  ...overrides,
485
639
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file The `sister-software/multiline-jsdoc` rule: a JSDoc block always spans multiple lines, even
6
+ * when its content would fit on one. A one-line block reads as an aside; the multi-line form reads
7
+ * as documentation, and it leaves somewhere to put the second sentence when one is needed.
8
+ *
9
+ * Two shapes are left alone. A JSDoc that shares its line with code is a type cast or an inline
10
+ * annotation, and expanding those changes what the line means. A JSDoc on a union or intersection
11
+ * member labels one alternative in what reads as a list — three lines per entry turns a list you
12
+ * can scan into a page you have to read.
13
+ */
14
+
15
+ import type { Rule } from "./plugin-types.js"
16
+
17
+ /**
18
+ * The JSDoc opener, so the rule can tell a documentation block from a plain block comment.
19
+ */
20
+ const JSDOC_OPENER = "/**"
21
+
22
+ /**
23
+ * A `|` or `&` as the next thing after a comment, meaning the comment labels one member of a union or intersection
24
+ * rather than documenting a declaration.
25
+ */
26
+ const UNION_MEMBER = /^\s*[|&]/
27
+
28
+ export const multilineJSDocRule: Rule = {
29
+ meta: {
30
+ name: "multiline-jsdoc",
31
+ type: "layout",
32
+ fixable: "whitespace",
33
+ schema: [{ type: "object", additionalProperties: true }],
34
+ },
35
+ create(context) {
36
+ const sourceCode = context.sourceCode ?? context.getSourceCode!()
37
+ const text = sourceCode.getText()
38
+
39
+ return {
40
+ Program() {
41
+ for (const comment of sourceCode.getAllComments()) {
42
+ if (comment.type !== "Block") continue
43
+ const raw = text.slice(comment.range[0], comment.range[1])
44
+
45
+ if (!raw.startsWith(JSDOC_OPENER) || raw.includes("\n")) continue
46
+
47
+ // `value` excludes the delimiters, so a one-line block arrives as `"* x "`.
48
+ const body = comment.value.replace(/^\*/, "").trim()
49
+
50
+ if (!body) continue
51
+
52
+ // Everything from the start of the line up to the comment. All-whitespace means the
53
+ // block leads its line and owns the indentation; anything else means it is inline.
54
+ const lineStart = text.lastIndexOf("\n", comment.range[0] - 1) + 1
55
+ const indent = text.slice(lineStart, comment.range[0])
56
+
57
+ if (indent.trim()) continue
58
+
59
+ // A block documenting a union or intersection member is a label on one alternative, and
60
+ // the members read as a list. Three lines per entry turns a scannable list into a page,
61
+ // so the requirement does not reach inside one.
62
+ if (UNION_MEMBER.test(text.slice(comment.range[1]))) continue
63
+
64
+ context.report({
65
+ node: { type: "Block", range: comment.range },
66
+ message: "JSDoc should span multiple lines.",
67
+ fix(fixer) {
68
+ return fixer.replaceTextRange(comment.range, `/**\n${indent} * ${body}\n${indent} */`)
69
+ },
70
+ })
71
+ }
72
+ },
73
+ }
74
+ },
75
+ }
@@ -15,16 +15,24 @@
15
15
 
16
16
  import type { AstNode, Fixer, Rule, RuleContext } from "./plugin-types.js"
17
17
 
18
- /** Comparisons meaning "non-empty", which become the bare length. */
18
+ /**
19
+ * Comparisons meaning "non-empty", which become the bare length.
20
+ */
19
21
  const TRUTHY_FORMS = new Set(["> 0", "!== 0", "!= 0", ">= 1"])
20
22
 
21
- /** Comparisons meaning "empty", which become a negated length. */
23
+ /**
24
+ * Comparisons meaning "empty", which become a negated length.
25
+ */
22
26
  const FALSY_FORMS = new Set(["=== 0", "== 0", "< 1"])
23
27
 
24
- /** Members whose length-ness the rule understands. */
28
+ /**
29
+ * Members whose length-ness the rule understands.
30
+ */
25
31
  const LENGTH_PROPERTIES = new Set(["length", "size"])
26
32
 
27
- /** The comparison rendered as `<operator> <literal>`, or null when it is not a length comparison. */
33
+ /**
34
+ * The comparison rendered as `<operator> <literal>`, or null when it is not a length comparison.
35
+ */
28
36
  function classify(node: AstNode): { member: AstNode; negate: boolean } | null {
29
37
  if (node.type !== "BinaryExpression" || !node.operator || !node.left || !node.right) return null
30
38
 
@@ -84,7 +92,9 @@ export const preferLengthTruthinessRule: Rule = {
84
92
  })
85
93
  }
86
94
 
87
- /** Walk into a boolean context: logical operands and `!` arguments stay boolean. */
95
+ /**
96
+ * Walk into a boolean context: logical operands and `!` arguments stay boolean.
97
+ */
88
98
  function visitCondition(node: AstNode | null | undefined) {
89
99
  if (!node) return
90
100
 
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file The `sister-software/multiline-statement-padding` rule: a statement that spans lines gets a
6
+ * blank line on each side, so a reader can see where it starts and stops without matching brackets.
7
+ *
8
+ * One rule covers what look like two habits — a big object literal jammed against the next
9
+ * declaration, and a multi-line call jammed against the counter after it. Both are the same thing:
10
+ * a statement tall enough to read as a block, sitting flush against its neighbour.
11
+ *
12
+ * Neither edge is required at a block boundary, where the brace already separates. `console.*` calls
13
+ * are left to `console-padding`, which groups consecutive ones — this rule would split a run that
14
+ * happens to contain a tall call, and a run of output is one thing.
15
+ */
16
+
17
+ import { createPaddingHelpers } from "./padding-utils.js"
18
+ import type { AstNode, Rule } from "./plugin-types.js"
19
+
20
+ /**
21
+ * The object whose calls belong to `console-padding` rather than to this rule.
22
+ */
23
+ const CONSOLE_OBJECT = "console"
24
+
25
+ /**
26
+ * Does this statement's own source span more than one line?
27
+ */
28
+ function isMultiline(node: AstNode, text: string): boolean {
29
+ return text.slice(node.range[0], node.range[1]).includes("\n")
30
+ }
31
+
32
+ /**
33
+ * Is this statement a bare `console.<method>(…)` call, which `console-padding` owns?
34
+ */
35
+ function isConsoleStatement(node: AstNode | undefined): boolean {
36
+ if (node?.type !== "ExpressionStatement") return false
37
+ const callee = node.expression?.type === "CallExpression" ? node.expression.callee : undefined
38
+
39
+ return callee?.type === "MemberExpression" && callee.object?.name === CONSOLE_OBJECT
40
+ }
41
+
42
+ export const multilineStatementPaddingRule: Rule = {
43
+ meta: {
44
+ name: "multiline-statement-padding",
45
+ type: "layout",
46
+ fixable: "whitespace",
47
+ schema: [{ type: "object", additionalProperties: true }],
48
+ },
49
+ create(context) {
50
+ const sourceCode = context.sourceCode ?? context.getSourceCode!()
51
+ const text = sourceCode.getText()
52
+ const { requirePadding } = createPaddingHelpers(context)
53
+
54
+ function check(node: AstNode): void {
55
+ const parent = node.parent
56
+
57
+ if (!parent || !Array.isArray(parent.body)) return
58
+
59
+ if (!isMultiline(node, text) || isConsoleStatement(node)) return
60
+ const body = parent.body
61
+ const index = body.indexOf(node)
62
+
63
+ if (index === -1) return
64
+ const previous = body[index - 1]
65
+ const next = body[index + 1]
66
+
67
+ if (previous && !isConsoleStatement(previous)) {
68
+ requirePadding(previous, node, node, "Expected a blank line before this multi-line statement.")
69
+ }
70
+
71
+ if (next && !isConsoleStatement(next)) {
72
+ requirePadding(node, next, node, "Expected a blank line after this multi-line statement.")
73
+ }
74
+ }
75
+
76
+ // Every statement type that can stand in a statement list and span lines. Block-like statements are
77
+ // already padded from the front by `padding-lines`; this adds their trailing edge.
78
+ return Object.fromEntries(
79
+ [
80
+ "VariableDeclaration",
81
+ "ExpressionStatement",
82
+ "IfStatement",
83
+ "ForStatement",
84
+ "ForInStatement",
85
+ "ForOfStatement",
86
+ "WhileStatement",
87
+ "DoWhileStatement",
88
+ "SwitchStatement",
89
+ "TryStatement",
90
+ "FunctionDeclaration",
91
+ "ClassDeclaration",
92
+ "ReturnStatement",
93
+ "TSInterfaceDeclaration",
94
+ "TSTypeAliasDeclaration",
95
+ "TSEnumDeclaration",
96
+ "TSModuleDeclaration",
97
+ // An exported declaration arrives wrapped, and the wrapper spans the same lines as what it
98
+ // wraps — so matching the wrapper is enough. Without these, every `export` was invisible here.
99
+ "ExportNamedDeclaration",
100
+ "ExportDefaultDeclaration",
101
+ ].map((type) => [type, check])
102
+ )
103
+ },
104
+ }
@@ -3,19 +3,23 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  * @file A padding-lines rule, authored as an oxlint JS plugin (ESLint v9-compatible API). It
6
- * requires a blank line before `return` and block-like statements (the equivalent of ESLint's
7
- * `padding-line-between-statements` with `{ blankLine: "always", prev: "*", next: "return" | "block-like" }`),
8
- * and autofixes by inserting the missing blank line (before any leading comments).
6
+ * requires a blank line before the statements that END a path or a step — `return`, `continue`,
7
+ * `break`, a bare `x++`/`x--` and before block-like statements. Roughly ESLint's
8
+ * `padding-line-between-statements` with `{ blankLine: "always", prev: "*", next: "return" | "block-like" }`,
9
+ * widened to the rest of that family. Autofixes by inserting the blank line before any leading comments.
9
10
  */
10
11
 
12
+ import { createPaddingHelpers } from "./padding-utils.js"
11
13
  import type { AstNode, Rule } from "./plugin-types.js"
12
14
 
13
15
  /**
14
- * Statement node types that require a preceding blank line: `return` plus all "block-like" statements (matching
16
+ * Statement node types that require a preceding blank line: the path-enders plus all "block-like" statements (matching
15
17
  * ESLint's `block-like` selector).
16
18
  */
17
19
  const PADDED_STATEMENT_TYPES = [
18
20
  "ReturnStatement",
21
+ "ContinueStatement",
22
+ "BreakStatement",
19
23
  "BlockStatement",
20
24
  "IfStatement",
21
25
  "ForStatement",
@@ -25,8 +29,21 @@ const PADDED_STATEMENT_TYPES = [
25
29
  "DoWhileStatement",
26
30
  "SwitchStatement",
27
31
  "TryStatement",
32
+ // An interface or a type alias with a body reads as a block too — same braces, same weight on the
33
+ // page — so it wants the same separation from whatever precedes it.
34
+ "TSInterfaceDeclaration",
35
+ "TSTypeAliasDeclaration",
36
+ "TSEnumDeclaration",
37
+ "TSModuleDeclaration",
28
38
  ] as const
29
39
 
40
+ /**
41
+ * Does this node type require a preceding blank line?
42
+ */
43
+ function isPaddedType(type: string | undefined): boolean {
44
+ return !!type && (PADDED_STATEMENT_TYPES as readonly string[]).includes(type)
45
+ }
46
+
30
47
  export const paddingRule: Rule = {
31
48
  meta: {
32
49
  name: "padding-lines",
@@ -35,9 +52,7 @@ export const paddingRule: Rule = {
35
52
  schema: [{ type: "object", additionalProperties: true }],
36
53
  },
37
54
  create(context) {
38
- const sourceCode = context.sourceCode ?? context.getSourceCode!()
39
- const text = sourceCode.getText()
40
- const comments = sourceCode.getAllComments()
55
+ const { requirePadding } = createPaddingHelpers(context)
41
56
 
42
57
  function check(node: AstNode) {
43
58
  const parent = node.parent
@@ -49,32 +64,32 @@ export const paddingRule: Rule = {
49
64
  const index = parent.body.indexOf(node)
50
65
 
51
66
  if (index <= 0) return // first statement in the block — nothing to pad against.
52
- const previous = parent.body[index - 1]!
53
67
 
54
- // A blank line should sit before the statement's own leading comments, so measure the gap
55
- // up to the earliest comment between the previous statement and this one.
56
- let start = node.range[0]
68
+ requirePadding(parent.body[index - 1]!, node, node, "Expected a blank line before this statement.")
69
+ }
57
70
 
58
- for (const comment of comments) {
59
- if (comment.range[0] >= previous.range[1] && comment.range[1] <= node.range[0]) {
60
- start = Math.min(start, comment.range[0])
71
+ return {
72
+ ...Object.fromEntries(PADDED_STATEMENT_TYPES.map((type) => [type, check])),
73
+ // A bare `x++` / `x--` is a counter step, and reads like one only when it stands apart. It
74
+ // arrives as an ExpressionStatement, so it cannot be matched by node type alone.
75
+ ExpressionStatement(node: AstNode) {
76
+ if (node.expression?.type === "UpdateExpression") {
77
+ check(node)
61
78
  }
62
- }
63
-
64
- const gap = text.slice(previous.range[1], start)
65
-
66
- if ((gap.match(/\n/g) ?? []).length >= 2) return // already a blank line.
67
-
68
- context.report({
69
- node,
70
- message: "Expected a blank line before this statement.",
71
- fix(fixer) {
72
- return fixer.insertTextAfterRange([previous.range[1], previous.range[1]], "\n")
73
- },
74
- })
79
+ },
80
+ // An exported declaration arrives wrapped. Look through the wrapper so `export interface Foo {}`
81
+ // is padded exactly like the unexported form.
82
+ ExportNamedDeclaration(node: AstNode) {
83
+ if (isPaddedType(node.declaration?.type)) {
84
+ check(node)
85
+ }
86
+ },
87
+ ExportDefaultDeclaration(node: AstNode) {
88
+ if (isPaddedType(node.declaration?.type)) {
89
+ check(node)
90
+ }
91
+ },
75
92
  }
76
-
77
- return Object.fromEntries(PADDED_STATEMENT_TYPES.map((type) => [type, check]))
78
93
  },
79
94
  }
80
95