fix(scripts): rename_ref 搬整目录不再改脏目录内部内容,并补齐出链层级
2026-10-30 测试说明分层那次(28 个路径、209 文件)实测踩到两条,都是
「把不该改的东西改脏」,当时的补救全靠事后从 git 里捞原始字节:
B1 整目录搬迁会改写目录内部文件的内容。「相对引用文件所在目录」这条
解析规则不检验目标是否存在,于是台架日志里不是路径的斜杠片段被当成
「旧目录下的子路径」并插上目录名:`1/1 PASSED` → `test_data/1/1 PASSED`、
`13639/32768`、日期占位 `[----/--/-- --:--:--]` 全中招,连有效的
`see ../a.md` 也被换成指向不存在的 `see td/a.md`。23 个证据文件是
用 `git show HEAD:<旧路径>` 逐字节还原才救回来的。
B2 目录内部文件指向外部的 `../` 出链不补层级。emit_dir 取的是
`new_abs.parent`,整目录搬迁时少算一层,`../images/x.svg` 搬深一层后
仍指旧层级;图片按文档目录相对(§3.2),check_doc_links 看不见 → 静默断链。
改法(尽量小面):
- resolve_token:按引用文件目录解析时要求目标真存在;根相对写法保持原宽松口径。
- rewrite_text:moved 文件的 emit_dir 改成「本文件搬完实际所在的目录」;
单文件搬迁走原分支,一字未动。
- 整目录搬迁时目录内部互为相对的引用一律不改(两侧一起搬,改了必错);
「旧路径是不是目录」必须在搬迁前记进 old_is_dir_global——--apply 是先
git mv 再改写,那时旧路径已经不存在了。
- glob 形态改成看得见:搬目录时前缀整体跟着搬(本来就该改);搬单文件时
一条 glob 可能同时盖住搬走与没搬走的两批文件,不猜,只点名列出。
分层那次有 4 处这种引用是静默漏改、事后手改的。
回归:新增 `--selftest`(在一次性临时 git 仓里跑本脚本的副本,`.gitignore`
先排除 /scripts/ 免得脚本自己进扫描面)。案例 A 守整目录搬迁(证据文件
逐字节不变、出链补层、别人文件的入链照改、glob 前缀跟着搬),案例 B 守
单文件搬迁的层级换算与 glob 提示,18 项断言全绿。反向对照也做了:同一
夹具在改前版本上 B1/B2 都复现,所以断言不是凭空写的。
文档同步:docs/文档规范.md §3.6 第 4/5 条从「现存危害」改写成「已修 +
验收判据」,新增第 6 条 glob 提示,并加「改本体必须先跑 --selftest」;
docs/测试说明/README.md 第 3/5 节口径同步;scripts/README.md 补 --selftest
与任务树 --no-verify 的用法行。顺带修掉分层那笔里我写错的日期:
2026-11-01 → 2026-10-30(文档规范 4 处、测试说明 README 2 处,真值就是今天)。
树内验证:AST OK;--selftest PASS;改动的 3 个 .md 单文件
check_doc_links --sections-strict RC=0(36 处章节引用、可判定 34、无法判定 0);
字节体检干净。未跑 @build(无 .c/.h 改动)。
This commit is contained in:
+25
-9
@@ -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` 的 `<FilePath>`、`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` 的 `<IncludePath>`(→ `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`、
|
||||
> `` 不补层),所以这些断言不是凭空写的。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-5
@@ -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 逐字节一致)。
|
||||
|
||||
+4
-1
@@ -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
@@ -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"
|
||||
+ "\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())
|
||||
|
||||
Reference in New Issue
Block a user