{"slug": "show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one", "title": "Show HN: I canceled my AI code reviewer and wrote a free local one", "summary": "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.", "body_md": "**Review the Python you changed, not the Python you inherited.**\n\nAvouch is a lightweight, Git-aware static analysis CLI for Python. It asks\nGit which files your next commit will touch, parses each changed `.py`\n\nfile with the standard `ast`\n\nmodule, and reports structural problems\nagainst limits you configure in `avouch.toml`\n\n.\n\nNo daemon. No network. No path lists to maintain. Run it in the seconds\nbefore `git push`\n\n, fix what it flags, push.\n\n```\npip install avouch\ncd your-repo\navouch\n```\n\n[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)\n\n**The review set is the diff, not the repository.** Avouch computes the review set from Git at run time (`git diff HEAD --name-only`\n\nplus 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`\n\nentry 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`\n\nclean,`1`\n\nviolations found,`2`\n\nAvouch 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`\n\nsubprocess calls and`ast`\n\n/`tomllib`\n\n. No daemon to keep alive; runtime is bounded by the size of your diff, not your repository.\n\nRequires **Python 3.10+** (rules use `ast.Match`\n\n; configuration uses\n`tomllib`\n\n) and **Git on PATH**.\n\n```\npip install avouch\n```\n\nor from source:\n\n```\ngit clone https://github.com/mukundzha/avouch.git\ncd avouch\npip install -e .\n```\n\nBoth register the `avouch`\n\nconsole script (`avouch.cli:main`\n\n).\n\nThe interface is one command with a small set of optional flags:\n\n```\ncd your-repo\n# ... make a change ...\navouch            # human report\navouch --json     # one JSON document on stdout\navouch --docs     # built-in documentation; no review performed\navouch --version  # print the version and exit\navouch --verbose  # step-by-step review details on stderr\navouch --quiet    # analyze, print no report; exit code only\navouch --changed  # compact added/deleted view of changed files vs HEAD\navouch --staged   # review only files staged for the next commit\navouch --all-files  # review every eligible Python file, not just the diff\navouch --not-git  # review every eligible .py file on disk; no Git repo needed\navouch --help     # every flag\n```\n\nThe review set is defined by Git, so there is nothing to configure at\ninvocation time. With `--not-git`\n\n, Avouch skips the Git requirement and\nreviews every eligible `.py`\n\nfile found by walking the current\ndirectory instead (skipping Git, cache, and virtual-environment\ndirectories). Avouch reviews:\n\n- tracked files modified vs.\n`HEAD`\n\n(`git diff HEAD --name-only`\n\n), and - untracked\n`.py`\n\nfiles (`git ls-files --others --exclude-standard`\n\n).\n\nDeleted paths and non-`.py`\n\nfiles are skipped. Committed, untouched files\nnever appear in the output. Files that look generated\n(`generated.py`\n\n, `*_generated.py`\n\n, `codegen.py`\n\n, `autogen.py`\n\n, … — see\n`src/avouch/utility/is_generated.py`\n\n) are skipped too.\n\nThe review-scope flags `--changed`\n\n, `--staged`\n\n, and `--all-files`\n\nare\nmutually exclusive — pick at most one. The output flags `--json`\n\n,\n`--verbose`\n\n, and `--quiet`\n\ncombine freely with any review scope.\n\n``` bash\n$ avouch\n\nAVOUCH · 2 FILES · 4 WARN\n────────────────────────────────────────────────────────────────────────────────\n\nbad.py:1: SCR002: Bare except detected. Catch a specific exception instead, e.g. except ValueError:.\n  │\n1 │ def connect(host, port, user, password, db, timeout):\n  │     ^^^^^^^ SCR002\n2 │     try:\n  │\n\nbad.py:1: SCR014: Too many parameters (6/5). Group related parameters into a data class or dictionary.\n  │\n1 │ def connect(host, port, user, password, db, timeout):\n  │     ^^^^^^^ SCR014\n2 │     try:\n  │\n\n────────────────────────────────────────────────────────────────────────────────\nBY RULE\n\n  SCR002 Bare except          1\n  SCR014 Too many parameters  1\n\n────────────────────────────────────────────────────────────────────────────────\nPASSED\n  ✓ src/util.py\n```\n\n**Header**—`AVOUCH · N FILES · W WARN · E ERR`\n\n: file and per-severity counts, followed by the per-file findings.**Findings**— each finding renders compiler-style: a`file:line`\n\nheader with the rule id and full message, then the offending code region with dimmed line numbers and a caret`^^^^^`\n\nunder 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]`\n\nnote when there are many.- Identical\n`(component, rule)`\n\nfindings 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.\n\n``` bash\n$ avouch\n\nAll clean.\nbash\n$ cd /tmp/somewhere-without-git\n$ avouch\nerror: no Git repository found\nhint: run Avouch from inside a Git repository, or use --not-git to review files without Git\n\n$ cd ~/fresh-checkout   # e.g. a CI runner\n$ avouch\nerror: nothing to review\nhint: nothing changed vs HEAD (CI checkouts are clean); use --all-files for a full review\n```\n\nColors are ANSI codes emitted only when stdout is a TTY. Piped output is\nplain, so `avouch | tee review.log`\n\nand CI capture work cleanly. Runtime\nerrors are written to stderr, so stdout stays clean for piping and\n`--json`\n\ncapture. The exit code is `0`\n\nwhen the review is clean, `1`\n\nwhen findings are reported, and `2`\n\nwhen Avouch cannot run.\n\n`avouch --docs`\n\nprints terminal documentation derived from this codebase —\nwhat Avouch does, the Git-aware workflow, every rule with its scope, every\nconfiguration key with its default, both output formats, and realistic\nexamples — then exits `0`\n\nwithout running a review. It works anywhere,\neven outside a Git repository. In a real terminal it opens as an\ninteractive browser (`H`\n\nelp, `G`\n\no, `M`\n\nain screen, `Q`\n\nuit); when stdout\nis piped it prints the plain text instead.\n\nFor automation and CI, `--json`\n\nprints the review as a single JSON document\non stdout, with no human-readable text mixed in:\n\n```\navouch --json\n{\n  \"version\": 1,\n  \"tool\": \"avouch\",\n  \"violations\": [\n    {\n      \"rule\": \"SCR014\",\n      \"severity\": \"WARNING\",\n      \"message\": \"Too many parameters (6/5). Group related parameters into a data class or dictionary.\",\n      \"file\": \"buggy.py\",\n      \"name\": \"extra\",\n      \"kind\": \"func\",\n      \"line\": 4\n    }\n  ],\n  \"summary\": {\n    \"total\": 1,\n    \"errors\": 0,\n    \"warnings\": 1,\n    \"files_with_violations\": 1\n  }\n}\n```\n\nEach violation carries the rule id (or a human-readable label when the\nfinding has none), its severity, the message, the file, the component name,\nits kind (`func`\n\n, `class`\n\n, or `file`\n\n), and the line the finding refers to\n(`null`\n\nfor file-level findings) — the same component and kind shown in\nthe human table. `files_with_violations`\n\nis the number of distinct\nfiles containing at least one violation.\n\nThe document is a stable, versioned contract for automation: `version`\n\nis the schema version (independent of the Avouch package version), `tool`\n\nidentifies the emitter, and the same input always produces the same JSON\n— no colors, timestamps, or diagnostics leak in. Exit codes behave\nexactly as in normal mode, so `avouch --json`\n\ncan gate CI: parse stdout\nfor the findings and react to the exit status (`0`\n\nclean, `1`\n\nviolations,\n`2`\n\nAvouch error).\n\n`--quiet`\n\nruns the exact same analysis but prints no report; only the\nexit code signals the outcome (`0`\n\nclean, `1`\n\nviolations, `2`\n\nAvouch\nerror), which makes it fit hooks and scripts that need only the status.\nErrors are never silenced: messages such as \"error: no Git repository found\" still print, `--json`\n\nstill emits its document, and\n`--verbose`\n\ndiagnostics still go to stderr.\n\nAvouch can run as a GitHub Actions check on every pull request and push.\n\nFor an existing project, a minimal workflow installs the published package and reviews the whole checkout on every PR and push:\n\n```\nname: Avouch\n\non:\n  pull_request:\n  push:\n\njobs:\n  avouch:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n\n      - name: Install Avouch\n        run: python -m pip install avouch\n\n      - name: Run Avouch\n        run: avouch --all-files --json\n```\n\n`actions/checkout`\n\nputs the pull request's code in the runner's working tree — Avouch analyzes the files that checkout provided, nothing more.`actions/setup-python`\n\nprovides a Python runtime; Avouch requires Python 3.10+.`python -m pip install avouch`\n\ninstalls the latest published release. Pin a version (`avouch==0.3.1`\n\n) for reproducible runs.`avouch --all-files --json`\n\nreviews every eligible`.py`\n\nfile and prints the machine-readable document to the job log.`permissions: contents: read`\n\nis the only permission needed — the workflow makes no API calls.\n\nThe default review set is files changed vs. Git `HEAD`\n\n, so a freshly\nchecked-out working tree — clean by construction — has nothing to review:\n`avouch`\n\nwould print `error: nothing to review`\n\nand exit `2`\n\n. The same\napplies to `--changed`\n\nand `--staged`\n\n; they only make sense locally,\nagainst your own working tree. Whole-repository review is the mode that\nworks in CI:\n\n| Command | Purpose | In CI |\n|---|---|---|\n`avouch` |\nreview files changed vs `HEAD` |\nempty set; don't use |\n`avouch --changed` |\ndiff view of changed files | empty set; don't use |\n`avouch --staged` |\nreview staged changes | empty set; don't use |\n`avouch --all-files` |\nreview every eligible Python file | the CI mode |\n`avouch --json` |\nmachine-readable document on stdout | combine with `--all-files` |\n`avouch --quiet` |\nsuppress report; exit code only | fine for gating |\n\nAvouch's exit code behaves in CI exactly as it does locally: `0`\n\nis clean,\n`1`\n\nmeans findings were reported, `2`\n\nmeans Avouch could not run. GitHub\nActions fails a job when a step exits non-zero, so `--all-files --json`\n\nfails the check on any finding, and the JSON document in the job log shows\nwhy. Nothing is hidden with `|| true`\n\n; findings already present in the\nrepository fail the check until they are fixed or excluded with\n`ignore_paths`\n\nin `avouch.toml`\n\n.\n\nThe Avouch repository itself ships `.github/workflows/avouch.yml`\n\n; enable it\nin the repository's **Actions** tab and it runs on its own. It installs\nthe repository's own source with `pip install -e .`\n\n, so it tests the code\nin the pull request rather than a published release, then reviews the\nwhole checked-out repository with `--all-files --json`\n\n.\n\nAvouch is a plain console command with a documented exit code, so any CI system can run it with the same three steps:\n\n- Install:\n`python -m pip install avouch`\n\n- Run:\n`avouch --all-files --json`\n\n- Treat the exit code as the result:\n`0`\n\npass,`1`\n\nfindings,`2`\n\nerror.\n\nThe JSON document on stdout is stable and versioned (see [JSON\noutput](#json-output)), so it can be parsed for job annotations, summary\ncomments, or dashboards.\n\nConfiguration is optional, partial, and declarative. Avouch looks for a\n`avouch.toml`\n\nin the **current working directory** — no upward search, so\nconfiguration is repository-local. Any subset of keys is merged over the\nbuilt-in defaults; a missing or empty file simply means defaults, with\nno warning.\n\n```\n[limits]        # numeric thresholds per rule\n[rules]         # on/off toggle per rule\nignore_paths = [\"tests\", \"migrations\"]   # top-level: paths to skip\n```\n\n**Name and format:**`avouch.toml`\n\nin 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`\n\n(the`AVOUCH_FONT`\n\nvariable only selects a terminal font).\n\nList the limit you want under `[limits]`\n\n; only the keys you name change,\neverything else stays at its default:\n\n```\n[limits]\nmax_parameters = 8    # allow up to 8 parameters instead of 5\nmax_file_lines = 2500 # tolerate larger files\n```\n\nPut the rule under `[rules]`\n\nand set it to `false`\n\n:\n\n```\n[rules]\nnested_function = false   # stop reporting SCR015\n```\n\nA one-line `[rules]`\n\nsection is a complete, valid configuration.\n\n| Key | Default | Rule |\n|---|---|---|\n`async_without_await` |\n`true` |\nSCR001 |\n`bare_except` |\n`true` |\nSCR002 |\n`max_boolean_conditions` |\n`true` |\nSCR003 |\n`detect_duplicateb` |\n`true` |\nSCR004 |\n`max_large_comprehensions` |\n`true` |\nSCR005 |\n`empty_except` |\n`true` |\nSCR006 |\n`max_if_else_chain` |\n`true` |\nSCR007 |\n`max_lambda_nodes` |\n`true` |\nSCR008 |\n`max_local_variables` |\n`true` |\nSCR009 |\n`max_class_lines` |\n`true` |\nSCR010 |\n`max_file_lines` |\n`true` |\nSCR011 |\n`max_function_lines` |\n`true` |\nSCR012 |\n`max_nesting` |\n`true` |\nSCR013 |\n`max_parameters` |\n`true` |\nSCR014 |\n`nested_function` |\n`true` |\nSCR015 |\n`max_return_statements` |\n`true` |\nSCR016 |\n`mutable_default_args` |\n`true` |\nSCR017 |\n`max_complexity` |\n`true` |\nfunction/class complexity |\n\nSetting a toggle to `false`\n\ndisables that rule's findings.\n\n| Key | Default | Rule | Meaning |\n|---|---|---|---|\n`max_parameters` |\n5 | SCR014 | Max positional + keyword params |\n`max_nesting` |\n5 | SCR013 | Max block nesting depth |\n`max_function_lines` |\n300 | SCR012 | Max function line span |\n`max_class_lines` |\n200 | SCR010 | Max class line span |\n`max_file_lines` |\n1000 | SCR011 | Max file line count |\n`max_complexity` |\n40 | — | Max cyclomatic complexity |\n`max_boolean_conditions` |\n5 | SCR003 | Max operands in one chain |\n`max_if_chain` |\n5 | SCR007 | Max if/elif links in a chain |\n`max_local_variables` |\n30 | SCR009 | Max distinct assigned names |\n`max_return_statements` |\n6 | SCR016 | Max `return` s per function |\n`max_lambda_nodes` |\n10 | SCR008 | Max AST nodes in a lambda body |\n`max_large_comprehensions` |\n40 | SCR005 | Max AST nodes in a comprehension |\n\nLimits are applied by key. A rule whose limit key is absent from the\nmerged config falls back to the limit hardcoded in its own module, so a\npartial `[limits]`\n\nnever turns a rule off. Every limit key in the table\nabove lives in `DEFAULT_LIMITS`\n\nand can be tuned from `avouch.toml`\n\n.\n\nTwo mechanisms exclude files, both matching repository-relative paths\ncomponent-wise — `tests`\n\nskips `tests/`\n\nand `tests/x.py`\n\nbut not\n`tests.py`\n\n; a bare `\".\"`\n\nskips the whole repository:\n\n`avouch --ignore-path PATH`\n\n— repeatable CLI flag, or`ignore_paths = [\"tests\", \"migrations\"]`\n\nat the top level of`avouch.toml`\n\n(must be a list; anything else raises).\n\nCLI and TOML paths are combined and de-duplicated before analysis.\nMatching is purely string-based (`src/avouch/utility/is_ignored.py`\n\n) —\nno filesystem access.\n\nRun `avouch --verbose`\n\n: when there is a review set, the first diagnostics\nline reports the config source and the active ignore-path count:\n\n```\navouch: config: avouch.toml, 2 ignore path(s)\navouch: ignore paths: tests, migrations\n```\n\nWithout a `avouch.toml`\n\nthe line reads `config: defaults (no avouch.toml), 0 ignore path(s)`\n\n. `avouch --docs`\n\nprints the same limits\nand rule defaults for reference.\n\n- Malformed TOML (or a non-list\n`ignore_paths`\n\n) prints`error: invalid avouch.toml configuration: ...`\n\non stderr and exits`2`\n\n. - Unknown keys are accepted and ignored silently — a typo makes the\nintended setting silently ineffective, and Avouch does not warn\n(\n`--verbose`\n\nshows only the file name and the ignore-path count). - Limit values are not type-checked: a non-numeric value such as\n`max_parameters = \"eight\"`\n\nis not rejected and fails at analysis time with an internal error (exit`2`\n\n).\n\n`--ignore-path`\n\nappends to the TOML`ignore_paths`\n\n(combined and de-duplicated); there is no CLI override for`[limits]`\n\nor`[rules]`\n\n.- Configuration applies equally to every review mode —\n`--changed`\n\n,`--staged`\n\n, and`--all-files`\n\n— and to every output mode:`--json`\n\n,`--quiet`\n\n, and`--verbose`\n\n. - Severity is not configurable: rule findings are\n`WARNING`\n\n;`ERROR`\n\nis reserved for files that cannot be read or parsed. `--docs`\n\nrenders the built-in documentation and exits before any configuration is read, so it is unaffected by`avouch.toml`\n\n.\n\n```\n# avouch.toml — the exact file this repository lives by\nignore_paths = [\"tests\"]\n\n[limits]\nmax_parameters = 5\nmax_nesting = 5\nmax_function_lines = 300\nmax_class_lines = 200\nmax_file_lines = 1000\nmax_complexity = 40\nmax_boolean_conditions = 5\nmax_if_chain = 5\nmax_local_variables = 30\nmax_return_statements = 6\nmax_lambda_nodes = 10\nmax_large_comprehensions = 40\n\n[rules]\nmax_parameters = true\nmax_nesting = true\nmax_function_lines = true\nmax_class_lines = true\nmax_file_lines = true\nmax_complexity = true\nmax_boolean_conditions = true\nmax_local_variables = true\nmax_return_statements = true\nmax_lambda_nodes = true\nmax_large_comprehensions = true\nmutable_default_args = true\n```\n\nAvouch ships 17 rule identifiers (SCR001–SCR017) plus two cyclomatic\ncomplexity checks on functions and classes sharing the `max_complexity`\n\nlimit. Every rule finding is a `WARNING`\n\n; `ERROR`\n\nfindings exist only for\nfiles that cannot be read or parsed. Rules with a threshold render\n`measured/limit`\n\n; presence-based rules render `detected`\n\n.\n\n| ID | Rule | Limit | Scope | Metric |\n|---|---|---|---|---|\n| SCR001 | Async without await | — | async funcs | `detected` |\n| SCR002 | Bare except | — | funcs | `detected` |\n| SCR003 | Boolean expression too complex | 5 | funcs, classes | `N/limit` |\n| SCR004 | Duplicate branch | — | funcs | `detected` |\n| SCR005 | Large comprehension | 40 | funcs | `N/limit` |\n| SCR006 | Duplicate branch | — | funcs, classes | `detected` |\n| SCR007 | Long if/elif chain | 5 | funcs, classes | `N/limit` |\n| SCR008 | Lambda too complex | 10 | funcs | `N/limit` |\n| SCR009 | Too many local variables | 30 | funcs | `N/limit` |\n| SCR010 | Class too large | 200 | classes | `N/limit` |\n| SCR011 | File too large | 1000 | files | `N/limit` |\n| SCR012 | Function too long | 300 | funcs | `N/limit` |\n| SCR013 | Nesting too deep | 5 | funcs | `N/limit` |\n| SCR014 | Too many parameters | 5 | funcs | `N/limit` |\n| SCR015 | Nested function definition | — | funcs | `detected` |\n| SCR016 | Too many return statements | 6 | funcs | `N/limit` |\n| SCR017 | Mutable default argument | — | funcs | `detected` |\n| — | Function too complex | 40 | funcs | `N/limit` |\n| — | Class too complex | 40 | classes | `N/limit` |\n\nFlags `async def`\n\nfunctions that never `await`\n\n. An async function without\nan `await`\n\nruns synchronously while still incurring event-loop overhead.\nThis is the only rule applied to `async def`\n\nfunctions; the other\nfunction rules do not run on them.\n\n``` python\n# bad\nasync def fetch_config():\n    return json.load(open(\"config.json\"))\n\n# good\ndef fetch_config():\n    return json.load(open(\"config.json\"))\n```\n\nFlags `except:`\n\nhandlers that catch every exception — including\n`KeyboardInterrupt`\n\nand `SystemExit`\n\n.\n\n```\n# bad\ntry:\n    return json.loads(raw)\nexcept:\n    return None\n\n# good\ntry:\n    return json.loads(raw)\nexcept (ValueError, TypeError):\n    return None\n```\n\nFlags a single `and`\n\n/`or`\n\nchain with too many operands. Nested chains sum\ntheir operands, so `a and (b or c)`\n\nscores 3.\n\n```\n# bad — 6 operands\nif a and b and c and d and e and f:\n    launch()\n\n# good\nif is_ready(a, b, c) and has_clearance(d, e, f):\n    launch()\n```\n\nFlags `if`\n\n/`elif`\n\nbranches whose bodies are identical — a copy-paste or a\ncondition that never varies. The trailing `else`\n\nbody is excluded from\nthe comparison. Two rule IDs cover the same detection:\nSCR004 (`detect_duplicateb`\n\n) runs on functions; SCR006 (`empty_except`\n\n)\nruns on functions and classes. Both emit the same finding, and the\nreport deduplicates identical rows, so one violation renders once.\n\n```\n# bad\nif kind == \"csv\":\n    rows = read_csv(path)\nelif kind == \"json\":\n    rows = read_csv(path)      # copy-paste\n\n# good\nif kind in (\"csv\", \"json\"):\n    rows = read_csv(path)\n```\n\nFlags list/set/dict comprehensions and generator expressions whose AST\nnode count exceeds `max_large_comprehensions`\n\n(default 40). Past a few\nnested clauses a comprehension stops being an expression and becomes a\nprogram.\n\n```\n# bad\nresult = [\n    [x * 100 for x in row if x != 0]\n    for row in matrix\n    if row and any(v > limit for v in row)\n]\n\n# good\ndef scale_row(row, factor):\n    return [x * factor for x in row if x != 0]\n\nresult = [scale_row(row, 100) for row in matrix if row]\n```\n\nFlags if/elif chains longer than `max_if_chain`\n\n(default 5); the\ntrailing `else`\n\nclause does not add to the chain length.\n\n```\n# bad\nif status == \"ok\":\n    ...\nelif status == \"warn\":\n    ...\nelif status == \"error\":\n    ...\nelif status == \"fatal\":\n    ...\nelif status == \"timeout\":\n    ...\nelse:\n    ...\n\n# good\nstatus_actions = {\"ok\": ok_action, \"warn\": warn_action}\nstatus_actions.get(status, unknown_action)()\n```\n\nFlags `lambda`\n\nbodies exceeding `max_lambda_nodes`\n\n(default 10) AST nodes.\n\n```\n# bad\ntransform = lambda v: v.strip().lower().split(\",\") if \",\" in v else [v]\n\n# good\ndef transform(v):\n    return v.strip().lower().split(\",\") if \",\" in v else [v]\n```\n\nFlags functions assigning more than `max_local_variables`\n\n(default 30)\ndistinct names — every new name is cognitive load and a chance for\nshadowing. The count covers plain `x = ...`\n\nassignment targets only\n(`ast.Assign`\n\nwith `ast.Name`\n\ntargets); augmented and unpacked\nassignments are not counted. Assignments inside nested functions count\ntoward the enclosing function's total. Fix: extract groups of\nassignments into helpers.\n\nFlags classes whose line span exceeds `max_class_lines`\n\n(default 200).\nA class past ~200 lines is usually several classes; fix by splitting by\nresponsibility.\n\nFlags files exceeding `max_file_lines`\n\n(default 1000). Fix: split into\nmodules with single concerns.\n\nFlags functions whose line span exceeds `max_function_lines`\n\n(default\n300). Fix: extract helpers — `process_order`\n\nbecomes `validate`\n\n,\n`reserve`\n\n, and `send`\n\n.\n\nFlags maximum nesting depth of block nodes above `max_nesting`\n\n(default\n5). Depth counts `if`\n\n, `for`\n\n, `while`\n\n, `async for`\n\n, `with`\n\n, `async with`\n\n, `try`\n\n, and `match`\n\nonly. Comprehensions, lambdas, and nested\n`def`\n\ns do **not** add depth; sibling blocks do not stack — the metric is\nmaximum depth, not block count.\n\n```\n# bad — 5 deep\nwith open(path) as f:               # 1\n    for row in f:                   # 2\n        if row.startswith(\"#\"):     # 3\n            try:                    # 4\n                parse(row)          # 5\n\n# good — early-return guards flatten it\ndef line_ready(row):\n    if not row:\n        return False\n    if row.startswith(\"#\"):\n        return False\n    return True\n\nwith open(path) as f:\n    for row in f:\n        if line_ready(row):\n            parse(row)\n```\n\nFlags functions with more than `max_parameters`\n\n(default 5) positional or\nkeyword parameters. The count is `node.args.args`\n\n, so `*args`\n\nand\n`**kwargs`\n\nare excluded; `self`\n\non methods counts as a parameter.\n\n``` python\n# bad\ndef connect(host, port, user, password, db, timeout):\n    ...\n\n# good\n@dataclass\nclass Connection:\n    host: str\n    port: int\n    user: str\n    password: str\n    db: str\n\ndef connect(cfg: Connection, timeout: int) -> None: ...\n```\n\nFlags a function defined inside another function. Closures that capture\ntheir enclosing scope run once per outer call and defeat unit testing.\nOnly plain `def`\n\ndefinitions are flagged; a nested `async def`\n\nis not.\n\n``` python\n# bad\ndef process_all(data):\n    def normalize(value):\n        return value.strip().lower()\n    return [normalize(x) for x in data]\n\n# good\ndef normalize(value):\n    return value.strip().lower()\n\ndef process_all(data):\n    return [normalize(x) for x in data]\n```\n\nFlags functions with more than `max_return_statements`\n\n(default 6)\n`return`\n\ns — every exit point is a path to maintain. Returns inside\nnested functions count toward the enclosing function's total.\n\nFlags default parameter values that are mutable — list/dict/set\nliterals (`[]`\n\n, `{}`\n\n, `{1, 2}`\n\n) or mutable constructor calls\n(`list()`\n\n, `dict()`\n\n, `set()`\n\n, `bytearray()`\n\n, `defaultdict()`\n\n,\n`OrderedDict()`\n\n). Defaults are evaluated once at definition time, so\nthe same object is shared across every call that omits the argument —\nstate leaks between unrelated calls.\n\n``` python\n# bad\ndef add_item(item, items=[]):\n    items.append(item)\n    return items\n\n# good\ndef add_item(item, items=None):\n    if items is None:\n        items = []\n    items.append(item)\n    return items\n```\n\nThe rule inspects only the function's own defaults — a mutable default\non a nested function is reported once, by that function's own finding,\nnever duplicated in the enclosing function's report. Immutable defaults\n(`None`\n\n, strings, numbers, tuples, `frozenset()`\n\n) are never flagged.\n\nFlags functions and classes whose McCabe cyclomatic complexity exceeds\n`max_complexity`\n\n(default 40). Base 1, then +1 for every `if`\n\n, `for`\n\n,\n`async for`\n\n, `while`\n\n, `try`\n\n, `except`\n\nhandler, `match`\n\n, ternary,\n`assert`\n\n, `with`\n\n, `async with`\n\n, and every `and`\n\n/`or`\n\nchain — an\n`and`\n\n/`or`\n\nchain counts 1 regardless of how many operands it combines,\nso `a and (b or c)`\n\nadds 2 (one per chain). The walk covers the whole\nsubtree: a class's complexity is the sum over its entire body, methods\nincluded.\n\nThe codebase is deliberately small: a CLI orchestrator, four pipeline\nmodules, two config modules, and one rule per file. The governing rule is\nthat ** cli.py only orchestrates** — every function it calls lives in\nanother module, and nothing imports\n\n`cli.py`\n\n.Execution flow — this is the full path of a run (`--docs`\n\nand\n`--version`\n\nshort-circuit before configuration):\n\n``` php\nflowchart TD\n    M[\"avouch.cli:main()\"] --> P[\"argparse<br/>--json · --quiet · --verbose · --ignore-path ·<br/>--changed · --staged · --all-files · --not-git\"]\n    P --> PD{\"--docs?\"}\n    PD -- \"yes\" --> D[\"utility/docs.py<br/>render_docs()\"]\n    D --> X0[\"exit 0\"]\n    PD -- \"no\" --> C[\"config/loader.py<br/>load_config(): avouch.toml merged over defaults\"]\n    C --> G{\"Git repository?\"}\n    G -- \"no · without --not-git\" --> EX2A[\"exit 2<br/>error: no Git repository found\"]\n    G -- \"yes, or --not-git\" --> S{\"Selection mode\"}\n    S -- \"--not-git\" --> F4[\"git.py: get_all_files_on_disk()<br/>*.py walked from CWD\"]\n    S -- \"--all-files\" --> F3[\"git.py: get_all_files()<br/>git ls-files\"]\n    S -- \"--staged\" --> F2[\"git.py: get_staged_files()<br/>git diff --cached --name-only\"]\n    S -- \"default\" --> F1[\"git.py: get_changed_files()<br/>git diff HEAD --name-only + untracked\"]\n    F1 --> R[\"git.py: get_reviewable_files()<br/>existing .py · not generated · not ignored\"]\n    F2 --> R\n    F3 --> R\n    F4 --> R\n    R -- \"none left\" --> EX2B[\"exit 2<br/>error: nothing to review\"]\n    R -- \"files\" --> A[\"analyzer.py: analyze_file()<br/>read file → ast.parse → walk cache → rules\"]\n    A --> O{\"Output mode\"}\n    O -- \"--json\" --> J[\"report.py: render_json()\"]\n    O -- \"--quiet\" --> Q[\"no report\"]\n    O -- \"default + --changed\" --> DIF[\"report.py: render_diff_view()<br/>git diff of the review set\"]\n    O -- \"default\" --> H[\"report.py: generate_report()<br/>terminal report\"]\n    J --> E{\"Any findings?\"}\n    Q --> E\n    DIF --> E\n    H --> E\n    E -- \"no\" --> EX0[\"exit 0\"]\n    E -- \"yes\" --> EX1[\"exit 1\"]\n```\n\nModule dependencies — what imports what (each arrow is a real `import`\n\n):\n\n``` php\nflowchart LR\n    CLI[\"cli.py<br/>orchestration only\"] -->|load_config, DEFAULT_RULES| CFG[\"config/loader.py\"]\n    CLI -->|DEFAULT_LIMITS| DEF[\"config/default.py\"]\n    CLI -->|review-set computation| GIT[\"git.py\"]\n    CLI -->|analyze_file| AN[\"analyzer.py\"]\n    CLI -->|render_json · render_diff_view<br/>generate_report · vlog| REP[\"report.py\"]\n    CLI -->|render_docs| DOC[\"utility/docs.py\"]\n    CFG --> DEF\n    AN --> RULES[\"rules/*.py<br/>one analyze(node, limits) per rule\"]\n    AN --> COM[\"rules/complexity.py<br/>calculate_complexity\"]\n    RULES -->|walk| WAL[\"utility/walk.py<br/>cached ast.walk, reset per file\"]\n    GIT --> IG[\"utility/is_generated.py\"]\n    GIT --> II[\"utility/is_ignored.py\"]\n    REP -->|get_file_diff| GIT\n```\n\n| Module | Role | Key exports |\n|---|---|---|\n`cli.py` |\nPipeline wiring | `main()` |\n`docs.py` (in `utility/` ) |\nBuilt-in `--docs` text |\n`DOCS` |\n`git.py` |\nGit interaction | `is_gitrepo` , `get_changed_files` , `get_staged_files` , `get_reviewable_files` |\n`analyzer.py` |\nAST analysis | `read_file` , `analyze_file` |\n`rules/*.py` |\nOne rule per module | `analyze(node, limits)` |\n`utility/walk.py` |\nCached AST traversal | `walk` , `reset_walk_cache` |\n`report.py` |\nTerminal + JSON rendering | `render_report` , `generate_report` , `render_json` |\n`config/default.py` |\nDefault limits | `DEFAULT_LIMITS` |\n`config/loader.py` |\nTOML load + merge | `load_config` , `merge_limits` , `merge_rules` , `DEFAULT_RULES` |\n\n`cli.main()`\n\nloads config (`limits`\n\n+`rules`\n\nmerged over defaults).`git.is_gitrepo()`\n\n—`git rev-parse --is-inside-work-tree`\n\n; exits the run with a message if not a repo.`git.get_changed_files()`\n\n—`git diff HEAD --name-only`\n\nplus untracked files;`git.get_staged_files()`\n\n—`git diff --cached --name-only`\n\n— is used with`--staged`\n\n;`get_reviewable_files()`\n\nkeeps existing`.py`\n\npaths that are neither generated (`is_generated`\n\n) nor covered by ignore paths (`is_ignored`\n\n); if none remain, prints a message and exits`2`\n\n.- Per file,\n`analyzer.analyze_file(path, limits, rules)`\n\n:- reads UTF-8 (\n`OSError`\n\n→`ERROR`\n\nreport), parses with`ast.parse`\n\n(`SyntaxError`\n\n→`ERROR`\n\nreport; the rest of the run continues), - resets the walk cache (\n`utility/walk.py`\n\n), then walks the AST, dispatching`FunctionDef`\n\n,`AsyncFunctionDef`\n\n, and`ClassDef`\n\nnodes to their rules (rule toggles are checked before dispatch, so disabled rules never run), - returns\n`(function_reports, file_reports, class_reports)`\n\n.\n\n- reads UTF-8 (\n`report.render_report(...)`\n\ngroups issues by file in a single pass and renders the`AVOUCH`\n\nheader, per-file findings, the BY RULE summary, and the`[PASSING]`\n\ngrid.\n\n`cli.py`\n\nwith `--docs`\n\nshort-circuits before config loading and calls\n`docs.render_docs()`\n\n, so no Git or analysis code runs. In a TTY that\nrenders an interactive browser over `docs.DOCS`\n\n; piped stdout prints\nthe plain text.\n\nTerminal rendering is hand-rolled ANSI in `src/avouch/report.py`\n\n— the\n`rich`\n\ndependency declared in `pyproject.toml`\n\nis not imported. Colors\nare emitted only when stdout is a TTY; piped output is plain. Each\nfinding renders compiler-style: a `file:line`\n\nheader with rule id and\nmessage, the offending code region with dimmed line numbers, and a\ncaret under the flagged name. Identical `(component, rule)`\n\nfindings\nare deduplicated per file, and the BY RULE summary counts deduplicated\nfindings, sorted most common first. The `[PASSING]`\n\ngrid collapses to\nat most a few lines, with a `[+N more]`\n\nnote when it overflows.\n`AVOUCH_FONT=name`\n\nis an opt-in OSC 50 font switch honored only by\ncapable terminals.\n\n```\navouch/\n├── pyproject.toml          # packaging, console script\n├── avouch.toml              # limits this repo lives by\n├── src/avouch/\n│   ├── cli.py              # entry point; orchestration only\n│   ├── git.py              # review-set computation\n│   ├── analyzer.py         # AST walk, rule dispatch\n│   ├── report.py           # terminal report UI\n│   ├── rules/              # one module per rule\n│   │   ├── complexity.py           # cyclomatic metric (no issues itself)\n│   │   ├── max_nesting.py          # get_depth + BLOCK_NODES\n│   │   └── ...                     # one analyze(node, limits) per rule\n│   ├── utility/\n│   │   ├── walk.py         # cached ast.walk + per-file cache reset\n│   │   ├── docs.py         # --docs terminal documentation text\n│   │   ├── is_generated.py # generated-file patterns\n│   │   └── is_ignored.py   # ignore-path matching\n│   └── config/\n│       ├── default.py      # DEFAULT_LIMITS\n│       └── loader.py       # load_config, merge_limits, merge_rules\n└── tests/\n    └── test_git.py         # 74 tests, incl. a real-git end-to-end run\n```\n\nA rule is a module in `src/avouch/rules/`\n\nexposing\n`analyze(node, limits) -> list[issue]`\n\n, where an issue is:\n\n```\n{\"rule\": \"SCR017\", \"severity\": \"WARNING\",\n \"message\": \"Description (value/limit). Remediation guidance.\"}\n```\n\nPlus a `[rules]`\n\ntoggle in `DEFAULT_RULES`\n\n(and a limit in\n`DEFAULT_LIMITS`\n\nif the rule has a threshold). Wire the dispatch into\n`analyze_file`\n\nwith a toggle guard, then write the tests: one for the\nviolation, one for the boundary. The renderer displays any\n`(severity, message)`\n\npair it receives, so no report code changes.\n\nAll 74 tests run in a fraction of a second — no network, no package installs:\n\n```\npip install -e .\npython -m pytest tests/\n```\n\nCoverage includes the git helpers, config merging, every nesting block\ntype, every complexity decision point, boolean-chain measurement,\nrule boundaries, analysis failure paths (unreadable/syntax-error files),\nreport output, the `--docs`\n\nflag (asserted to exit cleanly without\ntouching Git, inside or outside a repository), and an end-to-end run\nagainst a **real temporary git repository** — Git itself is not mocked.\nMocking is limited to `subprocess.run`\n\nwhere a real Git isn't needed.\n\nThe detailed implementation plan for the next release lives in\n[ roadmap.md](/mukundzha/avouch/blob/main/roadmap.md) — v0.3.3 ships eight new capabilities under the\ntheme \"first run clean, every run relevant\" (\n\n`avouch init`\n\n, a findings\nbaseline, parallel review, CI-native output formats, rule man pages, a\npre-commit hook, and inline diff annotations).Beyond v0.3.3, informed by documented limitations, ordered by the pain they remove:\n\n**0.4 — Configuration hardening**\n\n- Validate\n`avouch.toml`\n\nvalues with readable errors (today: a malformed file raises) - Search upward from the working directory for\n`avouch.toml`\n\n(today: CWD only)\n\n**1.0 — CI-grade interface**\n\n- Configurable exit codes, so enforcement thresholds can be tuned without changing avouch's review-only default\n\nNew rules must survive the philosophy section — the ceiling is raised deliberately, not by accretion.\n\n**Why only changed files?**\nPre-existing issues are noise. A whole-repo run buries the few findings\nyou introduced under hundreds you didn't. The review set is the diff, so\nthe output is always relevant to the next push.\n\n**Why git diff HEAD and not git diff?**\nPlain\n\n`git diff`\n\ncovers only unstaged changes. `HEAD`\n\ncovers staged plus\nunstaged — the complete set of files about to be pushed — and avouch adds\nuntracked files on top, so brand-new files are never missed.**Why AST instead of regex?**\nRegex cannot count parentheses across lines, measure nesting, or\ndistinguish a definition from a call. The AST answers structural\nquestions exactly for every valid Python file.\n\n**What are the exit codes?**\nAvouch returns `0`\n\nwhen the review is clean, `1`\n\nwhen findings are\nreported, and `2`\n\nwhen Avouch cannot run. It still reviews rather than\ngates — enforcement stays in whatever calls it — but CI can now react to\nthe outcome directly.\n\n**Does it need a network or a daemon?**\nNo. Three `git`\n\nsubprocess calls and the standard library. Runtime is\nbounded by the size of your diff, not your repository.\n\n**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.\n\nSetup:\n\n```\ngit clone https://github.com/mukundzha/avouch.git\ncd avouch\npip install -e .\npython -m pytest tests/\n```\n\nMIT — see `LICENSE`\n\n.", "url": "https://wpnews.pro/news/show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one", "canonical_source": "https://github.com/mukundzha/avouch", "published_at": "2026-08-18 13:13:17+00:00", "updated_at": "2026-08-18 14:11:18.437744+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Mukund Zha", "Avouch", "Python", "Git"], "alternates": {"html": "https://wpnews.pro/news/show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one", "markdown": "https://wpnews.pro/news/show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one.md", "text": "https://wpnews.pro/news/show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one.txt", "jsonld": "https://wpnews.pro/news/show-hn-i-canceled-my-ai-code-reviewer-and-wrote-a-free-local-one.jsonld"}}