Files
xiaotutongxue/scripts/check-api-parity.py
T
lovife db6a51d617 docs: 清掉三处"会静默过期"的小账 + check-test-profile 加固(F-98)
一、附录 A 从"手抄表"改成"可再生成的命令"
   check-api-parity.py 加 --extra,现算"代码有、契约 §4 没有"的接口 = 41 个
   (旧手抄表写 83/62/21,已差近一倍,而且没人会记得回来改它)。
   并按"谁去改什么"分六类 —— 只有 E 类(8 个)要客户定口径,其余是文档侧补记。

二、F-6 已修:frontend-pc 的 Mock 说明
   AGENTS.md 那句改正之外,连带修了 .agents/skills/project-api/SKILL.md ——
   它整篇仍以 vite-plugin-mock / mock/** 为主线,而该项目里 mock/ 目录与插件都已删除。
   只改指路牌、不改路的尽头,等于没改。

三、docs/03 两处文档内部口径冲突加 ⚠️ 注(不改口径,只把冲突摆明)
   §2 notifyBizType 的 5/6 与 §13.5、落库值不一致 → 实现以 §13.5 为准(催报去重按 5);
   §3.6/§9.5 的 collect_file.source 字段表(入口来源)与建表注释(谁传的)冲突 →
   实现以建表口径为准。两处都写明"待客户确认",见 F-30 / F-38 / F-39。

四、check-test-profile.py 加固(新增 F-98)
   · 子进程输出落盘(不再用没人读的 PIPE):失败时能看见"停在哪一行",
     成功时打出启动用时(基线 ~10 秒 / 72 行 / 9.5KB);
   · 开跑前按 jar + profile 清残留 JVM,且保留 dev 后端(E2E 正打着它);
   · 等待期间每 15 秒报一次进度,卡住时不留沉默。
   教训:随机端口这个防撞设计把残留 JVM 藏起来了 —— 撞不到端口就永远发现不了,
   于是表现成"时好时坏",而失败时连日志都读不到。

验证:python scripts/verify-all.py --with-e2e → 通过 8 / 失败 0 / 跳过 0
      (E2E 36 脚本 / 1548 断言 / 278s;prodguard 9/9;test profile 能起)
      python scripts/verify-docs.py → 22 项全部通过
2026-09-30 09:10:34 +08:00

172 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""契约 ↔ 代码 接口实现度比对(可复现的盘点工具)。
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。
代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。
★ 两个必须容忍的写法(踩过):
1. 全限定注解 `@org.springframework.web.bind.annotation.PostMapping("/x")`
—— FillController 就是这么写的;用 `@PostMapping` 短名匹配会漏掉 3 个接口;
2. `@RequestMapping(value="/x", method=RequestMethod.POST)` 这种显式写法。
用法:
python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单
python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用)
python scripts/check-api-parity.py --extra # 反向:代码有、契约 §4 清单没有的(F-4 底稿)
"""
from __future__ import annotations
import glob
import os
import re
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOC = os.path.join(ROOT, "docs", "03-接口设计.md")
JAVA = os.path.join(ROOT, "backend", "xt-server", "src", "main", "java", "cn", "tianbaotong", "fill")
# §4 里这两节是索引(交叉引用别处已计数的接口),不重复计入
INDEX_SECTIONS = ("4.9", "4.11")
def norm(path: str) -> str:
"""归一化:去掉 /api/v1 前缀、统一占位符(`{id}` 与 `{fileId}` 视为同一个)。"""
p = path.replace("/api/v1", "")
p = re.sub(r"\{[^}]+\}", "{}", p)
return p.rstrip("/") or "/"
def doc_overview() -> dict[str, list[tuple[str, str]]]:
"""解析 §4.1~§4.10 的清单表,返回 分类 -> [(METHOD, path)]。"""
text = open(DOC, encoding="utf-8").read()
sec = text[text.index("## §4"):text.index("## §5")]
out: dict[str, list[tuple[str, str]]] = {}
cur = None
for line in sec.splitlines():
h = re.match(r"^### (4\.\d+) (.+)$", line)
if h:
cur = f"{h.group(1)} {h.group(2)}"
out[cur] = []
continue
if cur is None or not line.startswith("|") or cur.startswith(INDEX_SECTIONS):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2 or set(cells[0]) <= set("-: "):
continue
method, path = cells[0].replace("⭐", "").strip(), cells[1].strip("` ")
if not path.startswith("/"):
continue
for m in re.findall(r"GET|POST|PUT|DELETE|PATCH", method.upper()) or ["?"]:
out[cur].append((m, path))
return out
def code_endpoints() -> list[tuple[str, str, str]]:
"""解析所有 controller,返回 [(文件, METHOD, 完整路径)]。"""
out = []
for f in glob.glob(os.path.join(JAVA, "module", "**", "controller", "*.java"), recursive=True):
src = open(f, encoding="utf-8").read()
mb = re.search(r'@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]+)"', src)
base = mb.group(1) if mb else ""
for mm in re.finditer(
r'@(?:[\w.]+\.)?(Get|Post|Put|Delete|Patch)Mapping(?:\(\s*(?:value\s*=\s*)?"([^"]*)")?',
src):
out.append((os.path.basename(f), mm.group(1).upper(),
(base + (mm.group(2) or "")) or "/"))
for mm in re.finditer(
r'@RequestMapping\(\s*value\s*=\s*"([^"]+)"\s*,\s*method\s*=\s*'
r'(?:RequestMethod\.)?(GET|POST|PUT|DELETE|PATCH)', src):
out.append((os.path.basename(f), mm.group(2), base + mm.group(1)))
return sorted(set(out), key=lambda x: (x[0], x[2]))
def main() -> int:
quiet = "--quiet" in sys.argv
only_missing = "--missing" in sys.argv
only_extra = "--extra" in sys.argv
doc = doc_overview()
code = code_endpoints()
code_keys = {(m, norm(p)) for _, m, p in code}
doc_keys = {(m, norm(p)) for eps in doc.values() for m, p in eps}
claimed = implemented = 0
missing: list[tuple[str, str, str]] = []
rows = []
for cat, eps in doc.items():
uniq = sorted(set(eps))
done = [e for e in uniq if (e[0], norm(e[1])) in code_keys]
miss = [e for e in uniq if (e[0], norm(e[1])) not in code_keys]
claimed += len(uniq)
implemented += len(done)
missing += [(cat, m, p) for m, p in miss]
rows.append((cat, len(uniq), len(done), len(miss)))
# ── 反向差集:代码注册了、而契约 §4 清单里没有 ──
# ⚠️ 这个清单**必须由工具生成**:它以前是人工做一次差集后手抄进文档的,
# 于是每加一个接口就悄悄过期一次(数字 83/62/21 与实际差了 3 倍)。
# 手抄件会过期,命令不会。
extra = [(f, m, p) for f, m, p in code if (m, norm(p)) not in doc_keys]
if only_extra:
print("=" * 78)
print("代码有、契约 §4 清单没有:%d 个" % len(extra))
print("(代码共注册 %d 个;契约声称 %d 个,其中 %d 个已在代码里)"
% (len(code), claimed, implemented))
print("=" * 78)
cur = None
for f, m, p in extra:
if f != cur:
print("\n【%s】" % f)
cur = f
print(" %-6s %s" % (m, p))
print()
return 0
if not quiet and not only_missing:
print("=" * 78)
print("代码注册接口:%d 个" % len(code))
print("=" * 78)
for f, m, p in code:
print(" %-30s %-6s %s" % (f, m, p))
print()
print("=" * 78)
print("契约 §4 总览逐分类")
print("=" * 78)
for cat, n, d, miss_n in rows:
print(" %-22s 声称 %2d 已实现 %2d 缺口 %2d" % (cat, n, d, miss_n))
print()
print("合计:契约 %d / 已实现 %d / 缺口 %d / 实现度 %.0f%%"
% (claimed, implemented, len(missing), 100.0 * implemented / claimed))
if not quiet:
print()
cur = None
for cat, m, p in missing:
if cat != cur:
print("\n【%s】" % cat)
cur = cat
print(" %-6s %s" % (m, p))
# `--missing` 就是"只看还差什么":上面那两段(代码清单 + 逐分类数字)
# 在"接着往下做"时反而是噪声,而这是最常被问的问题
if only_missing:
print()
print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。")
# 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题
if claimed != 121:
print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())