diff --git a/tests/unit/tools/test_docs_endpoint_ids.py b/tests/unit/tools/test_docs_endpoint_ids.py index 850a4a7..6cc5f7c 100644 --- a/tests/unit/tools/test_docs_endpoint_ids.py +++ b/tests/unit/tools/test_docs_endpoint_ids.py @@ -54,9 +54,15 @@ def test_real_document_has_no_duplicate_endpoint_ids() -> None: def test_real_document_actually_scans_enough_endpoints() -> None: """防止脚本坏掉后"什么都没扫到"却报通过。""" 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(set(ids)) == len(ids) + # §19 除端点总目录外,还有一张「段 / 前缀 / 挂载来源 / 权限口径」的说明表。 + # 它必须被识别为**非**端点表并跳过 —— 否则那 6 个分组名 + # (`**场外基金**` 等)会被误报成"首列无法识别",而误报会让这个检查 + # 失去可信度(真出问题时会被当成噪声忽略)。 + assert skipped >= 1, "§19 的说明表没有被识别为独立表格块" def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None: diff --git a/tools/check_docs_endpoint_ids.py b/tools/check_docs_endpoint_ids.py index fc8b8aa..48d3923 100644 --- a/tools/check_docs_endpoint_ids.py +++ b/tools/check_docs_endpoint_ids.py @@ -9,12 +9,17 @@ ## 口径(含覆盖自检) -- 扫描范围:§19 章节内的**所有**表格行首列; +- 扫描范围:§19 里**端点总目录**那张表的数据行首列 —— 以**表头首列是不是 `编号`** + 判定。§19 还有一张「段 / 前缀 / 挂载来源 / 权限口径」的说明表,它的首列是分组名 + (`**场外基金**` 等)、表头是 `段`,**会被整体跳过并计数**(跳过了几张表会打印出来, + 所以"忽略了什么"是可见的)。早期实现把整章的表都当端点表扫,把那 6 个分组名 + + 1 个表头报成"首列无法识别" —— 那是误报,而**误报比漏报更伤**:真出问题时会被 + 当成噪声忽略掉; +- 端点表**内部**形状不对的首列仍会被显式列出来(而不是静默跳过); - **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次 "扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" —— 一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把 **"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对; -- 首列不是 `XXX###` 形状的行会被显式列出来(而不是静默跳过); - 只读:不修改任何文件。 用法: @@ -65,34 +70,60 @@ def read_section(path: Path = INTERFACE_DOC) -> list[str]: 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] = [] unrecognized: list[str] = [] + skipped_tables = 0 + in_endpoint_table = False + previous_line_was_row = False + for line in lines: match = TABLE_ROW.match(line.strip()) if match is None: + # 非表格行 = 表格块结束,下一行表格行将是新表的表头 + previous_line_was_row = False continue + 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 - if first_cell == "编号": # 表头 + + previous_line_was_row = True + if not in_endpoint_table: + continue + if not first_cell or SEPARATOR.match(first_cell): continue if ENDPOINT_ID.match(first_cell): ids.append(first_cell) else: unrecognized.append(first_cell) - return ids, unrecognized + + return ids, unrecognized, skipped_tables def collect_findings(path: Path = INTERFACE_DOC) -> list[str]: """返回问题列表;空列表表示一致。""" lines = read_section(path) - ids, unrecognized = collect_rows(lines) + ids, unrecognized, _ = collect_rows(lines) problems: list[str] = [] if not ids: @@ -103,17 +134,20 @@ def collect_findings(path: Path = INTERFACE_DOC) -> list[str]: problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次") for cell in sorted(set(unrecognized)): - problems.append(f"§19 首列无法识别为端点编号:{cell!r}") + problems.append(f"§19 端点表首列无法识别为端点编号:{cell!r}") return problems 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) 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: