Files
STM32F4-Base/scripts/check_doc_links.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

631 lines
29 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 -*-
"""
文档路径一致性 + 命名规范检查(D-3/D-6/D-7)。
由 pre-commit 调用:python scripts/check_doc_links.py <file...>,发现问题退出码 1。
检查三类:
A. Markdown 链接 [文本](相对路径) —— 相对当前文档所在目录解析
B. 反引号路径引用 `docs/xxx/yyy.md` —— 相对仓库根解析(本工程约定:文档间引用
一律用「仓库根相对路径」)
C. 文件名规范 目录名 / 文件名不得含空格与路径敏感字符(见 docs/文档规范.md §1)
D. 章节引用 `docs/xxx.md` §N.M —— 目标文档里必须真有该编号的标题。
刻意只校验**紧邻写法**(文档名与 § 之间只允许空白/反引号/括号,
实测可把误报压到 0);昵称式引用如「内存方案 §1.4」不校验。
豁免(不算失效,不报错):
* 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,见《项目审查与优化建议报告》D-3 建议)
* 含 `[示例]` 标记的行——引用报错样例 / 日志片段等含路径的引文
* 引用附近(±40 字符)出现「未创建/尚未创建/已删除/待排期」等状态词
* 含通配符 `*`、尖括号 `<模块>` 等占位符
* 模板变量样式(NN_ / XX_ / 界面名 / 模块名 / 日期 / 示例 / ${...} / %s 等)
* 行内代码 span(`...`)里的链接写法——那是「怎么写」的示例,不是真实引用
* 外部 URL(http/https/mailto)与纯锚点(#...)
* 第三方目录下的文档(由 pre-commit exclude 控制,脚本内亦再兜底)
* `docs/芯片手册/` 为外部资料:链接形态与文件名均不受控(整体豁免)
用法:
python scripts/check_doc_links.py docs/a.md docs/b.md ... # 检查指定文档
python scripts/check_doc_links.py --all # 检查全库文档
退出码:0 通过;1 有问题。
"""
import re
import sys
import pathlib
import urllib.parse
from collections import Counter
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
# 不参与检查的路径片段(第三方/生成物/外部资料)
SKIP_PARTS = ("node_modules", ".venv", ".git", "build", "obj", "__pycache__",
"Lib/lvgl", "simulator/SDL2", ".opencode/node_modules",
".workbuddy-ai", # 本地记忆/会话数据,非项目文档
"docs/芯片手册") # 外部数据手册转录,非项目自有文档,链接形态不受控
# Markdown 链接:[text](target)。目标须以「非空白、非右括号」构成,
# 且排除纯数字(字体尺寸等畸形写法)。
MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
# 行内代码 span:`...`(含 ``` 围栏行整行)。先剥离再扫链接——
# 反引号里的内容按文档约定是「字面量/示例」,不应被当作真实引用。
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
# 反引号路径:`docs/xxx/yyy.md`(仓库根相对);终止字符含中英文标点,防同行粘连
BACKTICK_PATH_RE = re.compile(r"`((?:docs|scripts|tools|test|simulator)/[^`\n]*?\.(?:md|c|h|py|json|txt|bat|svg|ini|tsv|yml|yaml))`")
# 行内状态词:出现这些说明该引用已失效/尚未创建,属正常叙述。
# ⚠️ 必须与引用「就近」匹配(见 has_status_hint),不能整行匹配——
# 否则文件名本身含「不存在」「删除」等字样时会误豁免同行的所有引用。
STATUS_HINTS = ("计划", "未创建", "尚未创建", "已删除", "删除", "不存在", "缺失",
"断链", "待建", "待创建")
# 显式「这是举例」标记:整行豁免。用于引用报错样例、日志片段、期望输出等
# 含路径的引文(如「典型报错:`docs/a.md:1 [markdown 链接] x.svg`」)。
EXAMPLE_MARKER = "[示例]"
# 状态词与引用之间的最大字符距离(就近判定窗口)
STATUS_PROXIMITY = 40
# 占位符 token
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:
return True
if "xxx" in text or "yyy" in text:
return True
return any(tok in text for tok in PLACEHOLDER_TOKENS)
def is_valid_link_target(target: str) -> bool:
"""过滤畸形写法:纯数字(字体尺寸等)、空目标、非路径形态。"""
if not target:
return False
if target.isdigit():
return False
# 必须含「/」或「.」才像路径(避免把普通文本/锚点当路径)
if "." not in target and "/" not in target:
return False
return True
def has_status_hint(text: str, pos: int = -1, span: int = 0) -> bool:
"""是否靠近某状态词。
pos<0(默认)→ 退化为整行匹配,兼容无位置信息的调用。
pos>=0 → 只在引用的 span 两侧 STATUS_PROXIMITY 字符内找状态词,
避免「文件名里含状态词」豁免掉同行的其它真实引用。
"""
if pos < 0:
return any(h in text for h in STATUS_HINTS)
lo = max(0, pos - STATUS_PROXIMITY)
hi = min(len(text), pos + max(span, 0) + STATUS_PROXIMITY)
window = text[lo:hi]
return any(h in window for h in STATUS_HINTS)
def strip_inline_code(line: str) -> str:
"""剥离行内代码 span(`...`),但保持字符数不变(同长空白替换),
以便后续报错时的列号/上下文仍可对应原文。
目的:文档常把「链接写法示例」放在反引号里(如 ![标题](相对路径.svg)),
这类字面量不是真实引用,不应参与存在性校验。
注意:反引号包裹的仓库根相对路径由 check_backtick_paths 单独处理,
故这里用逐段替换、只挖掉成对反引号内部内容。
"""
return INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), line)
def check_md_links(path: pathlib.Path, lines, errors):
"""A 类:markdown 链接 —— 相对当前文档目录解析。
扫描前剥离行内代码 span:反引号里的链接写法是「示例」,非真实引用。
"""
for lineno, line in enumerate(lines, 1):
scan = strip_inline_code(line)
for m in MD_LINK_RE.finditer(scan):
target = m.group(2).strip()
if not target or target.startswith(("http://", "https://", "mailto:", "#")):
continue
if not is_valid_link_target(target):
continue
# 去掉锚点后判断
raw_path = urllib.parse.unquote(target.split("#")[0])
if not raw_path:
continue
if is_placeholder(target) or "[计划]" in line or EXAMPLE_MARKER in line:
continue
if has_status_hint(line, m.start(), len(m.group(0))):
continue
resolved = (path.parent / raw_path).resolve()
if not resolved.exists():
errors.append((path, lineno, target, "markdown 链接"))
def check_backtick_paths(path: pathlib.Path, lines, errors):
"""B 类:反引号内的仓库根相对路径。
与 A 类相反:这里反引号**就是**引用容器(本工程约定 `docs/xxx.md` 表引用),
故不剥离代码 span,直接扫原文。
"""
for lineno, line in enumerate(lines, 1):
for m in BACKTICK_PATH_RE.finditer(line):
target = m.group(1).strip()
if is_placeholder(target) or "[计划]" in line or EXAMPLE_MARKER in line:
continue
if has_status_hint(line, m.start(), len(m.group(0))):
continue
if not (REPO_ROOT / target).exists():
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:
return p.resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
return p.as_posix()
def is_skipped(p: pathlib.Path) -> bool:
rel = _rel(p)
parts = rel.split("/")
for s in SKIP_PARTS:
if "/" in s:
if rel == s or rel.startswith(s + "/"):
return True
elif s in parts:
return True
return False
# --- 文件名规范(docs/文档规范.md §1) -----------------------------------
# 路径敏感 / 全角字符:禁止出现在文档目录名与文件名中
BAD_NAME_CHARS = set("()【】〔〕<>:|*?\"\\&")
# 外部资料目录:沿用上游原名,不受命名规范约束
NAME_EXEMPT_DIRS = ("docs/芯片手册",)
def check_name(path: pathlib.Path, errors):
"""校验文档「目录名 + 文件名」不含空格与路径敏感字符(规范 §1)。"""
rel = _rel(path)
for exempt in NAME_EXEMPT_DIRS:
if rel == exempt or rel.startswith(exempt + "/"):
return
parts = rel.split("/")[1:] # 去掉仓库根
for part in parts:
stem = part[:-3] if part.endswith(".md") else part
if " " in stem:
errors.append((path, 0, f"名称含空格: {stem}", "文件名规范"))
bad = sorted(set(stem) & BAD_NAME_CHARS)
if bad:
errors.append((path, 0,
f"名称含禁用字符 {' '.join(bad)}: {stem}", "文件名规范"))
def iter_docs(argv):
if "--all" in argv:
for p in REPO_ROOT.rglob("*.md"):
if not is_skipped(p):
yield p
return
for a in argv:
if a.startswith("--"):
continue
p = pathlib.Path(a)
if not p.is_absolute():
p = pathlib.Path.cwd() / p
if p.is_file() and p.suffix.lower() == ".md":
yield p.resolve()
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):
if is_skipped(doc):
continue
try:
text = doc.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
n_files += 1
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:
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
rel = path
loc = f"{rel}:{lineno}" if lineno else f"{rel}"
print(f" {loc} [{kind}] {target}")
print(f"\n检查 {n_files} 个文档,{len(errors)} 处问题。")
print("提示:引用计划中的文件请用 `[计划] <路径>` 前缀标记;"
"已删除的文件请写明「已删除」状态词;"
"引用报错样例/日志片段请在该行加 `[示例]` 标记;"
"命名规范见 docs/文档规范.md §1。")
print("章节引用失效([章节引用]):目标文档已无该编号小节 → 改指现有编号或直接写小节名。"
"可校验的写法:`路径.md` §N.M / 裸文件名 §N.M / 文档名前缀(V0.0)/ 已登记别名;"
"判不了目标的跑 --section-todo 看清单与改写建议。")
return 1
print(f"文档路径检查通过({n_files} 个文档)。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))