注释与提示信息全面中文化:源码 66 个 .py + 脚本/配置 45 个文件
背景:现场读代码的人只读中文。此前代码里的 docstring 是英文、行内注释是中文,
一半英文一半中文反而更难看懂 —— 新手打开一个 .py 不知道这个模块为什么存在。
范围(本仓库自己的代码,厂商上游不动)
src/arm_application/** 37 个文件:servo_node / perception_node / handeye_* / dh_pgea /
yolo_detector / kalman_filter / goto_ee_plane / dual_gripper …
src/arm_interfaces/** 11 个文件:paths / manifest / loader / config_guard / errors …
src/arm_control/** 5 个文件
src/arm_bringup/** 3 个 launch
scripts/** 6 个 .py + 29 个 .sh(scripts/ 是"唯一操作入口",用户屏幕上
看到的就是这些提示,所以提示信息也一并中文化)
config/ handeye/ devices/ src/arm_*/config/ 17 个 .yaml/.xml/.xacro/.cfg
口径(写进 docs/FRAMEWORK_RULES.md 新增的 R6.5,作为长期约束)
注释、docstring、print/日志/异常里给人看的句子 = 中文;
标识符、API 名、话题名、参数名、字段名、命令名 = 保留英文原文(用 `` `` 包起来);
单位、十六进制、协议报文、CRC 值、命令示例 = 原样保留;
src/flexiv_*/ = 厂商上游代码,不许改也不许翻译(R4.4)。
翻译保原意与原有详细程度:那些"为什么不能用另一种做法""踩过什么坑"的说明
全部完整保留 —— 它们才是这份代码里最贵的部分。
新增 scripts/check_text_only.py:只改文字时的自证工具
人工 review 靠不住(115 个文件、3000 多行 diff)。这个脚本把改动前后的源码解析成
语法树,删掉 docstring、抹平字符串常量、把 f-string 只留"插值了几次 + 插值是什么",
再比对:完全相同 = 改动范围内没有逻辑变化。
留了一个口子:f-string 的插值按内容排序后再比 —— 因为中文语序常常要和英文反着来
(f"on {port} read {idx} failed" → f"在 {port} 上读 {idx} 失败"),只换显示顺序不该判失败;
但多插一个、少插一个、换掉被插入的表达式,立刻会报出来。
本次 66 个 .py 全部通过该闸门。
验收
改动的 66 个 .py:scripts/check_text_only.py 全部通过(零逻辑变化)
45 个非 .py 文件:去注释后与 HEAD 逐字节相同(零配置变化)
docstring 总数:没有一个文件减少(不存在"翻译时顺手删了说明")
单测:arm_application 233 / arm_driver_flexiv 69 / arm_interfaces 46 /
arm_kinematics 24 / arm_sim_gazebo 236 = 608 passed,与改动前一致
全部 .sh:bash -n 通过;并额外实际执行了 --help 验证(见下一个提交:
bash -n 对 CRLF 脚本会漏报,只有真跑才暴露)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user