slop-police
v0.3.27
Published
Type-aware TypeScript linter for the dead code and needless indirection AI agents leave behind
Maintainers
Readme
Installation
Paste this into your agent:
Install and configure slop-police by following https://raw.githubusercontent.com/kubk/slop-police/main/INSTALL.mdLet your agent properly set it up so slop-police finds every real issue it can. For manual installation run npm i -D slop-police, add a slop-police.json and run npx slop-police
Rules
no-pass-through-wrapper
A function or method that only returns a call to another function.
function loadUser(id: string) {
return api.loadUser(id)
}no-identity-type-alias
A type alias that only renames another type.
type User = { id: string }
type Member = Userno-duplicate-code
Copy-pasted blocks of code. Options: minLines (default 11) and minTokens (default 50).
// stripe-webhook.ts
const mailer = await Mailer.connect({
host: 'smtp.example.com',
port: 465,
secure: true,
user: env.SMTP_USER,
password: env.SMTP_PASSWORD,
})
await mailer.send({ to: customer.email, subject: 'Your license' })
// reset-password.ts
const mailer = await Mailer.connect({
host: 'smtp.example.com',
port: 465,
secure: true,
user: env.SMTP_USER,
password: env.SMTP_PASSWORD,
})
await mailer.send({ to: user.email, subject: 'Reset your password' })no-duplicate-function
The same function written again in several files, with different variable names. Option: minFiles (default 3).
// users.ts
export async function loadUsers(id: string) {
const response = await fetchJson('/users/' + id)
if (!response.ok) throw new Error('failed to load users')
return response.data
}
// posts.ts
export async function loadPosts(postId: string) {
const res = await fetchJson('/posts/' + postId)
if (!res.ok) throw new Error('failed to load posts')
return res.data
}
// teams.ts
export async function loadTeams(teamId: string) {
const result = await fetchJson('/teams/' + teamId)
if (!result.ok) throw new Error('failed to load teams')
return result.data
}no-pass-through-getter
A getter that only returns another property.
class Store {
private items: Item[] = []
get list() {
return this.items
}
}no-redundant-value-alias
A variable that only renames another variable.
function render() {
const user = getUser()
const current = user
return current.name
}unused-object-keys
Object keys that are never read.
export const links = {
docs: 'https://example.com/docs',
legacy: 'https://example.com/old', // links.legacy is never read
}
open(links.docs)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.
unused-enum-members
Enum members that are never used.
enum Status {
Active,
Archived, // Enum member Status.Archived is never used
}
const isActive = (status: Status) => status === Status.Activeunused-exports
Exports that nothing imports, or that are only used in their own file.
// lib.ts
export type Options = { strict: boolean } // Export Options is only used in its own file
export function parse(text: string, options?: Options) {}
export function format() {} // Export format is never used
// main.ts
import { parse } from './lib'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.
"entry": ["src/main.tsx", "src/routes/**/*.tsx", "vite.config.ts"]unused-class-members
Class methods and fields that are never used. Here reset and retries are unused.
class Store {
retries = 0
load() {}
reset() {}
}
new Store().load()Fields with decorators aren't checked, since a framework may read them.
It's the only built-in rule that is off by default: on a big monorepo it takes most of the run's time and memory. Turn it on in rules:
"rules": { "unused-class-members": "error" }no-unresolved-import
Relative imports of files that don't exist.
import { render } from '../src' // Cannot find module ../srcOther imports that don't resolve stop the run instead, because then usages can't be linked to declarations: packages that aren't installed, and files that exist but don't resolve under the tsconfig.
Configuration
Put a slop-police.json in the directory you run it from:
{
"projects": ["packages/api/tsconfig.json", "packages/frontend/tsconfig.app.json"],
"testFiles": ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"]
}projects: tsconfig files to analyze. Usages are counted across all of them, so an export used only by another package still counts as used.ignore: glob patterns (relative to the config) for files to skip entirely. Files that.gitignoreignores are skipped too.testFiles: glob patterns for tests. Usages inside tests count, but declarations inside tests are not reported.entry: glob patterns for the files that something outside the analyzed code loads, likesrc/main.tsxor a package'ssrc/index.ts. With it,unused-exportsalso reports files that nothing imports from them. What they export is public API: its methods, getters, wrappers, type aliases, enum members and object keys aren't reported.rules: you usually don't need it, since every built-in rule exceptunused-class-membersis already on as an error. It's for rule options, likeno-duplicate-code'sminLines, and for turning onunused-class-membersand plugin rules. A rule takes"error","warn","off"or[severity, options].plugins: modules with custom rules, as paths relative to the config or package names.
CI
Make CI fail when something slips through
- run: npx slop-policeMemory
slop-police spends memory to be fast until it has used a quarter of the machine's memory (or of the container's limit), and saves memory at the cost of speed after that. SLOP_POLICE_MEMORY_GB moves that point, for example SLOP_POLICE_MEMORY_GB=1 on a small CI runner. It isn't a hard limit: a big monorepo still takes more.
Custom rules
Write rules in TypeScript or JavaScript, list their modules in plugins and turn them on in rules:
{
"projects": ["tsconfig.json"],
"plugins": ["./slop-rules.ts"],
"rules": {
"no-direct-fetch": ["error", { "allowIn": "src/api/" }],
"no-needless-nullish-fallback": "warn"
}
}A plugin exports rules made with defineRule, by default or by name. visit has a handler per node kind, called for every node of that kind in every linted file:
// slop-rules.ts
import { defineRule, isIdentifier, SyntaxKind } from 'slop-police'
export const noDirectFetch = defineRule({
name: 'no-direct-fetch',
// Option defaults. The config's options must have the same types.
options: { allowIn: '' },
visit: {
CallExpression(node, ctx) {
if (ctx.options.allowIn && ctx.file.path.startsWith(ctx.options.allowIn)) return
if (isIdentifier(node.expression) && node.expression.text === 'fetch') {
ctx.report(node.expression, 'Use the API client instead of fetch()')
}
},
},
})
export const noNeedlessNullishFallback = defineRule({
name: 'no-needless-nullish-fallback',
visit: {
BinaryExpression(node, ctx) {
if (node.operatorToken.kind !== SyntaxKind.QuestionQuestionToken) return
const { checker } = ctx
const type = checker.getTypeAtLocation(node.left)
if (!type) return
const nullable = [checker.getUndefinedType(), checker.getNullType()].some((t) =>
checker.isTypeAssignableTo(t, type),
)
if (!nullable) ctx.report(node.right, `${node.left.getText()} is never null or undefined`)
},
},
})Nodes, SyntaxKind, the is* guards and the checker come from the TypeScript 7 API that slop-police runs on. The handler's ctx has:
options: the defaults merged with the config's options.file: the file being visited:path(relative to the config),fileName,isTestandsourceFile.checkerandprogram: those of the file's project. Each checker call is a round trip to the linter process, so check the syntax first.report(node, message): reports a finding at the node's start.// slop-police-ignorecomments work as for built-in rules.
For rules that compare files, create(ctx) returns the visitor instead, so its state lives in a closure, and finish runs after the last file:
import { defineRule, type Node } from 'slop-police'
export const noRepeatedUrl = defineRule({
name: 'no-repeated-url',
create(ctx) {
const byUrl = new Map<string, Node[]>()
return {
StringLiteral(node) {
if (node.text.startsWith('https://'))
byUrl.set(node.text, [...(byUrl.get(node.text) ?? []), node])
},
finish() {
for (const [url, nodes] of byUrl) {
if (nodes.length > 2)
for (const node of nodes)
ctx.report(node, `${url} is hard-coded in ${nodes.length} places`)
}
},
}
},
})Test rules with lint from slop-police/testing, which runs a rule alone on a source string (as index.ts) or on files (an object of paths to contents) in a strict TypeScript project:
import { lint } from 'slop-police/testing'
import { noDirectFetch } from './slop-rules.ts'
test('no-direct-fetch', () => {
expect(lint(noDirectFetch, `fetch("/users");`)).toEqual([
{ file: 'index.ts', line: 1, column: 1, message: 'Use the API client instead of fetch()' },
])
})Plugins work only through the npm launcher (npx slop-police).
Ignoring findings
A // slop-police-ignore comment directly above a statement, class member or object property silences findings in it, including everything nested inside. List rule names to silence only those, and add a reason after --:
export const links = {
docs: '/docs',
// slop-police-ignore unused-object-keys -- read by the support widget
help: '/help',
}
// slop-police-ignore
export const legacyLinks = { a: '/a', b: '/b' }The unused-ignore rule reports ignore comments that silence nothing, either because the code they covered was fixed or because they name a rule that doesn't exist:
const links = {
// slop-police-ignore unused-object-keys -- Ignore comment for unused-object-keys silences nothing
docs: '/docs',
}
open(links.docs)Why not ESLint / Oxlint / tsgolint?
Because these tools are fundamentally limited. They run one file at a time so there's no way to detect:
- A method, export or object key is unused only if nothing in any other file uses it
- A helper AI rewrote instead of reusing lives in a different file from the original
slop-police links every usage across all your projects first, then reports. See the comparison below.
Comparison with other tools
| | slop-police | ESLint | Sonar | knip | jscpd | | ------------------------------------------ | :---------: | :----: | :---: | :--: | :---: | | Unused exports and files | ✓ | ¹ | | ✓ | | | Unused enum members | ✓ | | | ✓ | | | Unused class methods | ✓ | ² | ² | | | | Unused object keys | ✓ | | | | | | Copy-pasted code | ✓ | | ✓ | | ✓ | | Helpers rewritten instead of reused | ✓ | | ³ | | | | Pass-through wrappers, getters and aliases | ✓ | | ⁴ | | |
- Partial:
import/no-unused-modulesfinds exports nothing imports, but not exports used only in their own file or files unreachable from entry points. - Partial: only
#privatemembers. - Partial:
no-identical-functionscompares functions within one file and needs identical variable names. - Partial:
redundant-type-aliasescatches aliases of primitives and of type aliases in the same file.
Requirements
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.
License
MIT
