Files
STM32F4-Base/scripts/check_doc_links.py
T
wandering1 a6cdebde62 文档体系重组与 D-7 一致性门禁落地,清理芯片手册孤儿图片
- D-6 目录规范化:docs 根目录散落文档归入 UI设计/子系统设计/架构设计/审查记录/
  环境与部署/网络 等子目录,去掉目录名空格,重命名 嵌入式C语言代码规范_V1.0.md
- D-7 三档一致性门禁:新增 scripts/check_doc_links.py(链接存在性)、
  scripts/gen_task_table.py(FreeRTOS 任务表数值)、scripts/check_mem_numbers.py
  (内存/Flash 体积),均支持 --check/--write/--diff,pre-commit 钩子同步注册
- D-5 文档索引:新建 docs/README.md,新建 docs/文档规范.md 约束目录命名与链接写法
- D-1 芯片手册瘦身:删除 1069 张无引用孤儿图片(77.06 MB),
  docs/芯片手册 120 MB → 41 MB,已引用图片零断链
- 同步 C 源码注释中的文档路径引用(D-6 重组后),构建 0 错误 0 警告
2026-09-26 00:51:53 +08:00

250 lines
11 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-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))