Show HN: I canceled my AI code reviewer and wrote a free local one Mukund Zha released Avouch, a free, Git-aware static analysis CLI for Python that reviews only changed files before a commit, using the standard library's ast module and requiring Python 3.10+ and Git. The tool, available via pip install avouch, reports structural problems against configurable limits in avouch.toml and exits with code 0 for clean, 1 for violations, or 2 for errors, without gating commits. 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