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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # eslint-plugin-unslop
2
2
 
3
- ESLint plugin that catches common LLM-generated code smells the kind of subtle junk that sneaks in when your LLM is feeling creative. Smart quotes, invisible unicode, spaghetti imports, dead "shared" code that nobody shares, and declarations ordered for machines instead of humans.
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 recommended config enables the two most universal rules out of the box:
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.recommended,
22
+ unslop.configs.full,
39
23
  {
40
- rules: {
41
- 'unslop/no-false-sharing': ['error', { dirs: ['shared', 'utils'] }],
42
- 'unslop/read-friendly-order': 'warn',
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
- ## Rules
49
-
50
- ### `unslop/no-special-unicode`
51
-
52
- **Recommended**
46
+ This turns on:
53
47
 
54
- 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.
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
- Caught characters include: left/right smart quotes (`“” ‘’`), non-breaking space, en/em dash, horizontal ellipsis, zero-width space, and various other exotic whitespace.
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
- // Bad — these contain invisible special characters that look normal
60
- const greeting = 'Hello World' // a non-breaking space (U+00A0) is hiding between the words
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
- // Good
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
- Note: the bad examples above contain actual unicode characters that may be
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 you declare which modules are allowed to import from which, and anything undeclared gets turned away at the border.
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 no tunnelling into internals
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 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.
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
- **Requires TypeScript parser with program**
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
- #### Setup
125
+ #### Configuration
166
126
 
167
- This rule requires `typescript-eslint` with type information:
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 tseslint.config(
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
- rules: {
185
- 'unslop/no-false-sharing': [
186
- 'error',
187
- {
188
- dirs: ['shared', 'utils'],
189
- sourceRoot: 'src',
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/format-date.ts
201
- only imported by src/features/calendar/view.ts
202
- error: must be used by 2+ entities
203
-
204
- src/utils/old-helper.ts
205
- → not imported by anyone
206
- error: must be used by 2+ entities
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** Public/exported symbols should come before the private helpers they use. Read the API first, implementation details second.
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 helper defined before its consumer
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 consumer first, helper below
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** Constructor first, public fields next, then other members ordered by dependency.
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** Setup hooks (`beforeEach`, `beforeAll`) before teardown hooks (`afterEach`, `afterAll`), and both before test cases.
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 setup and tests buried between helpers
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 setup first, then tests, helpers at the bottom
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 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.
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 --workflow "PR Gate" --limit 5
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 `PR Gate`
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`