slop-police 0.3.8 → 0.3.9

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.
Files changed (2) hide show
  1. package/README.md +107 -141
  2. package/package.json +5 -5
package/README.md CHANGED
@@ -3,46 +3,12 @@
3
3
  <p align="center"><b>Stop LLM crimes in your codebase</b></p>
4
4
 
5
5
  <p align="center">
6
- A type-aware TypeScript linter for the dead code and needless indirection AI agents leave behind.
6
+ <picture>
7
+ <source media="(prefers-color-scheme: dark)" srcset="assets/readme-dark.webp">
8
+ <img src="assets/readme-light.webp" alt="slop-police flags an export only used in its own file, a function that just forwards, a variable that is just a rename, and a method that is never called">
9
+ </picture>
7
10
  </p>
8
11
 
9
- ```ts
10
- import { api } from "./api";
11
-
12
- export type User = { id: string; name: string };
13
-
14
- export function fetchUser(id: string) {
15
- return api.getUser(id);
16
- }
17
-
18
- export class UserCache {
19
- private users = new Map<string, User>();
20
-
21
- async get(id: string) {
22
- const user = this.users.get(id) ?? (await fetchUser(id));
23
- const cached = user;
24
- this.users.set(id, cached);
25
- return cached;
26
- }
27
-
28
- has(id: string) {
29
- return this.users.has(id);
30
- }
31
- }
32
- ```
33
-
34
- slop-police isn't:
35
-
36
- ```
37
- user-cache.ts:3:13 error unused-exports Export User is only used in its own file
38
- user-cache.ts:5:17 error no-pass-through-wrapper Function fetchUser only forwards to api.getUser
39
- user-cache.ts:5:17 error unused-exports Export fetchUser is only used in its own file
40
- user-cache.ts:14:11 error no-redundant-value-alias Local alias cached only renames user; use the original binding directly
41
- user-cache.ts:19:3 error unused-methods Method UserCache.has is never used
42
-
43
- 5 errors, 0 warnings
44
- ```
45
-
46
12
  ```sh
47
13
  pnpm add -D slop-police
48
14
  # or
@@ -51,7 +17,7 @@ npm i -D slop-police
51
17
 
52
18
  Add a [`slop-police.json`](#configuration) and run `npx slop-police` (or `pnpm exec slop-police`).
53
19
 
54
- ## Instead of knip and jscpd
20
+ ## Comparison with other tools
55
21
 
56
22
  | | slop-police | knip | jscpd |
57
23
  | ------------------------------------------ | :---------: | :--: | :---: |
@@ -62,7 +28,7 @@ Add a [`slop-police.json`](#configuration) and run `npx slop-police` (or `pnpm e
62
28
  | Copy-pasted code | ✓ | | ✓ |
63
29
  | Helpers rewritten instead of reused | ✓ | | |
64
30
  | Pass-through wrappers, getters and aliases | ✓ | | |
65
- | Placeholder comments, flattened errors | ✓ | | |
31
+ | Flattened errors | ✓ | | |
66
32
 
67
33
  ## CI
68
34
 
@@ -79,7 +45,6 @@ Put a `slop-police.json` in the directory you run it from:
79
45
  ```json
80
46
  {
81
47
  "projects": ["packages/api/tsconfig.json", "packages/frontend/tsconfig.app.json"],
82
- "ignore": ["**/.next/**"],
83
48
  "testFiles": ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"]
84
49
  }
85
50
  ```
@@ -94,174 +59,177 @@ Exit code is `1` when there are errors, `2` for an invalid config, `0` otherwise
94
59
 
95
60
  ## Rules
96
61
 
97
- ### unused-methods
62
+ ### no-pass-through-wrapper
98
63
 
99
- Class methods, getters and setters that are never referenced anywhere in the analyzed projects.
64
+ A function or method that only returns a call to another function.
100
65
 
101
66
  ```ts
102
- class Store {
103
- load() {}
104
- reset() {} // Method Store.reset is never used
67
+ function loadUser(id: string) {
68
+ return api.loadUser(id);
105
69
  }
106
- new Store().load();
107
70
  ```
108
71
 
109
- It knows the ways a method gets called without being named: it implements an interface member that's used, or one from a library (the library calls it), it's picked with `Pick<Class, "name">`, or an instance goes to a JSX prop whose type needs it. Private and protected members are skipped unless you turn on `includePrivate` or `includeProtected`.
110
-
111
- ### unused-object-keys
72
+ ### no-identity-type-alias
112
73
 
113
- Keys of a top-level `const` object literal that are never read, as long as every use of the object reads statically known keys (`links.a`, `links["a"]`, `const { a } = links`). Objects that escape (passed to a function, spread, accessed dynamically) are skipped.
74
+ A type alias that only renames another type.
114
75
 
115
76
  ```ts
116
- export const links = {
117
- docs: "https://example.com/docs",
118
- legacy: "https://example.com/old", // links.legacy is never read
119
- };
120
- open(links.docs);
77
+ type User = { id: string };
78
+ type Member = User;
121
79
  ```
122
80
 
123
- ### unused-exports
81
+ ### no-duplicate-code
124
82
 
125
- Agents love to `export` things just in case. This rule finds exports used only in their own file (just drop `export`) and exports nothing uses at all. Imports and re-exports count as uses, and so do uses in tests and ignored files. Files that nothing imports are entry points (`main.tsx`, a framework route, a config file), so their exports are left alone. A dynamic import (`import("./page")`) or a namespace used as a value (`Object.keys(ns)`) counts as using everything in the module.
83
+ Copy-pasted blocks of code. Options: `minLines` (default `11`) and `minTokens` (default `50`).
126
84
 
127
85
  ```ts
128
- // lib.ts, imported by main.ts as import { parse } from "./lib"
129
- export type Options = { strict: boolean }; // Export Options is only used in its own file
130
- export function parse(text: string, options?: Options) {}
131
- export function format() {} // Export format is never used
132
- ```
133
-
134
- If you want to be extra strict, list your entry points in the `entry` option. slop-police then also finds whole files that nothing imports from them or from tests. Code used only by such dead files counts as unused too.
135
-
136
- ```json
137
- "unused-exports": ["error", { "entry": ["src/main.tsx", "src/routes/**/*.tsx", "vite.config.ts"] }]
86
+ // stripe-webhook.ts
87
+ const mailer = await Mailer.connect({
88
+ host: "smtp.example.com",
89
+ port: 465,
90
+ secure: true,
91
+ user: env.SMTP_USER,
92
+ password: env.SMTP_PASSWORD,
93
+ });
94
+ await mailer.send({ to: customer.email, subject: "Your license" });
95
+
96
+ // reset-password.ts
97
+ const mailer = await Mailer.connect({
98
+ host: "smtp.example.com",
99
+ port: 465,
100
+ secure: true,
101
+ user: env.SMTP_USER,
102
+ password: env.SMTP_PASSWORD,
103
+ });
104
+ await mailer.send({ to: user.email, subject: "Reset your password" });
138
105
  ```
139
106
 
140
- ### unused-enum-members
107
+ ### no-duplicate-function
141
108
 
142
- Enum members nothing uses. When the enum is used as a whole (`Object.values(Status)`, `keyof typeof Status`, passed to a function), any member could be in play, so the enum is skipped.
109
+ The same function written again in several files, with different variable names. Option: `minFiles` (default `3`).
143
110
 
144
111
  ```ts
145
- enum Status {
146
- Active,
147
- Archived, // Enum member Status.Archived is never used
112
+ // users.ts
113
+ export async function loadUsers(id: string) {
114
+ const response = await fetchJson("/users/" + id);
115
+ if (!response.ok) throw new Error("failed to load users");
116
+ return response.data;
148
117
  }
149
- const isActive = (status: Status) => status === Status.Active;
150
- ```
151
-
152
- ### no-identity-type-alias
153
118
 
154
- A type alias that only renames another type.
119
+ // posts.ts
120
+ export async function loadPosts(postId: string) {
121
+ const res = await fetchJson("/posts/" + postId);
122
+ if (!res.ok) throw new Error("failed to load posts");
123
+ return res.data;
124
+ }
155
125
 
156
- ```ts
157
- type User = { id: string };
158
- type Member = User; // Type alias Member only renames User
126
+ // teams.ts
127
+ export async function loadTeams(teamId: string) {
128
+ const result = await fetchJson("/teams/" + teamId);
129
+ if (!result.ok) throw new Error("failed to load teams");
130
+ return result.data;
131
+ }
159
132
  ```
160
133
 
161
134
  ### no-pass-through-getter
162
135
 
163
- A getter that only returns another property of `this`.
136
+ A getter that only returns another property.
164
137
 
165
138
  ```ts
166
139
  class Store {
167
140
  private items: Item[] = [];
168
- get list() { return this.items; } // Getter list only returns this.items
169
- }
170
- ```
171
-
172
- ### no-pass-through-wrapper
173
-
174
- A function or method whose body only returns a call (awaited or not) to a named function, passing its own parameters on as they are.
175
-
176
- ```ts
177
- function loadUser(id: string) { // Function loadUser only forwards to api.loadUser
178
- return api.loadUser(id);
141
+ get list() { return this.items; }
179
142
  }
180
143
  ```
181
144
 
182
- It uses types to skip wrappers that do something:
183
-
184
- - the shape is required: a method of a typed object literal (a `Proxy` handler, a Worker's `fetch`), a method that implements or overrides an inherited member, or a function assigned to a variable with a declared type. An override that only calls `super`'s method of the same name is still reported.
185
- - the wrapper adds types: type parameters, overloads, or parameter and return types declared differently from the callee's, like `t(key: TranslationKey)` around `translate(key: string)`.
186
- - it delegates to a private or protected member, like `get(key) { return this.items.get(key); }`.
187
-
188
- Calls on computed values (`getDay(date).getTime()`, `/^\s*$/.test(text)`) aren't forwarding, so they don't count.
189
-
190
145
  ### no-redundant-value-alias
191
146
 
192
- A local `const` that only renames another `const`, when using the original directly means the same thing.
147
+ A variable that only renames another variable.
193
148
 
194
149
  ```ts
195
150
  function render() {
196
151
  const user = getUser();
197
- const current = user; // Local alias current only renames user; use the original binding directly
152
+ const current = user;
198
153
  return current.name;
199
154
  }
200
155
  ```
201
156
 
202
157
  ### no-single-string-cn
203
158
 
204
- `cn()` called with a single static class string.
159
+ `cn()` with a single class string.
205
160
 
206
161
  ```tsx
207
- <div className={cn("flex gap-2")} /> // Use the class string directly instead of wrapping it in cn()
162
+ <div className={cn("flex gap-2")} />
208
163
  ```
209
164
 
210
- ### no-placeholder-comment
165
+ ### no-stringified-unknown-error
211
166
 
212
- A comment that only promises future work instead of describing the code: "add more validation", "handle more cases", "can be extended in the future", "extend this logic", "customize this behavior", "future enhancement", "implement … here". Ordinary TODOs don't match.
167
+ Turning a caught error into a string with `error instanceof Error ? error.message : String(error)`.
213
168
 
214
169
  ```ts
215
- function parse(input: string) {
216
- // Add more validation here
217
- return JSON.parse(input);
170
+ try {
171
+ await sendMessage(chatId, text);
172
+ } catch (error) {
173
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
218
174
  }
219
175
  ```
220
176
 
221
- ### no-duplicate-code
177
+ ### unused-object-keys
222
178
 
223
- Copy-pasted code: at least 50 tokens in a row that repeat earlier code (in the same file or one before it by path) and span at least 11 lines. Tokens are compared by their text, types included, so a renamed variable or a different type ends a copy, but comments, formatting and imports are left out. Test files are neither reported nor compared. `// slop-police-ignore no-duplicate-code` above a statement, class member or object property takes it out of the comparison, so it is not reported as a copy or as the original. Options: `minLines` (default `11`, counting every line a copy spans) and `minTokens` (default `50`).
179
+ Object keys that are never read.
224
180
 
225
181
  ```ts
226
- // update-card.ts and delete-card.ts both check the card the same way:
227
- const userId = ctx.userId;
228
- assert(userId);
182
+ export const links = {
183
+ docs: "https://example.com/docs",
184
+ legacy: "https://example.com/old", // links.legacy is never read
185
+ };
186
+ open(links.docs);
187
+ ```
229
188
 
230
- const cards = await getCardsWithDeck(ctx.db, [input.id]);
231
- if (cards.length === 0) {
232
- throw notFound("Card not found");
233
- }
234
- const card = cards[0];
235
- if (card.deckAuthorId !== userId) {
236
- throw forbidden("You don't own this card");
189
+ An index with a literal type reads only the keys in that type, so `labels[status]` with `status: "active" | "expired"` leaves a `trial` key unused. An index typed as `string` or `any`, or an object passed elsewhere, could read any key, so the object is skipped.
190
+
191
+ ### unused-enum-members
192
+
193
+ Enum members that are never used.
194
+
195
+ ```ts
196
+ enum Status {
197
+ Active,
198
+ Archived, // Enum member Status.Archived is never used
237
199
  }
238
- // delete-card.ts:6:5 Lines 6-16 duplicate update-card.ts:6-16
200
+ const isActive = (status: Status) => status === Status.Active;
239
201
  ```
240
202
 
241
- ### no-duplicate-function
203
+ ### unused-exports
242
204
 
243
- A function whose body has the same shape as functions in at least two other files: a helper that was rewritten instead of reused. Shapes ignore the names of parameters and locals and the values of literals, but not the names of what the body calls or reads. Bodies with fewer than 2 statements or 16 syntax nodes, and functions in test files, don't count. Option: `minFiles` (default `3`, at least `2`), the number of files a shape has to appear in.
205
+ Exports that nothing imports, or that are only used in their own file.
244
206
 
245
207
  ```ts
246
- // users.ts, posts.ts and teams.ts each have:
247
- export async function loadUsers(id: string) {
248
- const response = await fetchJson("/users/" + id);
249
- if (!response.ok) throw new Error("failed to load users");
250
- return response.data;
251
- }
252
- // Function loadUsers has the same body shape as loadPosts (posts.ts:1), loadTeams (teams.ts:1)
208
+ // lib.ts
209
+ export type Options = { strict: boolean }; // Export Options is only used in its own file
210
+ export function parse(text: string, options?: Options) {}
211
+ export function format() {} // Export format is never used
212
+
213
+ // main.ts
214
+ import { parse } from "./lib";
253
215
  ```
254
216
 
255
- ### no-stringified-unknown-error
217
+ If you want to be extra strict, list your entry points in the `entry` option. slop-police then also finds whole files that nothing imports from them or from tests. Code used only by such dead files counts as unused too.
218
+
219
+ ```json
220
+ "unused-exports": ["error", { "entry": ["src/main.tsx", "src/routes/**/*.tsx", "vite.config.ts"] }]
221
+ ```
222
+
223
+ ### unused-methods
256
224
 
257
- `error instanceof Error ? error.message : String(error)` (in either order), which flattens a caught value into a string and drops its type, stack and cause. A plain `instanceof Error` check counts when the variable is named `error`, `err`, `e` or `cause`; an `||` of `instanceof` checks counts for any name. A function that does nothing but this conversion, like `getErrorMessage(error)`, is where it belongs and isn't reported; use it instead of repeating the expression.
225
+ Class methods that are never called. Here `reset` is unused.
258
226
 
259
227
  ```ts
260
- try {
261
- await sendMessage(chatId, text);
262
- } catch (error) {
263
- return { ok: false, error: error instanceof Error ? error.message : String(error) }; // Caught value error is flattened into a string
228
+ class Store {
229
+ load() {}
230
+ reset() {}
264
231
  }
232
+ new Store().load();
265
233
  ```
266
234
 
267
235
  ## Custom rules
@@ -357,7 +325,7 @@ test("no-direct-fetch", () => {
357
325
  });
358
326
  ```
359
327
 
360
- Plugins need the npm launcher (`npx slop-police` or `pnpm exec slop-police`), which runs them in Node while the linter serves ASTs and types. Published plugin packages must ship JavaScript, since Node doesn't run TypeScript from `node_modules`.
328
+ Plugins work only through the npm launcher (`npx slop-police`).
361
329
 
362
330
  ## Ignoring findings
363
331
 
@@ -373,8 +341,6 @@ class Store {
373
341
  export const legacyLinks = { a: "/a", b: "/b" };
374
342
  ```
375
343
 
376
- For `no-placeholder-comment`, put the ignore comment on the line directly above the reported comment.
377
-
378
344
  ## Requirements
379
345
 
380
346
  macOS or Linux on arm64 or x64 (on Windows, use WSL), and Node.js 22.18 or later. The linter bundles its own TypeScript 7 compiler, so your tsconfigs need to work with TypeScript 7.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slop-police",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Type-aware TypeScript linter for the dead code and needless indirection AI agents leave behind",
5
5
  "keywords": [
6
6
  "typescript",
@@ -41,9 +41,9 @@
41
41
  "node": ">=22.18"
42
42
  },
43
43
  "optionalDependencies": {
44
- "slop-police-darwin-arm64": "0.3.8",
45
- "slop-police-darwin-x64": "0.3.8",
46
- "slop-police-linux-arm64": "0.3.8",
47
- "slop-police-linux-x64": "0.3.8"
44
+ "slop-police-darwin-arm64": "0.3.9",
45
+ "slop-police-darwin-x64": "0.3.9",
46
+ "slop-police-linux-arm64": "0.3.9",
47
+ "slop-police-linux-x64": "0.3.9"
48
48
  }
49
49
  }