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
+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} 个文档)。")