npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

slop-police

v0.3.27

Published

Type-aware TypeScript linter for the dead code and needless indirection AI agents leave behind

Readme

Installation

Paste this into your agent:

Install and configure slop-police by following https://raw.githubusercontent.com/kubk/slop-police/main/INSTALL.md

Let 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 = User

no-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.Active

unused-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 ../src

Other 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 .gitignore ignores 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, like src/main.tsx or a package's src/index.ts. With it, unused-exports also 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 except unused-class-members is already on as an error. It's for rule options, like no-duplicate-code's minLines, and for turning on unused-class-members and 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-police

Memory

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, isTest and sourceFile.
  • checker and program: 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-ignore comments 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 | ✓ | | ⁴ | | |

  1. Partial: import/no-unused-modules finds exports nothing imports, but not exports used only in their own file or files unreachable from entry points.
  2. Partial: only #private members.
  3. Partial: no-identical-functions compares functions within one file and needs identical variable names.
  4. Partial: redundant-type-aliases catches 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