"""校验 `docs/05-接口文档.md` §19「接口总目录」的端点编号唯一性(只读,不连数据库)。 ## 为什么需要它 `tools/check_authoritative_docs.py` 只校验 `docs/` 的**文件名编号**与权威性声明, **不校验 §19 里每个端点的编号**。2026-09-12 出过一次真实事故:两条线各自新增端点时 都占用了 `A034`/`A035`,合并后 §19 同时存在两个 `A034` 与两个 `A035`, 而文档守卫照样通过 —— 编号复用比"编号不够"更麻烦,而且**静默遗留**。 ## 口径(含覆盖自检) - 扫描范围:§19 章节内的**所有**表格行首列; - **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次 "扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" —— 一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把 **"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对; - 首列不是 `XXX###` 形状的行会被显式列出来(而不是静默跳过); - 只读:不修改任何文件。 用法: python tools/check_docs_endpoint_ids.py 退出码:0 = 无重复;1 = 有重复或文档结构异常。 """ from __future__ import annotations import re from collections import Counter from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] INTERFACE_DOC = PROJECT_ROOT / "docs" / "05-接口文档.md" #: §19 的起止标题(`: \\s*` 之间允许一个空格,避免依赖标题里的具体文字)。 SECTION_START = re.compile(r"^##\s*19\.") SECTION_END = re.compile(r"^##\s*20\.") #: 表格行:只取首列,容忍单元格两侧空白。 TABLE_ROW = re.compile(r"^\|(.+)\|\s*$") #: Markdown 分隔行:| --- | :---: | 之类。 SEPARATOR = re.compile(r"^[\s\-:|]+$") #: 端点编号形状:1~4 个大写字母 + 3~4 位数字(A034 / M003 / R001)。 ENDPOINT_ID = re.compile(r"^[A-Z]{1,4}\d{3,4}$") class DocumentStructureError(RuntimeError): """`docs/05` 的 §19 结构不符合预期,无法安全校验。""" def read_section(path: Path = INTERFACE_DOC) -> list[str]: """返回 §19 的正文行(不含起止标题)。""" if not path.exists(): raise DocumentStructureError(f"缺少接口权威文档:{path}") lines = path.read_text(encoding="utf-8").splitlines() start = next((i for i, line in enumerate(lines) if SECTION_START.match(line)), None) if start is None: raise DocumentStructureError("接口文档里找不到 §19 章节标题") end = next( (i for i in range(start + 1, len(lines)) if SECTION_END.match(lines[i])), len(lines), ) return lines[start + 1 : end] def collect_rows(lines: list[str]) -> tuple[list[str], list[str]]: """返回 `(首列编号列表, 无法识别的首列列表)`。 表头行、分隔行、非表格行都会被跳过;非空但形状不对的首列会被归类为 "无法识别",以便显式暴露而不是静默漏扫。 """ ids: list[str] = [] unrecognized: list[str] = [] for line in lines: match = TABLE_ROW.match(line.strip()) if match is None: continue first_cell = match.group(1).split("|", 1)[0].strip() if not first_cell or SEPARATOR.match(first_cell): continue if first_cell == "编号": # 表头 continue if ENDPOINT_ID.match(first_cell): ids.append(first_cell) else: unrecognized.append(first_cell) return ids, unrecognized def collect_findings(path: Path = INTERFACE_DOC) -> list[str]: """返回问题列表;空列表表示一致。""" lines = read_section(path) ids, unrecognized = collect_rows(lines) problems: list[str] = [] if not ids: problems.append("§19 没有扫描到任何端点编号,文档结构可能已变化,请人工确认") counter = Counter(ids) for endpoint_id in sorted(code for code, count in counter.items() if count > 1): problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次") for cell in sorted(set(unrecognized)): problems.append(f"§19 首列无法识别为端点编号:{cell!r}") return problems def describe_coverage(path: Path = INTERFACE_DOC) -> str: """返回覆盖范围报告 —— 让"扫到了什么"可见,而不是只报"没问题"。""" ids, _ = collect_rows(read_section(path)) prefixes = Counter(re.match(r"[A-Z]+", code).group() for code in ids) detail = "、".join(f"{prefix}×{count}" for prefix, count in sorted(prefixes.items())) return f"§19 覆盖:{len(ids)} 个端点编号 / {len(prefixes)} 个号段({detail})" def main() -> int: problems = collect_findings() print(describe_coverage()) if problems: for problem in problems: print(f" ✗ {problem}") print(f"docs/05 §19 端点编号校验失败:{len(problems)} 处问题") return 1 print("docs/05 §19 端点编号无重复") return 0 if __name__ == "__main__": raise SystemExit(main())