Skip to main content

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

  • The review set is the diff, not the repository. Scrut 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, Scrut does not claim it.
  • Errors are data. An unreadable or syntactically broken file becomes an ERROR entry in the report. One broken file never cancels the review of the others.
  • Scrut reviews; it does not gate. The exit code signals the outcome — 0 clean, 1 violations found, 2 Scrut error — but enforcement belongs in an opt-in interface, not in a tool you run before every push.
  • The runtime is the standard library. Three git subprocess calls and ast/tomllib. No daemon to keep alive; runtime is bounded by the size of your diff, not your repository.

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 .py files (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

  1. cli.main() loads config (limits + rules merged over defaults).
  2. git.is_gitrepo()git rev-parse --is-inside-work-tree; exits the run with a message if not a repo.
  3. git.get_changed_files()git diff HEAD --name-only plus untracked files; get_reviewable_files() keeps existing .py paths.
  4. Per file, analyzer.analyze_file(path, limits, rules):
    • reads UTF-8 (OSErrorERROR report), parses with ast.parse (SyntaxErrorERROR report; the rest of the run continues),
    • walks the AST once with ast.walk, dispatching FunctionDef, AsyncFunctionDef, and ClassDef nodes to their rules (rule toggles are checked before dispatch, so disabled rules never run),
    • returns (function_reports, file_reports, class_reports).
  5. 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.toml values 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

scrut-0.3.1.tar.gz (35.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scrut-0.3.1-py3-none-any.whl (29.3 kB view details)

Uploaded Python 3

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

Hashes for scrut-0.3.1.tar.gz
Algorithm Hash digest
SHA256 59ffd5ed73bec67ee641e48be39d2840be8a4fa071dad0c3e9e8a1eff019a1ba
MD5 210bab11579379ccb6d08147cb2cd743
BLAKE2b-256 fa6516eef440c6001ba0503f2dd4b99bf4df773deb46c0ac06675b5f6cc3eaea

See more details on using hashes here.

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

Hashes for scrut-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e2d36242dda3778d7a97c76a4b45cc176d49a43ac60016ac3da4a5809b48e6c4
MD5 f5ca7c9f9c90613251e0b80894693154
BLAKE2b-256 ff62dd3952588e7aeca19ab4288c835b9c3b0de61bfbbf945e84b3ddee143c7a

See more details on using hashes here.

Release history Release notifications | RSS feed

0.3.2

2 files

This release

0.3.1 This release

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page