@naturalcycles/dev-lib 20.8.0 → 20.10.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.
@@ -503,14 +503,14 @@ export default {
503
503
  '@typescript-eslint/dot-notation': 0, // not always desireable
504
504
  '@typescript-eslint/consistent-indexed-object-style': 0, // Record looses the name of the key
505
505
  '@typescript-eslint/no-unsafe-enum-comparison': 0, // not practically helpful
506
- // stylistic
507
- '@stylistic/padding-line-between-statements': [
508
- 2,
509
- { blankLine: 'always', prev: 'function', next: '*' },
510
- { blankLine: 'always', prev: '*', next: 'function' },
511
- { blankLine: 'always', prev: 'class', next: '*' },
512
- { blankLine: 'always', prev: '*', next: 'class' },
513
- ],
514
- '@stylistic/lines-between-class-members': [2, 'always', { exceptAfterSingleLine: true }],
506
+ // stylistic - replaced by custom oxlint rules
507
+ // '@stylistic/padding-line-between-statements': [
508
+ // 2,
509
+ // { blankLine: 'always', prev: 'function', next: '*' },
510
+ // { blankLine: 'always', prev: '*', next: 'function' },
511
+ // { blankLine: 'always', prev: 'class', next: '*' },
512
+ // { blankLine: 'always', prev: '*', next: 'class' },
513
+ // ],
514
+ // '@stylistic/lines-between-class-members': [2, 'always', { exceptAfterSingleLine: true }],
515
515
  },
516
516
  }
@@ -13,7 +13,6 @@ import eslintPluginOxlint from 'eslint-plugin-oxlint'
13
13
  import eslintPluginVitest from '@vitest/eslint-plugin'
14
14
  import eslintPluginImportX from 'eslint-plugin-import-x'
15
15
  import eslintPluginSimpleImportSort from 'eslint-plugin-simple-import-sort'
16
- import eslintPluginStylistic from '@stylistic/eslint-plugin'
17
16
  import eslintRules from './eslint-rules.js'
18
17
  import eslintVueRules from './eslint-vue-rules.js'
19
18
  import eslintVitestRules from './eslint-vitest-rules.js'
@@ -106,7 +105,7 @@ function getConfig(tsconfigPath) {
106
105
  // 'unused-imports': require('eslint-plugin-unused-imports'), // disabled in favor of biome rules
107
106
  'simple-import-sort': eslintPluginSimpleImportSort,
108
107
  // jsdoc: eslintPluginJsdoc, // oxlint
109
- '@stylistic': eslintPluginStylistic,
108
+ // '@stylistic': eslintPluginStylistic, // oxlint custom plugin
110
109
  },
111
110
  languageOptions: {
112
111
  ecmaVersion: 'latest',
@@ -0,0 +1,299 @@
1
+ const BLANK_LINE_PATTERN = /(?:\r?\n)[ \t]*(?:\r?\n)/
2
+ const FALLBACK_PADDING_OPTIONS = [
3
+ { blankLine: 'always', prev: 'function', next: '*' },
4
+ { blankLine: 'always', prev: '*', next: 'function' },
5
+ { blankLine: 'always', prev: 'class', next: '*' },
6
+ { blankLine: 'always', prev: '*', next: 'class' },
7
+ ]
8
+
9
+ /**
10
+ * Utility that unwraps export declarations so the underlying declaration
11
+ * (function/class) can be inspected.
12
+ */
13
+ function unwrapExported(node) {
14
+ if (!node) return null
15
+ if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
16
+ return node.declaration || null
17
+ }
18
+ return node
19
+ }
20
+
21
+ function isFunctionLikeStatement(node) {
22
+ const inner = unwrapExported(node)
23
+ if (!inner) return false
24
+
25
+ return inner.type === 'FunctionDeclaration'
26
+ }
27
+
28
+ function isFunctionOverloadStatement(node) {
29
+ const inner = unwrapExported(node)
30
+ if (!inner) return false
31
+
32
+ return inner.type === 'TSDeclareFunction'
33
+ }
34
+
35
+ function isClassLikeStatement(node) {
36
+ const inner = unwrapExported(node)
37
+ if (!inner) return false
38
+
39
+ return inner.type === 'ClassDeclaration'
40
+ }
41
+
42
+ function describeStatement(node) {
43
+ if (!node) return 'statement'
44
+ if (isFunctionLikeStatement(node)) return 'function declaration'
45
+ if (isClassLikeStatement(node)) return 'class declaration'
46
+ return 'statement'
47
+ }
48
+
49
+ function isMethodOverload(node) {
50
+ if (!node) return false
51
+ if (node.type !== 'MethodDefinition' && node.type !== 'TSAbstractMethodDefinition') return false
52
+ return node.value && node.value.type === 'TSEmptyBodyFunctionExpression'
53
+ }
54
+
55
+ function hasBlankLineBetween(sourceCode, prevNode, nextNode) {
56
+ if (!prevNode || !nextNode) return false
57
+ if (!prevNode.range || !nextNode.range) return false
58
+ if (prevNode.range[1] >= nextNode.range[0]) return false
59
+
60
+ const between = sourceCode.text.slice(prevNode.range[1], nextNode.range[0])
61
+ return BLANK_LINE_PATTERN.test(between)
62
+ }
63
+
64
+ function insertBlankLineBeforeNext(fixer, sourceCode, prevNode, nextNode) {
65
+ if (!prevNode || !nextNode) return null
66
+ if (!prevNode.range || !nextNode.range) return null
67
+ if (prevNode.range[1] >= nextNode.range[0]) return null
68
+
69
+ const between = sourceCode.text.slice(prevNode.range[1], nextNode.range[0])
70
+ const hasLinebreak = /\r?\n/.test(between)
71
+ const linebreak = sourceCode.text.includes('\r\n') ? '\r\n' : '\n'
72
+ const text = hasLinebreak ? linebreak : linebreak + linebreak
73
+
74
+ return fixer.insertTextBeforeRange([nextNode.range[0], nextNode.range[0]], text)
75
+ }
76
+
77
+ function matchesSelector(selector, candidate) {
78
+ if (selector == null) return false
79
+ if (selector === '*') return true
80
+ if (Array.isArray(selector)) return selector.some(item => matchesSelector(item, candidate))
81
+ return selector === candidate
82
+ }
83
+
84
+ const paddingLineBetweenStatementsRule = {
85
+ meta: {
86
+ type: 'layout',
87
+ docs: {
88
+ description: 'Require a blank line between functions and classes and surrounding statements',
89
+ },
90
+ fixable: 'whitespace',
91
+ schema: {
92
+ type: 'array',
93
+ items: {
94
+ type: 'object',
95
+ properties: {
96
+ blankLine: { enum: ['always'] },
97
+ prev: { anyOf: [{ enum: ['*', 'function', 'class'] }, { type: 'array' }] },
98
+ next: { anyOf: [{ enum: ['*', 'function', 'class'] }, { type: 'array' }] },
99
+ },
100
+ required: ['blankLine', 'prev', 'next'],
101
+ additionalProperties: false,
102
+ },
103
+ },
104
+ defaultOptions: [],
105
+ messages: {
106
+ expectedBlankLine: 'Expected blank line between {prev} and {next}.',
107
+ },
108
+ },
109
+ create(context) {
110
+ const sourceCode = context.sourceCode
111
+ const options =
112
+ Array.isArray(context.options) && context.options.length > 0
113
+ ? context.options
114
+ : FALLBACK_PADDING_OPTIONS
115
+
116
+ const requiresBlankLine = {
117
+ beforeFunction: false,
118
+ afterFunction: false,
119
+ beforeClass: false,
120
+ afterClass: false,
121
+ }
122
+
123
+ for (const option of options) {
124
+ if (!option || option.blankLine !== 'always') continue
125
+
126
+ if (matchesSelector(option.prev, 'function') && matchesSelector(option.next, '*')) {
127
+ requiresBlankLine.afterFunction = true
128
+ }
129
+ if (matchesSelector(option.prev, '*') && matchesSelector(option.next, 'function')) {
130
+ requiresBlankLine.beforeFunction = true
131
+ }
132
+ if (matchesSelector(option.prev, 'class') && matchesSelector(option.next, '*')) {
133
+ requiresBlankLine.afterClass = true
134
+ }
135
+ if (matchesSelector(option.prev, '*') && matchesSelector(option.next, 'class')) {
136
+ requiresBlankLine.beforeClass = true
137
+ }
138
+ }
139
+
140
+ const shouldCheckFunctions = requiresBlankLine.beforeFunction || requiresBlankLine.afterFunction
141
+ const shouldCheckClasses = requiresBlankLine.beforeClass || requiresBlankLine.afterClass
142
+
143
+ if (!shouldCheckFunctions && !shouldCheckClasses) {
144
+ // Nothing to enforce, bail early.
145
+ return {}
146
+ }
147
+
148
+ function needsBlankLine(prevNode, nextNode) {
149
+ if (shouldCheckFunctions) {
150
+ if (
151
+ requiresBlankLine.afterFunction &&
152
+ isFunctionLikeStatement(prevNode) &&
153
+ !isFunctionOverloadStatement(nextNode)
154
+ ) {
155
+ return true
156
+ }
157
+ if (
158
+ requiresBlankLine.beforeFunction &&
159
+ isFunctionLikeStatement(nextNode) &&
160
+ !isFunctionOverloadStatement(prevNode)
161
+ ) {
162
+ return true
163
+ }
164
+ }
165
+ if (shouldCheckClasses) {
166
+ if (requiresBlankLine.afterClass && isClassLikeStatement(prevNode)) return true
167
+ if (requiresBlankLine.beforeClass && isClassLikeStatement(nextNode)) return true
168
+ }
169
+ return false
170
+ }
171
+
172
+ function checkStatements(statements) {
173
+ if (!Array.isArray(statements) || statements.length < 2) return
174
+
175
+ for (let i = 1; i < statements.length; i += 1) {
176
+ const prevNode = statements[i - 1]
177
+ const nextNode = statements[i]
178
+
179
+ if (!prevNode || !nextNode) continue
180
+ if (!needsBlankLine(prevNode, nextNode)) continue
181
+ if (hasBlankLineBetween(sourceCode, prevNode, nextNode)) continue
182
+
183
+ context.report({
184
+ node: nextNode,
185
+ messageId: 'expectedBlankLine',
186
+ data: {
187
+ prev: describeStatement(prevNode),
188
+ next: describeStatement(nextNode),
189
+ },
190
+ fix(fixer) {
191
+ return insertBlankLineBeforeNext(fixer, sourceCode, prevNode, nextNode)
192
+ },
193
+ })
194
+ }
195
+ }
196
+
197
+ return {
198
+ Program(node) {
199
+ checkStatements(node.body)
200
+ },
201
+ BlockStatement(node) {
202
+ checkStatements(node.body)
203
+ },
204
+ StaticBlock(node) {
205
+ checkStatements(node.body)
206
+ },
207
+ SwitchCase(node) {
208
+ checkStatements(node.consequent)
209
+ },
210
+ }
211
+ },
212
+ }
213
+
214
+ function isSingleLine(node, sourceCode) {
215
+ if (!node || !node.range) return false
216
+ const start = sourceCode.getLocFromIndex(node.range[0])
217
+ const endIndex = node.range[1] > node.range[0] ? node.range[1] - 1 : node.range[1]
218
+ const end = sourceCode.getLocFromIndex(endIndex)
219
+ return start.line === end.line
220
+ }
221
+
222
+ const linesBetweenClassMembersRule = {
223
+ meta: {
224
+ type: 'layout',
225
+ docs: {
226
+ description: 'Require blank lines between class members',
227
+ },
228
+ fixable: 'whitespace',
229
+ schema: [
230
+ { enum: ['always'] },
231
+ {
232
+ type: 'object',
233
+ properties: {
234
+ exceptAfterSingleLine: { type: 'boolean' },
235
+ },
236
+ additionalProperties: false,
237
+ },
238
+ ],
239
+ defaultOptions: ['always', { exceptAfterSingleLine: false }],
240
+ messages: {
241
+ expectedBlankLine: 'Expected blank line between class members.',
242
+ },
243
+ },
244
+ create(context) {
245
+ const sourceCode = context.sourceCode
246
+ const mode = context.options?.[0] || 'always'
247
+ const exceptAfterSingleLine =
248
+ context.options?.[1]?.exceptAfterSingleLine !== undefined
249
+ ? Boolean(context.options[1].exceptAfterSingleLine)
250
+ : true
251
+ const exceptAfterOverload = true
252
+
253
+ if (mode !== 'always') {
254
+ return {}
255
+ }
256
+
257
+ function checkClassBody(body) {
258
+ if (!Array.isArray(body) || body.length < 2) return
259
+
260
+ const members = exceptAfterOverload ? body.filter(member => !isMethodOverload(member)) : body
261
+ if (members.length < 2) return
262
+
263
+ for (let i = 1; i < members.length; i += 1) {
264
+ const prevElement = members[i - 1]
265
+ const nextElement = members[i]
266
+ if (!prevElement || !nextElement) continue
267
+
268
+ if (exceptAfterSingleLine && isSingleLine(prevElement, sourceCode)) continue
269
+ if (hasBlankLineBetween(sourceCode, prevElement, nextElement)) continue
270
+
271
+ context.report({
272
+ node: nextElement,
273
+ messageId: 'expectedBlankLine',
274
+ fix(fixer) {
275
+ return insertBlankLineBeforeNext(fixer, sourceCode, prevElement, nextElement)
276
+ },
277
+ })
278
+ }
279
+ }
280
+
281
+ return {
282
+ ClassBody(node) {
283
+ checkClassBody(node.body)
284
+ },
285
+ }
286
+ },
287
+ }
288
+
289
+ const stylisticPlugin = {
290
+ meta: {
291
+ name: '@stylistic',
292
+ },
293
+ rules: {
294
+ 'padding-line-between-statements': paddingLineBetweenStatementsRule,
295
+ 'lines-between-class-members': linesBetweenClassMembersRule,
296
+ },
297
+ }
298
+
299
+ export default stylisticPlugin
@@ -11,7 +11,16 @@
11
11
  "promise",
12
12
  "vitest"
13
13
  ],
14
+ "jsPlugins": ["./oxlint-plugin-stylistic.mjs"],
14
15
  "rules": {
16
+ "@stylistic/padding-line-between-statements": [
17
+ 2,
18
+ { "blankLine": "always", "prev": "function", "next": "*" },
19
+ { "blankLine": "always", "prev": "*", "next": "function" },
20
+ { "blankLine": "always", "prev": "class", "next": "*" },
21
+ { "blankLine": "always", "prev": "*", "next": "class" }
22
+ ],
23
+ "@stylistic/lines-between-class-members": [2, "always", { "exceptAfterSingleLine": true }],
15
24
  "no-this-alias": 0,
16
25
  "no-async-promise-executor": 0,
17
26
  "no-standalone-expect": 0,
@@ -9,7 +9,7 @@
9
9
  "baseUrl": "${configDir}/src",
10
10
  "outDir": "${configDir}/dist",
11
11
  // Target/module
12
- "target": "es2023",
12
+ "target": "es2024",
13
13
  "lib": ["esnext"], // add "dom" if needed
14
14
  // module `nodenext` is a modern mode that auto-detects cjs/esm
15
15
  // it also defaults `esModuleInterop` and `allowSyntheticDefaultImports` to true
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.8.0",
4
+ "version": "20.10.0",
5
5
  "dependencies": {
6
6
  "@biomejs/biome": "^2",
7
7
  "@commitlint/cli": "^20",
@@ -10,7 +10,6 @@
10
10
  "@inquirer/prompts": "^7",
11
11
  "@naturalcycles/js-lib": "^15",
12
12
  "@naturalcycles/nodejs-lib": "^15",
13
- "@stylistic/eslint-plugin": "^5",
14
13
  "@vitest/coverage-v8": "^4",
15
14
  "@vitest/eslint-plugin": "^1",
16
15
  "eslint": "^9",
@@ -78,7 +77,7 @@
78
77
  "dev-lib": "dist/bin/dev-lib.js"
79
78
  },
80
79
  "engines": {
81
- "node": ">=22.12.0"
80
+ "node": ">=24.10.0"
82
81
  },
83
82
  "publishConfig": {
84
83
  "provenance": true,