Metadata-Version: 2.5
Name: anti-slop-py
Version: 0.1.1
Summary: Opinionated, readable lint rules that reject low-evidence Python patterns
Project-URL: Repository, https://github.com/infoslack/anti-slop-py
Project-URL: Issues, https://github.com/infoslack/anti-slop-py/issues
Author: Daniel Romero
License-Expression: MIT
License-File: LICENSE
Keywords: ai-slop,fastapi,flake8,lint,mypy,pydantic,ruff,type-safety
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Flake8
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Requires-Dist: flake8>=7.0
Description-Content-Type: text/markdown

# anti-slop-py

[![skills.sh](https://skills.sh/b/infoslack/anti-slop-py)](https://skills.sh/infoslack/anti-slop-py)

Opinionated lint rules that reject low-evidence, low-signal Python patterns. A port of [anti-slop](https://github.com/dmmulroy/anti-slop) (Oxlint/TypeScript) to the Python ecosystem.

Use it three ways: scan a project without adding a single file to it, install the [PyPI package](https://pypi.org/project/anti-slop-py/) for CI enforcement, or vendor the rules into your repository and change them until they say what your team actually believes. The bundled agent skill wires up whichever mode you pick.

## What it catches

Two snippets straight out of an AI assistant's comfort zone. First, a "typed" loader that proves nothing:

```python
from typing import Any, cast


def load_user(payload: dict[str, Any]) -> dict[str, Any]:
    if isinstance(payload.get("id"), str):
        return cast(dict[str, Any], payload)
    raise ValueError("bad payload")
```

```
loader.py:4:24: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
loader.py:4:43: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
loader.py:5:8: ASP002 ad hoc runtime narrowing; parse input at the boundary or move the check into a TypeGuard/TypeIs function
loader.py:6:16: ASP001 type assertion requires a '# SAFETY:' comment on the same or preceding line documenting the checked invariant
loader.py:6:21: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
```

The function checks one key, asserts the rest into existence, and every caller inherits a dict that could hold anything. The shape the rules push toward instead:

```python
from pydantic import BaseModel


class User(BaseModel):
    id: str
    name: str


def load_user(payload: bytes) -> User:
    return User.model_validate_json(payload)
```

One parse at the boundary, evidence everywhere after it. A typo in a field access is now a type error instead of a `None` in production, and all five findings disappear because the code stopped needing `Any`, `isinstance`, and `cast` at all.

Second, a FastAPI endpoint with its contract buried in the implementation (framework group enabled):

```python
@app.post("/orders")
async def create_order(request: Request):
    data = await request.json()
    order = save_order(data)
    return JSONResponse(content={"id": order.id, "status": "created"})
```

```
orders.py:2:1: ASF001 endpoint without a typed response contract; annotate the return with a model or set response_model
orders.py:3:18: ASF002 manual request parsing; declare a body model parameter so FastAPI parses and validates at the boundary
orders.py:5:12: ASF003 ad hoc dict response; return a response model instead of hand-built JSON
```

After:

```python
@app.post("/orders")
async def create_order(order_in: OrderIn) -> OrderOut:
    order = save_order(order_in)
    return OrderOut(id=order.id, status="created")
```

FastAPI now validates the request, serializes the response, and publishes both shapes in the OpenAPI schema; none of that existed in the first version. Both outputs above are real runs of the published package (`uvx --with anti-slop-py flake8`), not mockups.

## Architecture: three layers

Ruff still has no API for custom rules, so anti-slop-py can't be a single plugin. It's one opinionated configuration spread across three tools, each covering the part it already handles well:

1. **Ruff** (`configs/ruff-anti-slop.toml`), for the rules that already exist: `ANN401` (no `Any` in signatures), `PGH003` (no blanket `type: ignore`), `B009`/`B010` (no constant-name `getattr`/`setattr`), and `TID251` with a banned-API list that includes `unittest.mock.patch`.
2. **mypy strict flags** (`configs/mypy-anti-slop.ini`). `disallow_any_explicit` and friends catch `Any` in the places Ruff can't see: nested generics, aliases, plain variables.
3. **A Flake8 plugin** (`src/anti_slop/`, vendored as a [local plugin](https://flake8.pycqa.org/en/latest/user/configuration.html)) carrying the ten custom `ASP` rules below. It runs next to Ruff, scoped with `select = ASP`.

## Install with an agent skill

Clone the repository and point your coding agent at the bundled skill:

```bash
git clone https://github.com/infoslack/anti-slop-py
```

Then, from the target repository, ask the agent to install anti-slop-py following `<clone>/skills/install-anti-slop-py/SKILL.md`. The skill copies the plugin to `tools/flake8/anti_slop/`, registers it as a Flake8 local plugin, merges the Ruff and mypy layers into whatever configuration already exists, and validates the result. When `pydantic` or `fastapi` shows up as a direct dependency, it also enables the matching framework group.

If you have Node available, the [skills.sh](https://skills.sh) CLI does the fetch in one step and registers the skill with your agent:

```bash
npx skills add infoslack/anti-slop-py --skill install-anti-slop-py
```

Claude Code users can install it as a plugin instead:

```
/plugin marketplace add infoslack/anti-slop-py
/plugin install anti-slop-py@anti-slop-py
```

## Scan without installing

The rules also run straight from a clone, reporting findings without adding a single file to the target project. Flake8 resolves the plugin relative to the config file, so from the target repository:

```bash
flake8 --config <clone>/configs/flake8-scan.ini src tests
```

Pair it with CLI-only flags for the other layers (`ruff check --extend-select ANN401,PGH003,B009,B010`, `mypy --disallow-any-explicit`) and nothing in the repo changes. With the package published, the clone becomes optional too: `uvx --with anti-slop-py flake8 --enable-extensions=ASD,ASF src tests`. This is the audit mode; the two installation paths below are for repositories that want the rules enforced in CI for every developer.

## Install as a package

The lighter enforcement path, from [PyPI](https://pypi.org/project/anti-slop-py/):

```bash
uv add --dev anti-slop-py
# or: pip install anti-slop-py
```

The entry points register the `ASP` rules with Flake8 automatically; a fresh `flake8 src tests` already reports them. The framework groups ship off by default and turn on per project:

```ini
[flake8]
select = ASP
# with pydantic/fastapi as direct dependencies:
# select = ASP,ASD,ASF
# enable-extensions = ASD,ASF
```

Then merge `configs/ruff-anti-slop.toml` into your Ruff configuration and `configs/mypy-anti-slop.ini` into your mypy configuration (both ship inside the sdist as reference).

### Running in CI

With the package in the dev dependencies, the whole team and the CI run the same rules. A minimal GitHub Actions job:

```yaml
name: lint
on: [push, pull_request]

jobs:
  anti-slop:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv sync
      - run: uv run flake8 src tests
```

The same job is the natural home for the other two layers (`uv run ruff check`, `uv run mypy`). A team that has not adopted the dependency yet can still audit every PR with one line and zero repo changes:

```yaml
      - run: uvx --with anti-slop-py flake8 --enable-extensions=ASD,ASF src tests
```

## Vendored installation

For teams that want to own and edit the rules rather than track releases: copy `src/anti_slop/` into the target repository (say, at `tools/flake8/anti_slop/`), install `flake8` as a development dependency, and register the local plugin in `.flake8`, since Flake8 doesn't read `pyproject.toml`:

```ini
[flake8]
select = ASP

[flake8:local-plugins]
extension =
    ASP = anti_slop.checker:AntiSlopChecker
paths =
    ./tools/flake8
```

The Ruff and mypy layers merge the same way as in the package path. Avoid running the vendored copy and the installed package at once; each finding gets reported twice.

## Rules

Each rule documents which original anti-slop rule it ports and names the ready-made tool covering the complementary half.

| Code | Ports | Rejects |
|---|---|---|
| `ASP001` | `require-safety-comment-for-type-assertion` | `typing.cast(...)` or `# type: ignore` with no `# SAFETY:` comment documenting the checked invariant |
| `ASP002` | `no-runtime-typeof` | ad hoc `isinstance`/`hasattr`/`type(x) is` narrowing; parse at the boundary instead. `--anti-slop-allow-typeguards` permits checks inside `TypeGuard`/`TypeIs` functions |
| `ASP003` | `no-module-mocking` | `mock.patch(...)`, `mocker.patch(...)`, `monkeypatch.setattr(...)`; inject dependencies through real seams |
| `ASP004` | `no-chained-type-assertions` | `cast(User, cast(object, value))` |
| `ASP005` | `no-known-value-widening` | `handlers: dict[str, Handler] = {"start": ...}`, where the broad annotation throws away known keys; use inference, `Final`, or a `TypedDict` |
| `ASP006` | `no-reflect-get` / `no-reflect-apply` | `getattr(owner, dynamic_name)`; Ruff B009/B010 cover the constant-name forms |
| `ASP007` | `no-shape-in-symbol-names` | `shape` in class, function, parameter, or variable names |
| `ASP008` | `no-object-parameters` / `no-unknown-*` | parameters or returns typed bare `object`, Python's safe top type; dunder methods and `cause` parameters are exempt |
| `ASP009` | `no-unsafe-dictionary-type` | `dict[str, Any]`, `Mapping[str, object]`, and friends; define a `TypedDict`, dataclass, or model |
| `ASP010` | `no-unknown-type-aliases` | `type ExternalValue = Any` plus the `TypeAlias` and bare-assignment spellings |

Not ported: `no-conditional-empty-object-spread`, because the JS idiom barely exists in Python, and `no-widen-then-assert`, which needs local flow analysis and stays on the roadmap. The Effect rule `no-service-constructor-imports` maps to [import-linter](https://github.com/seddonym/import-linter) contracts rather than a lint rule.

## Framework groups (opt-in)

Framework policy lives in separate rule groups, the same split the original makes with `anti-slop-effect`. The groups are `off_by_default` Flake8 plugins: enable one only when its framework is a direct dependency, with `enable-extensions = ASD,ASF` next to `select` (package path) or together with the extension lines in `[flake8:local-plugins]` (vendored path). Each group repeats the three-layer split, with ready-made rules first and custom `ASD`/`ASF` rules only for the gaps.

### Pydantic (`ASD`)

Ready-made layer: [flake8-pydantic](https://github.com/Viicos/flake8-pydantic) for `PYD` hygiene rules, plus the official Pydantic mypy plugin (`configs/mypy-anti-slop-pydantic.ini`).

| Code | Rejects |
|---|---|
| `ASD001` | `model_construct()` or `construct()` without a `# SAFETY:` comment. Both skip validation; the docs allow them only for data you already trust |
| `ASD002` | `extra="allow"` in `ConfigDict` or a legacy `class Config`, which stores unvalidated keys without a contract |
| `ASD003` | model fields typed `Any`, bare or nested (`list[Any]`), opting the field out of validation |
| `ASD004` | `f(**model.model_dump())` and `x: dict[...] = model.model_dump()`: re-widening a validated model into an untyped dict, the Pydantic version of `no-widen-then-assert` |
| `ASD005` | `TypeAdapter(...)` inside a function; official performance guidance says build it once at module scope |

### FastAPI (`ASF`)

Ready-made layer: Ruff `FAST` (redundant response_model, non-`Annotated` `Depends`, unused path params) and `ASYNC` (blocking I/O inside async routes), via `configs/ruff-anti-slop-fastapi.toml`.

| Code | Rejects |
|---|---|
| `ASF001` | endpoints with no typed response contract: no return annotation and no `response_model`, or a return annotated `Any`/`dict` |
| `ASF002` | `await request.json()`, `.body()`, or `.form()` inside endpoints; declare a body model parameter and let the framework parse at the boundary |
| `ASF003` | `JSONResponse(content={...})` with a dict literal inside endpoints; return a model. Exception handlers and middleware stay unflagged |

Detection is syntactic. Models are found by base-class name (same-module subclasses included), endpoints by `@<receiver>.<http-verb>(...)` decorators; cross-module inheritance and aliased imports are known misses.

## Development

```bash
uv run --group dev pytest
uv run flake8 src tests scripts   # the project's own rules, on itself
uv run ruff check src tests scripts
uv run mypy
```

The repo dogfoods all three layers on its own source through the installed entry points; CI enforces it. One documented deviation lives in `.flake8`: ASP002 targets application boundaries, and an AST linter discriminating the closed `ast.*` sum type is that boundary, so the rule does not apply to this codebase.

`src/` is canonical. After changing production source, run `scripts/sync_skill_assets.py` so the skill's bundled copy stays identical; `--check` verifies without writing. Releases: bump `__version__` in `src/anti_slop/__init__.py`, tag `v*`, and the publish workflow ships to PyPI via trusted publishing.

## License

MIT
