## 问题
`test_real_document_has_no_duplicate_endpoint_ids` 失败,报 7 处"首列无法识别":
`**场外基金**`、`**场外运营**`、`**客户入驻**`、`**推广材料**`、`**访客令牌**`、
`**账户与交易**`、`段`。
查下去发现**文档没错、工具错了**:§19 里除端点总目录外,还有一张
「段 / 前缀 / 挂载来源 / 权限口径」的**说明表**(`docs/05` 行 1207 起)。它的首列是
分组名、表头是 `段` 而不是 `编号`。工具早期实现把**整章所有表格行**都当端点表扫,
于是那张说明表的 6 个分组名 + 1 个表头被报成结构异常。
**误报比漏报更伤**:这个脚本存在的意义是抓"编号被复用"(2026-09-12 真的发生过
`A034`/`A035` 各出现两次),可一旦它平时就在报噪声,真出问题时会被当成噪声忽略。
## 改法
`collect_rows` 改为**按表格块**识别:
- 表格块的第一行是表头;**只有表头首列是 `编号`** 的表格才是端点总目录;
- 其它表格整体跳过并**计数** —— 跳过几张表会打印出来,所以"忽略了什么"是可见的,
不是静默的;
- 端点表**内部**形状不对的首列**仍然报出来** —— 那才是真正的结构异常。
于是"§19 覆盖"这行现在长这样:
§19 覆盖:93 个端点编号 / 9 个号段(A×49、AD×11、C×7、K×4、M×4、O×3、P×2、R×4、T×9);跳过 1 张非端点表
跳过的表被显式写出来,覆盖范围仍然可核对 —— 这是原实现"不枚举前缀白名单、
把扫到的号段一并打印"那套自检思路的延续。
## 测试
`test_real_document_actually_scans_enough_endpoints` 因 `collect_rows` 返回值从
2 元组变 3 元组而需要同步;顺便**加了一条断言守住新行为**:
`skipped >= 1`,即"§19 的说明表确实被识别为独立表格块并跳过" —— 否则那几个分组名
会重新变成误报,而这条测试会先失败。
## 实测
- `python tools/check_docs_endpoint_ids.py` -> **exit 0**
「93 个端点编号 / 9 个号段;跳过 1 张非端点表 / 端点编号无重复」
- `pytest tests/unit tests/contract` -> **1428 passed, 2 skipped, 1 failed**
(剩下的 1 个是投顾工作台 `index.html` 被整体替换成自包含静态页所致,
属产品决策,见前述说明)
- `ruff check` -> All checks passed
116 lines
5.0 KiB
Python
116 lines
5.0 KiB
Python
"""`docs/05-接口文档.md` §19 端点编号唯一性(只读,不连库)。
|
||
|
||
规则与动机见 `tools/check_docs_endpoint_ids.py` 的模块说明:`check_authoritative_docs.py`
|
||
只校验 `docs/` 的**文件名编号**,不校验 §19 的**端点编号** —— 2026-09-12 两条线各自
|
||
新增端点时都占了 `A034`/`A035`,合并后同时存在两个 `A034` 与两个 `A035` 却仍然通过守卫。
|
||
|
||
这里额外守住两件事:
|
||
|
||
1. **不能"空扫通过"**:真实文档必须真的扫到足量编号,否则脚本坏掉了也会显示"无重复";
|
||
2. **覆盖范围要可见**:扫描不得依赖前缀白名单 —— 同一件事上曾因正则写成 `[AMKCS]`
|
||
而漏掉 `O`/`R` 两段,把 62 个端点报成 55 个;漏掉的号段一旦被复用,脚本仍会报
|
||
"重复 0"。所以覆盖报告必须列出每个号段的数量。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
from pathlib import Path
|
||
from types import ModuleType
|
||
|
||
import pytest
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||
#: 真实 §19 的规模下限:远小于实际数量,只为拦住"扫到 0 条也算通过"。
|
||
MIN_REAL_ENDPOINTS = 40
|
||
|
||
|
||
def _load_tool_module() -> ModuleType:
|
||
path = PROJECT_ROOT / "tools" / "check_docs_endpoint_ids.py"
|
||
spec = importlib.util.spec_from_file_location("check_docs_endpoint_ids", path)
|
||
assert spec is not None and spec.loader is not None
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def _write_doc(tmp_path: Path, body: str) -> Path:
|
||
doc = tmp_path / "05-接口文档.md"
|
||
doc.write_text(
|
||
"# 接口文档\n\n## 19. 接口总目录\n\n"
|
||
"| 编号 | 方法与路径 | 权限 | 幂等 | 成功状态 | 审计 |\n"
|
||
"|---|---|---|---|---|---|\n"
|
||
f"{body}\n\n## 20. 变更流程\n\n占位。\n",
|
||
encoding="utf-8",
|
||
)
|
||
return doc
|
||
|
||
|
||
def test_real_document_has_no_duplicate_endpoint_ids() -> None:
|
||
module = _load_tool_module()
|
||
assert module.collect_findings() == []
|
||
|
||
|
||
def test_real_document_actually_scans_enough_endpoints() -> None:
|
||
"""防止脚本坏掉后"什么都没扫到"却报通过。"""
|
||
module = _load_tool_module()
|
||
# 返回值是三元组:编号、无法识别的首列、跳过的非端点表数量
|
||
ids, _, skipped = module.collect_rows(module.read_section())
|
||
assert len(ids) >= MIN_REAL_ENDPOINTS
|
||
assert len(set(ids)) == len(ids)
|
||
# §19 除端点总目录外,还有一张「段 / 前缀 / 挂载来源 / 权限口径」的说明表。
|
||
# 它必须被识别为**非**端点表并跳过 —— 否则那 6 个分组名
|
||
# (`**场外基金**` 等)会被误报成"首列无法识别",而误报会让这个检查
|
||
# 失去可信度(真出问题时会被当成噪声忽略)。
|
||
assert skipped >= 1, "§19 的说明表没有被识别为独立表格块"
|
||
|
||
|
||
def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None:
|
||
module = _load_tool_module()
|
||
doc = _write_doc(
|
||
tmp_path,
|
||
"| A034 | `POST /api/v1/auth/tokens` | 公开 | 否 | `200` | 否 |\n"
|
||
"| A034 | `GET /api/v1/admin/customer-profile-candidates` | `x:y` | 否 | `200` | 否 |\n",
|
||
)
|
||
findings = module.collect_findings(doc)
|
||
assert any("A034" in item and "重复" in item for item in findings)
|
||
|
||
|
||
def test_unrecognized_first_column_is_reported(tmp_path: Path) -> None:
|
||
module = _load_tool_module()
|
||
doc = _write_doc(tmp_path, "| A034(临时) | `GET /x` | `a:b` | 否 | `200` | 否 |\n")
|
||
findings = module.collect_findings(doc)
|
||
assert any("无法识别" in item for item in findings)
|
||
|
||
|
||
def test_coverage_report_lists_every_prefix_segment(tmp_path: Path) -> None:
|
||
"""覆盖报告必须暴露各号段数量 —— 漏扫一个前缀就会在这里显形。"""
|
||
module = _load_tool_module()
|
||
doc = _write_doc(
|
||
tmp_path,
|
||
"| A001 | `GET /a1` | `a:b` | 否 | `200` | 否 |\n"
|
||
"| A002 | `GET /a2` | `a:b` | 否 | `200` | 否 |\n"
|
||
"| M003 | `GET /m3` | `a:b` | 否 | `200` | 否 |\n"
|
||
"| O001 | `GET /o1` | `a:b` | 否 | `200` | 否 |\n"
|
||
"| R001 | `GET /r1` | `a:b` | 否 | `200` | 否 |\n",
|
||
)
|
||
report = module.describe_coverage(doc)
|
||
for expected in ("5 个端点编号", "4 个号段", "A×2", "M×1", "O×1", "R×1"):
|
||
assert expected in report, report
|
||
|
||
|
||
def test_missing_section_raises(tmp_path: Path) -> None:
|
||
module = _load_tool_module()
|
||
doc = tmp_path / "05-接口文档.md"
|
||
doc.write_text("# 接口文档\n\n## 1. 概述\n\n没有 §19。\n", encoding="utf-8")
|
||
with pytest.raises(module.DocumentStructureError):
|
||
module.collect_findings(doc)
|
||
|
||
|
||
def test_empty_checklist_prepares_no_false_success(tmp_path: Path) -> None:
|
||
"""§19 存在但一张表都没有时,必须报结构异常,而不是"无重复"。"""
|
||
module = _load_tool_module()
|
||
doc = _write_doc(tmp_path, "本节没有表格。")
|
||
findings = module.collect_findings(doc)
|
||
assert any("没有扫描到任何端点编号" in item for item in findings)
|