czhao-dev/python-static-analyzer
GitHub: czhao-dev/python-static-analyzer
一个基于 AST 的轻量级 Python 静态分析器,通过九条内置规则在代码运行前发现质量、正确性和可维护性问题,支持作为本地检查工具或 CI 门禁使用。
Stars: 1 | Forks: 0
# Python 静态分析器
一个轻量级的 Python 静态分析器,用于在运行前发现常见的代码质量、正确性和可维护性问题。
它使用 `ast` 模块扫描 `.py` 文件(不会执行你的代码),报告带有稳定规则 ID 的文件及行级诊断信息,并在发现问题时以非零状态退出——因此它既可以作为本地检查工具,也可以作为 CI 门禁。
## 已实现的检查
| 规则 | 描述 |
|---------|--------------|
| `SA001` | 可变的默认参数,例如 `def add_item(item, items=[])`。 |
| `SA002` | 未使用的 import。 |
| `SA003` | 过宽的异常捕获,例如 `except Exception` 或光秃秃的 `except:`。 |
| `SA004` | 覆盖了内建名称,例如 `list`、`dict` 或 `id`。 |
| `SA005` | 圈复杂度过高的函数。 |
| `SA006` | 未使用的局部变量。 |
| `SA007` | 深度嵌套的控制流。 |
| `SA008` | 在看起来应该返回值的函数中缺少返回路径。 |
| `SA009` | 在 `return`、`raise`、`break` 或 `continue` 之后的不可达代码。 |
## 示例
给定这个 Python 文件:
```
def collect(value, values=[]):
try:
values.append(value)
return values
except Exception:
return []
```
分析器将报告:
```
example.py:1: SA001 Mutable default argument `values=[]`
example.py:5: SA003 Broad exception handler `except Exception`
```
## 安装说明
```
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## 使用说明
```
static-analyzer scan path/to/project
# 或者,不安装 console script:
python -m static_analyzer scan path/to/project
```
示例输出:
```
src/app.py:12: SA001 Mutable default argument `items=[]`
src/app.py:34: SA002 Unused import `json`
src/service.py:48: SA003 Broad exception handler `except Exception`
```
当未发现问题时,命令以 `0` 状态退出;当报告诊断信息时以 `1` 状态退出;发生使用错误(例如路径不存在)时则以 `2` 状态退出——这使其适合作为 CI 门禁。
默认情况下,扫描器会跳过常见的非项目目录(`.venv`、`venv`、`.git`、`__pycache__`、`build`、`dist`、`*.egg-info`、`.tox`、caches、`node_modules`)。
### CLI 选项
```
static-analyzer scan [paths ...]
--max-complexity N Cyclomatic complexity threshold (default: 10)
--max-nesting N Control flow nesting depth threshold (default: 4)
--select SA001,SA002 Only run these rule IDs (default: all rules)
--exclude PATTERN Glob pattern to exclude; repeatable
--no-config Ignore pyproject.toml configuration
```
## 配置
可以在命令行或 `pyproject.toml` 中进行设置:
```
[tool.static-analyzer]
exclude = ["tests/fixtures/*"]
max_complexity = 10
max_nesting = 4
enabled_rules = ["SA001", "SA002", "SA004"]
```
`enabled_rules` 默认为空列表,这意味着所有规则都会被启用。CLI 参数会覆盖从 `pyproject.toml` 加载的值。
## 开发说明
项目结构:
```
python-static-analyzer/
├── README.md
├── pyproject.toml
├── examples/
│ └── sample_issues.py
├── src/
│ └── static_analyzer/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── analyzer.py
│ ├── config.py
│ ├── diagnostics.py
│ └── rules/
│ ├── __init__.py
│ ├── mutable_defaults.py
│ ├── unused_imports.py
│ ├── unused_variables.py
│ ├── broad_exceptions.py
│ ├── shadowed_builtins.py
│ ├── complexity.py
│ ├── nesting.py
│ ├── missing_return.py
│ └── unreachable_code.py
└── tests/
├── test_analyzer.py
├── test_cli.py
└── test_*.py (one file per rule)
```
开发命令:
```
pip install -e ".[dev]"
pytest
static-analyzer scan examples/
```
## 设计
该分析器使用 Python 内置的 `ast` 模块:
1. 将每个 `.py` 文件解析为抽象语法树。
2. 在语法树上运行每个已启用规则的 `check(tree, path, config)` 函数。
3. 收集包含规则 ID、消息、文件路径和行号的诊断信息。
4. 以人类易读的 CLI 格式对结果进行排序和渲染。
5. 当发现问题时以非零状态退出,使该工具可在 CI 中使用。
每条规则都位于 `src/static_analyzer/rules/` 下各自的模块中,并公开一个 `RULE_ID` 和一个 `check()` 函数,因此添加一条新规则只需添加一个文件并在 `rules/__init__.py` 中注册即可。
## 测试结果
该项目包含 43 个单元测试和端到端测试,涵盖了所有规则以及 CLI:
```
$ pytest -q
...........................................
43 passed in 0.03s
```
在专门为触发每条规则而编写的文件 [examples/sample_issues.py](examples/sample_issues.py) 上运行分析器,确认了端到端的行为表现:
```
$ static-analyzer scan examples/sample_issues.py
examples/sample_issues.py:1: SA002 Unused import `json`
examples/sample_issues.py:2: SA002 Unused import `os`
examples/sample_issues.py:5: SA001 Mutable default argument `values=[]`
examples/sample_issues.py:9: SA003 Broad exception handler `except Exception`
examples/sample_issues.py:13: SA004 Parameter `list` shadows a built-in name
examples/sample_issues.py:14: SA006 Local variable `unused` is assigned but never used
examples/sample_issues.py:18: SA008 Function `classify` may not return a value on all code paths
examples/sample_issues.py:23: SA008 Function `first_even` may not return a value on all code paths
examples/sample_issues.py:27: SA009 Unreachable code after `return`
9 issue(s) found.
```
在该分析器自身的源码树上运行它,只发现了两处真实的复杂度问题,分别位于本质上包含大量分支的函数中(一个控制流分发器和一个 import 解析循环)——保持原样,而没有为了满足 linter 而人为地重构它们:
```
$ static-analyzer scan src
src/static_analyzer/rules/missing_return.py:38: SA005 Function `_stmt_always_exits` has cyclomatic complexity 16 (threshold 10)
src/static_analyzer/rules/unused_imports.py:48: SA005 Function `check` has cyclomatic complexity 12 (threshold 10)
2 issue(s) found.
```
## Rust 移植版
Rust 移植版位于 [`rust/`](rust/) 中,它是上述 Python CLI 在行为上的直接替代品:相同的 9 条规则、相同的规则 ID 和诊断消息、相同的 CLI 标志、相同的 `pyproject.toml` 配置语义、相同的默认排除项,以及相同的排序、面向行的输出格式和退出码(`0`/`1`/`2`)。
它使用 [`rustpython-ruff_python_ast`/`rustpython-ruff_python_parser`](https://crates.io/crates/rustpython-ruff_python_ast)(ruff 内部 Python AST/解析器 crate 的一个积极维护的镜像),而不是无人维护的 `rustpython-parser`,从而提供带有官方 visitor 模块来遍历的、形状类似 CPython 的 AST。
### 构建与运行
```
cd rust
cargo build --release
./target/release/static-analyzer scan path/to/project
```
CLI 层面(子命令、标志、退出码)与上面记录的 Python 版本完全相同。
### 测试结果
59 个测试通过——包括 52 个单元测试(规则逻辑、配置加载、fnmatch、文件发现)以及 7 个集成测试(CLI 行为以及与 Python 实现的逐字节标准输出对比):
```
$ cargo test
...
test result: ok. 52 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (tests/analyzer.rs)
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (tests/cli.rs)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (tests/golden.rs)
```
### 一致性验证
除了单元/集成测试之外,Rust 二进制文件的输出还在本仓库的每个目录(`src/`、`tests/` 和 `examples/`)的 stdout 和 stderr 上,与 Python CLI(`python -m static_analyzer scan --no-config`)的输出进行了直接 diff 对比。这三者的输出完全逐字节一致:
```
$ diff <(python -m static_analyzer scan src --no-config) <(./rust/target/release/static-analyzer scan src --no-config)
(no output — identical)
src/static_analyzer/rules/missing_return.py:38: SA005 Function `_stmt_always_exits` has cyclomatic complexity 16 (threshold 10)
src/static_analyzer/rules/unused_imports.py:48: SA005 Function `check` has cyclomatic complexity 12 (threshold 10)
2 issue(s) found.
```
移植过程中暴露了 CPython 的 `ast` 模块与 ruff 的 AST 结构之间的一些实际差异,这些差异需要专门处理(不仅仅是机械翻译):
- **Elif 链。** CPython 将 `elif` 表示为 `orelse` 中嵌套的 `ast.If`;ruff 则将 `if`/`elif`/`else` 扁平化为一个单一的 `elif_else_clauses` 列表。这改变了 [`SA005`](rust/src/rules/sa005_complexity.rs) 计算判断点的方式(每个 `elif` 依然会被计算,但不再是一个独立的节点),也改变了 [`SA008`](rust/src/rules/sa008_missing_return.rs) 判断穷尽性的方式(一个没有后续 `else` 的 `if`/`elif` 链是不穷尽的,即使现有的每个分支都返回了——原来的 Python 测试套件没有覆盖这种情况,现在已作为回归测试添加进来)。
- **双重访问的怪癖。** Ruff 生成的语句访问器会访问每个 `elif` 子句的测试表达式两次(一次是显式访问,另一次是在其自身的子句遍历器内部)。[`SA005`](rust/src/rules/sa005_complexity.rs) 手动驱动 `elif` 遍历,以避免重复计算位于 `elif` 条件中的布尔运算符。
- **[`SA007`](rust/src/rules/sa007_nesting.rs) 的嵌套** 实际上变得更简单了:CPython 的 `ast` 无法在结构上区分真正的 `elif` 与 `else:` 后面紧跟位于同一源码列的嵌套 `if`(Python 实现使用列偏移量启发式方法来处理这个问题)。Ruff 的 `elif_else_clauses` 通过 `test: Option`(`elif` 为 `Some`,`else` 为 `None`)直接区分它们,因此 Rust 移植版根本不需要这种启发式方法。
## 路线图
- [x] 使用 `pyproject.toml` 添加项目打包。
- [x] 实现 CLI 入口点。
- [x] 为 Python 文件实现 AST 解析。
- [x] 添加第一条规则:可变的默认参数。
- [x] 添加诊断格式化。
- [x] 为每条规则添加单元测试。
- [x] 添加配置支持。
- [x] 添加对 CI 友好的退出码。
- [ ] 添加 JSON 输出,以便用于编辑器和自动化集成。
- [x] 将分析器移植到 Rust,作为一个无依赖的单一二进制文件(参见 [Rust 移植版](#rust-port))。
- [ ] 待移植版本经过充分测试后,切换到 Rust 实现作为标准版本,并废弃 Python 源码。
## 许可证
本项目根据 [LICENSE](LICENSE) 中的条款授权。
标签:Python, SOC Prime, 可视化界面, 开发工具, 无后门, 逆向工具, 通知系统, 错误基检测, 静态代码分析