Files
ros_contorl/scripts/check_text_only.py
arm_dev 72353f3773 注释与提示信息全面中文化:源码 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>
2026-09-23 17:40:55 +08:00

171 lines
6.9 KiB
Python

#!/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())