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 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
+ }