merge: task/rrfix-rename-ref-dir-move 合入 v2-on-ui

任务 rrfix(1 个提交)经 task-worktree 回收:
- fix(scripts): rename_ref 搬整目录不再改脏目录内部内容,并补齐出链层级
This commit is contained in:
2026-10-30 09:34:53 +08:00
4 changed files with 244 additions and 17 deletions
+4 -1
View File
@@ -33,7 +33,7 @@
| 脚本 | 作用 |
|---|---|
| `rename_ids.py` | **编号(字面量)批量改名**:映射表 JSON 驱动,`files` 显式 scope(同一裸形字面量可能属多族,实测 `S3` 三义 → 全局替换必造新撞号);默认干跑,`--apply` 落盘并自动写 `id_aliases.json` 的 scoped 旧号→新号条目;`[示例]`/`[计划]` 行与变更记录表不改写;按原字节读写(保 BOM 与行尾)。用法与批次记录 → `scripts/archive/id_gate/编号治理历史.md` §8.3 |
| `rename_ref.py` | **改名/挪目录 + 全库引用自动改写**:默认 dry-run 预览,`--apply` 才落盘(`git mv` → 改写引用 → 自动跑 `check_doc_links --all` 复核)。覆盖反引号引用、Markdown 链接、图片 `../` 层级换算、`#include`、`uvprojx`/`CMakeLists.txt` 源列表。固定动作与边界 → `docs/文档规范.md` §3.6 |
| `rename_ref.py` | **改名/挪目录 + 全库引用自动改写**:默认 dry-run 预览,`--apply` 才落盘(`git mv` → 改写引用 → 自动跑 `check_doc_links --all` 复核)。覆盖反引号引用、Markdown 链接、图片 `../` 层级换算(**整目录搬迁时目录内部文件的出链按新目录补层**)、`#include`、`uvprojx`/`CMakeLists.txt` 源列表;搬**单文件**时指向旧目录的 glob 不自动改、但会点名列出。**改本脚本必须跑 `--selftest`**(一次性临时 git 仓,守住"搬目录把内部文件改脏"与"出链少补一层"两条实测踩过的坑)。固定动作与边界 → `docs/文档规范.md` §3.6 |
> `check_doc_links.py` 除链接存在性外,还校验 **`§N.M` 章节引用**是否指向目标文档真实存在的编号标题(口径见 `docs/文档规范.md` §3.5)。
@@ -77,6 +77,9 @@
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all
& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md # 预览
& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md --apply # 落盘
& '.venv\Scripts\python.exe' scripts/rename_ref.py --selftest # 改 rename_ref 本体必须跑(一次性临时 git 仓回归)
# 任务工作树里落盘请加 --no-verify:树内 simulator/SDL2 是目录联接,收尾的 check_doc_links --all
# 会把同一份仓库当第二副本扫出假红;全库 --all 复核留到主工作树跑
& '.venv\Scripts\python.exe' scripts/gen_task_table.py --check # 或 --write / --diff
& '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --check
& '.venv\Scripts\python.exe' scripts/check_test_suite.py # 门禁判定
+209 -2
View File
@@ -12,6 +12,11 @@
python scripts/rename_ref.py <旧路径> <新路径> --apply # 真正落盘(git mv + 改写引用)
python scripts/rename_ref.py <旧路径> <新目录/ # 挪进目录(保留文件名)
python scripts/rename_ref.py docs/a/ docs/b/ --apply # 整目录搬迁(前缀改写)
python scripts/rename_ref.py --selftest # 一次性临时 git 仓回归;**改本脚本必须跑**
--apply 落盘后会自动跑 `check_doc_links.py --all` 复核;**任务工作树**里有
`simulator/SDL2` 这类目录联接,`--all` 会把同一份仓库当第二副本一起扫出假红
→ 树内改用 `--no-verify`,全库 `--all` 留到主工作树跑。
路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。
@@ -35,8 +40,10 @@ import argparse
import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
# 控制台编码兜底(同 check_ids.py / check_doc_links.py / check_test_traceability.py):
@@ -150,8 +157,17 @@ def resolve_token(tok: str, file_dir: pathlib.Path, old_abs: pathlib.Path):
rc = pathlib.Path(os.path.normpath(base / b2))
except (OSError, ValueError):
continue
if rc == old_abs or str(rc).startswith(str(old_abs) + os.sep):
if rc == old_abs:
return rc, var_prefix, via_root
if str(rc).startswith(str(old_abs) + os.sep):
# 只有「目标真存在」才算引用(根相对写法保持原来的宽松口径)。
# 不加这道判断时,整目录搬迁会把目录**内部**文件里的斜杠片段
# 当成「旧目录下的子路径」并插上目录名:`1/1 PASSED` →
# `test_data/1/1 PASSED`、`13639/32768`、日期占位 `[----/--/-- …]`
# 全被改脏,而台架证据文件是不可再生的原始记录
# (2026-10-30 实测,23 个文件靠 `git show HEAD:<旧路径>` 逐字节还原)。
if via_root or rc.exists():
return rc, var_prefix, via_root
# ${VAR}/name.ext 之类:变量指向的目录未知,按「尾段后缀」匹配
parts = re.split(r"[/\\]", body)
@@ -210,6 +226,9 @@ def new_spelling(tok: str, resolved: pathlib.Path, var_prefix: str, via_root: bo
old_abs_global = [None]
new_abs_global = [None]
# 「旧路径是目录」必须在**搬迁之前**记下来(--apply 先 git mv 再改写,那时旧路径已不存在),
# 整目录搬迁与单文件搬迁的语义不同:目录内部互为相对的引用两侧一起搬,不该改写。
old_is_dir_global = [False]
def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=True):
@@ -223,7 +242,15 @@ def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=T
moved = (file_abs == old_abs
or str(file_abs).startswith(str(old_abs) + os.sep))
resolve_dir = file_path.parent
emit_dir = new_abs.parent if moved else file_path.parent
if not moved:
emit_dir = file_path.parent
elif file_abs == old_abs:
emit_dir = new_abs.parent
else:
# 整目录搬迁时本文件也跟着搬:它的**新目录**是 new_abs/<相对旧目录子路径>
# 的父目录。用 new_abs.parent 会少算一层,出链的 ../ 就补不出来
# (2026-10-30 实测:文档搬进子目录后 `../images/x.svg` 仍指旧层级)。
emit_dir = (new_abs / file_abs.relative_to(old_abs)).parent
out = []
pos = 0
@@ -244,6 +271,12 @@ def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=T
continue
if resolved is None:
continue
if (moved and old_is_dir_global[0] and not via_root and not var_prefix
and resolved != old_abs):
# 整目录搬迁:目录**内部**互为相对的引用(`./b.md`、`../a.md` 这类)
# 两侧一起搬,改写只会把 `./b.md` 抖成 `b.md`、把仍有效的 `../a.md`
# 换成错的一层——保持原样才是对的。根相对写法不受此条影响(照常改)。
continue
rep = new_spelling(tok, resolved, var_prefix, via_root, emit_dir)
if rep == tok:
continue
@@ -284,6 +317,35 @@ def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=T
return new_text
def glob_shaped_refs(text: str, candidate_prefixes):
"""挑出「目录/通配符」形态的引用(`docs/td/*.md`),只列出不改写。
为什么只提示:一条 glob 可能同时盖住「这次要搬的」和「不搬的」文件
(分层那次 `docs/测试说明/*_Test_Guide.md` 只有一部分文件进了 `板级测试/`),
自动改等于猜。这类形态此前**静默漏改**(token 匹配到 `*` 前就断了),
2026-10-30 实测有 4 处、全靠事后手改,所以这里把它变成看得见的东西。
"""
hits = []
for m in re.finditer(r"[^\s\"'`()<>|,;:,、]+[/\\][^\s\"'`()<>|,;:,、]*[*?]", text):
tok = m.group(0)
cut = tok.rfind("*")
if cut < 0:
cut = tok.rfind("?")
prefix = tok[:cut].replace("\\", "/").rstrip("/")
if not prefix:
continue
# 把通配符后面的尾巴(`docs/x/*.md` 里的 `.md`)也带上,提示才看得出是哪条 glob
end = m.end()
while end < len(text) and text[end] not in " \t\r\n\"'`()<>|,;:,、":
end += 1
shown = tok + text[m.end():end]
for cand in candidate_prefixes:
if cand and (prefix == cand or prefix.startswith(cand + "/")):
hits.append((text.count("\n", 0, m.start()) + 1, shown))
break
return hits
def main(argv=None):
ap = argparse.ArgumentParser(
description="改名/挪目录并自动改写全库引用(默认 dry-run)")
@@ -318,8 +380,18 @@ def main(argv=None):
old_abs_global[0] = old_abs
new_abs_global[0] = new_abs
old_is_dir_global[0] = old_abs.is_dir()
# 通配符形态引用的提示面(只提示、不改写):
# 搬**目录**:glob 前缀会被 token 改写整体一起搬走(`docs/td/*.md` →
# `docs/moved/td/*.md`),目录里的东西全搬 → 那是正确改写,不必提示;
# 搬**单个文件**:glob 前缀指向它原来所在的目录,脚本不动它 → 这条 glob
# 会**静默漏改**,而它可能同时盖住搬走和没搬走的两拨文件,自动改等于猜,
# 只能人工判断 → 列出来(2026-10-30 分层那次实测 4 处,全靠事后手改)。
old_parent_rel = old_rel.rsplit("/", 1)[0] if "/" in old_rel else ""
glob_cands = set() if old_is_dir_global[0] else {old_parent_rel}
plan = []
glob_hits = []
hit_generated = False
for f in tracked_files():
if is_generated(rel(f)):
@@ -329,6 +401,8 @@ def main(argv=None):
text, bom, ok = read_text(f)
if not ok:
continue
for lineno, tok in glob_shaped_refs(text, glob_cands):
glob_hits.append((rel(f), lineno, tok))
changes = []
new_text = rewrite_text(text, f, changes)
if changes:
@@ -345,6 +419,15 @@ def main(argv=None):
print(f" … 其余 {len(changes) - 12} 处略")
print()
if glob_hits:
print(f"⚠ 通配符形态引用 {len(glob_hits)} 处——本脚本**不改写**(一条 glob 可能同时"
f"盖住搬与不搬的文件),请人工确认这些 glob 该指哪一层:")
for r, lineno, tok in glob_hits[:20]:
print(f" {r}:{lineno} {tok}")
if len(glob_hits) > 20:
print(f" … 其余 {len(glob_hits) - 20} 处略")
print()
if not args.apply:
print("(dry-run:未做任何修改。确认无误后加 --apply)")
return 0
@@ -399,5 +482,129 @@ def main(argv=None):
return 0
def _st_make_repo(root: pathlib.Path, files: dict) -> None:
"""在 root 建一个一次性 git 仓并写入 files(仓库根相对 POSIX 路径 → 内容)。
`.gitignore` 里先排除 `/scripts/`:selftest 会把本脚本的副本放进临时仓,
不排除的话脚本自己也会进扫描面,断言就不再只针对夹具了。
"""
root.mkdir(parents=True, exist_ok=True)
(root / ".gitignore").write_text("/scripts/\n", encoding="utf-8")
for relp, body in files.items():
p = root / relp
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body, encoding="utf-8")
git = ["git", "-c", "core.autocrlf=false", "-c", "core.quotepath=off"]
for part in (["init", "-q"], ["config", "user.email", "selftest@example.invalid"],
["config", "user.name", "selftest"], ["add", "-A"], ["commit", "-qm", "init"]):
subprocess.run(git + part, cwd=root, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _st_run(root: pathlib.Path, *argv) -> tuple:
"""跑**本脚本的一份副本**(REPO_ROOT 取自 __file__,副本必须落在临时仓里)。"""
script = root / "scripts" / "rename_ref.py"
script.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(pathlib.Path(__file__), script)
r = subprocess.run([sys.executable, str(script)] + list(argv) + ["--no-verify"],
cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
return r.returncode, r.stdout.decode("utf-8", "replace")
def selftest() -> int:
"""在一次性临时 git 仓里复现并回归「整目录搬迁」的两个真 bug。
判据来自 2026-10-30 测试说明分层那次的实测事故(已写进 docs/文档规范.md §3.6
第 4/5 条,那里同时记着事故当时是怎么补救的):
B1 整目录搬迁会改写目录**内部**文件的内容——「相对引用文件所在目录」那条
解析规则不检验目标存在,于是 `1/1 PASSED`、`13639/32768`、日期占位
`[----/--/-- --:--:--]` 这类非路径斜杠片段被解析成「旧目录下的子路径」,
再被插上目录名。台架证据是不可再生的原始记录,改脏了就没了。
B2 目录内部文件的 `../` **出链**不补层级——emit_dir 取的是 new_abs.parent
而不是「本文件搬进新目录后的那一级」,少算一层,图片与相对链接仍指旧层级。
另外回归两件旧行为:单文件搬迁的 `../` 换算(B2 的改动不得把它带坏)、
以及 glob 形态引用在**搬文件**时只提示不改写。
"""
fails = []
def check(name, cond, extra=""):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(f"{name} :: {extra}")
junk = "比率 1/1 PASSED 与 0/0 与 13639/32768\n时间戳 [----/--/-- --:--:--]\n"
with tempfile.TemporaryDirectory(prefix="rr-selftest-") as td:
# ── 案例 A:整目录搬迁(B1 + B2 + 入链 + glob)────────────────────
root = pathlib.Path(td) / "a"
_st_make_repo(root, {
"docs/td/a.md": "# a\n" + junk
+ "根相对 `docs/td/data/e1.txt`\n"
+ "![img](../img/x.svg)\n"
+ "兄弟 [b](./b.md)\n",
"docs/td/b.md": "# b\n![i2](../img/x.svg)\n",
"docs/td/data/e1.txt": junk + "see ../a.md\n",
"docs/img/x.svg": "<svg/>\n",
"docs/other.md": "根相对 `docs/td/a.md` 与 [a](docs/td/a.md)\n"
"目录引用 `docs/td`\n通配 `docs/td/*.md`\n",
})
# 目标**不带**尾斜杠:带斜杠的含义是「挪进该目录并保留名字」,
# 那会得到 docs/moved/td/td(多套一层,断言的层级就不对了)
rc, out = _st_run(root, "docs/td", "docs/moved/td", "--apply")
check("A/rc==0", rc == 0, out[-500:])
moved = root / "docs/moved" / "td"
check("A/目录确实搬走了", moved.is_dir() and not (root / "docs/td").exists(), out[-300:])
ev = (moved / "data" / "e1.txt").read_text(encoding="utf-8")
check("A/B1 证据文件逐字节未变", ev == junk + "see ../a.md\n", repr(ev))
a = (moved / "a.md").read_text(encoding="utf-8")
check("A/B1 内部斜杠片段没被插前缀",
"1/1 PASSED" in a and "0/0" in a and "13639/32768" in a
and "[----/--/-- --:--:--]" in a and "td/1/1" not in a, repr(a))
check("A/内部根相对引用已跟到新位置", "`docs/moved/td/data/e1.txt`" in a, repr(a))
check("A/B2 出链 ../ 补了一层", "![img](../../img/x.svg)" in a, repr(a))
b = (moved / "b.md").read_text(encoding="utf-8")
check("A/B2 目录内每个文件都补", "![i2](../../img/x.svg)" in b, repr(b))
check("A/同目录相对链接原样保留(两侧一起搬)", "](./b.md)" in a and (moved / "b.md").is_file(), repr(a))
o = (root / "docs" / "other.md").read_text(encoding="utf-8")
check("A/别人文件的入链已改写",
"`docs/moved/td/a.md`" in o and "(docs/moved/td/a.md)" in o
and "`docs/moved/td`" in o, repr(o))
check("A/搬目录时 glob 前缀跟着搬走", "`docs/moved/td/*.md`" in o, repr(o))
check("A/搬目录时不该报 glob 提示", "通配符形态" not in out, out[-400:])
# ── 案例 B:单文件搬迁(旧行为不得被带坏 + glob 只提示)───────────
root2 = pathlib.Path(td) / "b"
_st_make_repo(root2, {
"docs/x/solo.md": "# solo\n![i](../img/x.svg)\n比率 3/4 PASSED\n",
"docs/x/sib.md": "# sib 见 docs/x/solo.md\n",
"docs/img/x.svg": "<svg/>\n",
"docs/other.md": "见 `docs/x/solo.md` 与 glob `docs/x/*.md`\n",
})
rc2, out2 = _st_run(root2, "docs/x/solo.md", "docs/x/sub/", "--apply")
check("B/rc==0", rc2 == 0, out2[-500:])
solo_p = root2 / "docs" / "x" / "sub" / "solo.md"
check("B/文件已搬进子目录", solo_p.is_file() and not (root2 / "docs/x/solo.md").exists(), out2[-300:])
solo = solo_p.read_text(encoding="utf-8") if solo_p.is_file() else ""
check("B/单文件搬迁的 ../ 换算照旧", "![i](../../img/x.svg)" in solo, repr(solo))
check("B/单文件内部片段不被插前缀", "比率 3/4 PASSED" in solo, repr(solo))
o2 = (root2 / "docs" / "other.md").read_text(encoding="utf-8")
sib = (root2 / "docs" / "x" / "sib.md").read_text(encoding="utf-8")
check("B/别人的入链已改写",
"`docs/x/sub/solo.md`" in o2 and "docs/x/sub/solo.md" in sib, repr(o2) + repr(sib))
check("B/搬文件时 glob 保持原样(不猜)", "`docs/x/*.md`" in o2, repr(o2))
check("B/搬文件时 glob 被点名提示",
"通配符形态" in out2 and "docs/x/*.md" in out2, out2[-500:])
print()
if fails:
print(f"SELFTEST FAIL:{len(fails)} 项")
for f in fails:
print(" - " + f)
return 1
print("SELFTEST PASS")
return 0
if __name__ == "__main__":
if "--selftest" in sys.argv[1:]:
sys.exit(selftest())
sys.exit(main())