Metadata-Version: 2.4
Name: oaklint
Version: 0.1.12
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
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: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: Quality Assurance
License-File: LICENSE
Summary: An opinionated, agent-first Python linter.
Keywords: linter,python,static-analysis,code-quality
Author: Omar Ali Khan
License-Expression: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Documentation, https://github.com/omaralikhn/oaklint/blob/main/docs/rules/README.md
Project-URL: Homepage, https://github.com/omaralikhn/oaklint
Project-URL: Issues, https://github.com/omaralikhn/oaklint/issues
Project-URL: Repository, https://github.com/omaralikhn/oaklint

# oaklint

[![CI](https://github.com/omaralikhn/oaklint/actions/workflows/ci.yml/badge.svg)](https://github.com/omaralikhn/oaklint/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/omaralikhn/oaklint/branch/main/graph/badge.svg)](https://codecov.io/gh/omaralikhn/oaklint)
[![PyPI](https://img.shields.io/pypi/v/oaklint.svg)](https://pypi.org/project/oaklint/)
[![Python](https://img.shields.io/pypi/pyversions/oaklint.svg)](https://pypi.org/project/oaklint/)
[![License](https://img.shields.io/pypi/l/oaklint.svg)](https://github.com/omaralikhn/oaklint/blob/main/LICENSE)

`oak` is an opinionated, agent-first Python linter - a cross between Black and Ruff that enforces a curated set of rules targeting common sources of technical debt. These are the patterns that accumulate quietly, from minor style drift up to real structural problems, and they show up most in agent-written code and junior-developer code. `oak` is built to be both a linter and a learning tool: every rule explains why it exists, so the code gets fixed and the author learns the reasoning behind the fix. Built in Rust on the `rustpython-ruff_python_parser` crate, so it parses exactly what a modern Python toolchain does while staying a small standalone binary.

`oak` runs alongside `ruff`, `black`, and whatever else is already in your toolchain rather than replacing any of them. It stays fully compatible and layers its curated rules on top, so you keep your existing formatter and linter and add `oak` for the checks they do not cover.

Some of these rules are hot takes - deliberately more opinionated than a general-purpose linter would risk. In practice I have found they are what keeps medium-to-large teams and their codebases maintainable as they grow.

The set grows by one rule: if a standard can be systematically deduced from the code - checked mechanically rather than by judgment - it gets added here. Anything that needs human taste to adjudicate stays out.

## Installation

`oak` ships as a prebuilt wheel on PyPI, so it installs with no Rust toolchain:

```bash
uv tool install oaklint       # install the oak command globally
uvx oaklint path/to/file.py   # or run it without installing
pip install oaklint           # or with pip
```

The distribution is named `oaklint`; the installed command is `oak`.

Android (Termux) is covered too: an `android_21_arm64_v8a` wheel is published for CPython that reports the `android` platform, so `uv tool install oaklint` there installs the prebuilt binary instead of compiling from source.

## Usage

```bash
oak path/to/file.py src/            # report violations, exit 1 if any
oak --fix src/                      # rewrite files to resolve fixable violations
oak docs                            # list every rule with a one-sentence summary
oak docs OAK005,OAK012              # print the full reasoning for one or more rules
```

Rule selection can be set inline without a config file. Each flag takes a comma-separated list and is repeatable:

```bash
oak --select OAK005,OAK012 src/     # lint only these, replacing any configured select
oak --select ALL src/               # run every rule
oak --extend-select OAK014 src/     # add a code on top of the active set (config select or default-on)
oak --ignore OAK002 src/            # drop a code, appended to any configured ignore
oak --exclude 'vendor/**' src/      # skip matching paths, appended to any configured exclude
```

The flags overlay the discovered config: `--select` replaces its `select`, `--extend-select` adds on top, and `--ignore`/`--exclude` append to their lists.

Every rule ships with a full documentation page that an agent or a human can read on demand. Running `oak docs <codes>` prints the complete rationale, Good/Bad examples, and the recommended fix for exactly those rules - so the reader learns why the rule exists and how to apply the change, without leaving the terminal or hunting through the repo. Running `oak docs` with no codes prints a one-sentence summary of every rule, so a reader can scan the whole set and then fetch the pages that matter.

## Output

A run is structured to be read by an agent under a token budget, not to repeat itself once per line:

```text
Run `oak docs OAK007,OAK014,OAK015` for full rule reasoning, or fetch each individually.

  Code    Count  Rule
  OAK007     10  A public function or class is defined below a private function in the same scope.
  OAK014      3  A function returns a fixed-shape dict literal instead of a named type.
  OAK015      5  A function or method name does not lead with an action verb.
  Total      18

OAK007 - Public function or class must be placed above private functions
  tests/conftest.py:106:5: `build_client`
  tests/conftest.py:126:5: `Ledger`

OAK014 - Function returning a record must use a class, not a dict
  tests/conftest.py:69:9

OAK015 - Function or method name must lead with an action verb
  tests/conftest.py:85:5: `gateway`
  tests/conftest.py:89:5: `ledger`

Found 18 violations
```

The shape is deliberately agent-friendly and context-length-aware:

* **The docs command leads.** One line points at the full reasoning for every rule the run hit, and says each can be fetched on its own - the agent pulls the deep explanation only for the rules it decides to act on, instead of paying for it up front.
* **The summary table amortizes the explanation.** Each rule's definition and its violation count appear exactly once, so an agent can triage which rules matter before reading a single location.
* **Locations are grouped, not annotated.** The per-rule message is stated once as a section header, then followed by bare `path:line:column` lines - each suffixed with the specific identifier at fault (a function name, import alias, or offending token) when the rule has one. A file that trips one rule a hundred times costs a hundred short lines, not a hundred repetitions of the same sentence and the same `oak docs` pointer.

This keeps a large run's output roughly proportional to the number of distinct rules plus the number of locations, rather than to the product of the two - so a sweep over a whole codebase stays inside an agent's context window.

## Rules

| Code | Rule | Why |
|------|------|-----|
| [OAK001](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK001_newlines_blocks.md) | Missing blank line after an indented block (`if`/`for`/`while`/`with`/`try`/`match`) or before a continuation (`elif`/`else`/`except`/`finally`). | A blank line marks where one branch of logic ends, so the block and the code around it do not read as one undifferentiated run. |
| [OAK002](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK002_newlines_returns.md) | Missing blank line before a `return`. | A blank line before the exit separates the value being returned from the work that produced it, so the result does not blend into the logic above it. |
| [OAK003](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK003_comments_prefix.md) | Comment must be a sentence-case NOTE/TODO/XXX ending with a period. | The prefix states intent - explain, defer, or warn - and the sentence shape keeps a comment a complete thought rather than a fragment where stale notes hide. |
| [OAK004](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK004_comments_align.md) | Continuation line must align under the comment's first word. | A shared left edge makes a wrapped comment read as one paragraph, so the eye tracks it as a single thought instead of stray fragments. |
| [OAK005](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK005_imports_aliases.md) | Import must not use an alias. | An alias is a second name for one thing, so a reader must hold the mapping in their head and a search for the real name misses every call site. |
| [OAK006](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK006_privacy_only_functions.md) | Only functions may be private; classes and module- or class-level names must be public. | A private class contradicts itself the moment a public signature names it, and a private constant only hides a knob a test or caller needs to read. |
| [OAK007](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK007_private_ordering.md) | Private functions must be placed below all public functions and classes in a scope. | Reading top to bottom, a scope should open with its public surface and descend into private mechanics, so you learn what it does before how. |
| [OAK008](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK008_variable_naming.md) | Name must not be a single character. | A single letter carries no meaning and is impossible to search for, so a reader must reconstruct what it holds from how it is used. |
| [OAK009](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK009_mock_assertions.md) | Test must assert observable behavior, not mock calls. | Asserting on the calls made couples the test to the code's current shape, so it breaks on a behaviour-preserving refactor and passes for a wrong result reached the expected way. |
| [OAK010](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK010_mock_library.md) | Mock library must not be used, prefer an in-process fake. | A mock answers every call and attribute, so it silently accepts calls the real collaborator would reject and lets a passing test cover code that fails in production. |
| [OAK011](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK011_raises_match.md) | `pytest.raises` must not use `match=`; assert the full error message. | `match=` only checks a substring, so a typo or a wrong value elsewhere in the message slips through; asserting the full message catches any drift. |
| [OAK012](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK012_returns_tuples.md) | Function returning multiple values must use a class, not a tuple. | A tuple names each value only by position, so a reordered or same-typed pair is unpacked wrong with nothing to complain until the bad value surfaces later. |
| [OAK013](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK013_empty_string_sentinels.md) | Empty string must not stand for an absent value; use `None`. | An empty string is a present value, so it sails through every `is None` presence check and gets used as real data; None is the one unambiguous marker of absence. |
| [OAK014](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK014_returns_dicts.md) | Function returning a record must use a class, not a dict. | A dict has no declared shape, so a misspelled or renamed key fails at runtime far from the function that owns the shape instead of at the call site. |
| [OAK015](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK015_action_names.md) | Function or method name must lead with an action verb. | A function does something, so a verb-led name states the action at the call site; a bare noun reads as an accessor and hides whether it computes, mutates, or fetches. |
| [OAK016](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK016_test_conditionals.md) | Test must not contain conditional logic (`if`/`elif`/`else` or an `x if cond else y` ternary). | A test has no test of its own, so a branch can silently skip its assertion or hide how many cases really run; straight-line tests assert unconditionally. |
| [OAK017](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK017_ordering_dependencies.md) | Definition must be placed below the definitions in its scope that reference it. | Reading top to bottom, callers should sit above the helpers they lean on, so each scope opens with its entry points and descends into the machinery. |
| [OAK018](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK018_module_line_limit.md) | Non-test module must not exceed the configured line limit (default 500). | Length is a reliable proxy for how many responsibilities a file has taken on, and past a few hundred lines it stops fitting in a reader's head and unrelated changes start colliding. |
| [OAK019](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK019_test_module_line_limit.md) | Test module must not exceed the configured line limit (default 2000). | A test file earns a larger budget than source, but past a couple of thousand lines it has usually merged several subjects and become hard to navigate to the case that failed. |
| [OAK020](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK020_bare_classes.md) | Class must extend a base beyond `object` or carry a decorator. | A bare class forces a hand-rolled `__init__` that invites heavy work on construction and hides the object's shape; a dataclass or base declares the fields and intent upfront. |
| [OAK021](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/OAK021_string_continuation.md) | Triple-quoted string must not open with a backslash line-continuation. | The backslash pins every line to column zero, breaking the surrounding indentation and hiding the line join in a trailing character a stray space silently breaks. |

OAK001 and OAK002 are fixable with `--fix`, which inserts the missing blank line. OAK003 through OAK021 are report-only. Each rule has a page in [`docs/rules/`](https://github.com/omaralikhn/oaklint/blob/main/docs/rules/README.md) with its rationale and a good/bad example.

## Configuration

`oak` reads settings from the first of `.oak.toml`, `oak.toml`, or `[tool.oak]` in `pyproject.toml` found by walking up from the current directory. A `pyproject.toml` without a `[tool.oak]` table is skipped and the search continues upward.

```toml
[tool.oak]
select = ["OAK001"]              # when set, only these codes lint (prefixes like "OAK" and "ALL" work)
ignore = ["OAK002"]              # removed from the active set after select
exclude = ["tests/**", "vendor"] # globs skipped entirely
action-verbs = ["yeet", "reconcile"] # extra leading verbs OAK015 accepts
module-line-limit = 500          # OAK018 line cap for a non-test module
test-module-line-limit = 2000    # OAK019 line cap for a test module

[tool.oak.per-file-ignores]
"tests/**" = ["OAK002"]          # codes silenced only for matching files
```

In a standalone `oak.toml` the same keys are written at the top level (no `[tool.oak]` header). Unknown keys are a hard error and keys are kebab-case.

### Inline suppression

A comment can silence a violation in place, following ruff's `# noqa` model:

```python
import numpy as np  # noak                 # silences every oak rule on this line
import numpy as np  # noak: OAK005         # silences only OAK005 on this line
import numpy as np  # noak: OAK005,OAK008  # silences a comma-separated list
```

A `# oak: noqa` comment silences a whole file, with the same optional code list:

```python
# oak: noqa               # silences every oak rule in this file
# oak: noqa: OAK005,OAK008  # silences only these codes in this file
```

A line directive is anchored to the line the violation is reported on, and the keyword reads case-insensitively (`# NOAK`). A bare directive with no codes blankets its scope; naming codes narrows it to exactly those.

### Default rule set

With no `select` key, `oak` runs the default-on set: `OAK001`, `OAK002`, `OAK004`, `OAK008`, `OAK012`, and `OAK013`. The remaining rules stay off until you name them in `select`.

Setting `select` replaces the default set rather than adding to it, so list every code you want to run, including the default-on ones you want to keep:

```toml
[tool.oak]
# NOTE: The default rules plus the record-dict rule.
select = [
    "OAK001",  # Blank line after a block or before a continuation.
    "OAK002",  # Blank line before a return.
    "OAK004",  # Continuation lines align under the comment's first word.
    "OAK008",  # No single-character names.
    "OAK012",  # No tuple returns; use a named type.
    "OAK013",  # No empty string for an absent value; use None.
    "OAK014",  # No record dict returns; use a named type.
]
```

`select = ["ALL"]` runs every rule. OAK015 fires against a maintained verb allowlist, so expect to tune it before turning it on broadly.

## Development

```bash
make check     # fmt --check + clippy -D warnings + tests (the CI gate)
make format    # cargo fmt
make coverage  # per-file source coverage report
```

`make coverage` uses Rust's built-in `-C instrument-coverage` and the system `llvm-cov`/`llvm-profdata`, so it needs neither `cargo-llvm-cov` nor a rustup component. Point it at other target directories or llvm binaries with `CARGO_TARGET_DIR`, `LLVM_COV`, and `LLVM_PROFDATA`, and pass extra flags straight through (`make coverage -- --show-missing-lines`).

The repo is a Cargo workspace under `crates/`: `oaklint-core` holds the language-agnostic plumbing (discovery, config, diagnostics, suppression, docs, CLI run) behind a `Frontend` extension point, and `oaklint-py` is the Python frontend and the `oak` binary. Python rules live in `crates/oaklint-py/src/rules/`, one module per code, named `oak0NN_<domain>_<thing>.rs`. Helpers shared between two codes of the same family sit in `src/rules/util/`.

A run flows through five stages:

1. `oaklint_core::config::Config::discover` resolves settings.
2. `oaklint_core::discovery` finds the frontend's files and drops excluded ones.
3. the frontend's `linter::check_source` parses each file and runs the rules.
4. the violations are filtered by `select`, `ignore`, and `per-file-ignores`.
5. `oaklint_core::diagnostics::Violation` reports them, and the frontend's `apply_fixes` rewrites files under `--fix`.

