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
.pyfiles and validates they exist before analysis - Parses source code with Python's built-in
astmodule - Flags functions with excessive parameters (default >5)
- Measures
for/if/whilenesting 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
- Verifies the current directory is inside a Git repository
- Runs
git diff --name-onlyto find unstaged changed files - Filters the list to only
.pyfiles that exist on disk - Reads the first
.pyfile from the filtered list - Parses it with
ast.parse - Walks the AST looking for
FunctionDefandClassDefnodes - Checks each node against four rules
- 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]inmain()). The data structures support multiple files but the loop does not iterate. - Unstaged changes only:
git diff --name-onlydoes not return staged files. Files added withgit addare 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.pydirectly.
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.tomlor 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
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.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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b3c2b5773fa6ba83d832135411991540b545701673db96b856178d24ebf2371
|
|
| MD5 |
8cee2272d05ed8777bfe73e1f810034d
|
|
| BLAKE2b-256 |
a8b8bb987fc974210cbf2eff153924d283e6f200e9d847e2710b385eef5dfdd0
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
091b0e513a916548bb091798ad2d76dac1842add2275738c6f5ea57bc179b70c
|
|
| MD5 |
e19c2c5e798a4b0d8f106f6416b0b1b1
|
|
| BLAKE2b-256 |
c3c6524e654871042498f1f6294578afc08e4d5f40b21602d21354688aea2e94
|