a6cdebde62
- 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 警告
250 lines
11 KiB
Python
250 lines
11 KiB
Python
#!/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(`...`),但保持字符数不变(同长空白替换),
|
||
以便后续报错时的列号/上下文仍可对应原文。
|
||
目的:文档常把「链接写法示例」放在反引号里(如 ),
|
||
这类字面量不是真实引用,不应参与存在性校验。
|
||
注意:反引号包裹的仓库根相对路径由 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))
|