eslint-plugin-unslop 0.2.2 → 0.3.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 +105 -115
- package/dist/index.cjs +306 -332
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +306 -332
- 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,67 @@ 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 declared module import boundaries |
|
|
51
|
+
| `unslop/export-control` | error | Restricts public exports to declared patterns |
|
|
52
|
+
| `unslop/no-false-sharing` | error | Flags shared modules only used by one consumer |
|
|
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
|
-
- same-module relative imports can only go one level deeper
|
|
75
|
+
- same-module relative imports can only go one level deeper - no tunnelling into internals
|
|
96
76
|
- files that don't match any declared module are denied (fail-closed, not fail-silently)
|
|
97
77
|
|
|
98
78
|
#### Configuration
|
|
@@ -103,6 +83,7 @@ import unslop from 'eslint-plugin-unslop'
|
|
|
103
83
|
|
|
104
84
|
export default [
|
|
105
85
|
{
|
|
86
|
+
plugins: { unslop },
|
|
106
87
|
settings: {
|
|
107
88
|
unslop: {
|
|
108
89
|
sourceRoot: 'src',
|
|
@@ -124,6 +105,7 @@ export default [
|
|
|
124
105
|
rules: {
|
|
125
106
|
'unslop/import-control': 'error',
|
|
126
107
|
'unslop/export-control': 'error',
|
|
108
|
+
'unslop/no-false-sharing': 'error',
|
|
127
109
|
},
|
|
128
110
|
},
|
|
129
111
|
]
|
|
@@ -133,77 +115,51 @@ export default [
|
|
|
133
115
|
|
|
134
116
|
The customs declaration form for the other direction: what are you actually exporting from your module's public entrypoints?
|
|
135
117
|
|
|
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
|
|
118
|
+
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.
|
|
137
119
|
|
|
138
120
|
### `unslop/no-false-sharing`
|
|
139
121
|
|
|
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 |
|
|
122
|
+
The "shared" folder anti-pattern detector. LLMs (and some humans also) love creating shared utilities that are only used by one consumer - or worse, by nobody at all. This rule requires that modules marked as `shared` in your architecture settings are actually imported by at least two separate directory-level consumers. If it's only used in one place, it's not shared - it's misplaced.
|
|
164
123
|
|
|
165
|
-
####
|
|
124
|
+
#### Configuration
|
|
166
125
|
|
|
167
|
-
|
|
126
|
+
Shared modules are declared via `shared: true` on module policies in
|
|
127
|
+
`settings.unslop.architecture`:
|
|
168
128
|
|
|
169
129
|
```js
|
|
170
130
|
// eslint.config.mjs
|
|
171
131
|
import unslop from 'eslint-plugin-unslop'
|
|
172
|
-
import tseslint from 'typescript-eslint'
|
|
173
132
|
|
|
174
|
-
export default
|
|
175
|
-
|
|
176
|
-
languageOptions: {
|
|
177
|
-
parserOptions: {
|
|
178
|
-
projectService: true,
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
unslop.configs.recommended,
|
|
133
|
+
export default [
|
|
134
|
+
unslop.configs.full,
|
|
183
135
|
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
'
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
136
|
+
settings: {
|
|
137
|
+
unslop: {
|
|
138
|
+
sourceRoot: 'src',
|
|
139
|
+
architecture: {
|
|
140
|
+
utils: { shared: true },
|
|
141
|
+
'shared/*': { shared: true },
|
|
190
142
|
},
|
|
191
|
-
|
|
143
|
+
},
|
|
192
144
|
},
|
|
193
145
|
},
|
|
194
|
-
|
|
146
|
+
]
|
|
195
147
|
```
|
|
196
148
|
|
|
149
|
+
The rule takes no options - all configuration comes from the shared architecture settings, consistent with `import-control` and `export-control`.
|
|
150
|
+
|
|
151
|
+
Consumer counting is always at the directory level: the first path segment relative to the source root. A file in `src/shared/format-date.ts` must be imported by files in at least two distinct top-level directories (e.g., `featureA` and `featureB`).
|
|
152
|
+
|
|
197
153
|
#### What it catches
|
|
198
154
|
|
|
199
155
|
```
|
|
200
156
|
src/shared/format-date.ts
|
|
201
|
-
|
|
202
|
-
|
|
157
|
+
-> only imported by src/features/calendar/view.ts
|
|
158
|
+
-> error: must be used by 2+ entities
|
|
203
159
|
|
|
204
160
|
src/utils/old-helper.ts
|
|
205
|
-
|
|
206
|
-
|
|
161
|
+
-> not imported by anyone
|
|
162
|
+
-> error: must be used by 2+ entities
|
|
207
163
|
```
|
|
208
164
|
|
|
209
165
|
### `unslop/read-friendly-order`
|
|
@@ -212,10 +168,10 @@ Enforces a top-down reading order for your code. The idea: when someone opens a
|
|
|
212
168
|
|
|
213
169
|
This rule covers three areas:
|
|
214
170
|
|
|
215
|
-
**Top-level ordering**
|
|
171
|
+
**Top-level ordering** - Public/exported symbols should come before the private helpers they use. Read the API first, implementation details second.
|
|
216
172
|
|
|
217
173
|
```js
|
|
218
|
-
// Bad
|
|
174
|
+
// Bad - helper defined before its consumer
|
|
219
175
|
function formatName(name) {
|
|
220
176
|
return name.trim().toLowerCase()
|
|
221
177
|
}
|
|
@@ -224,7 +180,7 @@ export function createUser(name) {
|
|
|
224
180
|
return { name: formatName(name) }
|
|
225
181
|
}
|
|
226
182
|
|
|
227
|
-
// Good
|
|
183
|
+
// Good - consumer first, helper below
|
|
228
184
|
export function createUser(name) {
|
|
229
185
|
return { name: formatName(name) }
|
|
230
186
|
}
|
|
@@ -234,7 +190,7 @@ function formatName(name) {
|
|
|
234
190
|
}
|
|
235
191
|
```
|
|
236
192
|
|
|
237
|
-
**Class member ordering**
|
|
193
|
+
**Class member ordering** - Constructor first, public fields next, then other members ordered by dependency.
|
|
238
194
|
|
|
239
195
|
```js
|
|
240
196
|
// Bad
|
|
@@ -252,10 +208,10 @@ class UserService {
|
|
|
252
208
|
}
|
|
253
209
|
```
|
|
254
210
|
|
|
255
|
-
**Test file ordering**
|
|
211
|
+
**Test file ordering** - Setup hooks (`beforeEach`, `beforeAll`) before teardown hooks (`afterEach`, `afterAll`), and both before test cases.
|
|
256
212
|
|
|
257
213
|
```js
|
|
258
|
-
// Bad
|
|
214
|
+
// Bad - setup and tests buried between helpers
|
|
259
215
|
function buildFixture(overrides) {
|
|
260
216
|
return { id: 1, ...overrides }
|
|
261
217
|
}
|
|
@@ -269,7 +225,7 @@ beforeEach(() => {
|
|
|
269
225
|
buildFixture()
|
|
270
226
|
})
|
|
271
227
|
|
|
272
|
-
// Good
|
|
228
|
+
// Good - setup first, then tests, helpers at the bottom
|
|
273
229
|
beforeEach(() => {
|
|
274
230
|
buildFixture()
|
|
275
231
|
})
|
|
@@ -284,9 +240,43 @@ function assertCorrect(value) {
|
|
|
284
240
|
}
|
|
285
241
|
```
|
|
286
242
|
|
|
243
|
+
### `unslop/no-special-unicode`
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
Caught characters include: left/right smart quotes (`“” ‘’`), non-breaking space, en/em dash, horizontal ellipsis, zero-width space, and various other exotic whitespace.
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
// Bad - these contain invisible special characters that look normal
|
|
251
|
+
const greeting = 'Hello World' // a non-breaking space (U+00A0) is hiding between the words
|
|
252
|
+
const quote = 'He said “hello”' // smart double quotes (U+201C, U+201D)
|
|
253
|
+
|
|
254
|
+
// Good
|
|
255
|
+
const greeting = 'Hello World' // regular ASCII space
|
|
256
|
+
const quote = 'He said "hello"' // plain ASCII quotes
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Note: the bad examples above contain actual unicode characters that may be
|
|
260
|
+
indistinguishable from their ASCII counterparts in your font - that's exactly
|
|
261
|
+
the problem this rule catches.
|
|
262
|
+
|
|
263
|
+
### `unslop/no-unicode-escape`
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
267
|
+
```js
|
|
268
|
+
// Bad
|
|
269
|
+
const copyright = '\u00A9 2025'
|
|
270
|
+
const arrow = '\u2192'
|
|
271
|
+
|
|
272
|
+
// Good
|
|
273
|
+
const copyright = '(c) 2025'
|
|
274
|
+
const arrow = '->'
|
|
275
|
+
```
|
|
276
|
+
|
|
287
277
|
## A Note on Provenance
|
|
288
278
|
|
|
289
|
-
Yes, a fair amount of this was vibe-coded with LLM assistance
|
|
279
|
+
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
280
|
|
|
291
281
|
The project also dogfoods itself: `eslint-plugin-unslop` is linted using `eslint-plugin-unslop`.
|
|
292
282
|
|
|
@@ -332,12 +322,12 @@ Run these checks periodically:
|
|
|
332
322
|
|
|
333
323
|
```bash
|
|
334
324
|
gh api repos/skhoroshavin/eslint-plugin-unslop/branches/main/protection
|
|
335
|
-
gh run list
|
|
325
|
+
gh run list -workflow "Test" -limit 5
|
|
336
326
|
```
|
|
337
327
|
|
|
338
328
|
Expected audit outcomes:
|
|
339
329
|
|
|
340
|
-
- `required_status_checks.contexts` includes `
|
|
330
|
+
- `required_status_checks.contexts` includes `Test`
|
|
341
331
|
- `required_pull_request_reviews.required_approving_review_count` is `1` or greater
|
|
342
332
|
- `required_pull_request_reviews.dismiss_stale_reviews` is `true`
|
|
343
333
|
- `enforce_admins.enabled` is `true`
|