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 已销项。
This commit is contained in:
2026-10-11 18:50:24 +08:00
parent eddbedeee8
commit d1e6bb4ba7
75 changed files with 3928 additions and 352 deletions
+395
View File
@@ -0,0 +1,395 @@
#!/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())