d1e6bb4ba7
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 已销项。
396 lines
16 KiB
Python
396 lines
16 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 # 整目录搬迁(前缀改写)
|
||
|
||
路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。
|
||
|
||
改写覆盖的引用形态:
|
||
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 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())
|