scrut
Review the Python you changed, not the Python you inherited.
Scrut 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 scrut.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 scrut
cd your-repo
scrut
Table of contents
- Why it exists
- Installation
- Quick start
- JSON output
- Configuration
- Rules
- How it works
- Repository layout
- Adding a rule
- Testing
- Roadmap
- FAQ
- Contributing
- License
Why it exists
- The review set is the diff, not the repository. Scrut computes the
review set from Git at run time (
git diff HEAD --name-onlyplus 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, Scrut does not claim it.
- Errors are data. An unreadable or syntactically broken file becomes
an
ERRORentry in the report. One broken file never cancels the review of the others. - Scrut reviews; it does not gate. The exit code signals the outcome —
0clean,1violations found,2Scrut 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
gitsubprocess calls andast/tomllib. No daemon to keep alive; runtime is bounded by the size of your diff, not your repository.
Installation
Requires Python 3.10+ (rules use ast.Match; configuration uses
tomllib) and Git on PATH.
pip install scrut
or from source:
git clone https://github.com/mukundzha/scrut.git
cd scrut
pip install -e .
Both register the scrut console script (scrut.cli:main).
Quick start
The entire interface is one command with a single optional flag:
cd your-repo
# ... make a change ...
scrut
The review set is defined by Git, so there is nothing to configure at invocation time. Scrut reviews:
- tracked files modified vs.
HEAD(git diff HEAD --name-only), and - untracked
.pyfiles (git ls-files --others --exclude-standard).
Deleted paths and non-.py files are skipped. Committed, untouched files
never appear in the output.
A run with findings
$ scrut
Scrut
3 files 2 with issues 5 findings
src/app.py
handle
SCR013 Nesting too deep
fake
SCR001 Async function
extra
SCR014 Too many parameters
tests/bad.py
messy
SCR015 Nested function
SCR014 Too many parameters
Summary
SCR014 Too many parameters 2
SCR013 Nesting too deep 1
SCR001 Async function 1
SCR015 Nested function 1
1 file passed
- Header — file/issue/finding counts.
- Per-file sections — bold filenames, indented function names, dim rule identifiers with short messages. Consecutive findings on the same function are grouped.
- Summary — findings grouped by rule id, ordered by frequency, with a passing-file count.
A clean run
$ scrut
Scrut
✓ All clean.
Edge cases
$ cd /tmp/somewhere-without-git
$ scrut
Not inside a Git repository.
$ cd ~/repo-with-no-python-changes
$ scrut
No Python files to review.
Colors are ANSI codes emitted only when stdout is a TTY. Piped output is
plain, so scrut | tee review.log and CI capture work cleanly. The exit
code is 0 when the review is clean, 1 when findings are reported, and
2 when Scrut cannot run.
JSON output
For automation and CI, --json prints the review as a single JSON document
on stdout, with no human-readable text mixed in:
scrut --json
{
"version": 1,
"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"
}
],
"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,
and its kind (func, class, or file) — the same component and kind
shown in the human table. files_with_violations is the number of distinct
files containing at least one violation. Exit codes behave exactly as in
normal mode, so scrut --json can gate CI: parse stdout for the findings
and react to the exit status (0 clean, 1 violations, 2 Scrut error).
Configuration
Configuration is optional, partial, and declarative. Scrut looks for a
scrut.toml in the current working directory and merges it over the
built-in defaults — any subset is valid. A malformed file raises instead
of being silently ignored.
[limits] # numeric thresholds per rule
[rules] # on/off toggle per rule
Rule toggles
| 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 |
max_complexity |
true |
function/class complexity |
Setting a toggle to false disables that rule's findings. A one-line
[rules] section is a complete, valid configuration.
Limits
| Key | Default | Rule | Meaning |
|---|---|---|---|
max_parameters |
5 | SCR014 | Max positional + keyword params |
max_nesting |
4 | SCR013 | Max block nesting depth |
max_function_lines |
50 | SCR012 | Max function line span |
max_class_lines |
200 | SCR010 | Max class line span |
max_file_lines |
400 | SCR011 | Max file line count |
max_complexity |
10 | — | Max cyclomatic complexity |
max_boolean_conditions |
5 | SCR003 | Max operands in one chain |
max_if_else_chain |
5 | SCR007 | Max if/elif links |
max_local_variables |
15 | SCR009 | Max distinct assigned names |
max_return_statements |
3 | SCR016 | Max returns per function |
max_lambda_nodes |
5 | SCR008 | Max AST nodes in a lambda body |
max_large_comprehensions |
10 | 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.
Example
# scrut.toml — the exact file this repository lives by
[limits]
max_parameters = 4
max_nesting = 5
max_function_lines = 50
max_class_lines = 50
max_file_lines = 50
max_complexity = 10
max_boolean_conditions = 6
max_local_variables = 15
max_return_statements = 6
max_lambda_nodes = 10
max_large_comprehensions = 12
[rules]
max_parameters = true
max_nesting = false
max_function_lines = false
max_class_lines = true
max_file_lines = true
max_complexity = false
max_boolean_conditions = true
max_local_variables = true
max_return_statements = true
max_lambda_nodes = true
max_large_comprehensions = true
Rules
Scrut ships 16 rule identifiers (SCR001–SCR016) 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 | 10 | funcs | N/limit |
| SCR006 | Duplicate branch | — | funcs, classes | detected |
| SCR007 | Long if/elif chain | 5 | funcs, classes | N/limit |
| SCR008 | Lambda too complex | 5 | funcs | N/limit |
| SCR009 | Too many local variables | 15 | funcs | N/limit |
| SCR010 | Class too large | 200 | classes | N/limit |
| SCR011 | File too large | 400 | files | N/limit |
| SCR012 | Function too long | 50 | funcs | N/limit |
| SCR013 | Nesting too deep | 4 | funcs | N/limit |
| SCR014 | Too many parameters | 5 | funcs | N/limit |
| SCR015 | Nested function definition | — | funcs | detected |
| SCR016 | Too many return statements | 3 | funcs | N/limit |
| — | Function too complex | 10 | funcs | N/limit |
| — | Class too complex | 10 | classes | N/limit |
SCR001 — Async without await
Flags async def functions that never await. An async function without
an await runs synchronously while still incurring event-loop overhead.
# bad
async def fetch_config():
return json.load(open("config.json"))
# good
def fetch_config():
return json.load(open("config.json"))
SCR002 — Bare except
Flags except: handlers that catch every exception — including
KeyboardInterrupt and SystemExit.
# bad
try:
return json.loads(raw)
except:
return None
# good
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
SCR003 — Boolean expression too complex
Flags a single and/or chain with too many operands. Nested chains sum
their operands, so a and (b or c) scores 3.
# bad — 6 operands
if a and b and c and d and e and f:
launch()
# good
if is_ready(a, b, c) and has_clearance(d, e, f):
launch()
SCR004 / SCR006 — Duplicate branch
Flags if/elif branches whose bodies are identical — a copy-paste or a
condition that never varies. Two rule IDs cover the same detection:
SCR004 (detect_duplicateb) runs on functions; SCR006 (empty_except)
runs on functions and classes. Both emit the same finding, and the
report deduplicates identical rows, so one violation renders once.
# bad
if kind == "csv":
rows = read_csv(path)
elif kind == "json":
rows = read_csv(path) # copy-paste
# good
if kind in ("csv", "json"):
rows = read_csv(path)
SCR005 — Large comprehension
Flags list/set/dict comprehensions and generator expressions whose AST
node count exceeds max_large_comprehensions (default 10). Past a few
nested clauses a comprehension stops being an expression and becomes a
program.
# bad
result = [
[x * 100 for x in row if x != 0]
for row in matrix
if row and any(v > limit for v in row)
]
# good
def scale_row(row, factor):
return [x * factor for x in row if x != 0]
result = [scale_row(row, 100) for row in matrix if row]
SCR007 — Long if/elif chain
Flags if/elif chains longer than max_if_else_chain (default 5).
# 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 too complex
Flags lambda bodies exceeding max_lambda_nodes (default 5) 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 many local variables
Flags functions assigning more than max_local_variables (default 15)
distinct names — every new name is cognitive load and a chance for
shadowing. Fix: extract groups of assignments into helpers.
SCR010 — Class too large
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.
SCR011 — File too large
Flags files exceeding max_file_lines (default 400). Fix: split into
modules with single concerns.
SCR012 — Function too long
Flags functions whose line span exceeds max_function_lines (default
50). Fix: extract helpers — process_order becomes validate,
reserve, and send.
SCR013 — Nesting too deep
Flags maximum nesting depth of block nodes above max_nesting (default
4). Depth counts if, for, while, async for, with, async with, try, and match only. Comprehensions, lambdas, and nested
defs do not add depth; sibling blocks do not stack — the metric is
maximum depth, not block count.
# bad — 5 deep
with open(path) as f: # 1
for row in f: # 2
if row.startswith("#"): # 3
try: # 4
parse(row) # 5
# good — early-return guards flatten it
def line_ready(row):
if not row:
return False
if row.startswith("#"):
return False
return True
with open(path) as f:
for row in f:
if line_ready(row):
parse(row)
SCR014 — Too many parameters
Flags functions with more than max_parameters (default 5) positional or
keyword parameters. *args, **kwargs, self, and keyword-only
parameters are excluded — the rule measures what makes calls hard to
read.
# 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 function definition
Flags a function defined inside another function. Closures that capture their enclosing scope run once per outer call and defeat unit testing.
# 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 many return statements
Flags functions with more than max_return_statements (default 3)
returns — every exit point is a path to maintain.
Function / Class too complex — cyclomatic complexity
Flags functions and classes whose McCabe cyclomatic complexity exceeds
max_complexity (default 10). Base 1, then +1 for every if, for,
while, try, except handler, match, ternary, assert, with,
and every extra and/or operand. The walk covers the whole subtree:
a class's complexity is the sum over its entire body, methods included.
How it works
The codebase is deliberately small: a CLI orchestrator, three 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.
flowchart LR
G[git.py<br/>review set: git diff HEAD + untracked] --> A[analyzer.py<br/>AST walk · rule dispatch]
C[config/<br/>scrut.toml + defaults] --> A
A --> R[report.py<br/>terminal report]
| Module | Role | Key exports |
|---|---|---|
cli.py |
Pipeline wiring | main() |
git.py |
Git interaction | is_gitrepo, get_changed_files, get_reviewable_files |
analyzer.py |
AST analysis | read_file, analyze_file |
rules/*.py |
One rule per module | analyze(node, limits) |
report.py |
Terminal rendering | render_report, generate_report |
config/default.py |
Default limits | DEFAULT_LIMITS |
config/loader.py |
TOML load + merge | load_config, merge_limits, merge_rules, DEFAULT_RULES |
Pipeline
cli.main()loads config (limits+rulesmerged 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-onlyplus untracked files;get_reviewable_files()keeps existing.pypaths.- Per file,
analyzer.analyze_file(path, limits, rules):- reads UTF-8 (
OSError→ERRORreport), parses withast.parse(SyntaxError→ERRORreport; the rest of the run continues), - walks the AST once with
ast.walk, dispatchingFunctionDef,AsyncFunctionDef, andClassDefnodes to their rules (rule toggles are checked before dispatch, so disabled rules never run), - returns
(function_reports, file_reports, class_reports).
- reads UTF-8 (
report.render_report(...)groups issues by file in a single pass and renders the bold header, per-file breakdown, and rule-aligned summary.
Reporting details
Terminal rendering uses a minimal Rich-based UI (Console, Text, Group,
Padding only) — no borders, no emojis, no tables. Column widths are not
computed in the terminal; instead, long messages truncate with … so output
never wraps. Identical (component, rule) rows are deduplicated per file, and
file-level rows sort first. SCRUT_FONT=name is an opt-in OSC 50 font switch
honored only by capable terminals.
Repository layout
scrut/
├── pyproject.toml # packaging, console script
├── scrut.toml # limits this repo lives by
├── src/scrut/
│ ├── 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
│ └── config/
│ ├── default.py # DEFAULT_LIMITS
│ └── loader.py # load_config, merge_limits, merge_rules
└── tests/
└── test_git.py # 36 tests, incl. a real-git end-to-end run
Adding a rule
A rule is a module in src/scrut/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.
Testing
All 36 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, 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.
Roadmap
Informed by documented limitations, ordered by the pain they remove:
0.4 — Configuration hardening
- Validate
scrut.tomlvalues with readable errors (today: a malformed file raises) - Search upward from the working directory for
scrut.toml(today: CWD only)
1.0 — CI-grade interface
- Configurable exit codes, so enforcement thresholds can be tuned without changing scrut's review-only default
New rules must survive the philosophy section — the ceiling is raised deliberately, not by accretion.
FAQ
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 scrut 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?
Scrut returns 0 when the review is clean, 1 when findings are
reported, and 2 when Scrut 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.
Contributing
- 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/scrut.git
cd scrut
pip install -e .
python -m pytest tests/
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file scrut-0.3.1.tar.gz.
File metadata
- Download URL: scrut-0.3.1.tar.gz
- Upload date:
- Size: 35.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59ffd5ed73bec67ee641e48be39d2840be8a4fa071dad0c3e9e8a1eff019a1ba
|
|
| MD5 |
210bab11579379ccb6d08147cb2cd743
|
|
| BLAKE2b-256 |
fa6516eef440c6001ba0503f2dd4b99bf4df773deb46c0ac06675b5f6cc3eaea
|
File details
Details for the file scrut-0.3.1-py3-none-any.whl.
File metadata
- Download URL: scrut-0.3.1-py3-none-any.whl
- Upload date:
- Size: 29.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e2d36242dda3778d7a97c76a4b45cc176d49a43ac60016ac3da4a5809b48e6c4
|
|
| MD5 |
f5ca7c9f9c90613251e0b80894693154
|
|
| BLAKE2b-256 |
ff62dd3952588e7aeca19ab4288c835b9c3b0de61bfbbf945e84b3ddee143c7a
|