diff --git a/docs/文档规范.md b/docs/文档规范.md index 60757590..db031d5e 100644 --- a/docs/文档规范.md +++ b/docs/文档规范.md @@ -14,7 +14,7 @@ |---|---| | **目录名一律不含空格** | 空格迫使脚本/链接/CI 处处加引号,且 URL 编码形态(`UI%20设计`)极易被漏改(2026-09-25 起强制;全库已无含空格目录) | | **按领域归入子目录** | 顶层不放散落 `.md`;新领域先建无空格子目录,再在 [`README.md`](README.md) 登记 | -| **子目录内文件过多时再分一层(只多分一层)** | `docs/测试说明/` 2026-11-01 起按用途分 `流程与规范/` `单元测试/` `板级测试/` `专项验证/` `测试工具/` `测试数据/` 六个子目录,**归层判据 → `docs/测试说明/README.md` §2**。再往下分不会让引用更短,只会让按目录递归的门禁 glob 更难核对 | +| **子目录内文件过多时再分一层(只多分一层)** | `docs/测试说明/` 2026-10-30 起按用途分 `流程与规范/` `单元测试/` `板级测试/` `专项验证/` `测试工具/` `测试数据/` 六个子目录,**归层判据 → `docs/测试说明/README.md` §2**。再往下分不会让引用更短,只会让按目录递归的门禁 glob 更难核对 | | 现有目录 | `架构设计/` `子系统设计/` `算法/` `网络/` `测试说明/` `代码规范/` `内存管理/` `问题记录/` `环境与部署/` `UI设计/` `硬件平台/` `芯片手册/` `images/` | ### 1.1b 代码目录的 README.md @@ -161,24 +161,40 @@ 它覆盖的引用形态:反引号根相对引用、Markdown 链接(根相对与文档相对都按原风格改写)、**图片链接的 `../` 层级换算**(§3.2 那条实证坑由它兜住)、`#include "x.h"` 裸文件名、`MDK-ARM/*.uvprojx` 的 ``、`simulator/CMakeLists.txt` 的 `${PROJECT_ROOT}/…` 与 `${VAR}/…`、以及 skill / 脚本 / 配置里的路径串。 -**它管不到的五件事**(脚本会在结尾提示): +**它管不到的事**(1–3 一直是手工活;4–5 曾是真会把东西改坏的坑,2026-10-30 已修并进了 `--selftest` 回归;6 由脚本主动点名): 1. **昵称式引用**(「内存方案 §2.1」)与正文里的裸文件名散文 → 落盘后 `git grep -n '<旧文件名>'` 扫一遍; 2. **生成物**(`docs/images/引用关系图.*`)不改写 → 重跑 `& 'tools\refgraph\gen_refgraph.bat'`; 3. 改的是 `.c/.h` 且**新增了目录** → 还要把目录加进 `uvprojx` 的 ``(→ `new-source-file` skill),随后 `@build` 验证。 -4. ⚠ **搬整目录时会改写目录内部文件的内容**(2026-11-01 `docs/测试说明/` 分层实测):脚本把内部文件里"不是路径的斜杠片段" - 当成旧目录前缀的引用 —— `1/1 PASSED` 被改成 `test_data/1/1 PASSED`、日志日期占位 `[----/--/-- --:--:--]` 也被插前缀。 - **一手证据与测试数据不接受这种改写** → 落盘后用 `git show HEAD:<旧路径>` 的原始字节逐文件还原,并核对 - `git diff --stat` 里**不该出现**"数据目录内容变更"(只该有改名)。 -5. ⚠ **搬整目录时,目录内部文件指向外部的 `../` 出链不补层级**(目录变深一层 → 静默断链;搬**单个**文件不受此影响, - 出链会正确加一层)→ 落盘后逐条核对数据目录内部 README 的出链。 +4. ~~搬整目录时会改写目录内部文件的内容~~ **已修**(2026-10-30,回归见 `--selftest` 案例 A):根因是 + 「相对引用文件所在目录」那条解析规则不检验目标是否存在,于是内部文件里**不是路径的斜杠片段**被 + 当成旧目录下的子路径并插上目录名 —— 分层那次 `1/1 PASSED` 改成 `test_data/1/1 PASSED`、日志日期占位 + `[----/--/-- --:--:--]` 同样中招,连有效的 `see ../a.md` 都被换成指向不存在文件的 `see td/a.md`。 + **修法**:该解析路径要求目标**真存在**(根相对写法保持原来的宽松口径),并且整目录搬迁时目录**内部** + 互为相对的引用一律不改(两侧一起搬,改了必错)。 + ⚠ **仍要做的验收**:搬完看 `git diff --stat`,数据目录里**不该出现内容变更**、只该有改名 —— + 一手证据与台架日志不接受任何改写,这条从"补救动作"降级成"核对判据"。 +5. ~~搬整目录时目录内部文件指向外部的 `../` 出链不补层级~~ **已修**(同一 `--selftest` 案例 A):根因是 + 改写时把「本文件的新目录」取成了 `new_abs.parent`,整目录搬迁时少算一层,于是 `../images/x.svg` + 搬深一层后仍指旧层级(**静默断链**:图片按 §3.2 是文档目录相对,`check_doc_links` 看不见)。 + **修法**:按「`new_abs` / 相对旧目录的子路径」的父目录来算,出链层级自动补对;搬**单个**文件的换算 + 一字未动,由 `--selftest` 案例 B 专门守着。 +6. **glob 形态引用**(如 `` `docs/td/*.md` ``):搬**目录**时前缀整体跟着搬(正确,不用管);搬**单个文件**时 + 一条 glob 可能同时盖住搬走和没搬走的两批文件,脚本不猜 → 但会把它**点名列出**(`⚠ 通配符形态引用 N 处`)。 + 分层那次有 4 处这种引用是**静默漏改**、事后靠人工补的,现在至少看得见。 > 同批实测的另两条工具侧事实:新路径**以 `/` 结尾**才是"挪进该目录并保留文件名"(见上例注释);`--apply` 收尾会自动跑 > `check_doc_links.py --all`,在带 junction 联接(`.venv`、`simulator/SDL2`)的任务工作树里会把联接目录扫成**假红**, > 所以树内批量搬迁要加 `--no-verify`,全库 `--all` 复核留到主工作树跑(→ `docs/环境与部署/并行任务工作树方案.md`)。 > 另:脚本结尾的 `ℹ` 提示在 GBK 代码页会抛 `UnicodeEncodeError` 让进程**以退出码 1 收场**(此时 `git mv` 与改写**已经落盘**, -> RC 与真实结果相反)→ 2026-11-01 已在 `scripts/rename_ref.py` 内把 stdout/stderr `reconfigure(encoding="utf-8")`, +> RC 与真实结果相反)→ 2026-10-30 已在 `scripts/rename_ref.py` 内把 stdout/stderr `reconfigure(encoding="utf-8")`, > 与 `check_ids.py` / `check_doc_links.py` / `check_test_traceability.py` 同法。 +> +> **改 `scripts/rename_ref.py` 本体必须先跑 `--selftest`**(`& '.venv\Scripts\python.exe' scripts/rename_ref.py --selftest`): +> 它在一次性临时 git 仓里跑**本脚本的副本**——案例 A 守整目录搬迁那两条(目录内证据文件逐字节不变、出链 `../` +> 补对层级、别人文件的入链照改、glob 前缀跟着搬走),案例 B 守单文件搬迁的层级换算与 glob 提示。 +> 反向对照也留了记录:同一夹具在改前版本上两条 bug **都复现**(`1/1 PASSED` → `td/1/1 PASSED`、 +> `![img](../img/x.svg)` 不补层),所以这些断言不是凭空写的。 --- diff --git a/docs/测试说明/README.md b/docs/测试说明/README.md index f1b93bb1..38281d49 100644 --- a/docs/测试说明/README.md +++ b/docs/测试说明/README.md @@ -1,6 +1,6 @@ # 测试说明目录导航 -> 本目录放**测试文档与测试采集数据**,2026-11-01 起按**用途**分六个子目录(原先 25 篇文档 +> 本目录放**测试文档与测试采集数据**,2026-10-30 起按**用途**分六个子目录(原先 25 篇文档 > 与 3 个数据目录平铺在一层,找文档靠记忆、命名靠约定)。 > **全量索引与每篇文档的内容说明在 [`docs/README.md`](../README.md) §6**(同一事实只在一处详述, > 本文件不重复那些描述),这里只回答两件事:**东西该放哪一层**、**每层现在有什么**。 @@ -58,14 +58,15 @@ docs/测试说明/ 根往下找 `*_Test_Guide.md` 与 `*单元测试说明.md`):模块指南落 `板级测试/`、单测说明落 `单元测试/`,**换层等于让追溯门禁看不见它**。 3. **改名 / 挪目录一律走 `scripts/rename_ref.py`**(先 dry-run 再 `--apply`),它会同步改写 - 全库引用;手工扫引用既慢又漏 → `docs/文档规范.md` §3.6。⚠ 该脚本目前有两个已实测的坑,见 - 下面第 5 节。 + 全库引用;手工扫引用既慢又漏 → `docs/文档规范.md` §3.6。它搬目录时原有两个会改坏内容的坑 + **已修**(同日加了 `--selftest` 回归),本目录当时的处置见下面第 5 节。 4. 引用本目录文档时用**仓库根相对路径**(形如 `docs/测试说明/<子目录>/<文件名>.md`);只有图片用 文档所在目录相对 → `docs/文档规范.md` §3.1 / §3.2。 -## 5. 本次分层的处置记录(2026-11-01) +## 5. 本次分层的处置记录(2026-10-30) -`scripts/rename_ref.py` 搬目录时有两个坑(**权威记录在 `docs/文档规范.md` §3.6 第 4/5 条**,这里只留本目录的处置): +`scripts/rename_ref.py` 当时搬目录有两个坑(**权威记录在 `docs/文档规范.md` §3.6 第 4/5 条**,两处已于 +同日修复并加 `--selftest` 回归;这里只留本目录的处置): - `测试数据/` 下 23 个证据文件的**内容**曾被脚本误改 → 已用 `git show HEAD:<旧路径>` 的原始字节逐字节还原, 现在这三个目录是**纯改名**(内容与 HEAD 逐字节一致)。 diff --git a/scripts/README.md b/scripts/README.md index 82d7d64a..ef515860 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 # 门禁判定 diff --git a/scripts/rename_ref.py b/scripts/rename_ref.py index ce012e67..ecd566b7 100644 --- a/scripts/rename_ref.py +++ b/scripts/rename_ref.py @@ -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": "\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())