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

Aug 18, 2026 08:13 PM - 2 hours ago 1

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

Avouch is simply a lightweight, Git-aware fixed study CLI for Python. It asks Git which files your adjacent perpetrate will touch, parses each changed .py file pinch the modular ast module, and reports structural problems against limits you configure successful avouch.toml.

No daemon. No network. No way lists to maintain. Run it successful the seconds before git push, hole what it flags, push.

pip instal avouch cd your-repo avouch

  • Why it exists
  • Installation
  • Quick start
  • JSON output
  • Quiet mode
  • GitHub Actions
  • Other CI systems
  • Configuration
  • Rules
  • How it works
  • Repository layout
  • Adding a rule
  • Testing
  • Roadmap
  • FAQ
  • Contributing
  • License

  • The reappraisal group is the diff, not the repository. Avouch computes the review group from Git astatine tally clip (git diff HEAD --name-only plus untracked files). Every uncovering is attributable to activity you are about to push — ne'er to the bequest you inherited.
  • Metrics are exact. Parameter counts, nesting depth, and statement spans come from the AST, not regex. If a metric cannot beryllium computed exactly, Avouch does not declare it.
  • Errors are data. An unreadable aliases syntactically surgery record becomes an ERROR introduction successful the report. One surgery record ne'er cancels the review of the others.
  • Avouch reviews; it does not gate. The exit codification signals the result — 0 clean, 1 violations found, 2 Avouch correction — but enforcement belongs in an opt-in interface, not successful a instrumentality you tally earlier each push.
  • The runtime is the modular library. Three git subprocess calls and ast/tomllib. No daemon to support alive; runtime is bounded by the size of your diff, not your repository.

Requires Python 3.10+ (rules usage ast.Match; configuration uses tomllib) and Git connected PATH.

or from source:

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

Both registry the avouch console book (avouch.cli:main).


The interface is 1 bid pinch a mini group of optional flags:

cd your-repo # ... make a alteration ... avouch # quality report avouch --json # 1 JSON archive connected stdout avouch --docs # built-in documentation; nary reappraisal performed avouch --version # people the type and exit avouch --verbose # step-by-step reappraisal specifications connected stderr avouch --quiet # analyze, people nary report; exit codification only avouch --changed # compact added/deleted position of changed files vs HEAD avouch --staged # reappraisal only files staged for the adjacent commit avouch --all-files # reappraisal each eligible Python file, not conscionable the diff avouch --not-git # reappraisal each eligible .py record connected disk; nary Git repo needed avouch --help # each flag

The reappraisal group is defined by Git, truthful location is thing to configure at invocation time. With --not-git, Avouch skips the Git request and reviews each eligible .py record recovered by stepping the current directory alternatively (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 look successful 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 — prime astatine astir one. The output flags --json, --verbose, and --quiet harvester freely pinch immoderate reappraisal scope.

$ avouch AVOUCH · 2 FILES · 4 WARN ──────────────────────────────────────────────────────────────────────────────── bad.py:1: SCR002: Bare isolated from detected. Catch a circumstantial objection instead, e.g. isolated from ValueError:. │ 1 │ def connect(host, port, user, password, db, timeout): │ ^^^^^^^ SCR002 2 │ try: │ bad.py:1: SCR014: Too galore parameters (6/5). Group related parameters into a information people aliases dictionary. │ 1 │ def connect(host, port, user, password, db, timeout): │ ^^^^^^^ SCR014 2 │ try: │ ──────────────────────────────────────────────────────────────────────────────── BY RULE SCR002 Bare isolated from 1 SCR014 Too galore parameters 1 ──────────────────────────────────────────────────────────────────────────────── PASSED ✓ src/util.py
  • Header — AVOUCH · N FILES · W WARN · E ERR: record and per-severity counts, followed by the per-file findings.
  • Findings — each uncovering renders compiler-style: a file:line header pinch the norm id and afloat message, past the offending code region pinch dimmed statement numbers and a caret ^^^^^ nether the flagged name (rule id successful bluish connected a TTY).
  • BY RULE summary — findings counted per rule, astir communal first, with counts aligned connected the right. Rendered only erstwhile findings exist.
  • PASSING grid — compliant files, compressed to a fewer lines pinch a [+N more] statement erstwhile location are many.
  • Identical (component, rule) findings are deduplicated per record — the header counts each finding, truthful pinch overlapping norm IDs (SCR004 / SCR006 duplicate-branch) the statement count tin beryllium little than the header count.
$ cd /tmp/somewhere-without-git $ avouch error: nary Git repository found hint: tally Avouch from wrong a Git repository, aliases usage --not-git to reappraisal files without Git $ cd ~/fresh-checkout # e.g. a CI runner $ avouch error: thing to review hint: thing changed vs HEAD (CI checkouts are clean); usage --all-files for a afloat review

Colors are ANSI codes emitted only erstwhile stdout is simply a TTY. Piped output is plain, truthful avouch | tee review.log and CI seizure activity cleanly. Runtime errors are written to stderr, truthful stdout stays cleanable for piping and --json capture. The exit codification is 0 erstwhile the reappraisal is clean, 1 when findings are reported, and 2 erstwhile Avouch cannot run.

avouch --docs prints terminal archiving derived from this codebase — what Avouch does, the Git-aware workflow, each norm pinch its scope, every configuration cardinal pinch its default, some output formats, and realistic examples — past exits 0 without moving a review. It useful anywhere, even extracurricular a Git repository. In a existent terminal it opens arsenic an interactive browser (Help, Go, Main screen, Quit); erstwhile stdout is piped it prints the plain matter instead.


For automation and CI, --json prints the reappraisal arsenic a azygous JSON document on stdout, pinch nary human-readable matter mixed in:

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

Each usurpation carries the norm id (or a human-readable explanation erstwhile the finding has none), its severity, the message, the file, the constituent name, its benignant (func, class, aliases file), and the statement the uncovering refers to (null for file-level findings) — the aforesaid constituent and benignant shown in the quality table. files_with_violations is the number of distinct files containing astatine slightest 1 violation.

The archive is simply a stable, versioned statement for automation: version is the schema type (independent of the Avouch package version), tool identifies the emitter, and the aforesaid input ever produces the aforesaid JSON — nary colors, timestamps, aliases diagnostics leak in. Exit codes behave exactly arsenic successful normal mode, truthful avouch --json tin gross CI: parse stdout for the findings and respond to the exit position (0 clean, 1 violations, 2 Avouch error).


--quiet runs the nonstop aforesaid study but prints nary report; only the exit codification signals the result (0 clean, 1 violations, 2 Avouch error), which makes it fresh hooks and scripts that request only the status. Errors are ne'er silenced: messages specified arsenic "error: nary Git repository found" still print, --json still emits its document, and --verbose diagnostics still spell to stderr.


Avouch tin tally arsenic a GitHub Actions cheque connected each propulsion petition and push.

Add Avouch to your pipeline

For an existing project, a minimal workflow installs the published package and reviews the full checkout connected each 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 instal avouch - name: Run Avouch run: avouch --all-files --json
  • actions/checkout puts the propulsion request's codification successful the runner's working tree — Avouch analyzes the files that checkout provided, thing more.
  • actions/setup-python provides a Python runtime; Avouch requires Python 3.10+.
  • python -m pip instal avouch installs the latest published release. Pin a type (avouch==0.3.1) for reproducible runs.
  • avouch --all-files --json reviews each eligible .py record and prints the machine-readable archive to the occupation log. permissions: contents: publication is the only support needed — the workflow makes nary API calls.

The default reappraisal group is files changed vs. Git HEAD, truthful a freshly checked-out moving character — cleanable by building — has thing to review: avouch would people error: thing to reappraisal and exit 2. The same applies to --changed and --staged; they only make consciousness locally, against your ain moving tree. Whole-repository reappraisal is the mode that works successful CI:

Command Purpose In CI
avouch review files changed vs HEAD empty set; don't use
avouch --changed diff position of changed files empty set; don't use
avouch --staged review staged changes empty set; don't use
avouch --all-files review each eligible Python file the CI mode
avouch --json machine-readable archive connected stdout combine pinch --all-files
avouch --quiet suppress report; exit codification only fine for gating

Avouch's exit codification behaves successful CI precisely arsenic it does locally: 0 is clean, 1 intends findings were reported, 2 intends Avouch could not run. GitHub Actions fails a occupation erstwhile a measurement exits non-zero, truthful --all-files --json fails the cheque connected immoderate finding, and the JSON archive successful the occupation log shows why. Nothing is hidden pinch || true; findings already coming successful the repository neglect the cheque until they are fixed aliases excluded with ignore_paths successful avouch.toml.

The repository's ain workflow

The Avouch repository itself ships .github/workflows/avouch.yml; alteration it in the repository's Actions tab and it runs connected its own. It installs the repository's ain root pinch pip instal -e ., truthful it tests the code in the propulsion petition alternatively than a published release, past reviews the whole checked-out repository pinch --all-files --json.


Avouch is simply a plain console bid pinch a documented exit code, truthful immoderate CI system tin tally it pinch the aforesaid 3 steps:

  1. Install: python -m pip instal avouch
  2. Run: avouch --all-files --json
  3. Treat the exit codification arsenic the result: 0 pass, 1 findings, 2 error.

The JSON archive connected stdout is unchangeable and versioned (see JSON output), truthful it tin beryllium parsed for occupation annotations, summary comments, aliases dashboards.


Configuration is optional, partial, and declarative. Avouch looks for a avouch.toml successful the current moving directory — nary upward search, so configuration is repository-local. Any subset of keys is merged complete the built-in defaults; a missing aliases quiet record simply intends 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 successful your moving directory, plain TOML.
  • Scope: the existent directory only. Avouch ne'er searches parent directories, truthful each task configures itself.
  • Missing aliases empty: defaults are utilized silently — location is no "no configuration found" warning.
  • Environment variables: none. Configuration comes only from avouch.toml (the AVOUCH_FONT adaptable only selects a terminal font).

List the limit you want nether [limits]; only the keys you sanction change, everything other stays astatine its default:

[limits] max_parameters = 8 # let up to 8 parameters alternatively of 5 max_file_lines = 2500 # tolerate larger files

Put the norm nether [rules] and group it to false:

[rules] nested_function = false # extremity reporting SCR015

A one-line [rules] conception is simply 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 mendacious disables that rule's findings.

Key Default Rule Meaning
max_parameters 5 SCR014 Max positional + keyword params
max_nesting 5 SCR013 Max artifact nesting depth
max_function_lines 300 SCR012 Max usability statement span
max_class_lines 200 SCR010 Max people statement span
max_file_lines 1000 SCR011 Max record statement count
max_complexity 40 Max cyclomatic complexity
max_boolean_conditions 5 SCR003 Max operands successful 1 chain
max_if_chain 5 SCR007 Max if/elif links successful a chain
max_local_variables 30 SCR009 Max chopped assigned names
max_return_statements 6 SCR016 Max returns per function
max_lambda_nodes 10 SCR008 Max AST nodes successful a lambda body
max_large_comprehensions 40 SCR005 Max AST nodes successful a comprehension

Limits are applied by key. A norm whose limit cardinal is absent from the merged config falls backmost to the limit hardcoded successful its ain module, truthful a partial [limits] ne'er turns a norm off. Every limit cardinal successful the table above lives successful DEFAULT_LIMITS and tin beryllium tuned from avouch.toml.

Two mechanisms exclude files, some matching repository-relative paths component-wise — tests skips tests/ and tests/x.py but not tests.py; a bare "." skips the full repository:

  • avouch --ignore-path PATH — repeatable CLI flag, or
  • ignore_paths = ["tests", "migrations"] astatine the apical level of avouch.toml (must beryllium a list; thing other raises).

CLI and TOML paths are mixed and de-duplicated earlier analysis. Matching is purely string-based (src/avouch/utility/is_ignored.py) — no filesystem access.

Verifying that your configuration was loaded

Run avouch --verbose: erstwhile location is simply a reappraisal set, the first diagnostics line reports the config root and the progressive ignore-path count:

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

Without a avouch.toml the statement sounds config: defaults (no avouch.toml), 0 disregard path(s). avouch --docs prints the aforesaid limits and norm defaults for reference.

Invalid and chartless configuration

  • Malformed TOML (or a non-list ignore_paths) prints error: invalid avouch.toml configuration: ... connected stderr and exits 2.
  • Unknown keys are accepted and ignored silently — a typo makes the intended mounting silently ineffective, and Avouch does not warn (--verbose shows only the record sanction and the ignore-path count).
  • Limit values are not type-checked: a non-numeric worth specified as max_parameters = "eight" is not rejected and fails astatine study time with an soul correction (exit 2).

How configuration interacts pinch the CLI

  • --ignore-path appends to the TOML ignore_paths (combined and de-duplicated); location is nary CLI override for [limits] aliases [rules].
  • Configuration applies arsenic to each reappraisal mode — --changed, --staged, and --all-files — and to each output mode: --json, --quiet, and --verbose.
  • Severity is not configurable: norm findings are WARNING; ERROR is reserved for files that cannot beryllium publication aliases parsed.
  • --docs renders the built-in archiving and exits earlier any configuration is read, truthful it is unaffected by avouch.toml.
# avouch.toml — the nonstop record 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 norm identifiers (SCR001–SCR017) positive 2 cyclomatic complexity checks connected functions and classes sharing the max_complexity limit. Every norm uncovering is simply a WARNING; ERROR findings beryllium only for files that cannot beryllium publication aliases parsed. Rules pinch a period 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 look excessively 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 excessively complex 10 funcs N/limit
SCR009 Too galore section variables 30 funcs N/limit
SCR010 Class excessively large 200 classes N/limit
SCR011 File excessively large 1000 files N/limit
SCR012 Function excessively long 300 funcs N/limit
SCR013 Nesting excessively deep 5 funcs N/limit
SCR014 Too galore parameters 5 funcs N/limit
SCR015 Nested usability definition funcs detected
SCR016 Too galore return statements 6 funcs N/limit
SCR017 Mutable default argument funcs detected
Function excessively complex 40 funcs N/limit
Class excessively complex 40 classes N/limit

SCR001 — Async without await

Flags async def functions that ne'er await. An async usability without an await runs synchronously while still incurring event-loop overhead. This is the only norm applied to async def functions; the other function rules do not tally connected them.

# 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 drawback each objection — including KeyboardInterrupt and SystemExit.

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

SCR003 — Boolean look excessively complex

Flags a azygous and/or concatenation pinch excessively galore operands. Nested chains sum their operands, truthful a and (b aliases 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()

SCR004 / SCR006 — Duplicate branch

Flags if/elif branches whose bodies are identical — a copy-paste aliases a condition that ne'er varies. The trailing other assemblage is excluded from the comparison. Two norm IDs screen the aforesaid detection: SCR004 (detect_duplicateb) runs connected functions; SCR006 (empty_except) runs connected functions and classes. Both emit the aforesaid finding, and the report deduplicates identical rows, truthful 1 usurpation 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)

SCR005 — Large comprehension

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 look 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]

SCR007 — Long if/elif chain

Flags if/elif chains longer than max_if_chain (default 5); the trailing other clause does not adhd to the concatenation 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)()

SCR008 — Lambda excessively complex

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]

SCR009 — Too galore section variables

Flags functions assigning much than max_local_variables (default 30) distinct names — each caller sanction is cognitive load and a chance for shadowing. The count covers plain x = ... duty targets only (ast.Assign pinch ast.Name targets); augmented and unpacked assignments are not counted. Assignments wrong nested functions count toward the enclosing function's total. Fix: extract groups of assignments into helpers.

Flags classes whose statement span exceeds max_class_lines (default 200). A people past ~200 lines is usually respective classes; hole by splitting by responsibility.

Flags files exceeding max_file_lines (default 1000). Fix: divided into modules pinch azygous concerns.

SCR012 — Function excessively long

Flags functions whose statement span exceeds max_function_lines (default 300). Fix: extract helpers — process_order becomes validate, reserve, and send.

SCR013 — Nesting excessively deep

Flags maximum nesting extent of artifact nodes supra max_nesting (default 5). Depth counts if, for, while, async for, with, async with, try, and lucifer only. Comprehensions, lambdas, and nested defs do not adhd depth; related blocks do not stack — the metric is maximum depth, not artifact count.

# bad — 5 deep with open(path) as f: # 1 for row in f: # 2 if row.startswith("#"): # 3 try: # 4 parse(row) # 5 # bully — 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)

SCR014 — Too galore parameters

Flags functions pinch much than max_parameters (default 5) positional or keyword parameters. The count is node.args.args, truthful *args and **kwargs are excluded; aforesaid connected methods counts arsenic a parameter.

# 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: ...

SCR015 — Nested usability definition

Flags a usability defined wrong different function. Closures that capture their enclosing scope tally erstwhile per outer telephone and conclusion portion testing. Only plain def definitions are flagged; a nested async def is not.

# 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]

SCR016 — Too galore return statements

Flags functions pinch much than max_return_statements (default 6) returns — each exit constituent is simply a way to maintain. Returns inside nested functions count toward the enclosing function's total.

SCR017 — Mutable default argument

Flags default parameter values that are mutable — list/dict/set literals ([], {}, {1, 2}) aliases mutable constructor calls (list(), dict(), set(), bytearray(), defaultdict(), OrderedDict()). Defaults are evaluated erstwhile astatine meaning time, so the aforesaid entity is shared crossed each telephone that omits the statement — state leaks betwixt unrelated calls.

# 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 norm inspects only the function's ain defaults — a mutable default on a nested usability is reported once, by that function's ain finding, never duplicated successful the enclosing function's report. Immutable defaults (None, strings, numbers, tuples, frozenset()) are ne'er flagged.

Function / Class excessively analyzable — cyclomatic complexity

Flags functions and classes whose McCabe cyclomatic complexity exceeds max_complexity (default 40). Base 1, past +1 for each if, for, async for, while, try, isolated from handler, match, ternary, assert, with, async with, and each and/or concatenation — an and/or concatenation counts 1 sloppy of really galore operands it combines, so a and (b aliases c) adds 2 (one per chain). The locomotion covers the whole subtree: a class's complexity is the sum complete its full body, methods included.


The codebase is deliberately small: a CLI orchestrator, 4 pipeline modules, 2 config modules, and 1 norm per file. The governing norm is that cli.py only orchestrates — each usability it calls lives in another module, and thing imports cli.py.

Execution travel — this is the afloat way of a tally (--docs and --version short-circuit earlier configuration):

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 complete defaults"] C --> G{"Git repository?"} G -- "no · without --not-git" --> EX2A["exit 2<br/>error: nary Git repository found"] G -- "yes, aliases --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: thing to review"] R -- "files" --> A["analyzer.py: analyze_file()<br/>read record → ast.parse → locomotion 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 reappraisal 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"]
Loading

Module limitations — what imports what (each arrow is simply a existent import):

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
Loading 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 norm 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
  1. cli.main() loads config (limits + rules merged complete defaults).
  2. git.is_gitrepo() — git rev-parse --is-inside-work-tree; exits the run pinch a connection if not a repo.
  3. git.get_changed_files() — git diff HEAD --name-only positive untracked files; git.get_staged_files() — git diff --cached --name-only — is used pinch --staged; get_reviewable_files() keeps existing .py paths that are neither generated (is_generated) nor covered by ignore paths (is_ignored); if nary remain, prints a connection and exits 2.
  4. Per file, analyzer.analyze_file(path, limits, rules):
    • reads UTF-8 (OSError → ERROR report), parses pinch ast.parse (SyntaxError → ERROR report; the remainder of the tally continues),
    • resets the locomotion cache (utility/walk.py), past walks the AST, dispatching FunctionDef, AsyncFunctionDef, and ClassDef nodes to their rules (rule toggles are checked earlier dispatch, so disabled rules ne'er run),
    • returns (function_reports, file_reports, class_reports).
  5. report.render_report(...) groups issues by record successful a azygous pass and renders the AVOUCH header, per-file findings, the BY RULE summary, and the [PASSING] grid.

cli.py pinch --docs short-circuits earlier config loading and calls docs.render_docs(), truthful nary Git aliases study codification runs. In a TTY that renders an interactive browser complete docs.DOCS; piped stdout prints the plain text.

Terminal rendering is hand-rolled ANSI successful src/avouch/report.py — the rich dependency declared successful pyproject.toml is not imported. Colors are emitted only erstwhile stdout is simply a TTY; piped output is plain. Each finding renders compiler-style: a file:line header pinch norm id and message, the offending codification region pinch dimmed statement numbers, and a caret nether the flagged name. Identical (component, rule) findings are deduplicated per file, and the BY RULE summary counts deduplicated findings, sorted astir communal first. The [PASSING] grid collapses to at astir a fewer lines, pinch a [+N more] statement erstwhile it overflows. AVOUCH_FONT=name is an opt-in OSC 50 font move honored only by capable terminals.


avouch/ ├── pyproject.toml # packaging, console script ├── avouch.toml # limits this repo lives by ├── src/avouch/ │ ├── cli.py # introduction point; orchestration only │ ├── git.py # review-set computation │ ├── analyzer.py # AST walk, norm dispatch │ ├── report.py # terminal study UI │ ├── rules/ # 1 module per rule │ │ ├── complexity.py # cyclomatic metric (no issues itself) │ │ ├── max_nesting.py # get_depth + BLOCK_NODES │ │ └── ... # 1 analyze(node, limits) per rule │ ├── utility/ │ │ ├── walk.py # cached ast.walk + per-file cache reset │ │ ├── docs.py # --docs terminal archiving 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 norm is simply a module successful src/avouch/rules/ exposing analyze(node, limits) -> list[issue], wherever an rumor is:

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

Plus a [rules] toggle successful DEFAULT_RULES (and a limit in DEFAULT_LIMITS if the norm has a threshold). Wire the dispatch into analyze_file pinch a toggle guard, past constitute the tests: 1 for the violation, 1 for the boundary. The renderer displays any (severity, message) brace it receives, truthful nary study codification changes.


All 74 tests tally successful a fraction of a 2nd — nary network, nary package installs:

pip instal -e . python -m pytest tests/

Coverage includes the git helpers, config merging, each nesting block type, each complexity determination point, boolean-chain measurement, rule boundaries, study nonaccomplishment paths (unreadable/syntax-error files), report output, the --docs emblem (asserted to exit cleanly without touching Git, wrong aliases extracurricular a repository), and an end-to-end run against a real impermanent git repository — Git itself is not mocked. Mocking is constricted to subprocess.run wherever a existent Git isn't needed.


The elaborate implementation scheme for the adjacent merchandise lives in roadmap.md — v0.3.3 ships 8 caller capabilities nether the theme "first tally clean, each tally relevant" (avouch init, a findings baseline, parallel review, CI-native output formats, norm 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 pinch readable errors (today: a malformed file raises)
  • Search upward from the moving directory for avouch.toml (today: CWD only)

1.0 — CI-grade interface

  • Configurable exit codes, truthful enforcement thresholds tin beryllium tuned without changing avouch's review-only default

New rules must past the accuracy conception — the ceiling is raised deliberately, not by accretion.


Why only changed files? Pre-existing issues are noise. A whole-repo tally buries the fewer findings you introduced nether hundreds you didn't. The reappraisal group is the diff, so the output is ever applicable to the adjacent push.

Why git diff HEAD and not git diff? Plain git diff covers only unstaged changes. HEAD covers staged plus unstaged — the complete group of files astir to beryllium pushed — and avouch adds untracked files connected top, truthful brand-new files are ne'er missed.

Why AST alternatively of regex? Regex cannot count parentheses crossed lines, measurement nesting, or distinguish a meaning from a call. The AST answers structural questions precisely for each valid Python file.

What are the exit codes? Avouch returns 0 erstwhile the reappraisal is clean, 1 erstwhile findings are reported, and 2 erstwhile Avouch cannot run. It still reviews alternatively than gates — enforcement stays successful immoderate calls it — but CI tin now respond to the result directly.

Does it request a web aliases a daemon? No. Three git subprocess calls and the modular library. Runtime is bounded by the size of your diff, not your repository.


  • Tests earlier code. A hole that cannot beryllium expressed arsenic a failing test first is not a hole yet.
  • Keep the diff small. A alteration that touches much than 2 modules needs a justification successful the PR description.
  • The standard-library runtime is the contract. No caller runtime dependencies without a written lawsuit that survives the philosophy section.
  • The README is the spec. If the behaviour changed, the README changes in the aforesaid commit.

Setup:

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

MIT — spot LICENSE.

More