eslint-plugin-unslop 0.2.2 → 0.4.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/README.md +111 -118
- package/dist/index.cjs +711 -586
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +711 -586
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# eslint-plugin-unslop
|
|
2
2
|
|
|
3
|
-
ESLint plugin
|
|
3
|
+
ESLint plugin for architecture enforcement and code quality. Define module boundaries, control imports and exports, catch false sharing, and fix common LLM-generated code smells - all from a single shared configuration.
|
|
4
4
|
|
|
5
5
|
Requires ESLint 9+ (flat config). TypeScript optional but recommended.
|
|
6
6
|
|
|
@@ -12,87 +12,68 @@ npm install --save-dev eslint-plugin-unslop
|
|
|
12
12
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
|
-
The
|
|
16
|
-
|
|
17
|
-
```js
|
|
18
|
-
// eslint.config.mjs
|
|
19
|
-
import unslop from 'eslint-plugin-unslop'
|
|
20
|
-
|
|
21
|
-
export default [unslop.configs.recommended]
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
This turns on:
|
|
25
|
-
|
|
26
|
-
| Rule | Severity | What it does |
|
|
27
|
-
| --------------------------- | -------- | ------------------------------------------------------------------- |
|
|
28
|
-
| `unslop/no-special-unicode` | error | Catches smart quotes, invisible spaces, and other unicode impostors |
|
|
29
|
-
| `unslop/no-unicode-escape` | error | Prefers `"©"` over `"\u00A9"` |
|
|
30
|
-
|
|
31
|
-
The remaining rules need explicit configuration:
|
|
15
|
+
The full config enables the complete rule suite - architecture enforcement plus symbol fixers:
|
|
32
16
|
|
|
33
17
|
```js
|
|
34
18
|
// eslint.config.mjs
|
|
35
19
|
import unslop from 'eslint-plugin-unslop'
|
|
36
20
|
|
|
37
21
|
export default [
|
|
38
|
-
unslop.configs.
|
|
22
|
+
unslop.configs.full,
|
|
39
23
|
{
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
24
|
+
settings: {
|
|
25
|
+
unslop: {
|
|
26
|
+
sourceRoot: 'src',
|
|
27
|
+
architecture: {
|
|
28
|
+
utils: { shared: true },
|
|
29
|
+
'repository/*': {
|
|
30
|
+
imports: ['utils', 'models/*'],
|
|
31
|
+
exports: ['^create\\w+Repo$', '^Repository[A-Z]\\w+$'],
|
|
32
|
+
},
|
|
33
|
+
'models/*': {
|
|
34
|
+
imports: ['utils'],
|
|
35
|
+
},
|
|
36
|
+
app: {
|
|
37
|
+
imports: ['*'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
43
41
|
},
|
|
44
42
|
},
|
|
45
43
|
]
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
### `unslop/no-special-unicode`
|
|
51
|
-
|
|
52
|
-
**Recommended**
|
|
46
|
+
This turns on:
|
|
53
47
|
|
|
54
|
-
|
|
48
|
+
| Rule | Severity | What it does |
|
|
49
|
+
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
|
|
50
|
+
| `unslop/import-control` | error | Enforces boundaries and forbids local namespace imports |
|
|
51
|
+
| `unslop/export-control` | error | Restricts export patterns and forbids `export *` in module entrypoints |
|
|
52
|
+
| `unslop/no-false-sharing` | error | Flags shared entrypoint symbols with fewer than two consumer groups |
|
|
53
|
+
| `unslop/no-special-unicode` | error | Catches smart quotes, invisible spaces, and other unicode impostors |
|
|
54
|
+
| `unslop/no-unicode-escape` | error | Prefers `"(c)"` over `"\u00A9"` |
|
|
55
|
+
| `unslop/read-friendly-order` | error | Enforces top-down, dependency-friendly declaration order |
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
The `configs.minimal` config contains only the zero-config symbol fixers (`no-special-unicode` and `no-unicode-escape`). It is included automatically within `configs.full`, or can be used standalone for projects that don't need architecture enforcement:
|
|
57
58
|
|
|
58
59
|
```js
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
const quote = 'He said “hello”' // smart double quotes (U+201C, U+201D)
|
|
60
|
+
// eslint.config.mjs
|
|
61
|
+
import unslop from 'eslint-plugin-unslop'
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
const greeting = 'Hello World' // regular ASCII space
|
|
65
|
-
const quote = 'He said "hello"' // plain ASCII quotes
|
|
63
|
+
export default [unslop.configs.minimal]
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
indistinguishable from their ASCII counterparts in your font — that's exactly
|
|
70
|
-
the problem this rule catches.
|
|
71
|
-
|
|
72
|
-
### `unslop/no-unicode-escape`
|
|
73
|
-
|
|
74
|
-
**Recommended**
|
|
75
|
-
|
|
76
|
-
Prefers actual characters over `\uXXXX` escape sequences. If your string says `\u00A9`, just write `©` — your coworkers will thank you. LLM-generated code sometimes encodes characters as escape sequences for no good reason.
|
|
77
|
-
|
|
78
|
-
```js
|
|
79
|
-
// Bad
|
|
80
|
-
const copyright = '\u00A9 2025'
|
|
81
|
-
const arrow = '\u2192'
|
|
82
|
-
|
|
83
|
-
// Good
|
|
84
|
-
const copyright = '© 2025'
|
|
85
|
-
const arrow = '→'
|
|
86
|
-
```
|
|
66
|
+
## Rules
|
|
87
67
|
|
|
88
68
|
### `unslop/import-control`
|
|
89
69
|
|
|
90
|
-
Think of this as customs control for your modules
|
|
70
|
+
Think of this as customs control for your modules - you declare which modules are allowed to import from which, and anything undeclared gets turned away at the border.
|
|
91
71
|
|
|
92
72
|
The rule reads from a shared policy in `settings.unslop.architecture`. It's deny-by-default for cross-module imports, which means forgetting to declare a dependency is a loud error rather than a silent free-for-all. It also enforces:
|
|
93
73
|
|
|
94
74
|
- cross-module imports must arrive through the public gate (`index.ts` or `types.ts`)
|
|
95
|
-
-
|
|
75
|
+
- local cross-module namespace imports are forbidden (`import * as X from '<local-module>'`)
|
|
76
|
+
- same-module relative imports can only go one level deeper - no tunnelling into internals
|
|
96
77
|
- files that don't match any declared module are denied (fail-closed, not fail-silently)
|
|
97
78
|
|
|
98
79
|
#### Configuration
|
|
@@ -103,6 +84,7 @@ import unslop from 'eslint-plugin-unslop'
|
|
|
103
84
|
|
|
104
85
|
export default [
|
|
105
86
|
{
|
|
87
|
+
plugins: { unslop },
|
|
106
88
|
settings: {
|
|
107
89
|
unslop: {
|
|
108
90
|
sourceRoot: 'src',
|
|
@@ -124,6 +106,7 @@ export default [
|
|
|
124
106
|
rules: {
|
|
125
107
|
'unslop/import-control': 'error',
|
|
126
108
|
'unslop/export-control': 'error',
|
|
109
|
+
'unslop/no-false-sharing': 'error',
|
|
127
110
|
},
|
|
128
111
|
},
|
|
129
112
|
]
|
|
@@ -133,77 +116,53 @@ export default [
|
|
|
133
116
|
|
|
134
117
|
The customs declaration form for the other direction: what are you actually exporting from your module's public entrypoints?
|
|
135
118
|
|
|
136
|
-
When a module defines `exports` regex patterns in `settings.unslop.architecture`, every symbol exported from that module's `index.ts` or `types.ts` must match at least one pattern
|
|
119
|
+
When a module defines `exports` regex patterns in `settings.unslop.architecture`, every symbol exported from that module's `index.ts` or `types.ts` must match at least one pattern - otherwise it's stopped at the gate with an error at the export site. Modules without `exports` are waved through by default, so you can adopt this gradually. Regardless of module policy, `export * from ...` is rejected in `index.ts` and `types.ts` so symbol provenance stays explicit.
|
|
137
120
|
|
|
138
121
|
### `unslop/no-false-sharing`
|
|
139
122
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
The "shared" folder anti-pattern detector. LLMs love creating shared utilities that are only used by one consumer — or worse, by nobody at all. This rule requires that modules inside your designated shared directories are actually imported by at least two separate entities. If it's only used in one place, it's not shared — it's misplaced.
|
|
143
|
-
|
|
144
|
-
#### Options
|
|
145
|
-
|
|
146
|
-
```js
|
|
147
|
-
;[
|
|
148
|
-
'error',
|
|
149
|
-
{
|
|
150
|
-
dirs: [{ path: 'shared' }, { path: 'utils', mode: 'file' }],
|
|
151
|
-
mode: 'dir',
|
|
152
|
-
sourceRoot: 'src',
|
|
153
|
-
},
|
|
154
|
-
]
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
| Option | Type | Required | Description |
|
|
158
|
-
| ------------- | ----------------- | -------- | -------------------------------------------------------------------------------------------- |
|
|
159
|
-
| `dirs` | `array` | yes | Directories to enforce sharing rules on |
|
|
160
|
-
| `dirs[].path` | `string` | yes | Directory path relative to `sourceRoot` |
|
|
161
|
-
| `dirs[].mode` | `'file' \| 'dir'` | no | How to count consumers for this dir (overrides global `mode`) |
|
|
162
|
-
| `mode` | `'file' \| 'dir'` | no | Global consumer counting mode — `'file'` counts individual files, `'dir'` counts directories |
|
|
163
|
-
| `sourceRoot` | `string` | no | Source directory relative to project root |
|
|
123
|
+
The "shared" folder anti-pattern detector. LLMs (and some humans also) love creating shared APIs that are only used by one consumer - or worse, by nobody at all. This rule evaluates symbols exported from shared module entrypoints (`index.ts` and `types.ts`) and requires each exported symbol to be imported by at least two separate directory-level consumer groups. If a symbol is used in only one place, it's not shared - it's misplaced.
|
|
164
124
|
|
|
165
|
-
####
|
|
125
|
+
#### Configuration
|
|
166
126
|
|
|
167
|
-
|
|
127
|
+
Shared modules are declared via `shared: true` on module policies in
|
|
128
|
+
`settings.unslop.architecture`:
|
|
168
129
|
|
|
169
130
|
```js
|
|
170
131
|
// eslint.config.mjs
|
|
171
132
|
import unslop from 'eslint-plugin-unslop'
|
|
172
|
-
import tseslint from 'typescript-eslint'
|
|
173
133
|
|
|
174
|
-
export default
|
|
175
|
-
|
|
176
|
-
languageOptions: {
|
|
177
|
-
parserOptions: {
|
|
178
|
-
projectService: true,
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
unslop.configs.recommended,
|
|
134
|
+
export default [
|
|
135
|
+
unslop.configs.full,
|
|
183
136
|
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
'
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
137
|
+
settings: {
|
|
138
|
+
unslop: {
|
|
139
|
+
sourceRoot: 'src',
|
|
140
|
+
architecture: {
|
|
141
|
+
utils: { shared: true },
|
|
142
|
+
'shared/*': { shared: true },
|
|
190
143
|
},
|
|
191
|
-
|
|
144
|
+
},
|
|
192
145
|
},
|
|
193
146
|
},
|
|
194
|
-
|
|
147
|
+
]
|
|
195
148
|
```
|
|
196
149
|
|
|
150
|
+
The rule takes no options - all configuration comes from the shared architecture settings, consistent with `import-control` and `export-control`.
|
|
151
|
+
|
|
152
|
+
Consumer counting is always at the directory level: the importer file path relative to `sourceRoot`, minus filename. Both value imports and `import type` imports count as consumers, and alias imports such as `@/shared/index` are resolved the same as relative imports.
|
|
153
|
+
|
|
197
154
|
#### What it catches
|
|
198
155
|
|
|
199
156
|
```
|
|
200
|
-
src/shared/
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
157
|
+
src/shared/index.ts
|
|
158
|
+
export const formatDate = ...
|
|
159
|
+
-> imported only by src/features/calendar/view.ts
|
|
160
|
+
-> error: symbol "formatDate" has 1 consumer group(s) (group: features/calendar)
|
|
161
|
+
|
|
162
|
+
src/shared/types.ts
|
|
163
|
+
export type LegacyOptions = ...
|
|
164
|
+
-> not imported by anyone
|
|
165
|
+
-> error: symbol "LegacyOptions" has 0 consumer group(s) (no consumers found)
|
|
207
166
|
```
|
|
208
167
|
|
|
209
168
|
### `unslop/read-friendly-order`
|
|
@@ -212,10 +171,10 @@ Enforces a top-down reading order for your code. The idea: when someone opens a
|
|
|
212
171
|
|
|
213
172
|
This rule covers three areas:
|
|
214
173
|
|
|
215
|
-
**Top-level ordering**
|
|
174
|
+
**Top-level ordering** - Public/exported symbols should come before the private helpers they use. Read the API first, implementation details second.
|
|
216
175
|
|
|
217
176
|
```js
|
|
218
|
-
// Bad
|
|
177
|
+
// Bad - helper defined before its consumer
|
|
219
178
|
function formatName(name) {
|
|
220
179
|
return name.trim().toLowerCase()
|
|
221
180
|
}
|
|
@@ -224,7 +183,7 @@ export function createUser(name) {
|
|
|
224
183
|
return { name: formatName(name) }
|
|
225
184
|
}
|
|
226
185
|
|
|
227
|
-
// Good
|
|
186
|
+
// Good - consumer first, helper below
|
|
228
187
|
export function createUser(name) {
|
|
229
188
|
return { name: formatName(name) }
|
|
230
189
|
}
|
|
@@ -234,7 +193,7 @@ function formatName(name) {
|
|
|
234
193
|
}
|
|
235
194
|
```
|
|
236
195
|
|
|
237
|
-
**Class member ordering**
|
|
196
|
+
**Class member ordering** - Constructor first, public fields next, then other members ordered by dependency.
|
|
238
197
|
|
|
239
198
|
```js
|
|
240
199
|
// Bad
|
|
@@ -252,10 +211,10 @@ class UserService {
|
|
|
252
211
|
}
|
|
253
212
|
```
|
|
254
213
|
|
|
255
|
-
**Test file ordering**
|
|
214
|
+
**Test file ordering** - Setup hooks (`beforeEach`, `beforeAll`) before teardown hooks (`afterEach`, `afterAll`), and both before test cases.
|
|
256
215
|
|
|
257
216
|
```js
|
|
258
|
-
// Bad
|
|
217
|
+
// Bad - setup and tests buried between helpers
|
|
259
218
|
function buildFixture(overrides) {
|
|
260
219
|
return { id: 1, ...overrides }
|
|
261
220
|
}
|
|
@@ -269,7 +228,7 @@ beforeEach(() => {
|
|
|
269
228
|
buildFixture()
|
|
270
229
|
})
|
|
271
230
|
|
|
272
|
-
// Good
|
|
231
|
+
// Good - setup first, then tests, helpers at the bottom
|
|
273
232
|
beforeEach(() => {
|
|
274
233
|
buildFixture()
|
|
275
234
|
})
|
|
@@ -284,9 +243,43 @@ function assertCorrect(value) {
|
|
|
284
243
|
}
|
|
285
244
|
```
|
|
286
245
|
|
|
246
|
+
### `unslop/no-special-unicode`
|
|
247
|
+
|
|
248
|
+
Disallows special unicode punctuation and whitespace characters in string literals and template literals. LLMs love to sprinkle in smart quotes (`"like this"`), non-breaking spaces, and other invisible gremlins that look fine in a PR review but cause fun bugs at runtime.
|
|
249
|
+
|
|
250
|
+
Caught characters include: left/right smart quotes (`“” ‘’`), non-breaking space, en/em dash, horizontal ellipsis, zero-width space, and various other exotic whitespace.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
// Bad - these contain invisible special characters that look normal
|
|
254
|
+
const greeting = 'Hello World' // a non-breaking space (U+00A0) is hiding between the words
|
|
255
|
+
const quote = 'He said “hello”' // smart double quotes (U+201C, U+201D)
|
|
256
|
+
|
|
257
|
+
// Good
|
|
258
|
+
const greeting = 'Hello World' // regular ASCII space
|
|
259
|
+
const quote = 'He said "hello"' // plain ASCII quotes
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Note: the bad examples above contain actual unicode characters that may be
|
|
263
|
+
indistinguishable from their ASCII counterparts in your font - that's exactly
|
|
264
|
+
the problem this rule catches.
|
|
265
|
+
|
|
266
|
+
### `unslop/no-unicode-escape`
|
|
267
|
+
|
|
268
|
+
Prefers actual characters over `\uXXXX` escape sequences. If your string says `\u00A9`, just write `(c)` - your coworkers will thank you. LLM-generated code sometimes encodes characters as escape sequences for no good reason.
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
// Bad
|
|
272
|
+
const copyright = '\u00A9 2025'
|
|
273
|
+
const arrow = '\u2192'
|
|
274
|
+
|
|
275
|
+
// Good
|
|
276
|
+
const copyright = '(c) 2025'
|
|
277
|
+
const arrow = '->'
|
|
278
|
+
```
|
|
279
|
+
|
|
287
280
|
## A Note on Provenance
|
|
288
281
|
|
|
289
|
-
Yes, a fair amount of this was vibe-coded with LLM assistance
|
|
282
|
+
Yes, a fair amount of this was vibe-coded with LLM assistance - which is fitting, since that's exactly the context this plugin is designed for. That said, the ideas behind these rules, the decisions about what to catch and how to catch it, and the overall design are mine. Every piece of code went through human review, and the test cases in particular were written and verified with deliberate care.
|
|
290
283
|
|
|
291
284
|
The project also dogfoods itself: `eslint-plugin-unslop` is linted using `eslint-plugin-unslop`.
|
|
292
285
|
|
|
@@ -332,12 +325,12 @@ Run these checks periodically:
|
|
|
332
325
|
|
|
333
326
|
```bash
|
|
334
327
|
gh api repos/skhoroshavin/eslint-plugin-unslop/branches/main/protection
|
|
335
|
-
gh run list
|
|
328
|
+
gh run list -workflow "Test" -limit 5
|
|
336
329
|
```
|
|
337
330
|
|
|
338
331
|
Expected audit outcomes:
|
|
339
332
|
|
|
340
|
-
- `required_status_checks.contexts` includes `
|
|
333
|
+
- `required_status_checks.contexts` includes `Test`
|
|
341
334
|
- `required_pull_request_reviews.required_approving_review_count` is `1` or greater
|
|
342
335
|
- `required_pull_request_reviews.dismiss_stale_reviews` is `true`
|
|
343
336
|
- `enforce_admins.enabled` is `true`
|