# Show HN: I canceled my AI code reviewer and wrote a free local one

> Source: <https://github.com/mukundzha/avouch>
> Published: 2026-08-18 13:13:17+00:00

**Review the Python you changed, not the Python you inherited.**

Avouch is a lightweight, Git-aware static analysis CLI for Python. It asks
Git which files your next commit will touch, parses each changed `.py`

file with the standard `ast`

module, and reports structural problems
against limits you configure in `avouch.toml`

.

No daemon. No network. No path lists to maintain. Run it in the seconds
before `git push`

, fix what it flags, push.

```
pip install avouch
cd your-repo
avouch
```

[Why it exists](#why-it-exists)[Installation](#installation)[Quick start](#quick-start)[JSON output](#json-output)[Quiet mode](#quiet-mode)[GitHub Actions](#github-actions)[Other CI systems](#other-ci-systems)[Configuration](#configuration)[Rules](#rules)[How it works](#how-it-works)[Repository layout](#repository-layout)[Adding a rule](#adding-a-rule)[Testing](#testing)[Roadmap](#roadmap)[FAQ](#faq)[Contributing](#contributing)[License](#license)

**The review set is the diff, not the repository.** Avouch computes the review set from Git at run time (`git diff HEAD --name-only`

plus untracked files). Every finding is attributable to work you are about to push — never to the legacy you inherited.**Metrics are exact.** Parameter counts, nesting depth, and line spans come from the AST, not regex. If a metric cannot be computed exactly, Avouch does not claim it.**Errors are data.** An unreadable or syntactically broken file becomes an`ERROR`

entry in the report. One broken file never cancels the review of the others.**Avouch reviews; it does not gate.** The exit code signals the outcome —`0`

clean,`1`

violations found,`2`

Avouch error — but enforcement belongs in an opt-in interface, not in a tool you run before every push.**The runtime is the standard library.** Three`git`

subprocess calls and`ast`

/`tomllib`

. No daemon to keep alive; runtime is bounded by the size of your diff, not your repository.

Requires **Python 3.10+** (rules use `ast.Match`

; configuration uses
`tomllib`

) and **Git on PATH**.

```
pip install avouch
```

or from source:

```
git clone https://github.com/mukundzha/avouch.git
cd avouch
pip install -e .
```

Both register the `avouch`

console script (`avouch.cli:main`

).

The interface is one command with a small set of optional flags:

```
cd your-repo
# ... make a change ...
avouch            # human report
avouch --json     # one JSON document on stdout
avouch --docs     # built-in documentation; no review performed
avouch --version  # print the version and exit
avouch --verbose  # step-by-step review details on stderr
avouch --quiet    # analyze, print no report; exit code only
avouch --changed  # compact added/deleted view of changed files vs HEAD
avouch --staged   # review only files staged for the next commit
avouch --all-files  # review every eligible Python file, not just the diff
avouch --not-git  # review every eligible .py file on disk; no Git repo needed
avouch --help     # every flag
```

The review set is defined by Git, so there is nothing to configure at
invocation time. With `--not-git`

, Avouch skips the Git requirement and
reviews every eligible `.py`

file found by walking the current
directory instead (skipping Git, cache, and virtual-environment
directories). Avouch reviews:

- tracked files modified vs.
`HEAD`

(`git diff HEAD --name-only`

), and - untracked
`.py`

files (`git ls-files --others --exclude-standard`

).

Deleted paths and non-`.py`

files are skipped. Committed, untouched files
never appear in the output. Files that look generated
(`generated.py`

, `*_generated.py`

, `codegen.py`

, `autogen.py`

, … — see
`src/avouch/utility/is_generated.py`

) are skipped too.

The review-scope flags `--changed`

, `--staged`

, and `--all-files`

are
mutually exclusive — pick at most one. The output flags `--json`

,
`--verbose`

, and `--quiet`

combine freely with any review scope.

``` bash
$ avouch

AVOUCH · 2 FILES · 4 WARN
────────────────────────────────────────────────────────────────────────────────

bad.py:1: SCR002: Bare except detected. Catch a specific exception instead, e.g. except ValueError:.
  │
1 │ def connect(host, port, user, password, db, timeout):
  │     ^^^^^^^ SCR002
2 │     try:
  │

bad.py:1: SCR014: Too many parameters (6/5). Group related parameters into a data class or dictionary.
  │
1 │ def connect(host, port, user, password, db, timeout):
  │     ^^^^^^^ SCR014
2 │     try:
  │

────────────────────────────────────────────────────────────────────────────────
BY RULE

  SCR002 Bare except          1
  SCR014 Too many parameters  1

────────────────────────────────────────────────────────────────────────────────
PASSED
  ✓ src/util.py
```

**Header**—`AVOUCH · N FILES · W WARN · E ERR`

: file and per-severity counts, followed by the per-file findings.**Findings**— each finding renders compiler-style: a`file:line`

header with the rule id and full message, then the offending code region with dimmed line numbers and a caret`^^^^^`

under the flagged name (rule id in blue on a TTY).**BY RULE summary**— findings counted per rule, most common first, with counts aligned on the right. Rendered only when findings exist.** PASSING grid**— compliant files, compressed to a few lines with a`[+N more]`

note when there are many.- Identical
`(component, rule)`

findings are deduplicated per file — the header counts every finding, so with overlapping rule IDs (SCR004 / SCR006 duplicate-branch) the row count can be lower than the header count.

``` bash
$ avouch

All clean.
bash
$ cd /tmp/somewhere-without-git
$ avouch
error: no Git repository found
hint: run Avouch from inside a Git repository, or use --not-git to review files without Git

$ cd ~/fresh-checkout   # e.g. a CI runner
$ avouch
error: nothing to review
hint: nothing changed vs HEAD (CI checkouts are clean); use --all-files for a full review
```

Colors are ANSI codes emitted only when stdout is a TTY. Piped output is
plain, so `avouch | tee review.log`

and CI capture work cleanly. Runtime
errors are written to stderr, so stdout stays clean for piping and
`--json`

capture. The exit code is `0`

when the review is clean, `1`

when findings are reported, and `2`

when Avouch cannot run.

`avouch --docs`

prints terminal documentation derived from this codebase —
what Avouch does, the Git-aware workflow, every rule with its scope, every
configuration key with its default, both output formats, and realistic
examples — then exits `0`

without running a review. It works anywhere,
even outside a Git repository. In a real terminal it opens as an
interactive browser (`H`

elp, `G`

o, `M`

ain screen, `Q`

uit); when stdout
is piped it prints the plain text instead.

For automation and CI, `--json`

prints the review as a single JSON document
on stdout, with no human-readable text mixed in:

```
avouch --json
{
  "version": 1,
  "tool": "avouch",
  "violations": [
    {
      "rule": "SCR014",
      "severity": "WARNING",
      "message": "Too many parameters (6/5). Group related parameters into a data class or dictionary.",
      "file": "buggy.py",
      "name": "extra",
      "kind": "func",
      "line": 4
    }
  ],
  "summary": {
    "total": 1,
    "errors": 0,
    "warnings": 1,
    "files_with_violations": 1
  }
}
```

Each violation carries the rule id (or a human-readable label when the
finding has none), its severity, the message, the file, the component name,
its kind (`func`

, `class`

, or `file`

), and the line the finding refers to
(`null`

for file-level findings) — the same component and kind shown in
the human table. `files_with_violations`

is the number of distinct
files containing at least one violation.

The document is a stable, versioned contract for automation: `version`

is the schema version (independent of the Avouch package version), `tool`

identifies the emitter, and the same input always produces the same JSON
— no colors, timestamps, or diagnostics leak in. Exit codes behave
exactly as in normal mode, so `avouch --json`

can gate CI: parse stdout
for the findings and react to the exit status (`0`

clean, `1`

violations,
`2`

Avouch error).

`--quiet`

runs the exact same analysis but prints no report; only the
exit code signals the outcome (`0`

clean, `1`

violations, `2`

Avouch
error), which makes it fit hooks and scripts that need only the status.
Errors are never silenced: messages such as "error: no Git repository found" still print, `--json`

still emits its document, and
`--verbose`

diagnostics still go to stderr.

Avouch can run as a GitHub Actions check on every pull request and push.

For an existing project, a minimal workflow installs the published package and reviews the whole checkout on every PR and push:

```
name: Avouch

on:
  pull_request:
  push:

jobs:
  avouch:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install Avouch
        run: python -m pip install avouch

      - name: Run Avouch
        run: avouch --all-files --json
```

`actions/checkout`

puts the pull request's code in the runner's working tree — Avouch analyzes the files that checkout provided, nothing more.`actions/setup-python`

provides a Python runtime; Avouch requires Python 3.10+.`python -m pip install avouch`

installs the latest published release. Pin a version (`avouch==0.3.1`

) for reproducible runs.`avouch --all-files --json`

reviews every eligible`.py`

file and prints the machine-readable document to the job log.`permissions: contents: read`

is the only permission needed — the workflow makes no API calls.

The default review set is files changed vs. Git `HEAD`

, so a freshly
checked-out working tree — clean by construction — has nothing to review:
`avouch`

would print `error: nothing to review`

and exit `2`

. The same
applies to `--changed`

and `--staged`

; they only make sense locally,
against your own working tree. Whole-repository review is the mode that
works in CI:

| Command | Purpose | In CI |
|---|---|---|
`avouch` |
review files changed vs `HEAD` |
empty set; don't use |
`avouch --changed` |
diff view of changed files | empty set; don't use |
`avouch --staged` |
review staged changes | empty set; don't use |
`avouch --all-files` |
review every eligible Python file | the CI mode |
`avouch --json` |
machine-readable document on stdout | combine with `--all-files` |
`avouch --quiet` |
suppress report; exit code only | fine for gating |

Avouch's exit code behaves in CI exactly as it does locally: `0`

is clean,
`1`

means findings were reported, `2`

means Avouch could not run. GitHub
Actions fails a job when a step exits non-zero, so `--all-files --json`

fails the check on any finding, and the JSON document in the job log shows
why. Nothing is hidden with `|| true`

; findings already present in the
repository fail the check until they are fixed or excluded with
`ignore_paths`

in `avouch.toml`

.

The Avouch repository itself ships `.github/workflows/avouch.yml`

; enable it
in the repository's **Actions** tab and it runs on its own. It installs
the repository's own source with `pip install -e .`

, so it tests the code
in the pull request rather than a published release, then reviews the
whole checked-out repository with `--all-files --json`

.

Avouch is a plain console command with a documented exit code, so any CI system can run it with the same three steps:

- Install:
`python -m pip install avouch`

- Run:
`avouch --all-files --json`

- Treat the exit code as the result:
`0`

pass,`1`

findings,`2`

error.

The JSON document on stdout is stable and versioned (see [JSON
output](#json-output)), so it can be parsed for job annotations, summary
comments, or dashboards.

Configuration is optional, partial, and declarative. Avouch looks for a
`avouch.toml`

in the **current working directory** — no upward search, so
configuration is repository-local. Any subset of keys is merged over the
built-in defaults; a missing or empty file simply means defaults, with
no warning.

```
[limits]        # numeric thresholds per rule
[rules]         # on/off toggle per rule
ignore_paths = ["tests", "migrations"]   # top-level: paths to skip
```

**Name and format:**`avouch.toml`

in your working directory, plain TOML.**Scope:** the current directory only. Avouch never searches parent directories, so each project configures itself.**Missing or empty:** defaults are used silently — there is no "no configuration found" warning.**Environment variables:** none. Configuration comes only from`avouch.toml`

(the`AVOUCH_FONT`

variable only selects a terminal font).

List the limit you want under `[limits]`

; only the keys you name change,
everything else stays at its default:

```
[limits]
max_parameters = 8    # allow up to 8 parameters instead of 5
max_file_lines = 2500 # tolerate larger files
```

Put the rule under `[rules]`

and set it to `false`

:

```
[rules]
nested_function = false   # stop reporting SCR015
```

A one-line `[rules]`

section is a complete, valid configuration.

| Key | Default | Rule |
|---|---|---|
`async_without_await` |
`true` |
SCR001 |
`bare_except` |
`true` |
SCR002 |
`max_boolean_conditions` |
`true` |
SCR003 |
`detect_duplicateb` |
`true` |
SCR004 |
`max_large_comprehensions` |
`true` |
SCR005 |
`empty_except` |
`true` |
SCR006 |
`max_if_else_chain` |
`true` |
SCR007 |
`max_lambda_nodes` |
`true` |
SCR008 |
`max_local_variables` |
`true` |
SCR009 |
`max_class_lines` |
`true` |
SCR010 |
`max_file_lines` |
`true` |
SCR011 |
`max_function_lines` |
`true` |
SCR012 |
`max_nesting` |
`true` |
SCR013 |
`max_parameters` |
`true` |
SCR014 |
`nested_function` |
`true` |
SCR015 |
`max_return_statements` |
`true` |
SCR016 |
`mutable_default_args` |
`true` |
SCR017 |
`max_complexity` |
`true` |
function/class complexity |

Setting a toggle to `false`

disables that rule's findings.

| Key | Default | Rule | Meaning |
|---|---|---|---|
`max_parameters` |
5 | SCR014 | Max positional + keyword params |
`max_nesting` |
5 | SCR013 | Max block nesting depth |
`max_function_lines` |
300 | SCR012 | Max function line span |
`max_class_lines` |
200 | SCR010 | Max class line span |
`max_file_lines` |
1000 | SCR011 | Max file line count |
`max_complexity` |
40 | — | Max cyclomatic complexity |
`max_boolean_conditions` |
5 | SCR003 | Max operands in one chain |
`max_if_chain` |
5 | SCR007 | Max if/elif links in a chain |
`max_local_variables` |
30 | SCR009 | Max distinct assigned names |
`max_return_statements` |
6 | SCR016 | Max `return` s per function |
`max_lambda_nodes` |
10 | SCR008 | Max AST nodes in a lambda body |
`max_large_comprehensions` |
40 | SCR005 | Max AST nodes in a comprehension |

Limits are applied by key. A rule whose limit key is absent from the
merged config falls back to the limit hardcoded in its own module, so a
partial `[limits]`

never turns a rule off. Every limit key in the table
above lives in `DEFAULT_LIMITS`

and can be tuned from `avouch.toml`

.

Two mechanisms exclude files, both matching repository-relative paths
component-wise — `tests`

skips `tests/`

and `tests/x.py`

but not
`tests.py`

; a bare `"."`

skips the whole repository:

`avouch --ignore-path PATH`

— repeatable CLI flag, or`ignore_paths = ["tests", "migrations"]`

at the top level of`avouch.toml`

(must be a list; anything else raises).

CLI and TOML paths are combined and de-duplicated before analysis.
Matching is purely string-based (`src/avouch/utility/is_ignored.py`

) —
no filesystem access.

Run `avouch --verbose`

: when there is a review set, the first diagnostics
line reports the config source and the active ignore-path count:

```
avouch: config: avouch.toml, 2 ignore path(s)
avouch: ignore paths: tests, migrations
```

Without a `avouch.toml`

the line reads `config: defaults (no avouch.toml), 0 ignore path(s)`

. `avouch --docs`

prints the same limits
and rule defaults for reference.

- Malformed TOML (or a non-list
`ignore_paths`

) prints`error: invalid avouch.toml configuration: ...`

on stderr and exits`2`

. - Unknown keys are accepted and ignored silently — a typo makes the
intended setting silently ineffective, and Avouch does not warn
(
`--verbose`

shows only the file name and the ignore-path count). - Limit values are not type-checked: a non-numeric value such as
`max_parameters = "eight"`

is not rejected and fails at analysis time with an internal error (exit`2`

).

`--ignore-path`

appends to the TOML`ignore_paths`

(combined and de-duplicated); there is no CLI override for`[limits]`

or`[rules]`

.- Configuration applies equally to every review mode —
`--changed`

,`--staged`

, and`--all-files`

— and to every output mode:`--json`

,`--quiet`

, and`--verbose`

. - Severity is not configurable: rule findings are
`WARNING`

;`ERROR`

is reserved for files that cannot be read or parsed. `--docs`

renders the built-in documentation and exits before any configuration is read, so it is unaffected by`avouch.toml`

.

```
# avouch.toml — the exact file this repository lives by
ignore_paths = ["tests"]

[limits]
max_parameters = 5
max_nesting = 5
max_function_lines = 300
max_class_lines = 200
max_file_lines = 1000
max_complexity = 40
max_boolean_conditions = 5
max_if_chain = 5
max_local_variables = 30
max_return_statements = 6
max_lambda_nodes = 10
max_large_comprehensions = 40

[rules]
max_parameters = true
max_nesting = true
max_function_lines = true
max_class_lines = true
max_file_lines = true
max_complexity = true
max_boolean_conditions = true
max_local_variables = true
max_return_statements = true
max_lambda_nodes = true
max_large_comprehensions = true
mutable_default_args = true
```

Avouch ships 17 rule identifiers (SCR001–SCR017) plus two cyclomatic
complexity checks on functions and classes sharing the `max_complexity`

limit. Every rule finding is a `WARNING`

; `ERROR`

findings exist only for
files that cannot be read or parsed. Rules with a threshold render
`measured/limit`

; presence-based rules render `detected`

.

| ID | Rule | Limit | Scope | Metric |
|---|---|---|---|---|
| SCR001 | Async without await | — | async funcs | `detected` |
| SCR002 | Bare except | — | funcs | `detected` |
| SCR003 | Boolean expression too complex | 5 | funcs, classes | `N/limit` |
| SCR004 | Duplicate branch | — | funcs | `detected` |
| SCR005 | Large comprehension | 40 | funcs | `N/limit` |
| SCR006 | Duplicate branch | — | funcs, classes | `detected` |
| SCR007 | Long if/elif chain | 5 | funcs, classes | `N/limit` |
| SCR008 | Lambda too complex | 10 | funcs | `N/limit` |
| SCR009 | Too many local variables | 30 | funcs | `N/limit` |
| SCR010 | Class too large | 200 | classes | `N/limit` |
| SCR011 | File too large | 1000 | files | `N/limit` |
| SCR012 | Function too long | 300 | funcs | `N/limit` |
| SCR013 | Nesting too deep | 5 | funcs | `N/limit` |
| SCR014 | Too many parameters | 5 | funcs | `N/limit` |
| SCR015 | Nested function definition | — | funcs | `detected` |
| SCR016 | Too many return statements | 6 | funcs | `N/limit` |
| SCR017 | Mutable default argument | — | funcs | `detected` |
| — | Function too complex | 40 | funcs | `N/limit` |
| — | Class too complex | 40 | classes | `N/limit` |

Flags `async def`

functions that never `await`

. An async function without
an `await`

runs synchronously while still incurring event-loop overhead.
This is the only rule applied to `async def`

functions; the other
function rules do not run on them.

``` python
# bad
async def fetch_config():
    return json.load(open("config.json"))

# good
def fetch_config():
    return json.load(open("config.json"))
```

Flags `except:`

handlers that catch every exception — including
`KeyboardInterrupt`

and `SystemExit`

.

```
# bad
try:
    return json.loads(raw)
except:
    return None

# good
try:
    return json.loads(raw)
except (ValueError, TypeError):
    return None
```

Flags a single `and`

/`or`

chain with too many operands. Nested chains sum
their operands, so `a and (b or c)`

scores 3.

```
# bad — 6 operands
if a and b and c and d and e and f:
    launch()

# good
if is_ready(a, b, c) and has_clearance(d, e, f):
    launch()
```

Flags `if`

/`elif`

branches whose bodies are identical — a copy-paste or a
condition that never varies. The trailing `else`

body is excluded from
the comparison. Two rule IDs cover the same detection:
SCR004 (`detect_duplicateb`

) runs on functions; SCR006 (`empty_except`

)
runs on functions and classes. Both emit the same finding, and the
report deduplicates identical rows, so one violation renders once.

```
# bad
if kind == "csv":
    rows = read_csv(path)
elif kind == "json":
    rows = read_csv(path)      # copy-paste

# good
if kind in ("csv", "json"):
    rows = read_csv(path)
```

Flags list/set/dict comprehensions and generator expressions whose AST
node count exceeds `max_large_comprehensions`

(default 40). Past a few
nested clauses a comprehension stops being an expression and becomes a
program.

```
# bad
result = [
    [x * 100 for x in row if x != 0]
    for row in matrix
    if row and any(v > limit for v in row)
]

# good
def scale_row(row, factor):
    return [x * factor for x in row if x != 0]

result = [scale_row(row, 100) for row in matrix if row]
```

Flags if/elif chains longer than `max_if_chain`

(default 5); the
trailing `else`

clause does not add to the chain length.

```
# bad
if status == "ok":
    ...
elif status == "warn":
    ...
elif status == "error":
    ...
elif status == "fatal":
    ...
elif status == "timeout":
    ...
else:
    ...

# good
status_actions = {"ok": ok_action, "warn": warn_action}
status_actions.get(status, unknown_action)()
```

Flags `lambda`

bodies exceeding `max_lambda_nodes`

(default 10) AST nodes.

```
# bad
transform = lambda v: v.strip().lower().split(",") if "," in v else [v]

# good
def transform(v):
    return v.strip().lower().split(",") if "," in v else [v]
```

Flags functions assigning more than `max_local_variables`

(default 30)
distinct names — every new name is cognitive load and a chance for
shadowing. The count covers plain `x = ...`

assignment targets only
(`ast.Assign`

with `ast.Name`

targets); augmented and unpacked
assignments are not counted. Assignments inside nested functions count
toward the enclosing function's total. Fix: extract groups of
assignments into helpers.

Flags classes whose line span exceeds `max_class_lines`

(default 200).
A class past ~200 lines is usually several classes; fix by splitting by
responsibility.

Flags files exceeding `max_file_lines`

(default 1000). Fix: split into
modules with single concerns.

Flags functions whose line span exceeds `max_function_lines`

(default
300). Fix: extract helpers — `process_order`

becomes `validate`

,
`reserve`

, and `send`

.

Flags maximum nesting depth of block nodes above `max_nesting`

(default
5). Depth counts `if`

, `for`

, `while`

, `async for`

, `with`

, `async with`

, `try`

, and `match`

only. Comprehensions, lambdas, and nested
`def`

s do **not** add depth; sibling blocks do not stack — the metric is
maximum depth, not block count.

```
# bad — 5 deep
with open(path) as f:               # 1
    for row in f:                   # 2
        if row.startswith("#"):     # 3
            try:                    # 4
                parse(row)          # 5

# good — early-return guards flatten it
def line_ready(row):
    if not row:
        return False
    if row.startswith("#"):
        return False
    return True

with open(path) as f:
    for row in f:
        if line_ready(row):
            parse(row)
```

Flags functions with more than `max_parameters`

(default 5) positional or
keyword parameters. The count is `node.args.args`

, so `*args`

and
`**kwargs`

are excluded; `self`

on methods counts as a parameter.

``` python
# bad
def connect(host, port, user, password, db, timeout):
    ...

# good
@dataclass
class Connection:
    host: str
    port: int
    user: str
    password: str
    db: str

def connect(cfg: Connection, timeout: int) -> None: ...
```

Flags a function defined inside another function. Closures that capture
their enclosing scope run once per outer call and defeat unit testing.
Only plain `def`

definitions are flagged; a nested `async def`

is not.

``` python
# bad
def process_all(data):
    def normalize(value):
        return value.strip().lower()
    return [normalize(x) for x in data]

# good
def normalize(value):
    return value.strip().lower()

def process_all(data):
    return [normalize(x) for x in data]
```

Flags functions with more than `max_return_statements`

(default 6)
`return`

s — every exit point is a path to maintain. Returns inside
nested functions count toward the enclosing function's total.

Flags default parameter values that are mutable — list/dict/set
literals (`[]`

, `{}`

, `{1, 2}`

) or mutable constructor calls
(`list()`

, `dict()`

, `set()`

, `bytearray()`

, `defaultdict()`

,
`OrderedDict()`

). Defaults are evaluated once at definition time, so
the same object is shared across every call that omits the argument —
state leaks between unrelated calls.

``` python
# bad
def add_item(item, items=[]):
    items.append(item)
    return items

# good
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
```

The rule inspects only the function's own defaults — a mutable default
on a nested function is reported once, by that function's own finding,
never duplicated in the enclosing function's report. Immutable defaults
(`None`

, strings, numbers, tuples, `frozenset()`

) are never flagged.

Flags functions and classes whose McCabe cyclomatic complexity exceeds
`max_complexity`

(default 40). Base 1, then +1 for every `if`

, `for`

,
`async for`

, `while`

, `try`

, `except`

handler, `match`

, ternary,
`assert`

, `with`

, `async with`

, and every `and`

/`or`

chain — an
`and`

/`or`

chain counts 1 regardless of how many operands it combines,
so `a and (b or c)`

adds 2 (one per chain). The walk covers the whole
subtree: a class's complexity is the sum over its entire body, methods
included.

The codebase is deliberately small: a CLI orchestrator, four pipeline
modules, two config modules, and one rule per file. The governing rule is
that ** cli.py only orchestrates** — every function it calls lives in
another module, and nothing imports

`cli.py`

.Execution flow — this is the full path of a run (`--docs`

and
`--version`

short-circuit before configuration):

``` php
flowchart TD
    M["avouch.cli:main()"] --> P["argparse<br/>--json · --quiet · --verbose · --ignore-path ·<br/>--changed · --staged · --all-files · --not-git"]
    P --> PD{"--docs?"}
    PD -- "yes" --> D["utility/docs.py<br/>render_docs()"]
    D --> X0["exit 0"]
    PD -- "no" --> C["config/loader.py<br/>load_config(): avouch.toml merged over defaults"]
    C --> G{"Git repository?"}
    G -- "no · without --not-git" --> EX2A["exit 2<br/>error: no Git repository found"]
    G -- "yes, or --not-git" --> S{"Selection mode"}
    S -- "--not-git" --> F4["git.py: get_all_files_on_disk()<br/>*.py walked from CWD"]
    S -- "--all-files" --> F3["git.py: get_all_files()<br/>git ls-files"]
    S -- "--staged" --> F2["git.py: get_staged_files()<br/>git diff --cached --name-only"]
    S -- "default" --> F1["git.py: get_changed_files()<br/>git diff HEAD --name-only + untracked"]
    F1 --> R["git.py: get_reviewable_files()<br/>existing .py · not generated · not ignored"]
    F2 --> R
    F3 --> R
    F4 --> R
    R -- "none left" --> EX2B["exit 2<br/>error: nothing to review"]
    R -- "files" --> A["analyzer.py: analyze_file()<br/>read file → ast.parse → walk cache → rules"]
    A --> O{"Output mode"}
    O -- "--json" --> J["report.py: render_json()"]
    O -- "--quiet" --> Q["no report"]
    O -- "default + --changed" --> DIF["report.py: render_diff_view()<br/>git diff of the review set"]
    O -- "default" --> H["report.py: generate_report()<br/>terminal report"]
    J --> E{"Any findings?"}
    Q --> E
    DIF --> E
    H --> E
    E -- "no" --> EX0["exit 0"]
    E -- "yes" --> EX1["exit 1"]
```

Module dependencies — what imports what (each arrow is a real `import`

):

``` php
flowchart LR
    CLI["cli.py<br/>orchestration only"] -->|load_config, DEFAULT_RULES| CFG["config/loader.py"]
    CLI -->|DEFAULT_LIMITS| DEF["config/default.py"]
    CLI -->|review-set computation| GIT["git.py"]
    CLI -->|analyze_file| AN["analyzer.py"]
    CLI -->|render_json · render_diff_view<br/>generate_report · vlog| REP["report.py"]
    CLI -->|render_docs| DOC["utility/docs.py"]
    CFG --> DEF
    AN --> RULES["rules/*.py<br/>one analyze(node, limits) per rule"]
    AN --> COM["rules/complexity.py<br/>calculate_complexity"]
    RULES -->|walk| WAL["utility/walk.py<br/>cached ast.walk, reset per file"]
    GIT --> IG["utility/is_generated.py"]
    GIT --> II["utility/is_ignored.py"]
    REP -->|get_file_diff| GIT
```

| Module | Role | Key exports |
|---|---|---|
`cli.py` |
Pipeline wiring | `main()` |
`docs.py` (in `utility/` ) |
Built-in `--docs` text |
`DOCS` |
`git.py` |
Git interaction | `is_gitrepo` , `get_changed_files` , `get_staged_files` , `get_reviewable_files` |
`analyzer.py` |
AST analysis | `read_file` , `analyze_file` |
`rules/*.py` |
One rule per module | `analyze(node, limits)` |
`utility/walk.py` |
Cached AST traversal | `walk` , `reset_walk_cache` |
`report.py` |
Terminal + JSON rendering | `render_report` , `generate_report` , `render_json` |
`config/default.py` |
Default limits | `DEFAULT_LIMITS` |
`config/loader.py` |
TOML load + merge | `load_config` , `merge_limits` , `merge_rules` , `DEFAULT_RULES` |

`cli.main()`

loads config (`limits`

+`rules`

merged over defaults).`git.is_gitrepo()`

—`git rev-parse --is-inside-work-tree`

; exits the run with a message if not a repo.`git.get_changed_files()`

—`git diff HEAD --name-only`

plus untracked files;`git.get_staged_files()`

—`git diff --cached --name-only`

— is used with`--staged`

;`get_reviewable_files()`

keeps existing`.py`

paths that are neither generated (`is_generated`

) nor covered by ignore paths (`is_ignored`

); if none remain, prints a message and exits`2`

.- Per file,
`analyzer.analyze_file(path, limits, rules)`

:- reads UTF-8 (
`OSError`

→`ERROR`

report), parses with`ast.parse`

(`SyntaxError`

→`ERROR`

report; the rest of the run continues), - resets the walk cache (
`utility/walk.py`

), then walks the AST, dispatching`FunctionDef`

,`AsyncFunctionDef`

, and`ClassDef`

nodes to their rules (rule toggles are checked before dispatch, so disabled rules never run), - returns
`(function_reports, file_reports, class_reports)`

.

- reads UTF-8 (
`report.render_report(...)`

groups issues by file in a single pass and renders the`AVOUCH`

header, per-file findings, the BY RULE summary, and the`[PASSING]`

grid.

`cli.py`

with `--docs`

short-circuits before config loading and calls
`docs.render_docs()`

, so no Git or analysis code runs. In a TTY that
renders an interactive browser over `docs.DOCS`

; piped stdout prints
the plain text.

Terminal rendering is hand-rolled ANSI in `src/avouch/report.py`

— the
`rich`

dependency declared in `pyproject.toml`

is not imported. Colors
are emitted only when stdout is a TTY; piped output is plain. Each
finding renders compiler-style: a `file:line`

header with rule id and
message, the offending code region with dimmed line numbers, and a
caret under the flagged name. Identical `(component, rule)`

findings
are deduplicated per file, and the BY RULE summary counts deduplicated
findings, sorted most common first. The `[PASSING]`

grid collapses to
at most a few lines, with a `[+N more]`

note when it overflows.
`AVOUCH_FONT=name`

is an opt-in OSC 50 font switch honored only by
capable terminals.

```
avouch/
├── pyproject.toml          # packaging, console script
├── avouch.toml              # limits this repo lives by
├── src/avouch/
│   ├── cli.py              # entry point; orchestration only
│   ├── git.py              # review-set computation
│   ├── analyzer.py         # AST walk, rule dispatch
│   ├── report.py           # terminal report UI
│   ├── rules/              # one module per rule
│   │   ├── complexity.py           # cyclomatic metric (no issues itself)
│   │   ├── max_nesting.py          # get_depth + BLOCK_NODES
│   │   └── ...                     # one analyze(node, limits) per rule
│   ├── utility/
│   │   ├── walk.py         # cached ast.walk + per-file cache reset
│   │   ├── docs.py         # --docs terminal documentation text
│   │   ├── is_generated.py # generated-file patterns
│   │   └── is_ignored.py   # ignore-path matching
│   └── config/
│       ├── default.py      # DEFAULT_LIMITS
│       └── loader.py       # load_config, merge_limits, merge_rules
└── tests/
    └── test_git.py         # 74 tests, incl. a real-git end-to-end run
```

A rule is a module in `src/avouch/rules/`

exposing
`analyze(node, limits) -> list[issue]`

, where an issue is:

```
{"rule": "SCR017", "severity": "WARNING",
 "message": "Description (value/limit). Remediation guidance."}
```

Plus a `[rules]`

toggle in `DEFAULT_RULES`

(and a limit in
`DEFAULT_LIMITS`

if the rule has a threshold). Wire the dispatch into
`analyze_file`

with a toggle guard, then write the tests: one for the
violation, one for the boundary. The renderer displays any
`(severity, message)`

pair it receives, so no report code changes.

All 74 tests run in a fraction of a second — no network, no package installs:

```
pip install -e .
python -m pytest tests/
```

Coverage includes the git helpers, config merging, every nesting block
type, every complexity decision point, boolean-chain measurement,
rule boundaries, analysis failure paths (unreadable/syntax-error files),
report output, the `--docs`

flag (asserted to exit cleanly without
touching Git, inside or outside a repository), and an end-to-end run
against a **real temporary git repository** — Git itself is not mocked.
Mocking is limited to `subprocess.run`

where a real Git isn't needed.

The detailed implementation plan for the next release lives in
[ roadmap.md](/mukundzha/avouch/blob/main/roadmap.md) — v0.3.3 ships eight new capabilities under the
theme "first run clean, every run relevant" (

`avouch init`

, a findings
baseline, parallel review, CI-native output formats, rule man pages, a
pre-commit hook, and inline diff annotations).Beyond v0.3.3, informed by documented limitations, ordered by the pain they remove:

**0.4 — Configuration hardening**

- Validate
`avouch.toml`

values with readable errors (today: a malformed file raises) - Search upward from the working directory for
`avouch.toml`

(today: CWD only)

**1.0 — CI-grade interface**

- Configurable exit codes, so enforcement thresholds can be tuned without changing avouch's review-only default

New rules must survive the philosophy section — the ceiling is raised deliberately, not by accretion.

**Why only changed files?**
Pre-existing issues are noise. A whole-repo run buries the few findings
you introduced under hundreds you didn't. The review set is the diff, so
the output is always relevant to the next push.

**Why git diff HEAD and not git diff?**
Plain

`git diff`

covers only unstaged changes. `HEAD`

covers staged plus
unstaged — the complete set of files about to be pushed — and avouch adds
untracked files on top, so brand-new files are never missed.**Why AST instead of regex?**
Regex cannot count parentheses across lines, measure nesting, or
distinguish a definition from a call. The AST answers structural
questions exactly for every valid Python file.

**What are the exit codes?**
Avouch returns `0`

when the review is clean, `1`

when findings are
reported, and `2`

when Avouch cannot run. It still reviews rather than
gates — enforcement stays in whatever calls it — but CI can now react to
the outcome directly.

**Does it need a network or a daemon?**
No. Three `git`

subprocess calls and the standard library. Runtime is
bounded by the size of your diff, not your repository.

**Tests before code.** A fix that cannot be expressed as a failing test first is not a fix yet.**Keep the diff small.** A change that touches more than two modules needs a justification in the PR description.**The standard-library runtime is the contract.** No new runtime dependencies without a written case that survives the philosophy section.**The README is the spec.** If the behavior changed, the README changes in the same commit.

Setup:

```
git clone https://github.com/mukundzha/avouch.git
cd avouch
pip install -e .
python -m pytest tests/
```

MIT — see `LICENSE`

.
