slopguard
Static checks for the specific failure modes of AI-generated code — the stuff that's correct but bad, 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 hook into ~/.claude/settings.json
slopguard install codex # append hooks 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
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 | warn | py | function 60%+ token-identical to another — a fork drifting apart (fixes landing on one side), or identical-except-literals code that wants parameterizing |
| duplicate-code | warn | all | copy-pasted block (~6+ normalized lines) elsewhere in the file set |
| unused-private | warn | py, ts, java, … | private function/method/field never referenced in its file |
| write-only-attr | warn | py | self._x assigned but never read |
| unused-import | warn | py | import never used |
| placeholder-body | warn | 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 | warn | all | "in a real implementation…"-style cop-outs |
| redundant-comment | warn/info | all | comment restates the code (warn if fully) |
| long-function / deep-nesting | warn | py | size thresholds (configurable) |
| as-any / ts-ignore | warn | ts | type-checker escapes |
| type-ignore / single-method-class / debug-artifact | info | py, js | never block |
In test files (*.test.ts, test_*.py, __tests__/, …) the conventional
patterns — as any mocks, repeated setup blocks, long functions — drop to
info instead of blocking.
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 | catches |
|---|---|
| no-assert-test | test never asserts — only proves the code doesn't crash |
| mock-only-test | every assertion is assert_called…/toHaveBeenCalled… — tests wiring, breaks on refactor |
| mock-echo-test | asserts the exact value the mock was told to return — verifies the mock, not the code |
| tautological-assert | assert True, expect(x).toBe(x), assertEqual(a, a) |
| conditional-assert | assertion inside an if — silently passes on some inputs |
| brittle-exact-string | equality against a ≥48-char literal — pins incidental wording |
| overspecified-assert | equality against a ≥8-entry literal dict/list — pins every field at once |
| parametrize-candidate | 3+ tests identical except literals — collapse into one @pytest.mark.parametrize / it.each |
| private-poke-test | test reads obj._private — pins internals instead of the public API |
| excessive-mocking | 6+ mocks/patches in one test — tests the wiring diagram |
| sleep-in-test | real sleep()/setTimeout waits — slow and flaky |
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— checks the file the agent just touched, immediately. - Codex CLI:
PostToolUseonapply_patch, plus aStophook that scans git-dirty files at end of turn (with a loop guard: the same finding set blocks a session's Stop only once).
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. Hooks fail open: an internal slopguard error never blocks the agent. Typical hook latency: <100 ms.
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:{"disable": ["long-function"], "max_function_lines": 120, "max_nesting": 5, "fail_on": "error", "hook_exclude": ["*/tests/fixtures/*"]}.hook_excludeusesfnmatchpatterns against absolute paths and affects hook targets only; explicitscanpaths are never excluded.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.2.0.tar.gz.
File metadata
- Download URL: slopguards-0.2.0.tar.gz
- Upload date:
- Size: 42.1 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 |
2f7719cd070f6a951526a64fcea9b39d7a43f501ec48f205f1f501233114482d
|
|
| MD5 |
e8c9d9b59bbe80647c0902ee19d9f391
|
|
| BLAKE2b-256 |
512db260fff0498a94e235e51ef6ec4532193a6202038c8e604cc09a25a92970
|