Metadata-Version: 2.4
Name: oaklint
Version: 0.1.21
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 curated set of rules formed over years of writing and reviewing code that targets the patterns which quietly accumulate into technical debt, from minor style drift up to real structural problems.

* **Agent-written code looks good on the surface but tends to be hard to maintain and worsens over time.** `oak` catches the structural habits - empty-string sentinels, tuple and dict returns, noun-named functions, oversized modules - that a surface read misses.
* **Teaching-first, so whoever reads it learns the fix.** Every rule explains why it exists in plain language, with attention to detail on every line of output, so the code gets fixed and the reasoning lands with the agent or person acting on it rather than just silencing a warning.

It complements your toolchain rather than replacing it:

* **Runs alongside `ruff`, `black`, `mypy`, and whatever else you already use.** It stays fully compatible and layers its own rules on top, so you keep your formatter, linter, and type checker and add `oak` for the checks they do not cover.
* **A unique, stricter rule set.** These are checks the general-purpose tools deliberately leave out - opinionated on purpose, and stricter than a tool that has to please everyone can be.

Use it as an extra check - an agent runs it before finishing a coding task, you run it before you commit, or it runs in CI - and the code that ships is that much cleaner and more maintainable.

The set grows by one rule at a time: if a standard can be systematically deduced from the code - checked mechanically rather than by judgment - it gets added. 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 OAKN01,OAKD04              # 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 OAKN01,OAKD04 src/     # lint only these, replacing any configured select
oak --select ALL src/               # run every rule
oak --extend-select OAKD06 src/     # add a code on top of the active set (config select or default-on)
oak --ignore OAKF02 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 built to be acted on directly: every violation carries the code that triggered it, the subject at fault, and the concrete fix, so an agent or a human can resolve the common case from the output alone.

```text
Run `oak docs OAKD02,OAKD06,OAKN04` for full rule reasoning, or fetch each individually.

  Code    Count  Rule
  OAKD02      1  A public function or class is defined below a private function in the same scope.
  OAKD06      1  A function returns a fixed-shape dict literal instead of a named type.
  OAKN04      2  A function or method name does not lead with an action verb.
  Total       4

OAKD02 - Private function must be placed below all public functions
  Fix: Move the private function below all public functions.
  src/orders.py:1:5: `_build_client is above 2 public definitions; move it below them`
    def _build_client(config):
        ^

OAKD06 - Function returning a record must use a class, not a dict
  Fix: Return a frozen dataclass, NamedTuple, or Pydantic model; when a function returns several distinct shapes, give each shape its own class and tie them with a union alias.
  src/orders.py:10:5: `{host, port}`
        return {"host": config.host, "port": config.port}
        ^

OAKN04 - Function or method name must lead with an action verb
  Fix: Rename to lead with an approved verb (see `oak docs OAKN04`), or add yours to action-verbs.
  src/orders.py:5:5: `widget_count`
    def widget_count(items):
        ^
  src/orders.py:9:5: `connection`
    def connection(config):
        ^

Found 4 violations
```

Every part of the output earns its place:

* **Each location shows its code in context.** A `path:line:column` line is followed by the offending source line with a caret under the column, the way ruff and rustc render, so you read the code at fault without opening the file and counting to the column.
* **The message names the specific subject.** Where the rule can name what it fired on - a function name, the dict keys that form the shape, the sentinel form that stands in for `None` - that subject rides on the location line, so the fix is about this occurrence, not the rule in the abstract.
* **The `Fix:` line states the concrete change.** Each rule's section leads with its message and a one-line fix stated once, and when the situation has a variant (several return shapes, a specific sentinel) the fix names it, so the common case resolves without a second command.
* **The summary table and docs pointer stay out of the way.** One table gives each rule's definition and count once, and one leading line points at the full reasoning for any rule the run hit - there to reach for, never repeated per line.

## Rules

The rules group into five categories. The `Code` column is the code the linter uses everywhere - `oak docs OAKD04`, `--select OAKD04`, `# noak: OAKD04` - and links to the rule's full page.

### Design

| Code | Rule | Why |
|------|------|-----|
| [OAKD01](https://breaking-changes.blog/oaklint-rules/declare-class-base/) | 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. |
| [OAKD02](https://breaking-changes.blog/oaklint-rules/put-private-last/) | 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. |
| [OAKD03](https://breaking-changes.blog/oaklint-rules/order-callers-first/) | 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. |
| [OAKD04](https://breaking-changes.blog/oaklint-rules/no-tuple-return/) | 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. |
| [OAKD05](https://breaking-changes.blog/oaklint-rules/use-none-for-absent/) | 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. |
| [OAKD06](https://breaking-changes.blog/oaklint-rules/return-named-record/) | 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. |

### Formatting

| Code | Rule | Why |
|------|------|-----|
| [OAKF01](https://breaking-changes.blog/oaklint-rules/newlines-after-blocks/) | 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. |
| [OAKF02](https://breaking-changes.blog/oaklint-rules/newlines-before-return/) | 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. |
| [OAKF03](https://breaking-changes.blog/oaklint-rules/dedent-strings/) | 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. |
| [OAKF04](https://breaking-changes.blog/oaklint-rules/shape-comments/) | 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. |
| [OAKF05](https://breaking-changes.blog/oaklint-rules/align-comments/) | 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. |

### Naming

| Code | Rule | Why |
|------|------|-----|
| [OAKN01](https://breaking-changes.blog/oaklint-rules/import-real-names/) | 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. |
| [OAKN02](https://breaking-changes.blog/oaklint-rules/underscore-functions-only/) | 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. |
| [OAKN03](https://breaking-changes.blog/oaklint-rules/no-single-char-names/) | 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. |
| [OAKN04](https://breaking-changes.blog/oaklint-rules/lead-names-with-a-verb/) | 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. |

### Size

| Code | Rule | Why |
|------|------|-----|
| [OAKS01](https://breaking-changes.blog/oaklint-rules/limit-module-length/) | 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. |
| [OAKS02](https://breaking-changes.blog/oaklint-rules/limit-test-module-length/) | 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. |

### Testing

| Code | Rule | Why |
|------|------|-----|
| [OAKT01](https://breaking-changes.blog/oaklint-rules/fake-dont-mock/) | 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. |
| [OAKT02](https://breaking-changes.blog/oaklint-rules/assert-full-error/) | `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. |
| [OAKT03](https://breaking-changes.blog/oaklint-rules/no-test-conditionals/) | 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. |
| [OAKT04](https://breaking-changes.blog/oaklint-rules/assert-outcomes/) | 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. |

OAKF01 and OAKF02 are fixable with `--fix`, which inserts the missing blank line. Every other rule is 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 = ["OAKF01"]              # when set, only these codes lint (a category prefix like "OAKT", the "OAK" family, or "ALL" works)
ignore = ["OAKF02"]              # removed from the active set after select
exclude = ["tests/**", "vendor"] # globs skipped entirely
action-verbs = ["yeet", "reconcile"] # extra leading verbs OAKN04 accepts
module-line-limit = 500          # OAKS01 line cap for a non-test module
test-module-line-limit = 2000    # OAKS02 line cap for a test module

[tool.oak.per-file-ignores]
"tests/**" = ["OAKF02"]          # 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: OAKN01         # silences only OAKN01 on this line
import numpy as np  # noak: OAKN01,OAKN03  # 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: OAKN01,OAKN03  # 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: `OAKF01`, `OAKF02`, `OAKF05`, `OAKN03`, `OAKD04`, and `OAKD05`. 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 = [
    "OAKF01",  # Blank line after a block or before a continuation.
    "OAKF02",  # Blank line before a return.
    "OAKF05",  # Continuation lines align under the comment's first word.
    "OAKN03",  # No single-character names.
    "OAKD04",  # No tuple returns; use a named type.
    "OAKD05",  # No empty string for an absent value; use None.
    "OAKD06",  # No record dict returns; use a named type.
]
```

`select = ["ALL"]` runs every rule. OAKN04 checks a name's leading token against a bundled English verb corpus plus a small hand-curated set of programming verbs, so expect to tune it with `action-verbs` 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 `oak<category><NN>_<domain>_<thing>.rs` (e.g. `oakf01_newlines_blocks.rs`, `oakn04_action_names.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`.

