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 existsInstallationQuick startJSON outputQuiet modeGitHub ActionsOther CI systemsConfigurationRulesHow it worksRepository layoutAdding a ruleTestingRoadmapFAQContributingLicense
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 anERROR
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. Threegit
subprocess calls andast
/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
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.
$ 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: afile: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.
$ 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), 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 fromavouch.toml
(theAVOUCH_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, orignore_paths = ["tests", "migrations"]
at the top level ofavouch.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
) printserror: invalid avouch.toml configuration: ...
on stderr and exits2
. - 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 (exit2
).
--ignore-path
appends to the TOMLignore_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 byavouch.toml
.
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.
async def fetch_config():
return json.load(open("config.json"))
def fetch_config():
return json.load(open("config.json"))
Flags except:
handlers that catch every exception β including
KeyboardInterrupt
and SystemExit
.
try:
return json.loads(raw)
except:
return None
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.
if a and b and c and d and e and f:
launch()
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.
if kind == "csv":
rows = read_csv(path)
elif kind == "json":
rows = read_csv(path) # copy-paste
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.
result = [
[x * 100 for x in row if x != 0]
for row in matrix
if row and any(v > limit for v in row)
]
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.
if status == "ok":
...
elif status == "warn":
...
elif status == "error":
...
elif status == "fatal":
...
elif status == "timeout":
...
else:
...
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.
transform = lambda v: v.strip().lower().split(",") if "," in v else [v]
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.
with open(path) as f: # 1
for row in f: # 2
if row.startswith("#"): # 3
try: # 4
parse(row) # 5
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.
def connect(host, port, user, password, db, timeout):
...
@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.
def process_all(data):
def normalize(value):
return value.strip().lower()
return [normalize(x) for x in data]
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.
def add_item(item, items=[]):
items.append(item)
return items
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):
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/.py<br/>load_config(): avouch.toml merged over defaults"]
C --> G{"Git repository?"}
G -- "no Β· without --not-git" --> EX2A["exit 2<br/>error: no Git repository found"]
G -- "yes, or --not-git" --> S{"Selection mode"}
S -- "--not-git" --> F4["git.py: get_all_files_on_disk()<br/>*.py walked from CWD"]
S -- "--all-files" --> F3["git.py: get_all_files()<br/>git ls-files"]
S -- "--staged" --> F2["git.py: get_staged_files()<br/>git diff --cached --name-only"]
S -- "default" --> F1["git.py: get_changed_files()<br/>git diff HEAD --name-only + untracked"]
F1 --> R["git.py: get_reviewable_files()<br/>existing .py Β· not generated Β· not ignored"]
F2 --> R
F3 --> R
F4 --> R
R -- "none left" --> EX2B["exit 2<br/>error: nothing to review"]
R -- "files" --> A["analyzer.py: analyze_file()<br/>read file β ast.parse β walk cache β rules"]
A --> O{"Output mode"}
O -- "--json" --> J["report.py: render_json()"]
O -- "--quiet" --> Q["no report"]
O -- "default + --changed" --> DIF["report.py: render_diff_view()<br/>git diff of the review set"]
O -- "default" --> H["report.py: generate_report()<br/>terminal report"]
J --> E{"Any findings?"}
Q --> E
DIF --> E
H --> E
E -- "no" --> EX0["exit 0"]
E -- "yes" --> EX1["exit 1"]
Module dependencies β what imports what (each arrow is a real import
):
flowchart LR
CLI["cli.py<br/>orchestration only"] -->|load_config, DEFAULT_RULES| CFG["config/.py"]
CLI -->|DEFAULT_LIMITS| DEF["config/default.py"]
CLI -->|review-set computation| GIT["git.py"]
CLI -->|analyze_file| AN["analyzer.py"]
CLI -->|render_json Β· render_diff_view<br/>generate_report Β· vlog| REP["report.py"]
CLI -->|render_docs| DOC["utility/docs.py"]
CFG --> DEF
AN --> RULES["rules/*.py<br/>one analyze(node, limits) per rule"]
AN --> COM["rules/complexity.py<br/>calculate_complexity"]
RULES -->|walk| WAL["utility/walk.py<br/>cached ast.walk, reset per file"]
GIT --> IG["utility/is_generated.py"]
GIT --> II["utility/is_ignored.py"]
REP -->|get_file_diff| GIT
| Module | Role | Key exports |
|---|---|---|
cli.py |
||
| Pipeline wiring | main() |
|
docs.py (in utility/ ) |
||
Built-in --docs text |
||
DOCS |
||
git.py |
||
| Git interaction | is_gitrepo , get_changed_files , get_staged_files , get_reviewable_files |
|
analyzer.py |
||
| AST analysis | read_file , analyze_file |
|
rules/*.py |
||
| One rule per module | analyze(node, limits) |
|
utility/walk.py |
||
| Cached AST traversal | walk , reset_walk_cache |
|
report.py |
||
| Terminal + JSON rendering | render_report , generate_report , render_json |
|
config/default.py |
||
| Default limits | DEFAULT_LIMITS |
|
config/.py |
||
| TOML load + merge | load_config , merge_limits , merge_rules , DEFAULT_RULES |
cli.main()
loads config (limits
+rules
merged over defaults).git.is_gitrepo()
βgit rev-parse --is-inside-work-tree
; exits the run with a message if not a repo.git.get_changed_files()
βgit diff HEAD --name-only
plus untracked files;git.get_staged_files()
βgit diff --cached --name-only
β is used with--staged
;get_reviewable_files()
keeps existing.py
paths that are neither generated (is_generated
) nor covered by ignore paths (is_ignored
); if none remain, prints a message and exits2
.- Per file,
analyzer.analyze_file(path, limits, rules)
:- reads UTF-8 (
OSError
βERROR
report), parses withast.parse
(SyntaxError
βERROR
report; the rest of the run continues), - resets the walk cache (
utility/walk.py
), then walks the AST, dispatchingFunctionDef
,AsyncFunctionDef
, andClassDef
nodes to their rules (rule toggles are checked before dispatch, so disabled rules never run), - returns
(function_reports, file_reports, class_reports)
.
- reads UTF-8 (
report.render_report(...)
groups issues by file in a single pass and renders theAVOUCH
header, per-file findings, the BY RULE summary, and the[PASSING]
grid.
cli.py
with --docs
short-circuits before config and calls
docs.render_docs()
, so no Git or analysis code runs. In a TTY that
renders an interactive browser over docs.DOCS
; piped stdout prints the plain text.
Terminal rendering is hand-rolled ANSI in src/avouch/report.py
β the
rich
dependency declared in pyproject.toml
is not imported. Colors
are emitted only when stdout is a TTY; piped output is plain. Each
finding renders compiler-style: a file:line
header with rule id and
message, the offending code region with dimmed line numbers, and a
caret under the flagged name. Identical (component, rule)
findings
are deduplicated per file, and the BY RULE summary counts deduplicated
findings, sorted most common first. The [PASSING]
grid collapses to
at most a few lines, with a [+N more]
note when it overflows.
AVOUCH_FONT=name
is an opt-in OSC 50 font switch honored only by capable terminals.
avouch/
βββ pyproject.toml # packaging, console script
βββ avouch.toml # limits this repo lives by
βββ src/avouch/
β βββ cli.py # entry point; orchestration only
β βββ git.py # review-set computation
β βββ analyzer.py # AST walk, rule dispatch
β βββ report.py # terminal report UI
β βββ rules/ # one module per rule
β β βββ complexity.py # cyclomatic metric (no issues itself)
β β βββ max_nesting.py # get_depth + BLOCK_NODES
β β βββ ... # one analyze(node, limits) per rule
β βββ utility/
β β βββ walk.py # cached ast.walk + per-file cache reset
β β βββ docs.py # --docs terminal documentation text
β β βββ is_generated.py # generated-file patterns
β β βββ is_ignored.py # ignore-path matching
β βββ config/
β βββ default.py # DEFAULT_LIMITS
β βββ .py # load_config, merge_limits, merge_rules
βββ tests/
βββ test_git.py # 74 tests, incl. a real-git end-to-end run
A rule is a module in src/avouch/rules/
exposing
analyze(node, limits) -> list[issue]
, where an issue is:
{"rule": "SCR017", "severity": "WARNING",
"message": "Description (value/limit). Remediation guidance."}
Plus a [rules]
toggle in DEFAULT_RULES
(and a limit in
DEFAULT_LIMITS
if the rule has a threshold). Wire the dispatch into
analyze_file
with a toggle guard, then write the tests: one for the
violation, one for the boundary. The renderer displays any
(severity, message)
pair it receives, so no report code changes.
All 74 tests run in a fraction of a second β no network, no package installs:
pip install -e .
python -m pytest tests/
Coverage includes the git helpers, config merging, every nesting block
type, every complexity decision point, boolean-chain measurement,
rule boundaries, analysis failure paths (unreadable/syntax-error files),
report output, the --docs
flag (asserted to exit cleanly without
touching Git, inside or outside a repository), and an end-to-end run
against a real temporary git repository β Git itself is not mocked.
Mocking is limited to subprocess.run
where a real Git isn't needed.
The detailed implementation plan for the next release lives in roadmap.md β v0.3.3 ships eight new capabilities under the theme "first run clean, every run relevant" (
avouch init
, a findings baseline, parallel review, CI-native output formats, rule man pages, a pre-commit hook, and inline diff annotations).Beyond v0.3.3, informed by documented limitations, ordered by the pain they remove:
0.4 β Configuration hardening
- Validate
avouch.toml
values with readable errors (today: a malformed file raises) - Search upward from the working directory for
avouch.toml
(today: CWD only)
1.0 β CI-grade interface
- Configurable exit codes, so enforcement thresholds can be tuned without changing avouch's review-only default
New rules must survive the philosophy section β the ceiling is raised deliberately, not by accretion.
Why only changed files? Pre-existing issues are noise. A whole-repo run buries the few findings you introduced under hundreds you didn't. The review set is the diff, so the output is always relevant to the next push.
Why git diff HEAD and not git diff? Plain
git diff
covers only unstaged changes. HEAD
covers staged plus unstaged β the complete set of files about to be pushed β and avouch adds untracked files on top, so brand-new files are never missed.Why AST instead of regex? Regex cannot count parentheses across lines, measure nesting, or distinguish a definition from a call. The AST answers structural questions exactly for every valid Python file.
What are the exit codes?
Avouch returns 0
when the review is clean, 1
when findings are
reported, and 2
when Avouch cannot run. It still reviews rather than gates β enforcement stays in whatever calls it β but CI can now react to the outcome directly.
Does it need a network or a daemon?
No. Three git
subprocess calls and the standard library. Runtime is bounded by the size of your diff, not your repository.
Tests before code. A fix that cannot be expressed as a failing test first is not a fix yet.Keep the diff small. A change that touches more than two modules needs a justification in the PR description.The standard-library runtime is the contract. No new runtime dependencies without a written case that survives the philosophy section.The README is the spec. If the behavior changed, the README changes in the same commit.
Setup:
git clone https://github.com/mukundzha/avouch.git
cd avouch
pip install -e .
python -m pytest tests/
MIT β see LICENSE
.