anti-slop-py

The type checker was satisfied.
That was the problem.

anti-slop-py is a standalone, zero-dependency Python linter that rejects low-evidence patterns — the escape hatches a coding agent reaches for instead of naming a contract. A type checker permits Any, cast, and unexplained # type: ignore by construction: they are legal holes in its own type system. anti-slop bans exactly those holes.

WHAT THE AGENT WROTE
from typing import Any, cast


def promote(user: User) -> None:
    raw: Any = user
    admin = cast(User, raw)
    grant(admin)

# ruff: clean.  ty: 0 errors.
WHAT ANTI-SLOP SAYS
service.py:6:13 no-widen-then-cast

raw is user widened to Any a few lines up, and this cast claims a narrow type back from it. … Delete the widening step and use user directly, with the type it already carries.

service.py:6:13 require-safety-comment

cast to User asserts a claim the type checker cannot verify: from here on the value is User on your word alone, with nothing checked at runtime. … If no such invariant exists, do not cast: parse the value into User where it enters this code … so the type is proven, not asserted.

WHAT THE AGENT WROTE
from unittest.mock import patch


@patch("app.services.user_store.save")
def test_save(mock_save) -> None: ...
WHAT ANTI-SLOP SAYS
test_save.py:4:2 no-module-mocking

patch reaches into another module and swaps an attribute out at runtime. The test then rests on the import graph instead of on the design … after a rename, a re-export or an inlining the patch silently replaces nothing while the test keeps passing. Take the collaborator as an argument instead — a parameter typed by a Protocol, an ABC, or a narrow callable type — and pass a real test implementation from the test.

WHAT THE AGENT WROTE
config: object = {"retries": 3}
WHAT ANTI-SLOP SAYS
config.py:1:1 no-known-value-widening

The value assigned here is syntactically known right at this line — a literal dict/list/set/tuple or constant — but the object annotation throws that knowledge away: every reader and every downstream check has to treat it as arbitrary data again. Narrow the annotation to what is actually known: Final for a value that never changes, a TypedDict for a literal dict shape, or the specific domain type this value represents.

Real output, trimmed at the ellipses. Every diagnostic ends in a recipe, not a prohibition — written to be executed by the same agent that just wrote the slop.

Ruffstyle & correctness
ty · mypytypes
anti-slopevidence

anti-slop catches the moment an agent silences the checker without adding evidence. The stricter the type checker, the more that control matters: the moment an agent hits a type error, cast / ignore is the first thing it reaches for — and that is exactly where these rules fire.

Fifteen rules, two tiers

Analysis is purely syntactic — stdlib ast and tokenize, no type checker in the loop. Every rule declares its tier and confidence as machine-readable metadata; that is what --list-rules, --explain, and the presets read.

Escape-hatch

10 rules

Constructs whose main effect is to discard evidence the type checker already had. Near-universal — enable them first, everywhere.

highno-any-parameters

Parameters must name a domain type, not the Any escape hatch.

highno-any-returns

Return annotations must name a domain type, not Any.

highno-any-type-aliases

Type aliases must name a domain type, not hide Any behind a name.

highno-chained-casts

A cast call must not take another cast call as its value.

highno-conditional-empty-dict-spread

A dict spread’s source must not be a ternary with an empty dict on either branch.

highno-dynamic-dispatch

Dispatch must not go through a namespace subscript or an attrgetter/methodcaller built from a runtime name.

highno-known-value-widening

A literal value’s annotation must not widen it to Any or object.

mediumno-unsafe-dict-values

Dict-like value types must name a domain type, not Any/object.

highno-widen-then-cast

A value must not be widened to Any/object and then cast back to a narrow type: the evidence was already there before the widening.

highrequire-safety-comment

Every cast and every type-checker suppression must state its verified invariant in a # SAFETY: comment, and every suppression must name a code.

Architectural

5 rules

Executable policy for the places ordinary linters stay silent — and a policy some teams reject wholesale. Every rule takes a per-rule level; every finding is suppressible by rule id. The defaults are a posture, not a ceiling.

policyno-adhoc-isinstance

isinstance()/issubclass() must live inside a TypeGuard/TypeIs function, not branch ad hoc.

policyno-module-mocking

Inject dependencies through real seams instead of patching modules.

policyno-object-parameters

Parameters must name a domain type, not the object top type.

policyno-shape-in-symbol-names

A declared name must not encode a banned structural term.

policyno-string-attribute-access

getattr()/setattr()/delattr() must not stand in for direct attribute access or unparsed dynamic access.

Four more rules ship in the opt-in fastapi group — framework policy stays out of the core. And this repository practices what it configures: its own pyproject.toml sets no-adhoc-isinstance to off, because an AST analyzer’s domain objects are ast nodes. That is the configuration model working as intended, not an exception to it.

Vendored, not depended upon

The copy is meant to be read and adjusted to your team’s standards — in vendored mode nothing is installed at all. No virtualenv, no PYTHONPATH: running the directory lints the repository on its own interpreter.

$ npx skills add TinyFrontier/anti-slop-py --skill install-anti-slop-py
# then ask your agent to install anti-slop in the current repository
$ python tools/anti_slop --list-rules
$ python -m anti_slop review --base origin/main
# only what this change touched, read back as a review
$ 

The install skill inspects the target repository, copies the linter into tools/anti_slop/, merges [tool.anti-slop], wires up pre-commit and CI, and hands Ruff’s duplicate rules over to their anti-slop equivalents.

Python ≥ 3.12 zero runtime dependencies purely syntactic — stdlib ast + tokenize fix = "none", by design