d1e6bb4ba7
N-19 FTP 鉴权:
- lftpd_auth(USER/PASS 状态机 + 连续5次失败锁60s)+ SHA-256
(user||pwd) 口令哈希;config.ini [ftp] 段 user/pwd_hash/write,
出厂 admin/admin 在用有启动告警;lftpd_log.c 删除并入 lftpd_auth
- ftp_cfg 配置读写;台架 ftp_auth_bench.py 13/13
N-30 / Trap 25(CH395F_Trap_Records.md 四次定案全记录):
- 修复1 closed-with-data accept:客户端发完正常 FIN 后芯片按
FUN_PARA(0x09) §5.52② 保留 Socket+RX 缓冲,旧代码把 CLOSED 一律
relisten 自毁缓冲 → STOR 必挂 425;改为接受"已关但可读"连接排空
- 修复2 丢 CONNECT 中断探针 net_lost_connect_probe + last_peer_port
幽灵甄别(拆链窗口握手完成但中断丢失,双信源去抖补 accept)
- 修复3 send_list/send_nlst 批量单发:背靠背 ~60B 小段毒化芯片 TX
(芯片报 SEND_OK 但对端全丢),整份目录一次性写入
- 证伪存档:固定沉降/Socket 轮换冷却/listen 门控(台架 v7 矩阵)
- 台架工具 test/board/ch395f_reuse_bench.{c,h} + host 脚本存档
(不注册编译;实验分支 n30-chip-bench 保留)
N-20:Error_Handler 死循环降级(记录+复位路径)
N-23:scripts/check_registration.py 双端注册反向校验(pre-commit)
N-26:GD5F NAND 直 DMA 调用方缓冲窗口断言
验证:BUILD_OK 0/0;ftp_regression 连跑 4 轮 11/11(修复前
LIST#4/#7 100% 挂、STOR#2 曾 100% 425);ftp_auth_bench 13/13;
@test 启动标记 PASS;ci_local L0+L0.5 全过;doc-links 通过。
登记簿 N-30 已销项。
631 lines
29 KiB
Python
631 lines
29 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. 章节引用 `docs/xxx.md` §N.M —— 目标文档里必须真有该编号的标题。
|
||
刻意只校验**紧邻写法**(文档名与 § 之间只允许空白/反引号/括号,
|
||
实测可把误报压到 0);昵称式引用如「内存方案 §1.4」不校验。
|
||
|
||
豁免(不算失效,不报错):
|
||
* 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,见《项目审查与优化建议报告》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
|
||
|
||
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(`...`),但保持字符数不变(同长空白替换),
|
||
以便后续报错时的列号/上下文仍可对应原文。
|
||
目的:文档常把「链接写法示例」放在反引号里(如 ),
|
||
这类字面量不是真实引用,不应参与存在性校验。
|
||
注意:反引号包裹的仓库根相对路径由 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`):「内存方案」→ 具体文档。
|
||
|
||
存在的意义:昵称式引用(「内存方案 §1.4」)读起来顺、机器判不了;登记一次别名,
|
||
正文不用改,引用就纳入校验。表里没有的昵称不猜(宁缺毋滥)。
|
||
"""
|
||
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)
|
||
|
||
|
||
# 昵称前常挂的动词/介词:「见内存方案 §1.4」「顺序按报告 §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))
|