4a4ad6bc5f
现状口径:/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 全过;字节卫生零漂移。
611 lines
28 KiB
Python
611 lines
28 KiB
Python
#!/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. 图片链接  —— 含"文档挪层级后 ../ 前缀要跟着变"这条实证坑
|
||
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"
|
||
+ "\n"
|
||
+ "兄弟 [b](./b.md)\n",
|
||
"docs/td/b.md": "# b\n\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 出链 ../ 补了一层", "" in a, repr(a))
|
||
b = (moved / "b.md").read_text(encoding="utf-8")
|
||
check("A/B2 目录内每个文件都补", "" 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\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/单文件搬迁的 ../ 换算照旧", "" 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())
|