修复 §19 端点编号检查的误报:按表格块识别,而不是"整章所有表格行"
## 问题
`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
This commit is contained in:
@@ -54,9 +54,15 @@ def test_real_document_has_no_duplicate_endpoint_ids() -> None:
|
|||||||
def test_real_document_actually_scans_enough_endpoints() -> None:
|
def test_real_document_actually_scans_enough_endpoints() -> None:
|
||||||
"""防止脚本坏掉后"什么都没扫到"却报通过。"""
|
"""防止脚本坏掉后"什么都没扫到"却报通过。"""
|
||||||
module = _load_tool_module()
|
module = _load_tool_module()
|
||||||
ids, _ = module.collect_rows(module.read_section())
|
# 返回值是三元组:编号、无法识别的首列、跳过的非端点表数量
|
||||||
|
ids, _, skipped = module.collect_rows(module.read_section())
|
||||||
assert len(ids) >= MIN_REAL_ENDPOINTS
|
assert len(ids) >= MIN_REAL_ENDPOINTS
|
||||||
assert len(set(ids)) == len(ids)
|
assert len(set(ids)) == len(ids)
|
||||||
|
# §19 除端点总目录外,还有一张「段 / 前缀 / 挂载来源 / 权限口径」的说明表。
|
||||||
|
# 它必须被识别为**非**端点表并跳过 —— 否则那 6 个分组名
|
||||||
|
# (`**场外基金**` 等)会被误报成"首列无法识别",而误报会让这个检查
|
||||||
|
# 失去可信度(真出问题时会被当成噪声忽略)。
|
||||||
|
assert skipped >= 1, "§19 的说明表没有被识别为独立表格块"
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None:
|
def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -9,12 +9,17 @@
|
|||||||
|
|
||||||
## 口径(含覆盖自检)
|
## 口径(含覆盖自检)
|
||||||
|
|
||||||
- 扫描范围:§19 章节内的**所有**表格行首列;
|
- 扫描范围:§19 里**端点总目录**那张表的数据行首列 —— 以**表头首列是不是 `编号`**
|
||||||
|
判定。§19 还有一张「段 / 前缀 / 挂载来源 / 权限口径」的说明表,它的首列是分组名
|
||||||
|
(`**场外基金**` 等)、表头是 `段`,**会被整体跳过并计数**(跳过了几张表会打印出来,
|
||||||
|
所以"忽略了什么"是可见的)。早期实现把整章的表都当端点表扫,把那 6 个分组名 +
|
||||||
|
1 个表头报成"首列无法识别" —— 那是误报,而**误报比漏报更伤**:真出问题时会被
|
||||||
|
当成噪声忽略掉;
|
||||||
|
- 端点表**内部**形状不对的首列仍会被显式列出来(而不是静默跳过);
|
||||||
- **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次
|
- **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次
|
||||||
"扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" ——
|
"扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" ——
|
||||||
一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把
|
一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把
|
||||||
**"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对;
|
**"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对;
|
||||||
- 首列不是 `XXX###` 形状的行会被显式列出来(而不是静默跳过);
|
|
||||||
- 只读:不修改任何文件。
|
- 只读:不修改任何文件。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
@@ -65,34 +70,60 @@ def read_section(path: Path = INTERFACE_DOC) -> list[str]:
|
|||||||
return lines[start + 1 : end]
|
return lines[start + 1 : end]
|
||||||
|
|
||||||
|
|
||||||
def collect_rows(lines: list[str]) -> tuple[list[str], list[str]]:
|
def collect_rows(lines: list[str]) -> tuple[list[str], list[str], int]:
|
||||||
"""返回 `(首列编号列表, 无法识别的首列列表)`。
|
"""返回 `(首列编号列表, 无法识别的首列列表, 跳过的非端点表数量)`。
|
||||||
|
|
||||||
表头行、分隔行、非表格行都会被跳过;非空但形状不对的首列会被归类为
|
⚠️ 按**表格块**识别,而不是"§19 里所有表格行"。
|
||||||
"无法识别",以便显式暴露而不是静默漏扫。
|
§19 除端点总目录外还有一张说明表(`段 / 前缀 / 挂载来源 / 权限口径`),
|
||||||
|
它的首列是分组名(`**场外基金**`、`账户与交易`…),表头是 `段` 而不是 `编号`。
|
||||||
|
早期实现把整章的表都当端点表扫,于是那张说明表的 6 个分组名 + 1 个表头被
|
||||||
|
报成"首列无法识别" —— **属于误报**,而误报会让这个检查失去可信度
|
||||||
|
(真出问题时会被当成噪声忽略)。
|
||||||
|
|
||||||
|
所以这里只在**表头首列是 `编号`** 的表格里收集数据行;其它表格整体跳过并计数,
|
||||||
|
跳过数量会打印出来,保证"忽略了什么"是可见的而不是静默的。
|
||||||
|
端点表**内部**形状不对的首列仍然会被报出来 —— 那才是真正的结构异常。
|
||||||
"""
|
"""
|
||||||
ids: list[str] = []
|
ids: list[str] = []
|
||||||
unrecognized: list[str] = []
|
unrecognized: list[str] = []
|
||||||
|
skipped_tables = 0
|
||||||
|
in_endpoint_table = False
|
||||||
|
previous_line_was_row = False
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
match = TABLE_ROW.match(line.strip())
|
match = TABLE_ROW.match(line.strip())
|
||||||
if match is None:
|
if match is None:
|
||||||
|
# 非表格行 = 表格块结束,下一行表格行将是新表的表头
|
||||||
|
previous_line_was_row = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
first_cell = match.group(1).split("|", 1)[0].strip()
|
first_cell = match.group(1).split("|", 1)[0].strip()
|
||||||
if not first_cell or SEPARATOR.match(first_cell):
|
|
||||||
|
if not previous_line_was_row:
|
||||||
|
# 新表格的表头行:只有首列是「编号」的才是端点总目录
|
||||||
|
in_endpoint_table = first_cell == "编号"
|
||||||
|
if not in_endpoint_table:
|
||||||
|
skipped_tables += 1
|
||||||
|
previous_line_was_row = True
|
||||||
continue
|
continue
|
||||||
if first_cell == "编号": # 表头
|
|
||||||
|
previous_line_was_row = True
|
||||||
|
if not in_endpoint_table:
|
||||||
|
continue
|
||||||
|
if not first_cell or SEPARATOR.match(first_cell):
|
||||||
continue
|
continue
|
||||||
if ENDPOINT_ID.match(first_cell):
|
if ENDPOINT_ID.match(first_cell):
|
||||||
ids.append(first_cell)
|
ids.append(first_cell)
|
||||||
else:
|
else:
|
||||||
unrecognized.append(first_cell)
|
unrecognized.append(first_cell)
|
||||||
return ids, unrecognized
|
|
||||||
|
return ids, unrecognized, skipped_tables
|
||||||
|
|
||||||
|
|
||||||
def collect_findings(path: Path = INTERFACE_DOC) -> list[str]:
|
def collect_findings(path: Path = INTERFACE_DOC) -> list[str]:
|
||||||
"""返回问题列表;空列表表示一致。"""
|
"""返回问题列表;空列表表示一致。"""
|
||||||
lines = read_section(path)
|
lines = read_section(path)
|
||||||
ids, unrecognized = collect_rows(lines)
|
ids, unrecognized, _ = collect_rows(lines)
|
||||||
|
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -103,17 +134,20 @@ def collect_findings(path: Path = INTERFACE_DOC) -> list[str]:
|
|||||||
problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次")
|
problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次")
|
||||||
|
|
||||||
for cell in sorted(set(unrecognized)):
|
for cell in sorted(set(unrecognized)):
|
||||||
problems.append(f"§19 首列无法识别为端点编号:{cell!r}")
|
problems.append(f"§19 端点表首列无法识别为端点编号:{cell!r}")
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def describe_coverage(path: Path = INTERFACE_DOC) -> str:
|
def describe_coverage(path: Path = INTERFACE_DOC) -> str:
|
||||||
"""返回覆盖范围报告 —— 让"扫到了什么"可见,而不是只报"没问题"。"""
|
"""返回覆盖范围报告 —— 让"扫到了什么"可见,而不是只报"没问题"。"""
|
||||||
ids, _ = collect_rows(read_section(path))
|
ids, _, skipped = collect_rows(read_section(path))
|
||||||
prefixes = Counter(re.match(r"[A-Z]+", code).group() for code in ids)
|
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()))
|
detail = "、".join(f"{prefix}×{count}" for prefix, count in sorted(prefixes.items()))
|
||||||
return f"§19 覆盖:{len(ids)} 个端点编号 / {len(prefixes)} 个号段({detail})"
|
return (
|
||||||
|
f"§19 覆盖:{len(ids)} 个端点编号 / {len(prefixes)} 个号段({detail})"
|
||||||
|
f";跳过 {skipped} 张非端点表"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
Reference in New Issue
Block a user