#!/usr/bin/env python3 """纯文本改动的安全闸门:证明一次改动**只动了文字**,没碰逻辑。 ============================================================================= 为什么需要这个工具 ============================================================================= "把英文注释翻成中文""补一批说明文档"这类改动,看起来零风险,其实经常出事: 改注释的人手滑动了变量名、顺手调了语句顺序、翻译 f-string 时漏掉一个 ``{}`` 插值。 这些都不会报错,只在某天变成一句"结果怎么不对了"。 人工 review 靠不住(几十个文件、几千行 diff,没人真的逐行看)。 所以这里用 AST 做机器判据:**把两份源码解析成语法树,抹掉所有文字,再比对。** 抹掉规则: 1. 删掉所有 docstring 2. 所有字符串常量换成同一个占位符 3. f-string 只保留"插值了几次、插值的是什么表达式", 字面量部分整段丢掉、插值按内容排序 第 3 条是为中文语序专门留的口子。英文 ``f"on {port} read {idx} failed"`` 翻成 ``f"在 {port} 上读 {idx} 失败"`` 只是把两个插值的**显示顺序**换了, 语义没变,不该判失败。但只要有人**多插一个、少插一个、或换掉被插入的表达式**, 这里立刻报出来。 ============================================================================= 用法 ============================================================================= python3 scripts/check_text_only.py # 检查所有改过的 .py python3 scripts/check_text_only.py <文件> [...] # 只检查指定文件 python3 scripts/check_text_only.py --staged # 只检查已 git add 的 比较基准固定为 ``git HEAD``(也就是"上一次提交"),所以要用在**提交之前**。 退出码 0 = 改动范围内没有逻辑变化;非 0 = 有文件动了不该动的东西(会打印差异)。 ============================================================================= """ from __future__ import annotations import argparse import ast import difflib import subprocess import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] # 被抹平后的字符串统一长这样。用控制字符是为了保证它不会和真实内容撞车。 PLACEHOLDER = "\0STR\0" class _Normalize(ast.NodeTransformer): """把语法树里的"文字"全部抹掉,只留下结构与标识符。""" def _strip_docstring(self, node): """删掉模块 / 类 / 函数的第一个字符串表达式(docstring)。""" self.generic_visit(node) body = getattr(node, "body", None) if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \ and isinstance(body[0].value.value, str): node.body = body[1:] return node visit_Module = _strip_docstring visit_FunctionDef = _strip_docstring visit_AsyncFunctionDef = _strip_docstring visit_ClassDef = _strip_docstring def visit_Constant(self, node: ast.Constant): if isinstance(node.value, str): return ast.copy_location(ast.Constant(value=PLACEHOLDER), node) return node def visit_JoinedStr(self, node: ast.JoinedStr): """f-string:只留插值,不留字面量;插值按内容排序(见模块 docstring)。""" self.generic_visit(node) parts = [ast.dump(v) for v in node.values if isinstance(v, ast.FormattedValue)] return ast.copy_location( ast.Constant(value="FSTR[" + "|".join(sorted(parts)) + "]"), node) def normalize(source: str) -> str: """源码 -> 抹掉文字后的规范化结构字符串。""" return ast.dump(_Normalize().visit(ast.parse(source))) def _git(*args: str) -> subprocess.CompletedProcess: return subprocess.run(["git", *args], capture_output=True, text=True, cwd=REPO_ROOT) def head_source(path: str) -> str | None: """取该文件在 HEAD 里的内容;返回 None 表示 HEAD 里没有它(新文件)。""" r = _git("show", f"HEAD:{path}") return r.stdout if r.returncode == 0 else None def changed_python_files() -> list[str]: r = _git("diff", "--name-only", "HEAD") return [p for p in r.stdout.split() if p.endswith(".py")] def staged_python_files() -> list[str]: r = _git("diff", "--cached", "--name-only") return [p for p in r.stdout.split() if p.endswith(".py")] def first_diff(old: str, new: str) -> str: """给出一段人能看懂的差异上下文。""" matcher = difflib.SequenceMatcher(None, old, new, autojunk=False) for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag != "equal": return (f" 结构差异类型: {tag}\n" f" 改前: ...{old[max(0, i1 - 70):i2 + 70]}...\n" f" 改后: ...{new[max(0, j1 - 70):j2 + 70]}...") return " (未找到差异片段——理论上不该走到这里)" def main() -> int: ap = argparse.ArgumentParser( description="证明一次改动只动了注释/字符串,没有改逻辑", ) ap.add_argument("paths", nargs="*", help="要检查的文件;留空 = 所有改过的 .py") ap.add_argument("--staged", action="store_true", help="只检查已 git add 的文件") args = ap.parse_args() paths = args.paths or (staged_python_files() if args.staged else changed_python_files()) if not paths: print("没有需要检查的 .py 文件(工作区是干净的?)。") return 0 ok, problems, skipped = 0, [], [] for path in paths: disk = REPO_ROOT / path if not disk.is_file(): skipped.append((path, "工作区里不存在(被删除了?)")) continue old = head_source(path) if old is None: skipped.append((path, "HEAD 里没有它(新增文件),没有基准可比")) continue try: same = normalize(old) == normalize(disk.read_text(encoding="utf-8")) except SyntaxError as exc: problems.append((path, f" 语法错误,先修好再看:{exc}")) continue if same: ok += 1 else: problems.append((path, first_diff(normalize(old), normalize(disk.read_text(encoding="utf-8"))))) print(f"通过(只改了文字): {ok}") for path, why in skipped: print(f"跳过 {path} —— {why}") if problems: print(f"\n!! 有 {len(problems)} 个文件动了文字之外的东西:") for path, why in problems: print(f" {path}") print(why) print("\n注意:如果差异只出现在 f-string 的插值顺序上,那是允许的(见脚本说明)。") return 1 print("\n闸门通过:改动范围内没有逻辑变化。") return 0 if __name__ == "__main__": sys.exit(main())