Files
STM32F4-Base/scripts/rename_ref.py
T
wandering1 d1e6bb4ba7 W5波+N-19收口:FTP鉴权落地、Trap25三连定案修复(N-30)、N-20/N-23/N-26
N-19 FTP 鉴权:
- lftpd_auth(USER/PASS 状态机 + 连续5次失败锁60s)+ SHA-256
  (user||pwd) 口令哈希;config.ini [ftp] 段 user/pwd_hash/write,
  出厂 admin/admin 在用有启动告警;lftpd_log.c 删除并入 lftpd_auth
- ftp_cfg 配置读写;台架 ftp_auth_bench.py 13/13

N-30 / Trap 25(CH395F_Trap_Records.md 四次定案全记录):
- 修复1 closed-with-data accept:客户端发完正常 FIN 后芯片按
  FUN_PARA(0x09) §5.52② 保留 Socket+RX 缓冲,旧代码把 CLOSED 一律
  relisten 自毁缓冲 → STOR 必挂 425;改为接受"已关但可读"连接排空
- 修复2 丢 CONNECT 中断探针 net_lost_connect_probe + last_peer_port
  幽灵甄别(拆链窗口握手完成但中断丢失,双信源去抖补 accept)
- 修复3 send_list/send_nlst 批量单发:背靠背 ~60B 小段毒化芯片 TX
  (芯片报 SEND_OK 但对端全丢),整份目录一次性写入
- 证伪存档:固定沉降/Socket 轮换冷却/listen 门控(台架 v7 矩阵)
- 台架工具 test/board/ch395f_reuse_bench.{c,h} + host 脚本存档
  (不注册编译;实验分支 n30-chip-bench 保留)

N-20:Error_Handler 死循环降级(记录+复位路径)
N-23:scripts/check_registration.py 双端注册反向校验(pre-commit)
N-26:GD5F NAND 直 DMA 调用方缓冲窗口断言

验证:BUILD_OK 0/0;ftp_regression 连跑 4 轮 11/11(修复前
LIST#4/#7 100% 挂、STOR#2 曾 100% 425);ftp_auth_bench 13/13;
@test 启动标记 PASS;ci_local L0+L0.5 全过;doc-links 通过。
登记簿 N-30 已销项。
2026-10-11 18:50:24 +08:00

396 lines
16 KiB
Python
Raw 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 # 整目录搬迁(前缀改写)
路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。
改写覆盖的引用形态:
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 subprocess
import sys
import urllib.parse
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 or str(rc).startswith(str(old_abs) + os.sep):
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]
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
emit_dir = new_abs.parent if moved else file_path.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
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 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
plan = []
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
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 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),随后跑 @build 验证")
print("ℹ 正文里的**裸文件名/昵称引用**(如「内存方案 §1.4」「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
if __name__ == "__main__":
sys.exit(main())