#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 文档路径一致性 + 命名规范检查(D-3/D-6/D-7)。 由 pre-commit 调用:python scripts/check_doc_links.py ,发现问题退出码 1。 检查三类: A. Markdown 链接 [文本](相对路径) —— 相对当前文档所在目录解析 B. 反引号路径引用 `docs/xxx/yyy.md` —— 相对仓库根解析(本工程约定:文档间引用 一律用「仓库根相对路径」) C. 文件名规范 目录名 / 文件名不得含空格与路径敏感字符(见 docs/文档规范.md §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 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", "<", ">") 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, "路径引用")) 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): errors = [] n_files = 0 for doc in iter_docs(argv[1:] if len(argv) > 1 else []): 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_name(doc, errors) 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。") return 1 print(f"文档路径检查通过({n_files} 个文档)。") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))