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
+12 -1
View File
@@ -11,7 +11,8 @@
|---|---|---|
| `check_coding_standard.py` | `C coding standard` | C 代码规范机械检查(命名前缀/常量右置/头文件保护宏等) |
| `check_mojibake.py` | `Forbid U+FFFD mojibake` | 扫描 `U+FFFD` 乱码(`EF BF BD` 字节) |
| `check_doc_links.py` | `doc-links` | **文档链接存在性** + 命名规范(D-3/D-7) |
| `check_doc_links.py` | `doc-links` | **文档链接存在性** + 命名规范(D-3/D-7)+ **`§N.M` 章节引用**(口径见 `docs/文档规范.md` §3.5)。钩子已带 `--sections-strict`(自引用失效也拦);`--section-todo` 列出机器判不了目标的 `§`(带改写建议)。每次跑打印「可判定比例」行,防覆盖率回落 |
| `section_aliases.json` | (被 `check_doc_links.py` 读) | 文档**昵称别名表**:「内存方案」→ `docs/内存管理/RAM使用分析与优化方案.md`。只登记无歧义昵称;登记一次即把该昵称的引用纳入硬门禁 |
| `gen_task_table.py` | `task-table` | FreeRTOS 任务表以 `Src/freertos.c` 为**唯一事实源**生成/校验(D-2/D-7) |
| `check_mem_numbers.py` | `mem-numbers` | 内存/Flash 数字以 `.map` 为唯一事实源校验(D-7 体积档) |
| `check_test_suite.py` | `test-suite-switch` | 测试套件开关=产品构建(防测试固件静默提交) |
@@ -23,6 +24,14 @@
| `ram_report.py` | 从 `.map` 生成**静态 RAM/Flash 占用**报告(→ `docs/内存管理/data/`) |
| `stack_report.py` | 从 Keil 静态调用图 + 串口 `[STACK]` 水位生成**每任务最坏链**报告 |
### 引用同步(改名 / 挪目录)
| 脚本 | 作用 |
|---|---|
| `rename_ref.py` | **改名/挪目录 + 全库引用自动改写**:默认 dry-run 预览,`--apply` 才落盘(`git mv` → 改写引用 → 自动跑 `check_doc_links --all` 复核)。覆盖反引号引用、Markdown 链接、图片 `../` 层级换算、`#include`、`uvprojx`/`CMakeLists.txt` 源列表。固定动作与边界 → `docs/文档规范.md` §3.6 |
> `check_doc_links.py` 除链接存在性外,还校验 **`§N.M` 章节引用**是否指向目标文档真实存在的编号标题(口径见 `docs/文档规范.md` §3.5)。
### CI 与本地等价(2026-10-01 T-6)
| 脚本 / 文件 | 作用 |
@@ -43,6 +52,8 @@
# 单个脚本
& '.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/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 # 门禁判定
+16
View File
@@ -142,6 +142,22 @@ CHECKS = [
hint="崩溃现场报告实现(打印 PC/LR/xPSR/CFSR/HFSR/BFAR/MMFAR)"
"须在 USER CODE BEGIN 0 区内(生成区内的标准保护位)。",
),
# N-20(2026-10-12):Error_Handler 统一降级。函数体本身在 CubeMX 的
# `USER CODE BEGIN/END Error_Handler_Debug` 区内(L2 受保护),重生成不丢;
# 本项是 L3 兜底:被打回模板默认(__disable_irq+while(1) 静默挂死)**不报编译错**,
# 只在真出外设初始化故障时暴露——整机只剩 IWDG 兜底且无自检日志。
dict(
id="error-handler-degrade",
kind="protected",
path="Src/main.c",
region="Error_Handler_Debug",
must_contain=["DBG_ERROR", "NVIC_SystemReset"],
must_not_contain=["__disable_irq", "__builtin_return_address"],
hint="Error_Handler 必须打 1 条自检日志后 NVIC_SystemReset(),不得回到 "
"__disable_irq()+while(1) 静默挂死(无日志、只剩 IWDG 兜底)。"
"⚠ 勿用 __builtin_return_address:AC5 编成库调用,MicroLIB 无符号 = L6218E(2026-10-12 实证)。"
"恢复位置 = `USER CODE BEGIN Error_Handler_Debug` 区。",
),
# ---------------- P1:FreeRTOS 加固 ----------------
# ⚠ 2026-10-09 升级:这两项原先靠 FreeRTOSConfig.h 里**自造**的
# `USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING` 区——模板不认这个区名,
+382 -1
View File
@@ -9,6 +9,9 @@
B. 反引号路径引用 `docs/xxx/yyy.md` —— 相对仓库根解析(本工程约定:文档间引用
一律用「仓库根相对路径」)
C. 文件名规范 目录名 / 文件名不得含空格与路径敏感字符(见 docs/文档规范.md §1)
D. 章节引用 `docs/xxx.md` §N.M —— 目标文档里必须真有该编号的标题。
刻意只校验**紧邻写法**(文档名与 § 之间只允许空白/反引号/括号,
实测可把误报压到 0);昵称式引用如「内存方案 §1.4」不校验。
豁免(不算失效,不报错):
* 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,见《项目审查与优化建议报告》D-3 建议)
@@ -30,6 +33,7 @@ import re
import sys
import pathlib
import urllib.parse
from collections import Counter
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -61,6 +65,50 @@ STATUS_PROXIMITY = 40
PLACEHOLDER_TOKENS = ("NN_", "XX_", "界面名", "模块名", "日期", "示例", "{{", "}}",
"${", "%s", "%d", "<", ">")
# --- D 类:章节引用 §N.M -------------------------------------------------
# 引用写法:§ + 数字编号(§10 / §10.4 / §2.1.1)
SEC_RE = re.compile(r"§\s*(\d+(?:\.\d+)*)")
# 文档名 token:带目录的路径,或裸文件名(中文/下划线/短横均可)。
# 非 .md 后缀也要认——`.pre-commit-config.yaml §7` 这类要识别成「非文档锚点」并跳过,
# 否则会误当自引用去校验本文档编号。
PATH_EXT = r"(?:md|ya?ml|py|c|h|txt|json|ini|ioc|s|bat)"
DOC_NAME_RE = re.compile(r"((?:[\w$.\-一-鿿]+/)+[\w$.\-一-鿿]+\." + PATH_EXT
+ r"|[\w$.\-一-鿿]+\." + PATH_EXT + r")")
MD_SUFFIX_RE = re.compile(r"\.md$")
# 裸文档名(不带 .md):紧邻 § 的词,用于「看门狗设计说明 §10」「V0.0 §4.7」这类写法
# (允许点号,否则 `V0.0` 会被截成 `0`)
STEM_RE = re.compile(r"([A-Za-z0-9_一-鿿][\w.一-鿿]{1,30})[`\s))】\]]{0,4}$")
# 自引用线索:§ 前面是这些词/符号 → 指向本文档
SELF_CUE_RE = re.compile(r"(见|参见|详见|如|按|第|本文|本节|下文|上文|下面|上方|$"
r"|[((、。::||\[\]])[`\s))】\]]{0,4}$")
# 文档昵称的常见结尾:命中说明 § 前面是「别的文档的名字」,不能当自引用
DOC_NOUN_TAIL = ("方案", "报告", "规划", "说明", "指南", "流程", "清单", "架构",
"总览", "手册", "规范", "记录", "审计", "分析", "对照", "要求",
"部署", "使用", "设计", "文档", "台账", "总结", "综述")
# 标题与其编号:`## 3. 链接规范` / `### 2.1.1 xxx`
HEAD_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$")
HEAD_NUM_RE = re.compile(r"^(\d+(?:\.\d+)*)\s*[.、))]?\s")
# 文档名与 § 之间允许的最大间隔字符数,且间隔只能是空白/反引号/括号类
SEC_MAX_GAP = 8
SEC_GAP_CHARS = "` \t)(()【】[]「」《》、::"
SEC_GAP_RE = re.compile("[" + re.escape(SEC_GAP_CHARS) + "]{0,8}$")
# § 链:同一句内连续引用(`a.md` §3.2 L2 设计点 / §10.2 术语表)——后续 § 继承前一个的目标。
# 中间允许描述性文字,但不允许句末标点,也不允许再出现文档名(那会换目标)
SEC_CHAIN_MAX = 40
SEC_TERMINATORS = "。;!?"
# 变更记录类小节:里面的 § 是「当时那个编号」的历史指称,不参与校验
CHANGELOG_WORDS = ("变更记录", "更新记录", "修订记录", "历史", "changelog")
# 外部资料编号(芯片手册等):不受本仓库标题编号约束
EXTERNAL_HINTS = ("RM0090", "参考手册", "数据手册", "手册", "datasheet",
"FreeRTOS", "CMSIS", "FatFs", "dhara", "LVGL", "Graphviz", "RFC")
# D 类计数(覆盖率可见化:每次跑都打印,防止比例悄悄回落)
SEC_STATS = Counter()
# 机器判不了目标的 § 引用清单(--section-todo 打印,供逐步改成紧邻写法)
SEC_TODO = []
# 昵称别名表:昵称 → 仓库根相对路径
ALIAS_FILE = REPO_ROOT / "scripts" / "section_aliases.json"
_alias_map = None
def is_placeholder(text: str) -> bool:
if "*" in text or "<" in text or ">" in text or "{" in text or "}" in text:
@@ -149,6 +197,286 @@ def check_backtick_paths(path: pathlib.Path, lines, errors):
errors.append((path, lineno, target, "路径引用"))
# --- D 类:章节引用 §N.M -------------------------------------------------
_doc_index = None # 文件名 -> [Path]
_head_cache = {} # 文档绝对路径 -> 该文档的数字编号标题集合
def build_doc_index():
"""全库 .md 索引(按文件名),用于解析裸文件名引用。"""
global _doc_index
if _doc_index is not None:
return _doc_index
idx = {}
for p in REPO_ROOT.rglob("*.md"):
if is_skipped(p):
continue
idx.setdefault(p.name, []).append(p)
_doc_index = idx
return idx
def numbered_headings(path: pathlib.Path):
"""目标文档里出现的数字编号标题(`## 3. xxx` / `### 2.1.1 xxx`)。"""
key = str(path)
if key in _head_cache:
return _head_cache[key]
out = set()
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
_head_cache[key] = out
return out
for line in text.splitlines():
m = HEAD_RE.match(line)
if not m:
continue
m2 = HEAD_NUM_RE.match(m.group(1).strip())
if m2:
out.add(m2.group(1))
_head_cache[key] = out
return out
def stem_candidates(token: str, by_stem):
"""解析裸文档名:先全名相等,再按前缀(`V0.0` → `V0.0_网表详细分析报告.md`)。
返回候选列表;调用方只在**唯一**时采信,所以「报告」「方案」这类会命中多篇而自动跳过。
前缀匹配要求 ≥3 字符,否则「文档」「手册」这类剥剩的通用词会乱认邻居(「主文档」→「文档」
→ 前缀命中「文档规范」)。
"""
if token in by_stem:
return by_stem[token]
if len(token) < 3:
return []
out = []
for stem, paths in by_stem.items():
if stem.startswith(token):
for p in paths:
if p not in out:
out.append(p)
return out
def load_aliases():
"""昵称别名表(`scripts/section_aliases.json`):「内存方案」→ 具体文档。
存在的意义:昵称式引用(「内存方案 §1.4」)读起来顺、机器判不了;登记一次别名,
正文不用改,引用就纳入校验。表里没有的昵称不猜(宁缺毋滥)。
"""
global _alias_map
if _alias_map is not None:
return _alias_map
_alias_map = {}
if ALIAS_FILE.exists():
try:
import json
data = json.loads(ALIAS_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
_alias_map = {k: v for k, v in data.items() if isinstance(v, str)}
except (ValueError, OSError, UnicodeDecodeError):
pass
return _alias_map
def resolve_name(name: str, by_stem):
"""裸文档名 → 目标:先文件名(含前缀唯一),再别名表。判不了返回 (None, None)。"""
cand = stem_candidates(name, by_stem)
if len(cand) == 1:
return cand[0], "stem"
alias = load_aliases().get(name)
apath = (REPO_ROOT / alias) if alias else None
if apath is not None and apath.exists():
return apath, "alias"
return None, None
def suggest_doc(token: str, by_stem):
"""给「昵称式引用」猜一个目标文档,用于 --section-todo 的改写建议。
只在**唯一命中**时给建议:昵称整体出现在文件名里,或「昵称前段出现在目录名 +
昵称后两字出现在文件名」(「内存方案」→ docs/内存管理/RAM使用分析与优化方案.md)。
"""
if not token or len(token) < 2:
return None
hits = []
for stem, paths in by_stem.items():
for p in paths:
rel = _rel(p)
if token in stem or stem in token:
hits.append(p)
elif len(token) > 3 and token[-2:] in stem and token[:-2] in rel:
hits.append(p)
uniq = []
for p in hits:
if p not in uniq:
uniq.append(p)
return uniq[0] if len(uniq) == 1 else None
def sec_plausible(heads, ref: str) -> bool:
"""编号存在,或是现有编号的祖先(§8 → 8.2 在位)/后代。"""
return ref in heads or any(h.startswith(ref + ".") for h in heads)
def sec_parent_exists(heads, ref: str) -> bool:
"""ref 的父级编号在文档里存在——不存在说明这个数量级不属于本文档,
该 § 其实是「省略了文档名的跨文档引用」,机器判不了(跳过而非误报)。"""
parts = ref.split(".")
if len(parts) == 1:
return any(h.split(".")[0] == parts[0] for h in heads)
parent = ".".join(parts[:-1])
return parent in heads or any(h.startswith(parent + ".") for h in heads)
# 昵称前常挂的动词/介词:「见内存方案 §1.4」「顺序按报告 §6.5」——先剥掉再查别名/文件名
CUE_STRIP_PREFIXES = ("参见", "详见", "并同步更新", "教训见", "用法与基线见", "反向依赖在",
"改动前先看", "多口需求见", "写入顺序违反", "宏位与", "顺序按",
"依据", "参照", "对照", "违反", "参考", "按", "见", "如", "该", "以",
"与", "和", "及", "或", "并", "在", "的", "第", "看", "主")
# 明确指「本文档」的昵称 → 按自引用处理
SELF_TOKENS = ("本文档", "本文", "该文", "该文档", "本节", "上文", "下文", "下面", "上方")
def strip_cue(token: str) -> str:
prev = None
while prev != token:
prev = token
for c in CUE_STRIP_PREFIXES:
if token.startswith(c) and len(token) > len(c) >= 1:
token = token[len(c):]
break
return token
def chain_blocked(between: str, by_stem) -> bool:
"""§ 链能否跨过这段文字:出现句末标点、或出现**与某文档文件名完全相同**的裸名就不许跨
(`… §3 + 看门狗设计说明 §11.4` 里的 §11.4 属于后者,不能继承前一个目标)。
只认完全相等:`内容页 / §5.6` 里的「内容页」是描述词,不该断链。"""
if len(between) > SEC_CHAIN_MAX:
return True
if any(t in between for t in SEC_TERMINATORS):
return True
if DOC_NAME_RE.search(between):
return True
for m in re.finditer(r"[A-Za-z0-9_一-鿿][\w.一-鿿]{1,30}", between):
if m.group(0) in by_stem:
return True
return False
def check_section_refs(path: pathlib.Path, lines, errors, warns):
"""D 类:§N.M 章节引用 —— 目标文档必须真有该编号标题。
目标判定优先级(能判就判,判不了就跳过并计数,口径同 mem-numbers 的「窄而准」):
1. 紧邻的文档名(`docs/x.md` / `x.md`,≤8 字符间隔)→ 唯一解析即采信
2. 同一句内的 § 链(`a.md` §3.2 … / §10.2)→ 继承前一个 § 的目标
3. 紧邻的裸文档名(`看门狗设计说明 §10`、`V0.0 §4.7`)→ 全名或前缀唯一匹配
4. 自引用线索(见/本文/本节/(/、/句首…)→ 目标 = 本文档
跳过:非 .md 锚点(`.pre-commit-config.yaml §7`)、变更记录小节、含状态词的行、
外部手册(RM0090 等)、目标文档不用数字编号标题、自引用但父级编号不存在。
两档严重性:**目标明确**的失效 = 错误(拦提交);**自引用**的失效 = 告警
(打印不拦,因为省略文档名的跨文档引用与真失效机器分不干净)。
"""
idx = build_doc_index()
by_stem = {}
for name, paths in idx.items():
for p in paths:
by_stem.setdefault(p.stem, []).append(p)
in_changelog = False
for lineno, line in enumerate(lines, 1):
hm = HEAD_RE.match(line)
if hm:
in_changelog = any(w in hm.group(1) for w in CHANGELOG_WORDS)
if EXAMPLE_MARKER in line or "[计划]" in line:
continue
prev_end = None
prev_tgt = None
prev_kind = None
for m in SEC_RE.finditer(line):
ref = m.group(1)
SEC_STATS["total"] += 1
window = line[max(0, m.start() - 120):m.start()]
tgt = None
kind = None
names = list(DOC_NAME_RE.finditer(window))
if names:
last = names[-1]
raw = urllib.parse.unquote(last.group(1))
# 注意:last 的坐标是**窗口内**的,不能拿 m.start()(整行坐标)去减
gap_txt = window[last.end():]
near = (len(gap_txt) <= SEC_MAX_GAP and SEC_GAP_RE.match(gap_txt))
if near and MD_SUFFIX_RE.search(raw):
cand = ([b / raw for b in (path.parent, REPO_ROOT)
if (b / raw).exists()]
if "/" in raw else idx.get(raw, []))
if len(cand) == 1:
tgt, kind = cand[0], "path"
elif near:
SEC_STATS["skip_nonmd_anchor"] += 1
prev_end, prev_tgt, prev_kind = None, None, None
continue
if tgt is None and prev_tgt is not None:
between = line[prev_end:m.start()]
if not chain_blocked(between, by_stem):
# 继承前一个 § 的目标**与它的判定档位**(自引用链仍按自引用处理)
tgt, kind = prev_tgt, prev_kind
if tgt is None:
sm = STEM_RE.search(window)
if sm:
stem = sm.group(1)
tgt, kind = resolve_name(stem, by_stem)
if tgt is None:
short = strip_cue(stem)
if stem in SELF_TOKENS or short in SELF_TOKENS:
tgt, kind = path, "self"
elif short != stem:
tgt, kind = resolve_name(short, by_stem)
if tgt is None and short.endswith(DOC_NOUN_TAIL):
SEC_STATS["skip_nickname"] += 1
SEC_TODO.append((path, lineno, ref, short, "昵称未解析"))
prev_end, prev_tgt, prev_kind = None, None, None
continue
if tgt is None and stem.endswith(DOC_NOUN_TAIL):
SEC_STATS["skip_nickname"] += 1
SEC_TODO.append((path, lineno, ref, stem, "昵称未解析"))
prev_end, prev_tgt, prev_kind = None, None, None
continue
if tgt is None:
if SELF_CUE_RE.search(window):
tgt, kind = path, "self"
else:
SEC_STATS["skip_unknown"] += 1
SEC_TODO.append((path, lineno, ref, "", "目标无法判定"))
prev_end, prev_tgt, prev_kind = None, None, None
continue
prev_end, prev_tgt, prev_kind = m.end(), tgt, kind
if any(h in line for h in EXTERNAL_HINTS) or "芯片手册" in window:
SEC_STATS["skip_external"] += 1
continue
if in_changelog or has_status_hint(line, m.start(), m.end() - m.start()):
SEC_STATS["skip_changelog"] += 1
continue
heads = numbered_headings(tgt)
if not heads:
SEC_STATS["skip_unnumbered"] += 1
continue
if sec_plausible(heads, ref):
SEC_STATS["checked"] += 1
continue
if kind == "self" and not sec_parent_exists(heads, ref):
SEC_STATS["skip_implicit_crossdoc"] += 1
SEC_TODO.append((path, lineno, ref, "", "省略了文档名"))
continue
SEC_STATS["checked"] += 1
where = f"§{ref}({tgt.relative_to(REPO_ROOT).as_posix()} 无此编号标题)"
if kind == "self":
warns.append((path, lineno, where))
else:
errors.append((path, lineno, where, "章节引用"))
def _rel(p: pathlib.Path) -> str:
"""仓库相对路径(正斜杠),用于与 SKIP_PARTS 前缀比较。"""
try:
@@ -210,9 +538,12 @@ def iter_docs(argv):
def main(argv):
argv = argv[1:] if len(argv) > 1 else []
strict = "--sections-strict" in argv
errors = []
warns = []
n_files = 0
for doc in iter_docs(argv[1:] if len(argv) > 1 else []):
for doc in iter_docs(argv):
if is_skipped(doc):
continue
try:
@@ -223,8 +554,55 @@ def main(argv):
lines = text.splitlines()
check_md_links(doc, lines, errors)
check_backtick_paths(doc, lines, errors)
check_section_refs(doc, lines, errors, warns)
check_name(doc, errors)
# 覆盖率可见化:每次跑都打印,防止「可校验比例」悄悄回落
total = SEC_STATS["total"]
checked = SEC_STATS["checked"]
if total:
print(f"章节引用:共 {total} 处,机器可判定 {checked} 处"
f"({100.0 * checked / total:.0f}%);跳过 → 昵称式 {SEC_STATS['skip_nickname']}"
f" / 非文档锚点 {SEC_STATS['skip_nonmd_anchor']}"
f" / 变更记录 {SEC_STATS['skip_changelog']}"
f" / 外部手册 {SEC_STATS['skip_external']}"
f" / 省略文档名的跨文档引用 {SEC_STATS['skip_implicit_crossdoc']}"
f" / 目标无编号体系 {SEC_STATS['skip_unnumbered']}"
f" / 无法判定 {SEC_STATS['skip_unknown']}")
if warns:
print(f"\n⚠️ 章节引用待确认({len(warns)} 处:按自引用解析后本文档没有该编号;"
"不拦提交):")
for path, lineno, where in warns[:40]:
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
rel = path
print(f" {rel}:{lineno} {where}")
if len(warns) > 40:
print(f" … 其余 {len(warns) - 40} 处略")
print(" 修法:确认它到底指哪篇 → 写成「`路径.md` §N.M」(此后自动纳入硬门禁),"
"或改指本文档现有编号。")
if strict:
for path, lineno, where in warns:
errors.append((path, lineno, where, "章节引用(自引用,--sections-strict)"))
if "--section-todo" in argv and SEC_TODO:
by_stem = {}
for name, paths in build_doc_index().items():
for p in paths:
by_stem.setdefault(p.stem, []).append(p)
print(f"\n📋 待改成紧邻写法的章节引用({len(SEC_TODO)} 处,机器判不了目标):")
for path, lineno, ref, token, why in SEC_TODO:
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
rel = path
sug = suggest_doc(token, by_stem) if token else None
tip = ""
if sug is not None:
tip = f" → 建议改写:`{sug.relative_to(REPO_ROOT).as_posix()}` §{ref}"
print(f" {rel}:{lineno} §{ref} [{why}]{(' ' + token) if token else ''}{tip}")
if errors:
print(f"发现的失效文档引用 / 命名问题({len(errors)} 处):")
for path, lineno, target, kind in errors:
@@ -239,6 +617,9 @@ def main(argv):
"已删除的文件请写明「已删除」状态词;"
"引用报错样例/日志片段请在该行加 `[示例]` 标记;"
"命名规范见 docs/文档规范.md §1。")
print("章节引用失效([章节引用]):目标文档已无该编号小节 → 改指现有编号或直接写小节名。"
"可校验的写法:`路径.md` §N.M / 裸文件名 §N.M / 文档名前缀(V0.0)/ 已登记别名;"
"判不了目标的跑 --section-todo 看清单与改写建议。")
return 1
print(f"文档路径检查通过({n_files} 个文档)。")
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check_registration.py - 双端注册反向校验门禁(纯标准库)。
背景(登记簿 N-23,出处审查报告 N-06 残余):
本工程 Keil 工程与 PC 模拟器都用**显式文件列表**(无通配扫描)。新 `.c`
必须手动注册两处才会被编译:
① `MDK-ARM/STM32F407-Demo.uvprojx` 的 `<FilePath>` 条目(MCU 固件构建)
② `simulator/CMakeLists.txt` 的显式源列表(PC 模拟器构建)
历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c` **两处都没注册**
= 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。
本脚本做什么(三类缺口):
扫描 `App/`、`Drivers/BSP/`、`Src/` 下全部 `.c`(排除第三方),对照两份注册表:
① 存在但未进 uvprojx -> 固件不编译(死代码 / 漏注册)
② 存在但未进 CMakeLists -> 模拟器不编译(**仅当该文件本应进模拟器时**才是缺口;
MCU-only 文件不进模拟器是**正确**的,见下方豁免)
③ 注册了但文件不存在(反向)-> 幽灵注册(改名/删除后残留的注册条目)
豁免机制(关键):
模拟器只建模 **UI + 网络配置共享核心**;其余模块(MCU 驱动 / 固件任务 / 纯算法 /
CubeMX 生成层)**不进模拟器是设计事实**,模拟器另有 `_sim` 替身或根本无对应。
豁免用**显式清单 + 每行理由注释**(命名模式 `*_mcu.c` + 个别点名),
**不做整目录一刀切**(N-23 教训正是"目录级盲区")。唯一例外是 `Src/`:
CubeMX 生成的外设初始化/中断/main,按构造即 MCU 专属、不可能有模拟器替身,
故整目录豁免(任务口径明确认可"Src/ 全部")。
已知缺口过渡(存量不拦、只对新缺口 FAIL):
`KNOWN_GAPS` 登记存量缺口(N-23 存量,Lead 处置中):命中 -> 打印 `[WARN]` 不拦;
新出现的缺口 -> 打印 `[GAP]` 并 FAIL。`--strict` 关闭白名单(存量也 FAIL),供审计。
退出码:
0 = 一致(无新缺口;存量缺口已白名单 WARN)
1 = 有缺口(存在未白名单的新缺口或幽灵注册)
2 = 脚本环境错误(注册表文件缺失 / 无法读取)
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' scripts/check_registration.py
& '.venv\\Scripts\\python.exe' scripts/check_registration.py --strict
设计依据:docs/待处理内容.md N-23;.dsh/skills/new-source-file
创建日期:2026-10-12
"""
import argparse
import fnmatch
import os
import re
import sys
# --- 仓库根(scripts/ 的上一级)---
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
UVPROJX = os.path.join(REPO_ROOT, "MDK-ARM", "STM32F407-Demo.uvprojx")
CMAKELISTS = os.path.join(REPO_ROOT, "simulator", "CMakeLists.txt")
SIM_DIR = os.path.join(REPO_ROOT, "simulator")
# --- 扫描范围与第三方排除 ---
SCAN_ROOTS = ["App", "Drivers/BSP", "Src"]
# 第三方目录(即便落在扫描根下也跳过)
THIRD_PARTY = [
"Drivers/STM32F4xx_HAL_Driver",
"Drivers/CMSIS",
"Middlewares",
"Lib",
]
# --- 模拟器豁免清单(category ②:不进模拟器是**正确**的)---
# 每项 = (fnmatch 模式(对仓库相对 posix 路径匹配), 理由)。
# 原则:命名模式 + 个别点名,不整目录一刀切;唯一目录级豁免 Src/(CubeMX 生成,MCU 专属)。
SIM_EXEMPT = [
# CubeMX 生成的外设初始化 / 中断向量 / main / freertos:按构造即 MCU 专属,
# 模拟器无对应替身(任务口径明确认可"Src/ 全部")。唯一目录级豁免。
("Src/*", "CubeMX 生成的外设初始化/中断/main,MCU 专属,模拟器无替身"),
# MCU 平台后端:命名约定 *_mcu.c,模拟器另有同名 *_sim.c 替身(在 simulator/sim/)。
("*_mcu.c", "MCU 平台后端,模拟器另有 *_sim.c 替身"),
# --- App/:固件任务 / MCU 依赖 / 纯算法(模拟器无消费路径)---
("App/algo/adc_health.c", "L1 有效性校验纯算法,模拟器无消费路径"),
("App/algo/adc_input.c", "依赖 HAL/TPAFE 取数,MCU 专属"),
("App/algo/phasor.c", "L2 相量纯算法,模拟器无消费路径"),
("App/algo/prot_elm.c", "L3 保护元件纯算法,模拟器无消费路径"),
("App/algo/prot_set.c", "L5 保护定值纯算法,模拟器无消费路径"),
("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"),
("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"),
("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"),
("App/log/console_log.c", "MCU 控制台输出,模拟器无消费路径"),
("App/sync/time_sync.c", "对时任务(net+RTOS),MCU 专属"),
("App/task/adc_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/default_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/net_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/rs485_task.c", "固件 RS-485 任务,MCU 专属(另:uvprojx 缺口见 KNOWN_GAPS)"),
("App/task/ui_task.c", "固件 UI 任务,模拟器另有 main.c 驱动 UI"),
("App/util/sha256.c", "纯工具(FTP 鉴权在 MCU 端),模拟器无消费路径"),
("App/util/sys_clock.c", "RTC/SD2506 依赖墙钟,MCU 专属"),
("App/util/task_prof.c", "任务性能探针,MCU 专属"),
("App/util/task_watchdog.c", "看门狗判定(RTOS 依赖),MCU 专属"),
# --- Drivers/BSP/:MCU 芯片/网络驱动与 FTP 服务(模拟器无对应)---
("Drivers/BSP/CH395F/ch395f.c", "MCU SPI 网卡驱动"),
("Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c", "MCU SPI NAND 驱动"),
("Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "dhara FTL 适配,MCU 存储栈"),
("Drivers/BSP/NET/lftpd/lftpd.c", "MCU FTP 服务器"),
("Drivers/BSP/NET/lftpd/lftpd_auth.c", "MCU FTP 鉴权"),
("Drivers/BSP/NET/lftpd/lftpd_inet.c", "MCU FTP 网络层"),
("Drivers/BSP/NET/lftpd/lftpd_io.c", "MCU FTP 文件 IO"),
("Drivers/BSP/NET/lftpd/lftpd_string.c", "MCU FTP 字符串工具"),
("Drivers/BSP/NET/net_select.c", "MCU socket 选择层"),
("Drivers/BSP/NET/net_socket.c", "MCU socket 后端,模拟器另有 net_socket_sim.c"),
("Drivers/BSP/RS485/rs485.c", "MCU RS-485 驱动"),
("Drivers/BSP/SD2506/sd2506.c", "MCU RTC 驱动"),
("Drivers/BSP/TPAFE5160/tpafe5160.c", "MCU 计量芯片驱动"),
("Drivers/BSP/UI/ui_port.c", "MCU UI 端口层(显示/触摸硬件),模拟器另有 sim/sim_port.c"),
]
# --- 已知缺口白名单(存量,N-23,Lead 已处置)---
# 命中 -> WARN 不拦;新缺口 -> GAP FAIL。仅针对"存在但未注册"方向。
KNOWN_GAPS = {
"App/task/rs485_task.c": "E-59 门禁刻意未接线(B2 消费者就绪时随任务立项注册)",
}
def to_repo_rel(path):
"""把绝对/相对路径归一为仓库相对 posix 路径。"""
p = os.path.normpath(path)
rel = os.path.relpath(p, REPO_ROOT)
return rel.replace("\\", "/")
def is_scan_scope(repo_rel):
"""是否在扫描范围内(App/、Drivers/BSP/、Src/ 下的 .c,排除第三方)。"""
if not repo_rel.endswith(".c"):
return False
for tp in THIRD_PARTY:
if repo_rel == tp or repo_rel.startswith(tp + "/"):
return False
for root in SCAN_ROOTS:
if repo_rel.startswith(root + "/"):
return True
return False
def scan_sources():
"""扫描扫描根下全部 .c,返回仓库相对 posix 路径集合。"""
found = set()
for root in SCAN_ROOTS:
abs_root = os.path.join(REPO_ROOT, root.replace("/", os.sep))
if not os.path.isdir(abs_root):
continue
for dirpath, _dirnames, filenames in os.walk(abs_root):
for fn in filenames:
if fn.endswith(".c"):
abs_p = os.path.join(dirpath, fn)
rel = to_repo_rel(abs_p)
if is_scan_scope(rel):
found.add(rel)
return found
def parse_uvprojx():
"""解析 uvprojx 的 <FilePath>,返回仓库相对 posix 路径集合(含 .c/.s)。"""
with open(UVPROJX, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
mdk_dir = os.path.dirname(UVPROJX)
entries = set()
for m in re.finditer(r"<FilePath>(.*?)</FilePath>", text):
raw = m.group(1).strip()
if not raw:
continue
# uvprojx 路径相对 MDK-ARM/ 目录,反斜杠或正斜杠混用
joined = os.path.join(mdk_dir, raw.replace("\\", os.sep))
entries.add(to_repo_rel(joined))
return entries
def _expand_var(s, varmap, depth=0):
"""递归展开 ${VAR}。"""
if depth > 12:
return s
def repl(m):
name = m.group(1)
val = varmap.get(name, m.group(0))
return _expand_var(val, varmap, depth + 1)
return re.sub(r"\$\{(\w+)\}", repl, s)
def parse_cmake():
"""解析 CMakeLists 的 *_SOURCES 列表(先展开变量),返回仓库相对 posix 路径集合。"""
with open(CMAKELISTS, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
# 1) 单值 set(NAME value):目录变量(不含 *_SOURCES)
varmap = {"CMAKE_CURRENT_SOURCE_DIR": SIM_DIR}
for m in re.finditer(r'set\s*\(\s*([A-Za-z_]\w*)\s+("?)([^"\r\n]*?)\2\s*\)', text):
name = m.group(1)
val = m.group(3).strip()
if name.endswith("_SOURCES"):
continue
varmap[name] = val
# 2) 多值 set(NAME ... ) 中名字以 _SOURCES 结尾的块
sources = set()
for m in re.finditer(r"set\s*\(\s*([A-Za-z_]\w*_SOURCES)\b(.*?)\)", text, re.DOTALL):
body = m.group(2)
# 逐行剥注释
lines = []
for line in body.splitlines():
idx = line.find("#")
if idx >= 0:
line = line[:idx]
lines.append(line)
body = " ".join(lines)
for tok in body.split():
expanded = _expand_var(tok, varmap)
if not expanded:
continue
# 展开后若仍是绝对/带盘符路径直接用,否则相对 simulator/ 目录
if os.path.isabs(expanded) or re.match(r"^[A-Za-z]:[\\/]", expanded):
cand = expanded
else:
cand = os.path.join(SIM_DIR, expanded)
sources.add(to_repo_rel(cand))
return sources
def main():
ap = argparse.ArgumentParser(description="双端注册反向校验(uvprojx + CMakeLists)")
ap.add_argument("--strict", action="store_true",
help="关闭 KNOWN_GAPS 白名单:存量缺口也按 FAIL 处理(供审计)")
args = ap.parse_args()
# 环境校验
for path, label in ((UVPROJX, "uvprojx"), (CMAKELISTS, "CMakeLists.txt")):
if not os.path.isfile(path):
print("[ENV-ERROR] 注册表缺失: %s (%s)" % (label, path))
return 2
try:
scanned = scan_sources()
uv = parse_uvprojx()
cmake = parse_cmake()
except OSError as e:
print("[ENV-ERROR] 读取失败: %s" % e)
return 2
uv_c = {p for p in uv if is_scan_scope(p)}
cmake_c = {p for p in cmake if is_scan_scope(p)}
gaps = [] # 新缺口 -> FAIL
warns = [] # 存量白名单 -> WARN
# ① 存在但未进 uvprojx(无豁免:扫描范围内每个 .c 都应编入固件)
for f in sorted(scanned):
if f not in uv:
if (not args.strict) and f in KNOWN_GAPS:
warns.append("[WARN] %s | 缺 uvprojx(存量:%s)" % (f, KNOWN_GAPS[f]))
else:
gaps.append("[GAP] %s | 缺 uvprojx" % f)
# ② 存在但未进 CMakeLists(MCU-only 豁免)
for f in sorted(scanned):
if f in cmake_c:
continue
if any(fnmatch.fnmatch(f, pat) for pat, _ in SIM_EXEMPT):
continue
if (not args.strict) and f in KNOWN_GAPS:
warns.append("[WARN] %s | 缺 CMakeLists(存量:%s)" % (f, KNOWN_GAPS[f]))
else:
gaps.append("[GAP] %s | 缺 CMakeLists" % f)
# ③ 幽灵注册:注册了但文件不存在(反向)
for f in sorted(uv_c):
if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))):
gaps.append("[GAP] %s | 幽灵注册(uvprojx)" % f)
for f in sorted(cmake):
if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))):
gaps.append("[GAP] %s | 幽灵注册(CMakeLists)" % f)
# 输出
print("== 双端注册反向校验 ==")
print("扫描 .c: %d | uvprojx(扫描范围): %d | CMakeLists(扫描范围): %d"
% (len(scanned), len(uv_c), len(cmake_c)))
for w in warns:
print(w)
for g in gaps:
print(g)
if gaps:
print("结果: FAIL(%d 个新缺口/幽灵注册,%d 个存量 WARN)" % (len(gaps), len(warns)))
return 1
if warns:
print("结果: PASS(无新缺口;%d 个存量缺口已白名单 WARN,N-23 Lead 处置中)" % len(warns))
return 0
print("结果: PASS(双端注册一致)")
return 0
if __name__ == "__main__":
sys.exit(main())
+11 -5
View File
@@ -19,17 +19,23 @@ MAP_DEFAULT = os.path.join(
)
# Rows in "Image component sizes" that re-aggregate objects (would double-count sums).
TOTALS_ROWS = {"Object Totals", "Library Totals", "User Totals", "mc_w.l", "mf_w.l"}
TOTALS_ROWS = {"Object Totals", "Library Totals", "User Totals"}
# ARM library aggregate rows (e.g. mc_w.l / mf_w.l / m_wm.l): the linker prints one
# aggregate line per library in addition to the individual member .o lines, so any
# "<lib>.l" row is a re-aggregation and must be dropped from per-file sums.
# (2026-10-11: sha256's 64-bit shifts pulled in m_wm.l — the hardcoded name list
# missed it and the self-consistency check went +58; match the name pattern instead.)
_LIB_AGG_RE = re.compile(r'^[a-z]{1,2}_[a-z0-9]+\.l$')
def _is_aggregate(name):
"""Aggregate rows to drop from per-file sums: library-name summaries and Totals lines.
'(incl. Padding)' / '(incl. Generated)' are linker metadata; they are excluded from
per-file tables but their contribution IS counted in Grand Totals, so the per-file
sums will differ from Grand Totals by the alignment-padding delta (~300B here).
'(incl. Padding)' / '(incl. Generated)' are linker metadata ledger rows; they ARE
kept so that per-file sums reconcile exactly with Grand Totals (self-check Δ=0).
"""
return name in TOTALS_ROWS
return name in TOTALS_ROWS or bool(_LIB_AGG_RE.match(name))
def parse_execution_region_header(line):
+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())
+6
View File
@@ -0,0 +1,6 @@
{
"内存方案": "docs/内存管理/RAM使用分析与优化方案.md",
"架构总览": "docs/架构设计/项目架构总览.md",
"规划": "docs/算法/继电保护功能复现规划.md",
"V0.0": "docs/硬件平台/V0.0_网表详细分析报告.md"
}