mukundzha/scrut
GitHub: mukundzha/scrut
Scrut 是一个零依赖的 Python 命令行工具,仅基于 Git diff 审查本次提交中变更的 .py 文件,通过 AST 分析报告函数长度、参数数量、嵌套深度等结构性问题。
Stars: 1 | Forks: 0
# Scrut
审查你即将提交的 Python 文件 —— 仅此而已。
Scrut 是一个零依赖的 CLI 工具,它会查看你的 Git 工作区,分离出
自上次提交以来每个被修改的 `.py` 文件,使用 Python 自带的
`ast` 模块解析每一个文件,并根据你可
配置的限制报告结构性问题。它离线运行,在几毫秒内完成,并很好地回答了一个问题:
*我刚写的代码是否仍然符合该项目所认可的规范?*
## 为什么会有 Scrut
代码审查可以发现作者遗漏的问题。但是大多数审查工具要么扫描
整个代码库(缓慢、嘈杂、充满预先存在的技术债务),要么需要
配置、服务器和插件生态系统才能输出有用的信息。
Scrut 从不同的前提出发:风险在于改变了什么。如果
某个函数在这次提交中增长到了 400 行,那就是新的技术债务。如果
它上周就已经是 400 行了,那是一个预先存在的问题 —— 这不是
pre-push 检查应该大喊大叫的东西。
这就是为什么 Scrut 从不扫描。它询问 Git 改变了什么,过滤出 Python
文件,并且只审查这些文件。结果是一个适合在
`git push` 之前的安静时刻使用的工具:足够快,可以每次都运行;足够简单,
可以完全理解;且带有坚定的主张,足以发挥其实用性。
基于 Regex 的 linting 无法可靠地完成这项工作。多行签名、嵌套
块,以及看起来像函数调用的定义,都会让模式匹配束手无策。
`ast` 模块将源代码解析为真正的语法树,这使得“这个
函数有多少个参数”从一场猜谜游戏变成了算术问题。
## 理念
该项目基于几个刻意的选择:
- **小巧。** 整个 pipeline 只是一个模块。你可以在十分钟内读完它。
- **快速。** 无缓存,无 daemon,无网络。每次运行都从头开始,
并在几毫秒内结束。
- **可预测。** 相同的输入总是产生相同的报告。没有
状态,没有持久化,没有任何隐藏的东西。
- **Git 优先。** Git 是审查内容的唯一事实来源。没有基于 flag 的
文件选择,没有列出路径的配置文件。
- **诚实地失败。** 一个不可读或语法损坏的文件会记录
错误并继续执行。一个损坏的文件永远不会让其他文件的报告静默。
- **没有不必要的抽象。** 五个规则,三种报告形态,一个
pipeline。当添加一个规则时,AST 遍历只需几行代码,而不是一个新
框架。
## 架构
```
┌────────────┐ ┌─────────────┐ ┌───────────────┐ ┌──────────┐
│ Git │────▶│ Changed │────▶│ .py filter │────▶│ Loader │
│ work tree │ │ file list │ │ + exists │ │ scrut.toml│
└────────────┘ └─────────────┘ └───────────────┘ └────┬─────┘
│ limits
▼
┌──────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────┐
│ Report │◀────│ Rule checks │◀────│ AST walk │◀────│ ast.parse│
│ generator│ │ 5 rules │ │ functions/ │ │ + read │
└──────────┘ └──────────────┘ │ classes │ └──────────┘
└───────────────┘
```
这个 pipeline 是一条直线。每个阶段都会缩小或转换其输入:
| 阶段 | 组件 | 职责 |
|---|---|---|
| 发现 | `get_changed_files()` | `git diff HEAD --name-only` —— 自上次提交以来修改的每个文件 |
| 过滤 | `get_reviewable_files()` | 仅保留现有的 `.py` 文件 |
| 限制 | `load_config()` | 读取 `scrut.toml`,在 `DEFAULT_LIMITS` 上合并 |
| 分析 | `analyze_file()` | 读取 → `ast.parse` → 遍历 → 应用这五个规则 |
| 指标 | `get_depth()` | 递归嵌套计数器(仅限 `For`、`If`、`While`) |
| 报告 | `generate_report()` | 打印汇总报告和摘要 |
失败处理位于文件边界:读取和解析错误会变成
报告中的 `ERROR` 条目,而不是停止运行的异常。
## 仓库结构
```
scrut/
├── pyproject.toml # Packaging, entry point, pytest config
├── scrut.toml # Limits used by this repository itself
├── README.md
├── src/
│ └── scrut/
│ ├── cli.py # The entire pipeline: Git, analysis, report
│ └── config/
│ ├── default.py # DEFAULT_LIMITS — single source of fallback truth
│ ├── loader.py # scrut.toml reading + merging
│ └── validator.py # Reserved for future validation
└── tests/
└── test_git.py # 12 unit tests
```
该布局遵循整洁打包所需的 src-layout 约定。
`cli.py` 是应用程序;`config` 包隔离了关于
限制来源的所有内容,因此 pipeline 永远不需要知道 TOML 或
文件系统路径。`validator.py` 故意留空 —— 这是一个占位符,以备将来
配置值需要进行类型和范围检查时使用。
## Scrut 的工作原理
当你运行 `scrut` 时,会按顺序发生以下事情:
1. **加载限制。** `load_config()` 在当前
目录中查找 `scrut.toml`。如果存在,其 `[limits]` 表将合并到默认值之上。
如果不存在,则按原样使用 `DEFAULT_LIMITS`。
2. **检查 Git 上下文。** `is_gitrepo()` 运行 `git rev-parse
--is-inside-work-tree`。在仓库之外,Scrut 会打印
`Not inside a Git repository.` 并停止。
3. **收集更改的文件。** `get_changed_files()` 运行
`git diff HEAD --name-only`。这涵盖了暂存和未暂存的更改
与上一次提交的对比 —— 正好是构成下一次
push 的文件集合。
4. **过滤。** `get_reviewable_files()` 仅保留以 `.py` 结尾且
仍然存在于磁盘上的文件。如果没有符合条件的文件,Scrut 会打印
`No Python files to review.` 并停止。
5. **分析每个文件。** 对于每个候选文件,`analyze_file()` 会读取
源代码,使用 `ast.parse` 进行解析,并遍历该树。每个 `FunctionDef`
都会测量长度、参数和嵌套深度;每个 `ClassDef` 测量
长度;文件本身测量行数。违规项被收集为
`WARNING` 条目。
6. **报告。** `generate_report()` 打印 FILE、FUNCTIONS 和 CLASSES
部分,随后是汇总的 SUMMARY。
该工具总是以状态 0 退出 —— 它报告发现的问题;它不监督
它们。
## 安装
要求:你的 `PATH` 中有 **Python 3.10+** 和 **Git 2.0+**。不存在或不会安装其他
依赖项。
**从 PyPI**
```
pip install scrut
```
**从源码**
```
git clone https://github.com/mukundzha/scrut.git
cd scrut
pip install -e .
```
这两种方式都会注册 `scrut` 控制台脚本,该脚本指向
`pyproject.toml` 中的 `scrut.cli:main`。
## 快速开始
```
cd your-repo
# 进行更改
scrut
```
没有参数也没有 flag。如果你在上次提交后更改了 Python 文件,你
就会得到一份报告。这就是整个接口。
## 配置
在你运行 `scrut` 的地方旁边创建一个 `scrut.toml`:
```
[limits]
max_parameters = 5
max_nesting = 4
max_function_lines = 50
max_class_lines = 200
max_file_lines = 400
```
配置是可选的,且在设计上是部分的。`[limits]` 表
合并到 `DEFAULT_LIMITS` 之上(`loader.py` 中的 `merge_limits()`),因此省略一个
key 意味着“使用默认值” —— 一个只包含 `max_parameters = 3` 的文件就是
一个完整、有效的配置。
| Key | 默认值 | 含义 |
|---|---|---|
| `max_parameters` | 5 | 每个函数的最大位置参数 |
| `max_nesting` | 4 | 函数内最大的 `for`/`if`/`while` 嵌套深度 |
| `max_function_lines` | 50 | 最大函数长度,包括签名 |
| `max_class_lines` | 200 | 最大类长度,包括方法 |
| `max_file_lines` | 400 | 最大文件长度 |
一个警告:该文件仅从当前工作目录解析。
从子目录运行 `scrut` 意味着找不到 `scrut.toml`,并且
将应用默认值。
## 规则引擎
每个规则在超过其阈值时都会发出一个 `WARNING` 条目。消息
总是带有测量值和限制值,因此报告读起来就像是一个
独立的解释:`Function too long (87/50)` 无需上下文。
### 函数长度
跨越数十行的函数很难阅读、测试,并且
容易累积职责。此规则会标记那些行跨度 —— 包含签名 —— 超过
`max_function_lines` 的函数。
```
def handle_request(payload): # line 1
... # 87 lines of logic
```
```
[WARNING] Function too long (87/50)
```
### 参数数量
每增加一个参数,调用者必须考虑的组合就会成倍增加。该规则根据 `max_parameters` 计数
`node.args.args` —— 位置和关键字参数 —— 。
```
def register_user(name, email, password, role, newsletter, locale, timezone):
```
```
[WARNING] Too many parameters (7/5)
```
### 嵌套深度
深度嵌套是构建不可读代码最廉价的方式。`get_depth()`
遍历整棵树,并且仅在 `For`、`If` 和 `While`
节点上递增。`Try`、`With` 及其 async 变体被故意排除在外 ——
该指标针对的是条件和循环复杂性,而不是一般的块结构。
```
def process(items):
for item in items:
if item.valid:
while retry(item):
...
```
```
[WARNING] Nesting too deep (3/4)
```
### 类长度
一个超过了 `max_class_lines` 的类通常已经变成了状态和行为的混合袋。
该指标计算完整的类跨度,包括方法。
```
[WARNING] Class too large (240/200)
```
### 文件长度
一个 2,000 行的模块不利于导航。此规则将解析的
行数与 `max_file_lines` 进行比较。
```
[WARNING] File too large (1200/400)
```
## 示例项目
考虑一个包含三个更改文件的小型库:
```
# utils.py
def normalize(text):
return text.strip().lower()
```
```
# api.py
class UserAPI:
def create(self, name, email, password, role, notify, retries):
if not name:
raise ValueError("name required")
...
```
```
# handlers.py
def main():
try:
api = UserAPI()
except Exception:
api = UserAPI(retries=5)
```
运行 `scrut`:
- `utils.py` —— 整洁。`normalize` 有一个参数,无嵌套,五行。
- `api.py` —— 被标记。`create` 有六个参数;`UserAPI` 跨越 48 行,
这没问题,但参数数量超过了默认限制五。
- `handlers.py` —— 整洁。
一个警告,一个文件,`utils.py` 或任何未触及的模块没有产生零噪音。
## 示例输出
```
$ scrut
SCRUT REPORT
==================================================
==================================================
FILE
--------------------------------------------------
Name : src/scrut/api.py
Lines : 320
Issues:
[WARNING] File too large (320/400)
FUNCTIONS
--------------------------------------------------
Function 1: create
Lines : 41
Parameters : 6
Nesting Depth : 3
Issues:
[WARNING] Too many parameters (6/5)
CLASSES
--------------------------------------------------
Class: UserAPI
Lines: 48
Issues: None
==================================================
SUMMARY
==================================================
Functions Reviewed : 3
Classes Reviewed : 1
Files Reviewed : 3
Issues Found : 2
==================================================
```
## 设计决策
**为什么使用 AST 而不是 regex?** Regex 无法跨行计算括号、
测量嵌套,或区分定义和调用。AST 为
每个有效的 Python 文件提供准确的答案,包括包含关键字注释和
多行签名等边缘情况。
**为什么使用 TOML?** 它是 Python 工具配置的事实标准,在
标准库(`tomllib`)中有一个零依赖的解析器,并且在
diff 中阅读清晰。配置是一个包含五个数字的扁平表 —— 任何更重的东西
都是多此一举。
**为什么用 `git diff HEAD` 而不是 `git diff`?** 单纯的 `git diff` 只涵盖
未暂存的更改。与 `HEAD` 进行比较可以同时捕获暂存和未暂存的
工作,这就是将在下一次 push 中提交的完整文件集。
**为什么只针对更改的文件?** 预先存在的问题是噪音。一个在 10 个文件的提交中报告
500 个警告的工具会淹没那些真正重要的
少数问题:作者刚刚引入的那些问题。
**为什么 CLI 优先?** 终端是进行审查的地方 —— 就在提交和
push 之前。没有 daemon,没有 watch 模式,没有 IDE 插件。一个命令,一份
报告,搞定。
## 性能
运行时间受限于你触及的内容,而不是你拥有的内容。每个更改的文件
都会被读取、解析一次,并遍历一次。解析在文件大小方面是线性的;AST
遍历和 `get_depth()` 递归在节点数量方面是线性的。这里
没有缓存,因为没有东西需要缓存 —— 总工作量只是少数
几个小文件。
两次 Git 子进程调用(`rev-parse`、`diff`)是每次运行唯一的外部
依赖。在典型的提交中,该工具在远低于 100
毫秒的时间内完成。
## 路线图
- 在分析之前验证 `scrut.toml` 的值(类型和范围)
- 包含未追踪的文件,并支持没有提交的仓库
- 添加 `--json` 输出和可配置的 CI 退出代码
- 计算 `*args`、`**kwargs` 和仅关键字参数
- 从子目录向上搜索 `scrut.toml`
- 支持 Pre-commit hook
## 许可证
MIT。请参阅 `LICENSE`。
标签:Git工具, Python, Python安全, SOC Prime, 云安全监控, 代码审查, 开发工具, 文档结构分析, 无后门, 网络安全研究, 自动化payload嵌入, 逆向工具, 静态分析