Files
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

611 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 的 <FilePath>、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"(<FilePath>)([^<\n]+)(</FilePath>)")
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"、<FilePath>x.s</FilePath>)
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 的 <IncludePath>(见 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": "<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())