#!/usr/bin/env python3 # -*- coding: utf-8 -*- """编号批量改名器(docs/编号规范.md §7.3 规划的 scripts/rename_ids.py)。 为什么必须有这个工具,而不是查找替换: 1. 编号是**跨文件**引用的——一次改名同时涉及属主登记处、引用方文档、代码注释、 门禁脚本文案;手改必漏(§3.6 给"改名"立规矩同此理由)。 2. 改完必须自动登记 `scripts/id_aliases.json`(旧号 → 新号),否则历史提交正文与 未改写的文档里的旧号会集体变成"悬空引用"(§8 校验 3)——别名表是改名的**前置条件**, 不是可选项。 3. 同一个裸形字面量在不同文件里可能是**不同族**(实测 `S3` 既是代码规范条款、又是 UI 像素回归状态、又是 2026-10-02 复审专项条目)→ **改名与别名都必须限定作用域**, 全局替换会制造新的撞号(正是 §1.2 记录的 N-31/N-32 类事故)。 用法: & '.venv\\Scripts\\python.exe' scripts/rename_ids.py --map .dsh/rename_batch1.json # 干跑(默认) & '.venv\\Scripts\\python.exe' scripts/rename_ids.py --map .dsh/rename_batch1.json --apply # 落盘 + 写别名表 可选 --only-family 族名 --no-alias --list 每处改动的 文件:行 旧→新 映射表 JSON: {"batches":[{"family":"CODE-RULE", "old":"\\\\bS(?P1?[0-9])\\\\b", # 必须含命名组,供 new 模板取值 "new":"CODE-RULE-{n:02d}", # str.format 模板,数字组自动转 int "files":["scripts/xxx.py", "docs/**/*.md"], # 显式路径或 glob(作用域即边界) "note":"为什么改、批次"}]} 不动的东西(刻意): - 含 `[示例]` / `[计划]` 的行(与 check_doc_links.py / check_ids.py 共用同一套豁免约定) - 变更记录表行(`| 2026-10-13 | …`)——历史快照按 §8.1 不改写,旧号靠别名表解析 - 第三方与生成物目录(Lib/、Middlewares/、docs/芯片手册/、docs/硬件平台/ 等) 编码:按原字节读写(保留 BOM 与行尾),只做 ASCII 子串替换。 退出码:0 = 成功(干跑或已落盘);1 = 映射表/正则有问题;2 = 环境错误。 """ import argparse import fnmatch import json import pathlib import re import sys REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent ALIASES_FILE = REPO_ROOT / "scripts" / "id_aliases.json" # 与 scripts/id_registry.json 的 excluded_paths 同口径(改名同样不该碰这些语料) EXCLUDED = [ "Lib/", "Middlewares/", "Drivers/STM32F4xx_HAL_Driver/", "Drivers/CMSIS/", "MDK-ARM/RTE/", "docs/芯片手册/", "docs/内存管理/data/", "simulator/tests/ref/", "node_modules/", "build/", "ci-artifacts/", ] SKIP_LINE = re.compile(r"\[示例\]|\[计划\]|^\s*\|\s*20\d\d-\d\d-\d\d\s*\|") def norm(path): """统一成仓库相对、正斜杠的路径,便于 glob 与别名 scope 一致。""" return str(path).replace("\\", "/") def tracked_text_files(): import subprocess out = subprocess.run(["git", "ls-files", "-z"], cwd=str(REPO_ROOT), capture_output=True) files = [norm(x) for x in out.stdout.decode("utf-8").split("\0") if x] keep = [] for rel in files: if any(rel.startswith(p) for p in EXCLUDED): continue if rel.endswith((".png", ".jpg", ".jpeg", ".gif", ".bin", ".ico", ".pdf", ".zstd", ".map", ".axf", ".htm")): continue keep.append(rel) return keep def match_files(patterns, candidates): hit = [] for pat in patterns: p = norm(pat) exact = p.lstrip("./") if exact in candidates: if exact not in hit: hit.append(exact) continue for rel in candidates: if fnmatch.fnmatch(rel, p) and rel not in hit: hit.append(rel) return hit def render(template, groups): """按模板渲染新号。**只有模板带格式说明符时**才把数字组转 int—— 否则 `TC-PROTSET-011` 会被渲染成 `TC-PROTSET-11`(丢前导零,实测差点毁掉整批补零形态)。""" vals = {} for k, v in groups.items(): if v is None: vals[k] = "" elif v.isdigit() and re.search(r"\{%s:[^}]+\}" % re.escape(k), template): vals[k] = int(v) else: vals[k] = v return template.format(**vals) def apply_batch(batch, candidates, args): """返回 (改动列表, 别名映射, 错误列表)。改动列表元素 = (rel, lineno, old, new)。""" errs, edits, alias_map = [], [], {} try: rx = re.compile(batch["old"]) except re.error as exc: errs.append("族 %s 的正则无法编译:%s" % (batch.get("family"), exc)) return edits, alias_map, errs if "n" not in rx.groupindex and "(?P<" not in batch["old"]: errs.append("族 %s 的 old 正则缺命名组,new 模板无法取值" % batch.get("family")) return edits, alias_map, errs files = match_files(batch.get("files", []), candidates) if not files: errs.append("族 %s:files 作用域没匹配到任何在库文件(检查 glob)" % batch.get("family")) for rel in files: p = REPO_ROOT / rel raw = p.read_bytes() bom = raw.startswith(b"\xef\xbb\xbf") try: text = raw.decode("utf-8-sig") except UnicodeDecodeError: errs.append("%s 不是 UTF-8,拒绝改名" % rel) continue nl = "\r\n" if "\r\n" in text else "\n" lines = text.split(nl) changed = 0 for i, line in enumerate(lines, 1): if SKIP_LINE.search(line): continue def sub(m): new = render(batch["new"], m.groupdict()) edits.append((rel, i, m.group(0), new)) alias_map.setdefault(m.group(0), set()).add(new) return new newline_ = rx.sub(sub, line) if newline_ != line: lines[i - 1] = newline_ changed += 1 if changed and args.apply: body = nl.join(lines) data = body.encode("utf-8") p.write_bytes((b"\xef\xbb\xbf" + data) if bom else data) return edits, alias_map, errs def merge_aliases(new_aliases, batch): """把本批 `旧号 → 新号` 并进 id_aliases.json,带 scope(同字面量多义的关键)。""" doc = json.loads(ALIASES_FILE.read_text(encoding="utf-8")) table = doc.setdefault("aliases", {}) # 已**完全限定**的字面量(如 `TC-PROTSET-011`)可以全局认别名;裸形(`S3`)必须带 scope scope = [] if batch.get("global_alias") else [norm(x) for x in batch.get("files", [])] added = 0 for old, news in sorted(new_aliases.items()): scope_txt = ("旧号仅在本批 scope 内指向新号;scope 外同字面量属其它族,不受本条影响" if scope else "全库生效(本批字面量已完全限定,无歧义)") entry = { "to": sorted(news)[0] if len(news) == 1 else sorted(news), "status": "renamed", "note": "%s(scripts/rename_ids.py 批:族 %s)|%s" % (batch.get("note", "批量改名"), batch.get("family"), scope_txt), } if scope: entry["scope"] = scope cur = table.get(old) if cur is None: table[old] = entry added += 1 else: entries = cur if isinstance(cur, list) else [cur] if not any(e.get("scope") == scope and e.get("to") == entry["to"] for e in entries): entries.append(entry) table[old] = entries[0] if len(entries) == 1 else entries added += 1 if added: doc["note"] = (doc.get("note", "") + " 带 scope 的条目只在所列文件内生效(同一裸形字面量可能属多族," "全局别名会把真撞号洗成已解析)。").strip() ALIASES_FILE.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") return added def main(): ap = argparse.ArgumentParser() ap.add_argument("--map", dest="mapfile", required=True, help="映射表 JSON") ap.add_argument("--apply", action="store_true", help="真正落盘(默认只干跑)") ap.add_argument("--list", action="store_true", help="逐处打印 文件:行 旧→新") ap.add_argument("--only-family", help="只跑指定族") ap.add_argument("--no-alias", action="store_true", help="不写别名表(调试用)") args = ap.parse_args() try: plan = json.loads(pathlib.Path(args.mapfile).read_text(encoding="utf-8")) except (OSError, ValueError) as exc: print("[ERR] 映射表读不了:%s" % exc, file=sys.stderr) return 2 batches = plan.get("batches", []) if args.only_family: batches = [b for b in batches if b.get("family") == args.only_family] candidates = tracked_text_files() print("映射表 %d 批;候选文本文件 %d 个;模式:%s" % (len(batches), len(candidates), "落盘" if args.apply else "干跑(不加 --apply 不写盘)")) all_errs = [] grand = {} for b in batches: edits, alias_map, errs = apply_batch(b, candidates, args) all_errs += errs files = {} for rel, _ln, old, new in edits: files.setdefault(rel, []).append((old, new)) pairs = sorted({(o, n) for _r, _l, o, n in edits}) print("\n=== 族 %s:%d 处 / %d 个文件;%d 个不同旧号" % (b.get("family"), len(edits), len(files), len(pairs))) for rel, xs in sorted(files.items(), key=lambda kv: -len(kv[1])): print(" %-56s %2d 处 例 %s → %s" % (rel, len(xs), xs[0][0], xs[0][1])) if args.list: for rel, ln, old, new in edits: print(" %s:%d %s → %s" % (rel, ln, old, new)) if not args.no_alias and self_apply_ok(args, b, alias_map): n = merge_aliases(alias_map, b) print(" 别名表:新增/更新 %d 条(scope 限定)" % n) grand[b["family"]] = len(edits) if all_errs: print("\n[ERR] 问题 %d 条:" % len(all_errs), file=sys.stderr) for e in all_errs: print(" - " + e, file=sys.stderr) return 1 print("\n合计:%s;%s" % (", ".join("%s=%d" % kv for kv in grand.items()), "已落盘,请接着登记注册表族并重跑 scripts/check_ids.py" if args.apply else "干跑结束,确认后加 --apply")) print("提醒:改名后必跑 check_ids.py + check_doc_links.py --all + ci_local.py;" "改到 test/board 或 Drivers/ 还要 @build。") return 0 def self_apply_ok(args, batch, alias_map): """干跑时不写别名表——否则干跑也会污染别名表。""" if not args.apply: return False return bool(alias_map) if __name__ == "__main__": for s in (sys.stdout, sys.stderr): if hasattr(s, "reconfigure"): s.reconfigure(encoding="utf-8", errors="replace") sys.exit(main())