#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 文档路径一致性 + 命名规范检查。 由 pre-commit 调用:python scripts/check_doc_links.py ,发现问题退出码 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", # 本地记忆/会话数据,非项目文档 ".mnemon", # 同上:记忆工具的版本快照目录,已 .gitignore "ci-artifacts", # 本地临时/草稿目录,已 .gitignore,不是项目文档 ".dsh-worktrees", # DSH worktree 插件在**仓库内**建的任务树(整份仓库的副本, # 扫它等于把同一篇文档按别人的任务状态判一遍——假红 + 白跑) "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" # 别名后缀匹配:STEM_RE 贪心会把挂在前面的动词/修饰词一并吃进 name # (「规格见需求档」「其行为以需求档」),精确查表落空后按**最长别名**做 endswith。 # 只放宽「此前判不了」的 token(原来进 skip),不改变已能判定的目标。 if not cand: for key in sorted(load_aliases(), key=len, reverse=True): if key != name and name.endswith(key): apath = REPO_ROOT / load_aliases()[key] if 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()] # 中间挂着的词若是已登记别名且**紧邻** §(间隔 ≤ SEC_MAX_GAP 字符), # 视为换目标,不许链着继承前一个 § 的文档;远处的词不算 gap_txt = between am = STEM_RE.search(line[max(0, m.start() - 120):m.start()]) if am and not any(c in SEC_TERMINATORS for c in gap_txt): # § 前紧邻的裸名(STEM_RE 锚定在窗口尾)若能解析成**另一篇**文档 # (「…§2.6 的 H1/H2,属需求档 §9.1.1」),链在此换目标; # 「本文 §3 / §4」的「本文」解析不出文档 → 仍走原继承。 atgt, _akind = resolve_name(am.group(1), by_stem) if atgt is not None and atgt != prev_tgt: prev_end, prev_tgt, prev_kind = None, None, None tgt = None if tgt is None and prev_tgt is not None: 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))