slopguard
An ironically self-slop-generated tool that runs static checks for specific failure modes of AI-generated code which type checkers and default linters wave through: duplicated helpers, fields added "just in case", placeholder bodies, swallowed exceptions, comments that restate the code.
Designed to run as a hook inside AI coding agents (Claude Code and OpenAI Codex CLI), so the agent gets blocking feedback the moment it writes slop and fixes it itself — no human review pass needed. Zero dependencies, Python ≥ 3.9.
Install
pip install slopguards # PyPI name is plural; the command is `slopguard`
slopguard install claude # wire PostToolUse + Stop into ~/.claude/settings.json
slopguard install codex # append PostToolUse + Stop to ~/.codex/config.toml
(From a checkout: pip install . or pip install git+https://github.com/WT-MM/slopguard.)
Zero dependencies; running straight from a checkout via bin/slopguard works
too (the installers prefer a pip-installed console script when one is on
PATH, else they pin the checkout's launcher path).
Usage
slopguard scan <paths> # human-readable report, exit 1 on warn+
slopguard scan --json --fail-on never
slopguard rules # list all rules
slopguard baseline . # grandfather existing warn+ debt
slopguard baseline . --update # remove fixed entries; new findings stay hot
Rules
| rule | sev | applies | catches |
|---|---|---|---|
| duplicate-function | error | py | structurally identical function elsewhere (identifiers normalized — catches renamed rewrites) |
| dead-code | error | py | unreachable statements after return/raise/break/continue |
| syntax-error | error | py | file doesn't parse |
| diverged-duplicate | info | py | function 60%+ token-identical to another — a possible fork drifting apart |
| duplicate-code | info | all | copy-pasted block (~6+ normalized lines) elsewhere in the file set |
| unused-private | info | py, ts, java, … | private function/method/field never referenced in its file |
| write-only-attr | info | py | self._x assigned but never read |
| unused-import | info | py | import never used |
| placeholder-body | info | py | body is pass/... — looks implemented, does nothing |
| swallowed-exception | warn | py, js, … | except: pass, empty catch {}, empty .catch() |
| bare-except | warn | py | bare except: |
| mutable-default | warn | py | def f(x=[]) |
| hedging-comment | info | all | "in a real implementation…"-style cop-outs |
| redundant-comment | info | all | comment restates the code |
| long-function / deep-nesting | warn | py | size thresholds (configurable) |
| as-any / ts-ignore | info | ts | type-checker escapes |
| type-ignore / single-method-class / debug-artifact | info | py, js | never block |
Exact duplicate functions are error-level in production code and info-level
in test files. In test files (*.test.ts, test_*.py, __tests__/, …),
long/deep functions and hand-built contract mappings also drop to info.
Test-suite rules (test files only)
These push toward minimal tests that pin observable behavior, not the implementation's wiring — the two big AI failure modes being over-mocking and over-specification:
| rule | sev | catches |
|---|---|---|
| no-assert-test | info | test never asserts — may only prove the code doesn't crash |
| mock-only-test | info | every assertion is assert_called…/toHaveBeenCalled… |
| mock-echo-test | info | asserts the exact value the mock was told to return |
| tautological-assert | warn | assert True, expect(x).toBe(x), assertEqual(a, a) |
| conditional-assert | info | assertion inside an if — may silently pass on some inputs |
| brittle-exact-string | info | equality against a ≥48-char literal — may pin incidental wording |
| overspecified-assert | info | equality against a ≥8-entry literal dict/list |
| parametrize-candidate | warn | 3+ tests identical except literals |
| private-poke-test | info | test reads obj._private |
| excessive-mocking | info | 6+ mocks/patches in one test |
| sleep-in-test | warn | real sleep(); nonzero setTimeout waits |
Custom assert helpers are recognized (functions with assert/check/verify/ expect/validate in their name count as assertions), so helper-based suites aren't flagged as assertion-free. Parametrize groups of 6+ additionally suggest stating the rule once as a property-based test.
Contract-drift rules (when message schemas are in the repo)
Code that disagrees with a message schema fails at runtime; when the schema
is in the repo, it's statically visible. Schema sources: Protobuf
(.proto, message-scoped; .textproto instance data, file-scoped), Avro
(.avsc), Thrift (.thrift), GraphQL SDL
(.graphql/.graphqls/.gql), and JSON
Schema / OpenAPI documents (*.json/*.yaml named like a schema —
*schema*, openapi*, swagger*, asyncapi*). Discovery is automatic (scan: under the
scanned paths; hook: each edited file's subtree, capped at 40 schema files
and 1,000 directories to preserve edit latency), plus two .slopguard.json
keys for schemas living elsewhere: "schema_roots": ["protos/"]
(directories, searched recursively) and
"contract_schemas": ["contracts/**/*.avsc"] (explicit globs), both
resolved relative to the config file.
Matching is message-scoped: a dict literal must substantially match ONE
message's fields (≥4 string keys, ≥75% of them fields of that message), so
vocabulary from unrelated messages can't combine to legitimize a stray key.
camelCase- and snake_case-declared schemas both work — keys are canonicalized
before matching, and proto json_name aliases are honored. Checks apply to
Python dict literals (proto3's JSON mapping legitimately camelCases in JS/TS):
| rule | sev | catches |
|---|---|---|
| contract-drift-key | warn | camelCase key with NO schema field, in a dict whose other keys are schema-defined — a removed/renamed field still being emitted |
| hand-rolled-contract | warn | dict literal hand-builds a schema-defined message — use the generated type so drift fails at build time |
| contract-case-skew | info | in-sync hand-mapping (parentFrame for existing parent_frame) — fragile but currently correct |
Self-checking
tests/run_tests.py includes a scan-mode rule-coverage meta-test: every rule
listed in slopguard rules must demonstrably fire on the test corpus, so an
analyzer refactor can't silently kill a rule (this caught a real one:
comment-masking had made @ts-ignore undetectable). Separate targeted tests
cover hook target discovery and blocking behavior; the meta-test does not
prove every rule is reachable through every hook protocol.
Hook behavior
Both agents speak the same protocol: hook gets a JSON event on stdin; exit code 2 with text on stderr feeds findings back to the model as blocking feedback the agent must address.
- Claude Code:
PostToolUseonEdit|Write|MultiEdit|NotebookEdit, plus aStophook. - Codex CLI:
PostToolUseonapply_patch, plus aStophook.
Sibling same-extension files are loaded as context so duplicate detection sees the neighbors the agent should have reused, but findings are only reported for the files actually changed. The fast PostToolUse phase runs per-file checks and line-clone detection; expensive exact/near function-clone passes run at Stop, once per turn. A loop guard lets the same Stop finding set block a session only once.
Hooks fail open: an internal slopguard error never blocks the agent. On this repository, a 25-run PostToolUse benchmark measured 87 ms median / 92 ms p95; latency varies with the edited directory. Stop performs the fuller analysis and may take several hundred milliseconds.
Strictness profiles
Defaults are calibrated for repositories slopguard has never seen: rules
that measured below ~50% precision on held-out open-source codebases are
advisory — visible in scans at info, never blocking. Rules that
measured 64-100% stay warn/error. Once slopguard is tuned to a repo (or
you trust its opinions), {"profile": "strict"} in .slopguard.json
promotes the advisory tier back to blocking. The three-tier measurement
behind this: 88% precision on the codebase it was developed against, 60%
on tuned external repos, 39% cold.
Escape hatches
-
slopguard:ignorein a comment on (or directly above) the flagged line. Name rules to scope it —# slopguard:ignore swallowed-exception — expected on our own cancel()suppresses only that rule; a bare ignore suppresses everything on the line. Reasons after a dash are encouraged and never parsed as rule names. -
.slopguard.jsonat repo root controls both scans and hooks:{ "disable": ["long-function"], "fail_on": "error", "max_function_lines": 120, "max_nesting": 5, "relaxed_paths": ["/tutorials/"], "schema_roots": ["protos/"], "contract_schemas": ["contracts/**/*.avsc"], "hook_exclude": ["*/tests/fixtures/*"] }
fail_oniswarn(default),error, ornever.relaxed_pathsadds normalized path substrings whose heuristic findings become info;relaxed_paths_onlyreplaces the built-in tutorial/example/locale/benchmark list.schema_rootsandcontract_schemasare resolved relative to the config file.hook_excludeusesfnmatchagainst absolute paths and affects hook targets only; explicitscanpaths are never excluded. -
slopguard baseline .writes.slopguard-baseline.json, grandfathering current warn+ findings for both scans and hooks.slopguard baseline . --updateremoves fixed entries without admitting new debt. -
SLOPGUARD_DISABLE=1env var kills the hook entirely;SLOPGUARD_DISABLE_RULES=rule,ruledisables specific rules.
Tests
python3 tests/run_tests.py
Fixtures in tests/fixtures/ deliberately contain every kind of slop; the
suite asserts every rule fires there, that clean code produces zero findings,
and that both hook protocols (block, pass, loop-guard, garbage stdin) behave.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file slopguards-0.3.0.tar.gz.
File metadata
- Download URL: slopguards-0.3.0.tar.gz
- Upload date:
- Size: 51.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
631d25f41e50cc90fd10a79d61260a6780853cb7a4b84302ac5c4e196ff27999
|
|
| MD5 |
c9bc1d7b405d1992d706a23fd941c15d
|
|
| BLAKE2b-256 |
b16f2b0e2bc410970419595cd6435270820fc4b079e911ee7e6401b8ad6abec4
|