#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 改名 / 挪目录 + 全库引用自动改写(把"同步引用链接"从手工活变成一条命令)。 背景:本仓库 1800+ 处路径引用(`docs/xxx.md` 反引号引用 + Markdown 链接 + `#include` + `uvprojx`/`CMakeLists.txt` 源文件列表),改名或挪目录后要靠人 逐文件扫改(历史上一次改名波及 10~30 个文件)。本脚本把这件事机械化。 用法: python scripts/rename_ref.py <旧路径> <新路径> # 默认 dry-run,只打印将要改什么 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` 留到主工作树跑。 路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。 改写覆盖的引用形态: 1. 反引号根相对引用 `docs/xxx/yyy.md` 2. Markdown 链接 [文本](docs/xxx/yyy.md)(根相对) / [文本](../xxx/yyy.md)(文档相对) 3. 图片链接 ![x](images/a.svg) —— 含"文档挪层级后 ../ 前缀要跟着变"这条实证坑 4. C 的 #include #include "yyy.h"(改名时连裸文件名引用一起改) 5. 构建文件 MDK-ARM/*.uvprojx 的 、simulator/CMakeLists.txt 的 ${PROJECT_ROOT}/… 与 ${VAR}/… 源文件列表 6. 任意文本文件里的路径串(.py/.bat/.yaml/.json/.txt/skill 等) 保真约定(见 docs/文档规范.md §2、scripts/README.md「脚本编写约定」): * 按原样回写 BOM 与换行符(LF/CRLF),无改动的文件不写 * 只扫 git 跟踪的文本文件;第三方/生成物目录跳过 * 默认 dry-run;--apply 才落盘,且落盘后自动跑 check_doc_links.py --all 复核 退出码:0 成功;1 有错误(旧路径不存在 / 新路径已存在 / 复核失败);2 用法错误。 """ 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): # 裸跑环境的代码页常是 936(GBK),而本脚本收尾提示里有 `ℹ` → print 抛 UnicodeEncodeError。 # 偏偏那时 **文件已经 git mv + 引用改写落盘**,于是脚本带 traceback 退出 1: # 退出码与真实结果相反(2026-10-30 一次挪 28 个路径实测,28 次全是「做完了却报 1」)。 for _stream in (sys.stdout, sys.stderr): if hasattr(_stream, "reconfigure"): _stream.reconfigure(encoding="utf-8", errors="replace") REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent # 不扫描的路径片段(第三方 / 生成物 / 本地状态) SKIP_PARTS = ("Lib/", "Middlewares/", "Drivers/STM32F4xx_HAL_Driver/", "Drivers/CMSIS/", "MDK-ARM/objects/", "MDK-ARM/Listings/", "MDK-ARM/RTE/", "MDK-ARM/STM32F407-Demo/", ".venv/", "node_modules/", "__pycache__/", "build/", "obj/", ".workbuddy-ai/", ".mnemon/", "simulator/SDL2/", "docs/芯片手册/") # 生成物:不改写,只在结尾提示重跑生成器(改了也是白改,下次生成会覆盖) GENERATED = ("docs/images/引用关系图.",) # 不扫描的扩展名(二进制 / 生成物) SKIP_EXT = {".o", ".d", ".map", ".axf", ".bin", ".hex", ".elf", ".htm", ".lst", ".png", ".jpg", ".jpeg", ".gif", ".ttf", ".woff", ".dll", ".lib", ".pyc", ".zip", ".gz", ".pdf", ".docx", ".xlsx", ".ico", ".cur"} # 路径 token:至少含一个分隔符;允许 ${VAR} 前缀、中文、点、下划线、短横 CH = r"[\w$.\-{}一-鿿]" TOKEN_RE = re.compile(CH + r"+(?:[/\\]" + CH + r"+)+") VAR_PREFIX_RE = re.compile(r"^(\$\{[^}]+\}/)(.+)$") INCLUDE_RE = re.compile(r'(#\s*include\s*)([<"])([^<>"\n]+)([">])') FILEPATH_RE = re.compile(r"()([^<\n]+)()") BOM = b"\xef\xbb\xbf" def rel(p: pathlib.Path) -> str: """仓库根相对 POSIX 路径。""" try: return p.resolve().relative_to(REPO_ROOT).as_posix() except ValueError: return p.as_posix() def is_skipped(relpath: str) -> bool: for s in SKIP_PARTS: if relpath == s.rstrip("/") or relpath.startswith(s): return True return False def is_generated(relpath: str) -> bool: """生成物(引用关系图等):跳过改写,只提示重跑生成器。""" return any(relpath.startswith(g) for g in GENERATED) def tracked_files(): """git 跟踪 + 未跟踪但未忽略的文件(自动排除生成物与 .gitignore 内容)。""" out = subprocess.run(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd=REPO_ROOT, stdout=subprocess.PIPE, check=True).stdout for name in out.decode("utf-8").split("\0"): if not name: continue name = name.replace("\\", "/") if is_skipped(name): continue if pathlib.Path(name).suffix.lower() in SKIP_EXT: continue p = REPO_ROOT / name if p.is_file(): yield p def read_text(p: pathlib.Path): """返回 (text, bom, ok)。二进制/非 UTF-8 → ok=False。""" data = p.read_bytes() bom = data.startswith(BOM) if bom: data = data[len(BOM):] try: return data.decode("utf-8"), bom, True except UnicodeDecodeError: return None, bom, False def resolve_token(tok: str, file_dir: pathlib.Path, old_abs: pathlib.Path): """把 token 解析成绝对路径;解析不到 old 就返回 (None, "", False)。 返回 (解析到的绝对路径, ${VAR}/ 前缀, 是否按仓库根解析)。 依次尝试:${VAR}/ 前缀剥离 → 相对仓库根 → 相对引用文件目录 → 尾段后缀匹配。 """ var_prefix = "" body = tok m = VAR_PREFIX_RE.match(tok) if m: var_prefix, body = m.group(1), m.group(2) unquoted = urllib.parse.unquote(body) roots = [] if not unquoted.startswith("."): roots.append((REPO_ROOT, True)) roots.append((file_dir, False)) if unquoted.startswith("."): roots.insert(0, (REPO_ROOT, True)) for base, via_root in roots: for b in (unquoted, body): b2 = b.rstrip(".") try: rc = pathlib.Path(os.path.normpath(base / b2)) except (OSError, ValueError): continue 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) old_parts = list(old_abs.parts) if var_prefix and len(parts) <= 2: if parts[-1] == old_parts[-1] and (len(parts) == 1 or parts[-2] == old_parts[-2]): return old_abs, var_prefix, True return None, "", False def resolve_relative(tok: str, file_dir: pathlib.Path): """按「相对引用文件所在目录」解析,且目标必须真实存在(用于被移动文件的出链)。""" if VAR_PREFIX_RE.match(tok): return None for b in (urllib.parse.unquote(tok), tok): b2 = b.rstrip(".") p = pathlib.Path(os.path.normpath(file_dir / b2)) if p.exists(): return p return None def rel_spelling(tok: str, target: pathlib.Path, base_dir: pathlib.Path) -> str: """保持原分隔符风格,重算相对 base_dir 的写法。""" body = os.path.relpath(target, base_dir).replace("\\", "/") if "\\" in tok: body = body.replace("/", "\\") if "%" in tok: body = urllib.parse.quote(body, safe="/") return body def new_spelling(tok: str, resolved: pathlib.Path, var_prefix: str, via_root: bool, base_dir: pathlib.Path) -> str: """按原写法风格算出新 token(根相对保持根相对,相对保持相对)。""" old_abs = old_abs_global[0] new_abs = new_abs_global[0] if resolved == old_abs: remainder = pathlib.Path(".") else: remainder = resolved.relative_to(old_abs) target = new_abs if remainder == pathlib.Path(".") else new_abs / remainder if var_prefix or via_root: new_body = os.path.relpath(target, REPO_ROOT) else: new_body = os.path.relpath(target, base_dir) if "\\" in tok: new_body = new_body.replace("/", "\\") else: new_body = new_body.replace("\\", "/") if "%" in tok: new_body = urllib.parse.quote(new_body, safe="/") return var_prefix + new_body 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): """改写一个文件里的所有引用;changes 收集 (lineno, old, new)。""" old_abs = old_abs_global[0] new_abs = new_abs_global[0] file_abs = file_path.resolve() # 被移动文件(含整目录搬迁时目录内的文件)自身的相对链接(图片等): # **解析**用旧目录(链接当前指向的位置),**改写**用新目录 # ——docs/文档规范.md §3.2 记的实证坑(迁目录后 images/ 未跟着改 ../) moved = (file_abs == old_abs or str(file_abs).startswith(str(old_abs) + os.sep)) resolve_dir = 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 for m in TOKEN_RE.finditer(text): tok = m.group(0) resolved, var_prefix, via_root = resolve_token(tok, resolve_dir, old_abs) if resolved is None and moved: # 被移动文件自身的**出链**(图片等):目标没变但基准目录变了 → 重算写法 own = resolve_relative(tok, resolve_dir) if own is not None: rep = rel_spelling(tok, own, emit_dir) if rep != tok: lineno = text.count("\n", 0, m.start()) + 1 changes.append((lineno, tok, rep)) out.append(text[pos:m.start()]) out.append(rep) pos = m.end() 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 lineno = text.count("\n", 0, m.start()) + 1 changes.append((lineno, tok, rep)) out.append(text[pos:m.start()]) out.append(rep) pos = m.end() out.append(text[pos:]) new_text = "".join(out) # 改名时:裸文件名引用(#include "x.h"、x.s) old_name = old_abs.name new_name = new_abs.name if old_name != new_name and old_abs != new_abs: def fix_include(m): inc = m.group(3) if pathlib.PurePosixPath(inc.replace("\\", "/")).name == old_name: base = inc[: len(inc) - len(old_name)] new_inc = base + new_name changes.append((text.count("\n", 0, m.start()) + 1, inc, new_inc)) return m.group(1) + m.group(2) + new_inc + m.group(4) return m.group(0) def fix_filepath(m): fp = m.group(2) if pathlib.PurePosixPath(fp.replace("\\", "/")).name == old_name: base = fp[: len(fp) - len(old_name)] new_fp = base + new_name changes.append((text.count("\n", 0, m.start()) + 1, fp, new_fp)) return m.group(1) + new_fp + m.group(3) return m.group(0) new_text = INCLUDE_RE.sub(fix_include, new_text) if file_path.suffix.lower() in (".uvprojx", ".uvoptx"): new_text = FILEPATH_RE.sub(fix_filepath, new_text) 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)") ap.add_argument("old", help="旧路径(仓库根相对)") ap.add_argument("new", help="新路径(仓库根相对;以 / 结尾表示挪进该目录)") ap.add_argument("--apply", action="store_true", help="真正落盘(默认只预览)") ap.add_argument("--no-git", action="store_true", help="用 os.rename 代替 git mv") ap.add_argument("--no-verify", action="store_true", help="跳过 check_doc_links 复核") args = ap.parse_args(argv) old_rel = args.old.replace("\\", "/").rstrip("/") new_rel = args.new.replace("\\", "/") if new_rel.endswith("/"): new_rel = new_rel + old_rel.rsplit("/", 1)[-1] new_rel = new_rel.rstrip("/") if not new_rel or old_rel == new_rel: print("错误:新旧路径相同或新路径为空") return 2 old_abs = (REPO_ROOT / old_rel).resolve() new_abs = (REPO_ROOT / new_rel).resolve() if not old_abs.exists(): print(f"错误:旧路径不存在:{old_rel}") return 1 if new_abs.exists(): print(f"错误:新路径已存在:{new_rel}") return 1 if is_skipped(old_rel): print(f"错误:{old_rel} 属于第三方/生成物范围,不应由本脚本改写") return 1 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)): if resolve_token(old_rel, f.parent, old_abs)[0] is not None: hit_generated = True continue 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: plan.append((f, text, new_text, bom, changes)) n_refs = sum(len(c) for _, _, _, _, c in plan) print(f"计划:{old_rel} → {new_rel}") print(f"受影响文件 {len(plan)} 个,引用改写 {n_refs} 处\n") for f, _, _, _, changes in plan: print(f"── {rel(f)} ({len(changes)} 处)") for lineno, o, n in changes[:12]: print(f" :{lineno} {o} → {n}") if len(changes) > 12: 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 # 1) 先移动文件本身 new_abs.parent.mkdir(parents=True, exist_ok=True) if args.no_git: os.rename(old_abs, new_abs) else: r = subprocess.run(["git", "mv", "--", old_rel, new_rel], cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if r.returncode != 0: os.rename(old_abs, new_abs) print(f"已移动:{old_rel} → {new_rel}") # 2) 再改写引用(保 BOM / 换行) # 被移动文件本身已换位置 → 内容必须写到**新**路径,否则会在旧路径复活一份 def mapped(f: pathlib.Path) -> pathlib.Path: fa = f.resolve() if fa == old_abs: return new_abs if str(fa).startswith(str(old_abs) + os.sep): return new_abs / fa.relative_to(old_abs) return f for f, _, new_text, bom, _ in plan: target = mapped(f) target.parent.mkdir(parents=True, exist_ok=True) data = new_text.encode("utf-8") if bom: data = BOM + data target.write_bytes(data) print(f"已改写 {len(plan)} 个文件、{n_refs} 处引用") # 2b) 收尾提示(脚本管不到的部分) if hit_generated: print("ℹ 引用关系图是生成物,未改写 → 重跑 & 'tools\\refgraph\\gen_refgraph.bat'") if old_abs.suffix.lower() in (".c", ".h") or new_abs.suffix.lower() in (".c", ".h"): print("ℹ 改了 .c/.h:已同步 uvprojx 与 CMakeLists;若**新增目录**还需把目录加进 " "uvprojx 的 (见 new-source-file skill),随后跑 /keil-build 验证") print("ℹ 正文里的**裸文件名/昵称引用**(如「内存方案 §2.1」「app_main.c/h」)无法机械判定," "已尽量改写,仍建议 grep 一遍旧名:git grep -n '<旧文件名>'") # 3) 复核 if not args.no_verify: r = subprocess.run([sys.executable, str(REPO_ROOT / "scripts" / "check_doc_links.py"), "--all"], cwd=REPO_ROOT) if r.returncode != 0: print("\n⚠️ check_doc_links 未通过——上面列出的断链需人工确认(多为裸文件名引用)") return 1 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": "\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": "\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())