Skip to main content

Scrut

scrut is a static analysis tool that reviews unstaged Python changes before they reach a pull request. It works entirely offline, depends only on the standard library, and runs in under a second.

It checks four structural rules — function parameters, nesting depth, file size, and class size — by parsing your changed files with Python's AST module.


Features

  • Detects whether you are inside a Git repository
  • Collects unstaged changed files via git diff --name-only
  • Filters to .py files and validates they exist before analysis
  • Parses source code with Python's built-in ast module
  • Flags functions with excessive parameters (default >5)
  • Measures for/if/while nesting depth inside functions (default >4)
  • Flags files exceeding 400 lines and classes exceeding 200 lines
  • Prints a formatted report to stdout

Architecture

Scrut runs as a single synchronous pipeline. No caching, no async, no external services.

Git repository
     |
     v
is_gitrepo()          — exits if the working directory is not inside a Git work tree
     |
     v
get_changed_files()   — runs git diff --name-only
     |
     v
get_reviewable_files() — filters changed files to .py, checks they exist
     |
     v
read_file()           — reads the first changed Python file
     |
     v
ast.parse()           — parses source into an AST
     |
     v
ast.walk()            — iterates FunctionDef and ClassDef nodes
     |
     v
Rule checks           — parameter count, nesting depth, file/class line limits
     |
     v
generate_report()     — prints the formatted report to stdout

Every invocation starts from scratch. There is no incremental or cached analysis.

The data model for reports is a list of dictionaries:

# Function report
{
    "name": "process_data",
    "lines": 34,
    "parameters": 8,
    "nesting_depth": 5,
    "issues": [
        {"severity": "WARNING", "message": "Too many parameters (8/5)"},
        {"severity": "WARNING", "message": "Nesting too deep (5/4)"}
    ]
}
# File and class reports
{
    "name": "src/handler.py",
    "lines": 450,
    "issues": [
        {"severity": "WARNING", "message": "File too large (450/400)"}
    ]
}

Project structure

scrut/
├── pyproject.toml        # packaging, metadata, pytest config
├── LICENSE
├── README.md
├── .gitignore
├── src/
│   └── scrut/
│       ├── __init__.py   # package marker (empty)
│       └── cli.py        # all analysis logic (233 lines)
└── tests/
    └── test_git.py       # 7 unit tests (113 lines)

The only source file is src/scrut/cli.py. It contains the full pipeline — Git detection, file filtering, AST parsing, rule checking, and report generation — in a single module.

The test file is named test_git.py for historical reasons. It imports from scrut.cli.


Installation

Requires Python 3.10 or later and Git 2.0+. No other dependencies.

git clone https://github.com/mukundzha/scrut.git
cd scrut
python -m venv venv
source venv/bin/activate
pip install -e .

The pyproject.toml registers a CLI entry point during installation:

[project.scripts]
scrut = "scrut.cli:main"

After install, the scrut command is available on your PATH.


Usage

scrut

Or without installing:

PYTHONPATH=src python -m scrut.cli

What the tool does

  1. Verifies the current directory is inside a Git repository
  2. Runs git diff --name-only to find unstaged changed files
  3. Filters the list to only .py files that exist on disk
  4. Reads the first .py file from the filtered list
  5. Parses it with ast.parse
  6. Walks the AST looking for FunctionDef and ClassDef nodes
  7. Checks each node against four rules
  8. Prints the report to stdout

Exit code

The tool always exits with code 0, regardless of issues found.


Example output

Running against a file that triggers all four rules:

==================================================
SCRUT REPORT
==================================================

FILE
--------------------------------------------------
Name   : src/handler.py
Lines  : 500
Issues:
  [WARNING] File too large (500/400)

FUNCTIONS
--------------------------------------------------

Function 1: process_user_data
Lines         : 45
Parameters    : 12
Nesting Depth : 6
Issues:
  [WARNING] Too many parameters (12/5)
  [WARNING] Nesting too deep (6/4)

Function 2: normalize_email
Lines         : 8
Parameters    : 1
Nesting Depth : 0
Issues: None

CLASSES
--------------------------------------------------
No classes found.

==================================================
SUMMARY
==================================================
Functions Reviewed : 2
Classes Reviewed   : 0
Files Reviewed     : 1
Issues Found       : 3
==================================================

Error messages:

Scenario Output
Not in a Git repo Not inside a Git repository.
No changed Python files No Python files to review.
Python syntax error Python syntax error.
File not readable Couldn't read <path>

Review rules

All thresholds are module-level constants in src/scrut/cli.py:

PARAMETER_LIMIT = 5
NESTING_LIMIT = 4
FILE_LINE_LIMIT = 400
CLASS_LINE_LIMIT = 200
Rule Threshold Severity Description
Maximum parameters >5 WARNING Function has more arguments than allowed
Maximum nesting >4 WARNING for/if/while blocks nested deeper than 4 levels inside a function
Maximum file size >400 lines WARNING Source file exceeds the line count limit
Maximum class size >200 lines WARNING Class definition exceeds the line count limit

All issues carry WARNING severity. There is no ERROR level.

Nesting depth

get_depth() walks child AST nodes and counts depth only for ast.For, ast.If, and ast.While nodes. This targets logical complexity — with, try, and async for do not increase the counter.

def get_depth(node, depth=0):
    max_depth = depth
    for child in ast.iter_child_nodes(node):
        if isinstance(child, (ast.For, ast.If, ast.While)):
            max_depth = max(max_depth, get_depth(child, depth + 1))
        else:
            max_depth = max(max_depth, get_depth(child, depth))
    return max_depth

Testing

Tests use pytest with unittest.mock for subprocess isolation.

pytest -v

Expected output:

tests/test_git.py::test_get_reviewable_files PASSED
tests/test_git.py::test_get_depth_no_nesting PASSED
tests/test_git.py::test_get_depth_nested PASSED
tests/test_git.py::test_read_file PASSED
tests/test_git.py::test_get_changed_files PASSED
tests/test_git.py::test_is_gitrepo_true PASSED
tests/test_git.py::test_is_gitrepo_false PASSED

What each test covers

Test What it validates
test_get_reviewable_files Only .py files that exist on disk pass the filter
test_get_depth_no_nesting A flat function returns depth 0
test_get_depth_nested if > while > for returns depth 3
test_read_file Reads file contents correctly using tmp_path
test_get_changed_files Mocks subprocess.run, verifies stdout is parsed into a list
test_is_gitrepo_true Returns True when git rev-parse exits with code 0
test_is_gitrepo_false Returns False when git rev-parse exits with code 1

Tests use tmp_path (a pytest built-in fixture) for filesystem operations and unittest.mock.patch for subprocess isolation. get_reviewable_files now creates real files via tmp_path.write_text() and checks for existence — a change from the earlier version that only filtered by extension.

pytest configuration:

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

The pythonpath setting adds src/ to sys.path so from scrut.cli import ... resolves during test collection.


Current limitations

  • Single-file review: Only the first changed Python file is analyzed (reviewable_files[0] in main()). The data structures support multiple files but the loop does not iterate.
  • Unstaged changes only: git diff --name-only does not return staged files. Files added with git add are invisible to Scrut.
  • No exit code signaling: The tool exits 0 regardless of whether issues are found.
  • No configuration: Thresholds are hardcoded. No config file, environment variables, or CLI flags.
  • Plain text output only: No JSON, SARIF, or other machine-readable formats.
  • No rule plugin system: Adding a new rule means editing cli.py directly.

Roadmap

  • Review all changed files instead of only the first one
  • Support staged files (git diff --cached)
  • Add JSON output for CI integration
  • Make rules configurable via pyproject.toml or a config file
  • Expand rule set (unused imports, bare except clauses, missing docstrings)
  • Add pre-commit hook support

Contributing

The codebase is a single 233-line module and one 113-line test file. Good starting points:

  • generate_report() has no dedicated test
  • Multi-file iteration in main() is the most requested fix
  • A new rule can be added by extending the AST walk loop in main()

Run pytest before submitting changes.


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.1.1.tar.gz (8.2 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.1.1-py3-none-any.whl (7.8 kB view details)

Uploaded Python 3

File details

Details for the file scrut-0.1.1.tar.gz.

File metadata

  • Download URL: scrut-0.1.1.tar.gz
  • Upload date:
  • Size: 8.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.4

File hashes

Hashes for scrut-0.1.1.tar.gz
Algorithm Hash digest
SHA256 9b3c2b5773fa6ba83d832135411991540b545701673db96b856178d24ebf2371
MD5 8cee2272d05ed8777bfe73e1f810034d
BLAKE2b-256 a8b8bb987fc974210cbf2eff153924d283e6f200e9d847e2710b385eef5dfdd0

See more details on using hashes here.

File details

Details for the file scrut-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: scrut-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 7.8 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.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 091b0e513a916548bb091798ad2d76dac1842add2275738c6f5ea57bc179b70c
MD5 e19c2c5e798a4b0d8f106f6416b0b1b1
BLAKE2b-256 c3c6524e654871042498f1f6294578afc08e4d5f40b21602d21354688aea2e94

See more details on using hashes here.

Release history Release notifications | RSS feed

0.3.2

2 files

0.3.1

2 files

This release

0.1.1 This release

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