eslint-plugin-solodev 1.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.
- package/LICENSE +21 -0
- package/README.md +300 -0
- package/index.mjs +57 -0
- package/package.json +38 -0
- package/rules/action-must-return.mjs +106 -0
- package/rules/no-loose-status-type.mjs +73 -0
- package/rules/no-plain-error-throw.mjs +65 -0
- package/rules/no-schema-parse.mjs +91 -0
- package/rules/no-silent-catch.mjs +111 -0
- package/rules/no-unsafe-type-assertions.mjs +50 -0
- package/rules/no-unvalidated-formdata.mjs +63 -0
- package/rules/one-export-per-file.mjs +117 -0
- package/rules/prefer-server-actions.mjs +84 -0
- package/rules/require-use-server.mjs +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julian Wagner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# eslint-plugin-solodev
|
|
2
|
+
|
|
3
|
+
10 ESLint rules that replace code review for solo developers.
|
|
4
|
+
|
|
5
|
+
When there's no one to catch your `throw new Error()` or your empty `catch {}`, these rules do it for you. Built from real patterns that caused production bugs in a one-person SaaS.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install eslint-plugin-solodev --save-dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup (flat config)
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// eslint.config.mjs
|
|
17
|
+
import solodev from 'eslint-plugin-solodev'
|
|
18
|
+
|
|
19
|
+
export default [
|
|
20
|
+
// All 7 framework-agnostic rules
|
|
21
|
+
solodev.configs.recommended,
|
|
22
|
+
|
|
23
|
+
// OR: all 10 rules including Next.js server action rules
|
|
24
|
+
solodev.configs.nextjs,
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or pick individual rules:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import solodev from 'eslint-plugin-solodev'
|
|
32
|
+
|
|
33
|
+
export default [
|
|
34
|
+
{
|
|
35
|
+
plugins: { solodev },
|
|
36
|
+
rules: {
|
|
37
|
+
'solodev/no-plain-error-throw': 'error',
|
|
38
|
+
'solodev/no-schema-parse': 'error',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
|
|
46
|
+
| Rule | Category | Description |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| [no-plain-error-throw](#no-plain-error-throw) | Error handling | Ban `throw new Error()` — force typed subclasses |
|
|
49
|
+
| [no-silent-catch](#no-silent-catch) | Error handling | Ban empty catch + log-only catch blocks |
|
|
50
|
+
| [no-schema-parse](#no-schema-parse) | Zod safety | Ban `schema.parse()` — force `safeParse()` |
|
|
51
|
+
| [no-unsafe-type-assertions](#no-unsafe-type-assertions) | Type safety | Ban `as unknown as Type` double-casts |
|
|
52
|
+
| [no-unvalidated-formdata](#no-unvalidated-formdata) | Type safety | Ban `formData.get('x') as string` |
|
|
53
|
+
| [no-loose-status-type](#no-loose-status-type) | Type safety | Ban `status: string` — force union types |
|
|
54
|
+
| [one-export-per-file](#one-export-per-file) | Code organization | One exported function per file |
|
|
55
|
+
| [action-must-return](#action-must-return) | Server actions | Actions must return via response helpers |
|
|
56
|
+
| [require-use-server](#require-use-server) | Server actions | `.action.ts` files must start with `"use server"` |
|
|
57
|
+
| [prefer-server-actions](#prefer-server-actions) | Server actions | Flag `fetch('/api/...')` — use server actions |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### no-plain-error-throw
|
|
62
|
+
|
|
63
|
+
A plain `Error` tells you nothing at 3am. Was it a network timeout you should retry? A validation failure you should surface? A bug you should page yourself for? Typed error subclasses encode this directly.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// Bad
|
|
67
|
+
throw new Error('Payment failed')
|
|
68
|
+
reject(new Error('Timed out'))
|
|
69
|
+
|
|
70
|
+
// Good
|
|
71
|
+
throw new TransientError('Payment gateway timeout', { cause: error })
|
|
72
|
+
throw new FatalError('Invalid card number')
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### no-silent-catch
|
|
76
|
+
|
|
77
|
+
Most linters catch empty `catch {}`. This rule also catches the more insidious pattern: logging the error and continuing as if nothing happened.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
// Bad
|
|
81
|
+
catch (e) {}
|
|
82
|
+
catch (e) { console.error(e) }
|
|
83
|
+
promise.catch(() => {})
|
|
84
|
+
promise.catch(e => console.log(e))
|
|
85
|
+
|
|
86
|
+
// Good
|
|
87
|
+
catch (e) { logger.error(e); throw e }
|
|
88
|
+
catch (e) { return fallbackValue }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### no-schema-parse
|
|
92
|
+
|
|
93
|
+
Zod's `.parse()` throws a `ZodError` on failure. In a server action, that's an uncontrolled exception with a useless stack trace. `safeParse()` returns a discriminated union you can handle gracefully.
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// Bad
|
|
97
|
+
const data = userSchema.parse(input)
|
|
98
|
+
z.array(schema).parse(items)
|
|
99
|
+
|
|
100
|
+
// Good
|
|
101
|
+
const result = userSchema.safeParse(input)
|
|
102
|
+
if (!result.success) return failed(result.error)
|
|
103
|
+
const data = result.data
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Skips test files automatically.
|
|
107
|
+
|
|
108
|
+
### no-unsafe-type-assertions
|
|
109
|
+
|
|
110
|
+
`as unknown as Type` is TypeScript's escape hatch. It silently breaks every type guarantee. If you need to convert between types, validate with a schema or write a type guard.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// Bad
|
|
114
|
+
const user = data as unknown as User
|
|
115
|
+
|
|
116
|
+
// Good
|
|
117
|
+
const result = userSchema.safeParse(data)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### no-unvalidated-formdata
|
|
121
|
+
|
|
122
|
+
`FormData.get()` returns `FormDataEntryValue | null`. Casting to `string` hides a null that will blow up at runtime.
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// Bad
|
|
126
|
+
const email = formData.get('email') as string
|
|
127
|
+
|
|
128
|
+
// Good
|
|
129
|
+
const email = formData.get('email')
|
|
130
|
+
if (!email || typeof email !== 'string') throw new Error('Missing email')
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### no-loose-status-type
|
|
134
|
+
|
|
135
|
+
`status: string` accepts any string. A typo like `"actve"` compiles fine and silently corrupts your data.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
// Bad
|
|
139
|
+
type Order = { status: string }
|
|
140
|
+
type Order = { status: string | null }
|
|
141
|
+
|
|
142
|
+
// Good
|
|
143
|
+
type Order = { status: 'pending' | 'paid' | 'shipped' }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### one-export-per-file
|
|
147
|
+
|
|
148
|
+
When you're solo, discoverability IS your architecture. One function per file means the filename tells you everything.
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
// Bad — two-exports.ts
|
|
152
|
+
export function createUser() { ... }
|
|
153
|
+
export function deleteUser() { ... }
|
|
154
|
+
|
|
155
|
+
// Good — create-user.ts
|
|
156
|
+
export function createUser() { ... }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Options:**
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
'solodev/one-export-per-file': ['error', { max: 2 }]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### action-must-return
|
|
166
|
+
|
|
167
|
+
A server action that doesn't return a typed response is invisible to the client. Did it succeed? Fail? The caller will never know.
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
// Bad — silent success
|
|
171
|
+
export async function updateProfile(data: FormData) {
|
|
172
|
+
await db.update(data)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Good
|
|
176
|
+
export async function updateProfile(data: FormData) {
|
|
177
|
+
await db.update(data)
|
|
178
|
+
return actionSuccessResponse('Profile updated')
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Options:**
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
'solodev/action-must-return': ['error', {
|
|
186
|
+
returnFunctions: ['actionSuccessResponse', 'actionFailureResponse'],
|
|
187
|
+
filePattern: '.action.ts'
|
|
188
|
+
}]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### require-use-server
|
|
192
|
+
|
|
193
|
+
Without the `"use server"` directive, Next.js silently treats your action file as a regular module. It works in dev, breaks in prod.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// Bad — missing directive
|
|
197
|
+
export async function createUser() { ... }
|
|
198
|
+
|
|
199
|
+
// Good
|
|
200
|
+
"use server"
|
|
201
|
+
export async function createUser() { ... }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Options:**
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
'solodev/require-use-server': ['error', { filePattern: '.server.ts' }]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### prefer-server-actions
|
|
211
|
+
|
|
212
|
+
`fetch('/api/users')` gives you untyped JSON and string URL typos. Server actions give you end-to-end TypeScript safety.
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
// Bad
|
|
216
|
+
const res = await fetch('/api/users')
|
|
217
|
+
const data = await res.json() // untyped
|
|
218
|
+
|
|
219
|
+
// Good
|
|
220
|
+
const result = await getUsers() // fully typed input + output
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Options:**
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
'solodev/prefer-server-actions': ['error', {
|
|
227
|
+
internalPatterns: ['/api/', '/trpc/']
|
|
228
|
+
}]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Skips external URLs and test files automatically.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Companion rules
|
|
236
|
+
|
|
237
|
+
These patterns are useful but don't need a custom plugin — use built-in ESLint config:
|
|
238
|
+
|
|
239
|
+
### Prefer safe JSON parse
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
// eslint.config.mjs
|
|
243
|
+
{
|
|
244
|
+
rules: {
|
|
245
|
+
'no-restricted-syntax': ['error', {
|
|
246
|
+
selector: "CallExpression[callee.object.name='JSON'][callee.property.name='parse']:not([arguments.1])",
|
|
247
|
+
message: 'Use safeJsonParse() or pass a reviver to JSON.parse().'
|
|
248
|
+
}]
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Prefer Promise.allSettled
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
{
|
|
257
|
+
rules: {
|
|
258
|
+
'no-restricted-syntax': ['error', {
|
|
259
|
+
selector: "CallExpression[callee.object.name='Promise'][callee.property.name='all']",
|
|
260
|
+
message: 'Prefer Promise.allSettled() — Promise.all() rejects on first failure and loses other results.'
|
|
261
|
+
}]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### No direct process.env
|
|
267
|
+
|
|
268
|
+
Use the [`n/no-process-env`](https://github.com/eslint-community/eslint-plugin-n/blob/master/docs/rules/no-process-env.md) rule from `eslint-plugin-n`.
|
|
269
|
+
|
|
270
|
+
### Consistent logging (no console)
|
|
271
|
+
|
|
272
|
+
```js
|
|
273
|
+
{
|
|
274
|
+
rules: {
|
|
275
|
+
'no-console': ['error', { allow: ['warn'] }]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Typographic quotes
|
|
281
|
+
|
|
282
|
+
Use [`eslint-plugin-human`](https://www.npmjs.com/package/eslint-plugin-human) — auto-fixes straight quotes to curly in JSX.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Philosophy
|
|
287
|
+
|
|
288
|
+
These rules exist because solo developers don't get code review. Every pattern here caused a real production bug that would have been caught by a second pair of eyes:
|
|
289
|
+
|
|
290
|
+
- A `throw new Error()` that should have been retried
|
|
291
|
+
- A `catch (e) { console.log(e) }` that silently broke a payment flow
|
|
292
|
+
- A `.parse()` that crashed a server action with an unreadable ZodError
|
|
293
|
+
- An `as unknown as` that passed TypeScript but failed at runtime
|
|
294
|
+
- A `status: string` that accepted a typo and corrupted 200 records
|
|
295
|
+
|
|
296
|
+
If you work with a team, you probably don't need all of these. If you work alone, turn them all on and never look back.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { rule as noPlainErrorThrow } from './rules/no-plain-error-throw.mjs'
|
|
2
|
+
import { rule as noSilentCatch } from './rules/no-silent-catch.mjs'
|
|
3
|
+
import { rule as noSchemaParse } from './rules/no-schema-parse.mjs'
|
|
4
|
+
import { rule as noUnsafeTypeAssertions } from './rules/no-unsafe-type-assertions.mjs'
|
|
5
|
+
import { rule as noUnvalidatedFormdata } from './rules/no-unvalidated-formdata.mjs'
|
|
6
|
+
import { rule as noLooseStatusType } from './rules/no-loose-status-type.mjs'
|
|
7
|
+
import { rule as oneExportPerFile } from './rules/one-export-per-file.mjs'
|
|
8
|
+
import { rule as actionMustReturn } from './rules/action-must-return.mjs'
|
|
9
|
+
import { rule as requireUseServer } from './rules/require-use-server.mjs'
|
|
10
|
+
import { rule as preferServerActions } from './rules/prefer-server-actions.mjs'
|
|
11
|
+
|
|
12
|
+
const plugin = {
|
|
13
|
+
rules: {
|
|
14
|
+
'no-plain-error-throw': noPlainErrorThrow,
|
|
15
|
+
'no-silent-catch': noSilentCatch,
|
|
16
|
+
'no-schema-parse': noSchemaParse,
|
|
17
|
+
'no-unsafe-type-assertions': noUnsafeTypeAssertions,
|
|
18
|
+
'no-unvalidated-formdata': noUnvalidatedFormdata,
|
|
19
|
+
'no-loose-status-type': noLooseStatusType,
|
|
20
|
+
'one-export-per-file': oneExportPerFile,
|
|
21
|
+
'action-must-return': actionMustReturn,
|
|
22
|
+
'require-use-server': requireUseServer,
|
|
23
|
+
'prefer-server-actions': preferServerActions,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
plugin.configs = {
|
|
28
|
+
recommended: {
|
|
29
|
+
plugins: { solodev: plugin },
|
|
30
|
+
rules: {
|
|
31
|
+
'solodev/no-plain-error-throw': 'error',
|
|
32
|
+
'solodev/no-silent-catch': 'error',
|
|
33
|
+
'solodev/no-schema-parse': 'error',
|
|
34
|
+
'solodev/no-unsafe-type-assertions': 'error',
|
|
35
|
+
'solodev/no-unvalidated-formdata': 'error',
|
|
36
|
+
'solodev/no-loose-status-type': 'error',
|
|
37
|
+
'solodev/one-export-per-file': 'error',
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
nextjs: {
|
|
41
|
+
plugins: { solodev: plugin },
|
|
42
|
+
rules: {
|
|
43
|
+
'solodev/no-plain-error-throw': 'error',
|
|
44
|
+
'solodev/no-silent-catch': 'error',
|
|
45
|
+
'solodev/no-schema-parse': 'error',
|
|
46
|
+
'solodev/no-unsafe-type-assertions': 'error',
|
|
47
|
+
'solodev/no-unvalidated-formdata': 'error',
|
|
48
|
+
'solodev/no-loose-status-type': 'error',
|
|
49
|
+
'solodev/one-export-per-file': 'error',
|
|
50
|
+
'solodev/action-must-return': 'error',
|
|
51
|
+
'solodev/require-use-server': 'error',
|
|
52
|
+
'solodev/prefer-server-actions': 'error',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default plugin
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-solodev",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "10 ESLint rules that replace code review for solo developers. Typed errors, safe Zod parsing, server action contracts, and more.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs",
|
|
9
|
+
"./rules/*": "./rules/*.mjs"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"eslint",
|
|
13
|
+
"eslintplugin",
|
|
14
|
+
"eslint-plugin",
|
|
15
|
+
"typescript",
|
|
16
|
+
"solo-dev",
|
|
17
|
+
"strict",
|
|
18
|
+
"zod",
|
|
19
|
+
"server-actions",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"code-review"
|
|
22
|
+
],
|
|
23
|
+
"author": "Julian Wagner",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/JWPapi/eslint-plugin-solodev"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"eslint": ">=8.0.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.mjs",
|
|
34
|
+
"rules/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: action-must-return
|
|
3
|
+
*
|
|
4
|
+
* Server actions must return via standard response helpers.
|
|
5
|
+
*
|
|
6
|
+
* A server action that doesn't return a typed response is invisible
|
|
7
|
+
* to the client. Did it succeed? Fail? You'll never know. This rule
|
|
8
|
+
* ensures every action returns through your response utilities.
|
|
9
|
+
*
|
|
10
|
+
* Options:
|
|
11
|
+
* {
|
|
12
|
+
* returnFunctions: ['actionSuccessResponse', 'actionFailureResponse'],
|
|
13
|
+
* filePattern: '.action.ts'
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Good:
|
|
17
|
+
* return actionSuccessResponse('Done', data)
|
|
18
|
+
* return actionFailureResponse('Something broke')
|
|
19
|
+
*
|
|
20
|
+
* Bad:
|
|
21
|
+
* await doSomething() // function ends without return
|
|
22
|
+
* return { success: true, data } // custom response object
|
|
23
|
+
*/
|
|
24
|
+
export const rule = {
|
|
25
|
+
meta: {
|
|
26
|
+
type: 'problem',
|
|
27
|
+
docs: {
|
|
28
|
+
description: 'Server actions must return via standard response helpers',
|
|
29
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#action-must-return'
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
mustReturnResponse:
|
|
33
|
+
'Server action must return via a standard response helper. Silent success/failure is not allowed.',
|
|
34
|
+
useStandardResponse:
|
|
35
|
+
'Use a standard response helper instead of a custom response object.'
|
|
36
|
+
},
|
|
37
|
+
schema: [
|
|
38
|
+
{
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
returnFunctions: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'string' }
|
|
44
|
+
},
|
|
45
|
+
filePattern: {
|
|
46
|
+
type: 'string'
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
create(context) {
|
|
54
|
+
const options = context.options[0] || {}
|
|
55
|
+
const returnFunctions = options.returnFunctions || ['actionSuccessResponse', 'actionFailureResponse']
|
|
56
|
+
const filePattern = options.filePattern || '.action.ts'
|
|
57
|
+
|
|
58
|
+
const filename = context.filename || context.getFilename()
|
|
59
|
+
if (!filename.endsWith(filePattern)) {
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let hasStandardReturn = false
|
|
64
|
+
let hasAnyReturn = false
|
|
65
|
+
let currentFunction = null
|
|
66
|
+
|
|
67
|
+
function checkReturnStatement(node) {
|
|
68
|
+
if (!currentFunction) return
|
|
69
|
+
|
|
70
|
+
hasAnyReturn = true
|
|
71
|
+
|
|
72
|
+
if (!node.argument) return
|
|
73
|
+
|
|
74
|
+
const arg = node.argument
|
|
75
|
+
|
|
76
|
+
if (arg.type === 'CallExpression' && arg.callee.type === 'Identifier') {
|
|
77
|
+
if (returnFunctions.includes(arg.callee.name)) {
|
|
78
|
+
hasStandardReturn = true
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (arg.type === 'ObjectExpression') {
|
|
84
|
+
const hasSuccessProperty = arg.properties.some(
|
|
85
|
+
prop => prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'success'
|
|
86
|
+
)
|
|
87
|
+
if (hasSuccessProperty) {
|
|
88
|
+
context.report({ node, messageId: 'useStandardResponse' })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
'ExportNamedDeclaration > FunctionDeclaration[async=true]'(node) {
|
|
95
|
+
currentFunction = node
|
|
96
|
+
hasStandardReturn = false
|
|
97
|
+
hasAnyReturn = false
|
|
98
|
+
},
|
|
99
|
+
'ExportNamedDeclaration > FunctionDeclaration[async=true]:exit'() {
|
|
100
|
+
currentFunction = null
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
ReturnStatement: checkReturnStatement
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-loose-status-type
|
|
3
|
+
*
|
|
4
|
+
* Ban `status: string` — force union types.
|
|
5
|
+
*
|
|
6
|
+
* A `status: string` accepts any string. A typo like "actve" compiles
|
|
7
|
+
* fine and silently corrupts your data. Union types or enums catch it
|
|
8
|
+
* at compile time.
|
|
9
|
+
*
|
|
10
|
+
* Good:
|
|
11
|
+
* status: 'active' | 'paused' | 'error'
|
|
12
|
+
* status: StatusEnum
|
|
13
|
+
* status: z.enum(['active', 'paused'])
|
|
14
|
+
*
|
|
15
|
+
* Bad:
|
|
16
|
+
* status: string
|
|
17
|
+
* status: string | null
|
|
18
|
+
*/
|
|
19
|
+
export const rule = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'suggestion',
|
|
22
|
+
docs: {
|
|
23
|
+
description: 'Disallow loose `status: string` — use union types or enums',
|
|
24
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-loose-status-type'
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
useUnionType:
|
|
28
|
+
'Avoid "status: string". Use a specific union type or enum to catch invalid values at compile time.'
|
|
29
|
+
},
|
|
30
|
+
schema: []
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
function isLooseStringType(typeAnnotation) {
|
|
34
|
+
if (!typeAnnotation) return false
|
|
35
|
+
|
|
36
|
+
if (typeAnnotation.type === 'TSStringKeyword') return true
|
|
37
|
+
|
|
38
|
+
if (typeAnnotation.type === 'TSUnionType') {
|
|
39
|
+
return typeAnnotation.types.some(
|
|
40
|
+
t =>
|
|
41
|
+
t.type === 'TSStringKeyword' ||
|
|
42
|
+
(t.type === 'TSTypeReference' && t.typeName.type === 'Identifier' && t.typeName.name === 'string')
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
TSPropertySignature(node) {
|
|
51
|
+
if (
|
|
52
|
+
node.key.type === 'Identifier' &&
|
|
53
|
+
node.key.name === 'status' &&
|
|
54
|
+
node.typeAnnotation?.typeAnnotation &&
|
|
55
|
+
isLooseStringType(node.typeAnnotation.typeAnnotation)
|
|
56
|
+
) {
|
|
57
|
+
context.report({ node, messageId: 'useUnionType' })
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
PropertyDefinition(node) {
|
|
62
|
+
if (
|
|
63
|
+
node.key.type === 'Identifier' &&
|
|
64
|
+
node.key.name === 'status' &&
|
|
65
|
+
node.typeAnnotation?.typeAnnotation &&
|
|
66
|
+
isLooseStringType(node.typeAnnotation.typeAnnotation)
|
|
67
|
+
) {
|
|
68
|
+
context.report({ node, messageId: 'useUnionType' })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-plain-error-throw
|
|
3
|
+
*
|
|
4
|
+
* Ban `throw new Error()` — force typed error subclasses.
|
|
5
|
+
*
|
|
6
|
+
* When you're the only reviewer, a plain Error tells you nothing.
|
|
7
|
+
* Typed subclasses (TransientError, FatalError, etc.) encode retry
|
|
8
|
+
* semantics, severity, and intent directly in the type system.
|
|
9
|
+
*
|
|
10
|
+
* Good:
|
|
11
|
+
* throw new TransientError('Network failed', { cause: error })
|
|
12
|
+
* throw new FatalError('Invalid config')
|
|
13
|
+
* reject(new TransientError('Timed out'))
|
|
14
|
+
*
|
|
15
|
+
* Bad:
|
|
16
|
+
* throw new Error('Something failed')
|
|
17
|
+
* reject(new Error('Timed out'))
|
|
18
|
+
*/
|
|
19
|
+
export const rule = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'suggestion',
|
|
22
|
+
docs: {
|
|
23
|
+
description: 'Disallow plain Error — use typed error subclasses for proper classification',
|
|
24
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-plain-error-throw'
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
useTypedError:
|
|
28
|
+
'Use a typed error subclass instead of plain Error. Typed errors enable retry logic, severity classification, and better debugging.',
|
|
29
|
+
useTypedErrorInReject:
|
|
30
|
+
'Use a typed error subclass instead of plain Error in reject(). Typed errors enable retry logic, severity classification, and better debugging.'
|
|
31
|
+
},
|
|
32
|
+
schema: []
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
function isPlainNewError(node) {
|
|
36
|
+
return (
|
|
37
|
+
node && node.type === 'NewExpression' && node.callee.type === 'Identifier' && node.callee.name === 'Error'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ThrowStatement(node) {
|
|
43
|
+
if (isPlainNewError(node.argument)) {
|
|
44
|
+
context.report({
|
|
45
|
+
node,
|
|
46
|
+
messageId: 'useTypedError'
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
CallExpression(node) {
|
|
51
|
+
if (
|
|
52
|
+
node.callee.type === 'Identifier' &&
|
|
53
|
+
node.callee.name === 'reject' &&
|
|
54
|
+
node.arguments.length > 0 &&
|
|
55
|
+
isPlainNewError(node.arguments[0])
|
|
56
|
+
) {
|
|
57
|
+
context.report({
|
|
58
|
+
node,
|
|
59
|
+
messageId: 'useTypedErrorInReject'
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-schema-parse
|
|
3
|
+
*
|
|
4
|
+
* Ban schema.parse() — force safeParse().
|
|
5
|
+
*
|
|
6
|
+
* Zod's .parse() throws a ZodError on failure. In a server action or
|
|
7
|
+
* API route, that crashes the process with an unhelpful stack trace.
|
|
8
|
+
* safeParse() returns a discriminated union you can handle gracefully.
|
|
9
|
+
*
|
|
10
|
+
* Good:
|
|
11
|
+
* const result = schema.safeParse(input)
|
|
12
|
+
* if (!result.success) return failed(result.error)
|
|
13
|
+
*
|
|
14
|
+
* Bad:
|
|
15
|
+
* schema.parse(input)
|
|
16
|
+
* z.array(schema).parse(data)
|
|
17
|
+
* schema.partial().parse(data)
|
|
18
|
+
*
|
|
19
|
+
* Skips test files automatically (*.test.*, *.spec.*).
|
|
20
|
+
*/
|
|
21
|
+
export const rule = {
|
|
22
|
+
meta: {
|
|
23
|
+
type: 'problem',
|
|
24
|
+
docs: {
|
|
25
|
+
description: 'Disallow schema.parse() — use safeParse() for controlled validation',
|
|
26
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-schema-parse'
|
|
27
|
+
},
|
|
28
|
+
messages: {
|
|
29
|
+
noSchemaParse:
|
|
30
|
+
'Avoid schema.parse() — it throws uncontrolled ZodErrors. Use safeParse() and handle the result.',
|
|
31
|
+
noPartialParse: 'Avoid schema.partial().parse(). Use safeParse() instead.',
|
|
32
|
+
noArrayParse: 'Avoid z.array(schema).parse(). Use safeParse() instead.'
|
|
33
|
+
},
|
|
34
|
+
schema: []
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
const filename = context.filename || context.getFilename()
|
|
38
|
+
|
|
39
|
+
if (filename.includes('.test.') || filename.includes('.spec.')) {
|
|
40
|
+
return {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSchemaLikeName(name) {
|
|
44
|
+
const lowerName = name.toLowerCase()
|
|
45
|
+
return lowerName === 'schema' || lowerName.endsWith('schema')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
CallExpression(node) {
|
|
50
|
+
if (
|
|
51
|
+
node.callee.type !== 'MemberExpression' ||
|
|
52
|
+
node.callee.property.type !== 'Identifier' ||
|
|
53
|
+
node.callee.property.name !== 'parse'
|
|
54
|
+
) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const object = node.callee.object
|
|
59
|
+
|
|
60
|
+
// schema.partial().parse()
|
|
61
|
+
if (
|
|
62
|
+
object.type === 'CallExpression' &&
|
|
63
|
+
object.callee.type === 'MemberExpression' &&
|
|
64
|
+
object.callee.property.type === 'Identifier' &&
|
|
65
|
+
object.callee.property.name === 'partial'
|
|
66
|
+
) {
|
|
67
|
+
context.report({ node, messageId: 'noPartialParse' })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// z.array(schema).parse()
|
|
72
|
+
if (
|
|
73
|
+
object.type === 'CallExpression' &&
|
|
74
|
+
object.callee.type === 'MemberExpression' &&
|
|
75
|
+
object.callee.object.type === 'Identifier' &&
|
|
76
|
+
object.callee.object.name === 'z' &&
|
|
77
|
+
object.callee.property.type === 'Identifier' &&
|
|
78
|
+
object.callee.property.name === 'array'
|
|
79
|
+
) {
|
|
80
|
+
context.report({ node, messageId: 'noArrayParse' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// schema.parse() where name matches
|
|
85
|
+
if (object.type === 'Identifier' && isSchemaLikeName(object.name)) {
|
|
86
|
+
context.report({ node, messageId: 'noSchemaParse' })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-silent-catch
|
|
3
|
+
*
|
|
4
|
+
* Ban catch blocks that swallow errors silently:
|
|
5
|
+
* - Empty catch blocks: catch {}
|
|
6
|
+
* - Catch blocks that only log: catch (e) { console.log(e) }
|
|
7
|
+
* - .catch(() => {}) callbacks
|
|
8
|
+
* - .catch(e => console.log(e)) callbacks
|
|
9
|
+
*
|
|
10
|
+
* The novel bit: detecting log-only catch blocks. Most linters catch
|
|
11
|
+
* empty catches, but a console.error() with no rethrow is equally
|
|
12
|
+
* dangerous — the app continues in a broken state.
|
|
13
|
+
*
|
|
14
|
+
* Good:
|
|
15
|
+
* catch (e) { logger.error(e); throw e }
|
|
16
|
+
* catch (e) { return fallbackValue }
|
|
17
|
+
*
|
|
18
|
+
* Bad:
|
|
19
|
+
* catch {}
|
|
20
|
+
* catch (e) { console.error(e) }
|
|
21
|
+
* .catch(() => {})
|
|
22
|
+
*/
|
|
23
|
+
export const rule = {
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description: 'Disallow catch blocks that silently swallow errors',
|
|
28
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-silent-catch'
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
emptyCatch: 'Empty catch block swallows errors silently. Either rethrow or handle the error properly.',
|
|
32
|
+
logOnlyCatch:
|
|
33
|
+
'Catch block only logs the error and continues execution. Either rethrow after logging or handle the error properly.',
|
|
34
|
+
emptyCatchCallback: '.catch(() => {}) swallows errors silently. Either rethrow or handle the error properly.',
|
|
35
|
+
logOnlyCatchCallback:
|
|
36
|
+
'.catch() only logs the error and continues execution. Either rethrow after logging or handle the error properly.'
|
|
37
|
+
},
|
|
38
|
+
schema: []
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
function isLogOnlyBlock(body) {
|
|
42
|
+
if (!body || body.length === 0) return false
|
|
43
|
+
|
|
44
|
+
return body.every(statement => {
|
|
45
|
+
if (statement.type === 'ExpressionStatement') {
|
|
46
|
+
const expr = statement.expression
|
|
47
|
+
if (
|
|
48
|
+
expr.type === 'CallExpression' &&
|
|
49
|
+
expr.callee.type === 'MemberExpression' &&
|
|
50
|
+
expr.callee.object.type === 'Identifier' &&
|
|
51
|
+
expr.callee.object.name === 'console'
|
|
52
|
+
) {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isEmptyOrLogOnlyArrow(node) {
|
|
61
|
+
if (node.body.type === 'BlockStatement') {
|
|
62
|
+
if (node.body.body.length === 0) return 'empty'
|
|
63
|
+
if (isLogOnlyBlock(node.body.body)) return 'logOnly'
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
node.body.type === 'CallExpression' &&
|
|
67
|
+
node.body.callee.type === 'MemberExpression' &&
|
|
68
|
+
node.body.callee.object.type === 'Identifier' &&
|
|
69
|
+
node.body.callee.object.name === 'console'
|
|
70
|
+
) {
|
|
71
|
+
return 'logOnly'
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
CatchClause(node) {
|
|
78
|
+
const body = node.body.body
|
|
79
|
+
|
|
80
|
+
if (body.length === 0) {
|
|
81
|
+
context.report({ node, messageId: 'emptyCatch' })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isLogOnlyBlock(body)) {
|
|
86
|
+
context.report({ node, messageId: 'logOnlyCatch' })
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
CallExpression(node) {
|
|
91
|
+
if (
|
|
92
|
+
node.callee.type === 'MemberExpression' &&
|
|
93
|
+
node.callee.property.type === 'Identifier' &&
|
|
94
|
+
node.callee.property.name === 'catch' &&
|
|
95
|
+
node.arguments.length > 0
|
|
96
|
+
) {
|
|
97
|
+
const callback = node.arguments[0]
|
|
98
|
+
|
|
99
|
+
if (callback.type === 'ArrowFunctionExpression' || callback.type === 'FunctionExpression') {
|
|
100
|
+
const result = isEmptyOrLogOnlyArrow(callback)
|
|
101
|
+
if (result === 'empty') {
|
|
102
|
+
context.report({ node, messageId: 'emptyCatchCallback' })
|
|
103
|
+
} else if (result === 'logOnly') {
|
|
104
|
+
context.report({ node, messageId: 'logOnlyCatchCallback' })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-unsafe-type-assertions
|
|
3
|
+
*
|
|
4
|
+
* Ban `as unknown as Type` double-casts.
|
|
5
|
+
*
|
|
6
|
+
* Double-casting is a type-system escape hatch that silently breaks
|
|
7
|
+
* every guarantee TypeScript gives you. If you need to convert between
|
|
8
|
+
* types, validate with Zod or write a proper type guard.
|
|
9
|
+
*
|
|
10
|
+
* Good:
|
|
11
|
+
* const parsed = schema.safeParse(data)
|
|
12
|
+
* if (isUser(data)) { ... }
|
|
13
|
+
*
|
|
14
|
+
* Bad:
|
|
15
|
+
* data as unknown as User
|
|
16
|
+
*/
|
|
17
|
+
export const rule = {
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'problem',
|
|
20
|
+
docs: {
|
|
21
|
+
description: 'Disallow unsafe `as unknown as Type` double-cast assertions',
|
|
22
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-unsafe-type-assertions'
|
|
23
|
+
},
|
|
24
|
+
messages: {
|
|
25
|
+
asUnknownAs: 'Avoid "as unknown as Type". Use schema validation or a type guard instead.'
|
|
26
|
+
},
|
|
27
|
+
schema: []
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
TSAsExpression(node) {
|
|
32
|
+
if (
|
|
33
|
+
node.typeAnnotation.type === 'TSUnknownKeyword' ||
|
|
34
|
+
(node.typeAnnotation.type === 'TSTypeReference' &&
|
|
35
|
+
node.typeAnnotation.typeName.type === 'Identifier' &&
|
|
36
|
+
node.typeAnnotation.typeName.name === 'unknown')
|
|
37
|
+
) {
|
|
38
|
+
const ancestors = context.sourceCode.getAncestors(node)
|
|
39
|
+
const parent = ancestors[ancestors.length - 1]
|
|
40
|
+
if (parent && parent.type === 'TSAsExpression') {
|
|
41
|
+
context.report({
|
|
42
|
+
node: parent,
|
|
43
|
+
messageId: 'asUnknownAs'
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: no-unvalidated-formdata
|
|
3
|
+
*
|
|
4
|
+
* Ban `formData.get('x') as string` without null checks.
|
|
5
|
+
*
|
|
6
|
+
* FormData.get() returns FormDataEntryValue | null. Casting directly
|
|
7
|
+
* to string hides a potential null that will blow up at runtime.
|
|
8
|
+
*
|
|
9
|
+
* Good:
|
|
10
|
+
* const email = formData.get('email')
|
|
11
|
+
* if (!email || typeof email !== 'string') throw new Error('Missing')
|
|
12
|
+
*
|
|
13
|
+
* Bad:
|
|
14
|
+
* const email = formData.get('email') as string
|
|
15
|
+
*/
|
|
16
|
+
export const rule = {
|
|
17
|
+
meta: {
|
|
18
|
+
type: 'problem',
|
|
19
|
+
docs: {
|
|
20
|
+
description: 'Disallow casting formData.get() directly to string without validation',
|
|
21
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#no-unvalidated-formdata'
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
validateFormData:
|
|
25
|
+
'Avoid "formData.get(...) as string". FormData.get() can return null. Validate the value before using it.'
|
|
26
|
+
},
|
|
27
|
+
schema: []
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
TSAsExpression(node) {
|
|
32
|
+
const expression = node.expression
|
|
33
|
+
|
|
34
|
+
if (expression.type !== 'CallExpression') return
|
|
35
|
+
if (expression.callee.type !== 'MemberExpression') return
|
|
36
|
+
|
|
37
|
+
const callee = expression.callee
|
|
38
|
+
|
|
39
|
+
if (callee.property.type !== 'Identifier' || callee.property.name !== 'get') return
|
|
40
|
+
|
|
41
|
+
const objectName = callee.object.type === 'Identifier' ? callee.object.name : null
|
|
42
|
+
if (!objectName) return
|
|
43
|
+
|
|
44
|
+
const formDataNames = ['formData', 'form', 'data', 'fd']
|
|
45
|
+
const isLikelyFormData =
|
|
46
|
+
formDataNames.some(name => objectName.toLowerCase().includes(name.toLowerCase())) ||
|
|
47
|
+
objectName === 'formData'
|
|
48
|
+
|
|
49
|
+
if (!isLikelyFormData) return
|
|
50
|
+
|
|
51
|
+
const typeAnnotation = node.typeAnnotation
|
|
52
|
+
if (
|
|
53
|
+
typeAnnotation.type === 'TSStringKeyword' ||
|
|
54
|
+
(typeAnnotation.type === 'TSTypeReference' &&
|
|
55
|
+
typeAnnotation.typeName.type === 'Identifier' &&
|
|
56
|
+
typeAnnotation.typeName.name === 'string')
|
|
57
|
+
) {
|
|
58
|
+
context.report({ node, messageId: 'validateFormData' })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: one-export-per-file
|
|
3
|
+
*
|
|
4
|
+
* Enforce one exported function per file.
|
|
5
|
+
*
|
|
6
|
+
* When you're solo, discoverability IS your architecture. If a file
|
|
7
|
+
* exports 5 functions, you'll forget 3 of them exist. One function
|
|
8
|
+
* per file means the filename tells you everything.
|
|
9
|
+
*
|
|
10
|
+
* Counts as exported function:
|
|
11
|
+
* export function foo() {}
|
|
12
|
+
* export default function() {}
|
|
13
|
+
* export const foo = () => {}
|
|
14
|
+
*
|
|
15
|
+
* Does NOT count:
|
|
16
|
+
* Type/interface exports, non-function const exports, re-exports
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* { max: 1 } — configurable max (default: 1)
|
|
20
|
+
*
|
|
21
|
+
* Exempt file patterns via ESLint config overrides:
|
|
22
|
+
* index.ts, *.schema.ts, *.types.ts, *.constants.ts
|
|
23
|
+
*/
|
|
24
|
+
export const rule = {
|
|
25
|
+
meta: {
|
|
26
|
+
type: 'suggestion',
|
|
27
|
+
docs: {
|
|
28
|
+
description: 'Enforce one exported function per file for discoverability',
|
|
29
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#one-export-per-file'
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
tooManyExports:
|
|
33
|
+
'File exports {{count}} functions ({{names}}). Split into one function per file for better discoverability.'
|
|
34
|
+
},
|
|
35
|
+
schema: [
|
|
36
|
+
{
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
max: {
|
|
40
|
+
type: 'integer',
|
|
41
|
+
minimum: 1
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
additionalProperties: false
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
create(context) {
|
|
49
|
+
const max = (context.options[0] && context.options[0].max) || 1
|
|
50
|
+
const exportedFunctions = []
|
|
51
|
+
|
|
52
|
+
function isFunctionInit(init) {
|
|
53
|
+
if (!init) return false
|
|
54
|
+
if (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ExportNamedDeclaration(node) {
|
|
60
|
+
if (!node.declaration) return
|
|
61
|
+
|
|
62
|
+
if (node.declaration.type === 'FunctionDeclaration' || node.declaration.type === 'TSDeclareFunction') {
|
|
63
|
+
exportedFunctions.push({
|
|
64
|
+
node: node.declaration,
|
|
65
|
+
name: node.declaration.id ? node.declaration.id.name : '<anonymous>'
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (node.declaration.type === 'VariableDeclaration') {
|
|
70
|
+
for (const declarator of node.declaration.declarations) {
|
|
71
|
+
if (isFunctionInit(declarator.init)) {
|
|
72
|
+
exportedFunctions.push({
|
|
73
|
+
node: declarator,
|
|
74
|
+
name: declarator.id ? declarator.id.name : '<anonymous>'
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
ExportDefaultDeclaration(node) {
|
|
82
|
+
const decl = node.declaration
|
|
83
|
+
if (
|
|
84
|
+
decl.type === 'FunctionDeclaration' ||
|
|
85
|
+
decl.type === 'ArrowFunctionExpression' ||
|
|
86
|
+
decl.type === 'FunctionExpression'
|
|
87
|
+
) {
|
|
88
|
+
exportedFunctions.push({
|
|
89
|
+
node: decl,
|
|
90
|
+
name: decl.id ? decl.id.name : 'default'
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
'Program:exit'() {
|
|
96
|
+
const seen = new Set()
|
|
97
|
+
const unique = exportedFunctions.filter(f => {
|
|
98
|
+
if (seen.has(f.name)) return false
|
|
99
|
+
seen.add(f.name)
|
|
100
|
+
return true
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (unique.length > max) {
|
|
104
|
+
const names = unique.map(f => f.name).join(', ')
|
|
105
|
+
context.report({
|
|
106
|
+
node: unique[0].node,
|
|
107
|
+
messageId: 'tooManyExports',
|
|
108
|
+
data: {
|
|
109
|
+
count: String(unique.length),
|
|
110
|
+
names
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: prefer-server-actions
|
|
3
|
+
*
|
|
4
|
+
* Flag fetch() calls to internal API routes.
|
|
5
|
+
*
|
|
6
|
+
* Server actions give you end-to-end type safety — TypeScript checks
|
|
7
|
+
* both input and output. fetch('/api/...') gives you untyped JSON
|
|
8
|
+
* and string URL typos.
|
|
9
|
+
*
|
|
10
|
+
* Options:
|
|
11
|
+
* { internalPatterns: ['/api/', '/app/'] }
|
|
12
|
+
*
|
|
13
|
+
* Detected:
|
|
14
|
+
* fetch('/api/users')
|
|
15
|
+
* fetch(`/api/users/${id}`)
|
|
16
|
+
*
|
|
17
|
+
* Not flagged:
|
|
18
|
+
* fetch('https://external.com/api')
|
|
19
|
+
* Test files (*.test.*, *.spec.*)
|
|
20
|
+
*/
|
|
21
|
+
export const rule = {
|
|
22
|
+
meta: {
|
|
23
|
+
type: 'suggestion',
|
|
24
|
+
docs: {
|
|
25
|
+
description: 'Prefer server actions over fetch() to internal API routes',
|
|
26
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#prefer-server-actions'
|
|
27
|
+
},
|
|
28
|
+
messages: {
|
|
29
|
+
preferAction:
|
|
30
|
+
"Prefer a server action over fetch('{{url}}'). Server actions provide end-to-end type safety."
|
|
31
|
+
},
|
|
32
|
+
schema: [
|
|
33
|
+
{
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
internalPatterns: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
const filename = context.filename || context.getFilename()
|
|
47
|
+
|
|
48
|
+
if (filename.includes('.test.') || filename.includes('.spec.')) {
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const options = context.options[0] || {}
|
|
53
|
+
const internalPatterns = options.internalPatterns || ['/api/', '/app/']
|
|
54
|
+
|
|
55
|
+
function extractUrl(node) {
|
|
56
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value
|
|
57
|
+
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) return node.quasis[0].value.raw
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isInternalApiRoute(url) {
|
|
62
|
+
if (!url) return false
|
|
63
|
+
if (url.startsWith('http://') || url.startsWith('https://')) return false
|
|
64
|
+
return internalPatterns.some(pattern => url.includes(pattern))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
CallExpression(node) {
|
|
69
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'fetch' && node.arguments.length > 0) {
|
|
70
|
+
const url = extractUrl(node.arguments[0])
|
|
71
|
+
|
|
72
|
+
if (isInternalApiRoute(url)) {
|
|
73
|
+
const displayUrl = url.length > 50 ? url.substring(0, 50) + '...' : url
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
messageId: 'preferAction',
|
|
77
|
+
data: { url: displayUrl }
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule: require-use-server
|
|
3
|
+
*
|
|
4
|
+
* Server action files must start with "use server".
|
|
5
|
+
*
|
|
6
|
+
* Without the directive, Next.js silently treats your action as a
|
|
7
|
+
* regular function. It works in dev, breaks in prod. This rule
|
|
8
|
+
* catches it before deploy.
|
|
9
|
+
*
|
|
10
|
+
* Options:
|
|
11
|
+
* { filePattern: '.action.ts' }
|
|
12
|
+
*
|
|
13
|
+
* Some projects use .server.ts — configure via options.
|
|
14
|
+
*/
|
|
15
|
+
export const rule = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'problem',
|
|
18
|
+
docs: {
|
|
19
|
+
description: 'Require "use server" directive in server action files',
|
|
20
|
+
url: 'https://github.com/JWPapi/eslint-plugin-solodev#require-use-server'
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
missingUseServer: 'Server action files must start with "use server".'
|
|
24
|
+
},
|
|
25
|
+
schema: [
|
|
26
|
+
{
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
filePattern: {
|
|
30
|
+
type: 'string'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
create(context) {
|
|
38
|
+
const options = context.options[0] || {}
|
|
39
|
+
const filePattern = options.filePattern || '.action.ts'
|
|
40
|
+
|
|
41
|
+
const filename = context.filename || context.getFilename()
|
|
42
|
+
if (!filename.endsWith(filePattern)) {
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
Program(node) {
|
|
48
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
49
|
+
const text = sourceCode.getText()
|
|
50
|
+
|
|
51
|
+
if (!text.startsWith('"use server"') && !text.startsWith("'use server'")) {
|
|
52
|
+
context.report({ node, messageId: 'missingUseServer' })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|