Files
STM32F4-Base/scripts/check_doc_links.py
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +08:00

639 lines
30 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 -*-
"""
文档路径一致性 + 命名规范检查。
由 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);昵称式引用如「内存方案 §2.1」不校验。
豁免(不算失效,不报错):
* 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,源于 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
# 控制台编码兜底:Windows 中文控制台(pre-commit 钩子环境,代码页 936/GBK)下打印
# ⚠️/📋 等非 GBK 字符会抛 UnicodeEncodeError → 钩子带 traceback 以退出码 1 死亡,
# 而这类告警本身「不拦提交」。启动处统一把 stdout/stderr 切到 UTF-8 + errors="replace"
# 兜底;hasattr 守卫兼容 stdout/stderr 被替换成无 reconfigure 的对象(如 None)。
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")
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`):「内存方案」→ 具体文档。
存在的意义:昵称式引用(「内存方案 §2.1」)读起来顺、机器判不了;登记一次别名,
正文不用改,引用就纳入校验。表里没有的昵称不猜(宁缺毋滥)。
"""
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)
# 昵称前常挂的动词/介词:「见内存方案 §2.1」「顺序按报告 §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))