
为什么自己写市面上的 Python 静态检查工具pylint / flake8 / ruff都挺成熟了。但我想要的东西它们都给不了要同时扫 Python / C / C / Java / JavaScript / TypeScript / Go——没有哪个工具覆盖全本地跑代码不出域——很多公司、政务、军工项目的源码绝对不能上传到云端 SaaS每条规则都说清为什么错——不是甩一个规则 ID 让你自己查文档。于是花一天时间用tree-sitter写了一个源码905 行。本文所有代码和输出都是真跑出来的仓库在文末。一、核心思路用语法树不用正则这是整个项目的地基。为什么不能用正则看这个pythonif a None: # 赋值错 ← 正则看不出这是错还是对 if a None: # 比较对 a is not None # 正确写法 —— 但粗暴的规则会把它误报成 not in None★x is not None是 Python 最常见的正确写法。第一版规则把它误报了改了。正则的本质是字符串匹配它不知道a None和a None在语义上完全不同。而 bug 检查必须理解语义结构 —— 所以必须上语法树。二、tree-sitter 有个坑官方文档不会告诉你如果你只按官方 quickstart 写到多语言时会撞墙。坑 1版本必须锁死python# tree-sitter 0.25.2 核心 tree_sitter 0.25.2 tree-sitter-python 0.25 tree-sitter-c 0.24 tree-sitter-java 0.23.5 tree-sitter-javascript 0.25 tree-sitter-typescript 0.23.2 tree-sitter-go 0.25★ 为什么不能升到 0.260.26 的核心不认 grammar 包返回的 PyCapsule直接报language must be a PyCapsule, not class object★ 为什么不用tree-sitter-language-pack那个包看起来很省事能一次装所有语言它运行时联网从 GitHub 下载 parser。一是违反零外联二是在国内经常下载失败。必须用预编译的 grammar wheel装完就是本地.pyd运行时不出网。坑 2Query.captures在 0.25 里被移除了网上绝大多数 tree-sitter 教程包括很多 CSDN 文章用的都是pythoncaptures query.captures(tree.root_node) # ★ 0.25 已经移除了这个 API0.25 只能自己递归遍历按node.type匹配。我在共享模块里封装了两个函数pythondef find_assignment(node): 穿透 parenthesized_expression 找裸赋值表达式 cur node while cur.type in (parenthesized_expression, expression_statement): # ★ is_named 排除 ( ) ; { } 这些匿名节点 ch next((c for c in cur.children if c.is_named), None) if ch is None: return None cur ch return cur if cur.type assignment_expression else None def walk_bugs(node, src_bytes, path, findings, lang, assign_in_condTrue): 递归遍历 逐节点规则判断tree-sitter 系的通用入口 if assign_in_cond: a find_assignment(node) # ← 上面那个穿透逻辑 if a: report(a) for ch in node.children: # ← 就是最朴素的递归 walk_bugs(ch, src_bytes, path, findings, lang, assign_in_cond)跟 Pythonast的思路一致不依赖版本特定的 query API。三、零误报这是我花时间最多的地方一条误报等于没有告报。用户扫出 10 条结果8 条是假的他就不信了。坑 3C 语言的if (x5)藏在括号里这个是最阴的一个。C 里赋值在括号内cif (x5) { } /* 赋值当条件错 */但语法树长这样if_statement └── parenthesized_expression ← condition 字段指向这层 └── assignment_expression ← 真正的判断目标在下一层★ 直接判condition.type assignment_expression会漏报。必须穿透括号pythonnode node_field(node, condition) while node.type parenthesized_expression: node first_named_child(node) # ★ 要用 is_named 跳过点号节点 if node.type assignment_expression: report(node)坑 4但穿透过头了会误报穿透之后又出新问题——(x y) ! null是 Java/Kotlin 的正常惯用法不是 bug。javaif ((error perform()) ! null) { ... } /* 正确写法 */→ 要排除内层是binary_expression的情况。坑 5div-by-zero只对叶子字面量报警pythonif x 0: return # ✓ 报 if x (0): return # ✓ 要报剥一层括号 if x 0.0: return # ✗ 不报浮点除零不是这个坑 if x n: return # ✗ 不报n 可能是 0但要跟踪数据流超出静态分析能力→ 判据必须是「叶子字面量 0」不是「表达式等于 0」。四、多语言架构怎么加一门语言只要 5 行分层的关键是把「遍历」和「判断」分开core/ finding.py Finding 统一结果结构 backend.py Backend 抽象 Registry按扩展名派发 langs/ python.py Python 后端用标准库 ast零依赖 c.py java.py javascript.py go.py ← 走 tree-sitter _ts_common.py ★ tree-sitter 系共享的遍历逻辑tree-sitter 系的共性规则div-by-zero / assign-in-cond全部抽到_ts_common.py所以新增一门语言只需要两件事装配 parser 决定扩展名映射。遍历逻辑零改动。python# 加一门语言实际要写的 class RustBackend(Backend): def __init__(self): try: import tree_sitter_rust self._p tree_sitter_rust.language() except ImportError: register(self, enabledFalse) # ★ 缺依赖就跳过不阻塞其他后端 def exts(self): return [.rs] def check(self, path): return self._ts_scan(path, rulesRUST_RULES)★ 依赖隔离是必须的Python 后端走标准库astfrom .. import scan_source零依赖永远可用tree-sitter 后端在类__init__内 import捕获异常后跳过。我实测过没装 tree-sitter 的环境它会打印[bugshield] 跳过 C 后端No module named tree_sitter [bugshield] 跳过 Java 后端No module named tree_sitter然后照常扫完 Python不报错也不中断。五、★ 打包成 exe 踩了 4 个坑这部分网上搜不到代码写完只是一半。要发给不会装 Python 的人用得打成 exe。PyInstaller 四个坑坑 6GUI 绝不能用--onefilebash# ★ 错进程干完活但不退出测试直接 timeout 124 pyinstaller --onefile --noconsole app.py # ★ 对 pyinstaller --onedir --noconsole app.py根因--onefile解压到临时目录时Tcl/Tk 初始化会死锁。最小复现两行代码import tkinterprint打--onefile必挂--onedir正常。连带坑命令行版只在函数内import tkinter懒加载但PyInstaller 静态分析照样会打进去→ 一样卡死。修法命令行版加--exclude-module tkinter。坑 7含 C 扩展的包必须--collect-alltree-sitter-c这类 grammar wheel 里含_binding.pyd--hidden-import抓不到二进制扩展bashpyinstaller --collect-all tree_sitter --collect-all tree_sitter_c \ --collect-all tree_sitter_java ...坑 8动态__import__的子模块PyInstaller 看不见最隐蔽我用__import__()动态加载各语言后端pythonmod __import__(fbugshield.langs.{name}, fromlist[*])★ PyInstaller 的静态分析完全看不到这种写法。即使加了--collect-all bugshield也只有静态可达的core/langs/python会被打进 PYZ动态的langs.c/langs.java/langs.go根本不在包里。运行时__import__失败被try/except吞掉 → exe 静默退化成只支持 Python表面显示 Build completetree-sitter 的 .pyd 也在但 5 语言只剩 1 种。这是最坑的一个—— 不报错不警告就是 functionality 悄悄没了。修法给每个动态后端加显式声明bash--hidden-import bugshield.langs.c --hidden-import bugshield.langs.java \ --hidden-import bugshield.langs.javascript --hidden-import bugshield.langs.go \ --hidden-import bugshield.langs._ts_common★ 怎么验证打进去了用pyi-archive_viewer看 PYZ 里有没有bugshield.langs.c这些条目。注意纯 Python 模块进 PYZ 不落盘别误判成没打包。坑 9Windows 上--noconsoleGUI 首窗会最小化双击 exe任务栏有图标但点开看不到窗口。根因是 Windows 的前台锁foreground lock不是业务逻辑错。修法mainloop()之前pythonroot.deiconify() root.lift() root.attributes(-topmost, True) root.update() root.attributes(-topmost, False) # ★ 置顶一次再取消逼 Windows 真把窗口绘出来 root.focus_force()六、怎么用bash# GUI 版双击学生用 学生程序检查器.exe # 命令行版headless写 CI 用 学生程序检查器.exe --check D:\我的项目 # 退出码0干净 1只有语法错 2有严重/注意级问题真实输出我刚跑的故意埋了 4 个错 学生程序检查器 · 检查完成多语言 看了 1 个文件 【Python】 [严重] bare-except 第 3 行 问题裸except 会吃掉 CtrlC 和 sys.exit 怎么改改成 except Exception:真要拦CtrlC 就连 KeyboardInterrupt 一起处理 [注意] open-without-with 第 2 行 [注意] except-pass 第 3 行 [注意] mutable-default 第 6 行★ 结果窗口里固定有一栏「它查不到什么」1. 逻辑算错 —— 比如 sum(数字)/总数分母写成了别的变量 2. 名字用错 —— 除了一两高频拼写错误 3. 跨函数的错 —— 一个函数返回的格式另一个没按它用 4. 效率问题 —— 能跑对但很慢的写法 ★ 所以这个工具帮你少犯低级错不帮你写对程序。我宁愿告诉你它查不到什么也不想让你以为它什么都能查。七、测试负样本必须零命中python# 这些是看起来像错、其实是对的写法全都必须零命中 with open(...) as f # ✓ 不报 if x is None: ... # ✓ 不报 if x is not None: ... # ✓ 不报最常见正确写法历史上误报过 def f(xNone): if x is None: x [] # ✓ 不报 try: ... except ValueError as e: ... # ✓ 不报★ 单元测试全过 ≠ 真实代码零 bug。我第一版测试全绿结果拿工具扫自己的源码立刻抓到真 bugast.walk返回 None 后被静默 pass。只有拿工具扫真实代码才暴露得出自己会犯的坑。八、开源https://gitee.com/waWAwlou/bugshield支持 Python / C·C / Java / JavaScript·TypeScript / GoMIT 协议。欢迎 Star / 提 Issue ——发现漏报或误报直接开 issue我都会看。如果你也踩过 tree-sitter 或 PyInstaller 的坑评论区聊聊我特别想知道有没有我没覆盖到的。写在最后这个工具让我明白一件事写工具最难的不是实现是知道自己查不到什么。那个✗ 不报n 可能是 0的取舍比任何一条规则都重要 ——静默漏报比误报更危险而诚实的边界声明能让你知道该去哪找。