You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
114 lines
4.3 KiB
114 lines
4.3 KiB
"""
|
|
前后端接口覆盖率审计。
|
|
|
|
用途:比对后端 OpenAPI(scripts/openapi.json)的全部操作与前端 src/api/*.ts 里
|
|
真实发出的 request() 调用,得出「已接入 / 未接入」清单,按域分组统计。
|
|
|
|
匹配规则:
|
|
- 路径归一化:`{jobId}` 与模板串 `${jobId}` 一律折成 `{}`,避免占位符命名差异误判。
|
|
- 方法:request 未显式传 method 时按 GET 计(http.ts 默认 GET)。
|
|
|
|
用法:python scripts/audit_coverage.py
|
|
"""
|
|
import json
|
|
import re
|
|
from collections import defaultdict
|
|
|
|
# 权威端点源 = 后端给的 markdown 文档(scripts/openapi.json 是 9/28 的旧快照,
|
|
# 比 md 少 28 个端点,含 /auth/identity/select,拿它当基准会把已接接口误判成幽灵)
|
|
SPEC_MD = "docs/职来后端接口.md"
|
|
API_FILES = ["src/api/api.ts", "src/api/auth.ts", "src/api/http.ts"]
|
|
|
|
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
|
|
|
|
def norm(path: str) -> str:
|
|
"""把 {id} / ${id} 统一折成 {}"""
|
|
p = re.sub(r"\$\{[^}]*\}", "{}", path)
|
|
p = re.sub(r"\{[^}]*\}", "{}", p)
|
|
if not p.startswith("/"):
|
|
p = "/" + p
|
|
return p.rstrip("/") or "/"
|
|
|
|
|
|
def load_spec():
|
|
"""从后端 markdown 文档解析端点:`## GET /jobs/search(标题)` 行给出方法与路径"""
|
|
# md 结构是「## GET 中文标题」+ 下一行「GET /real/path」,标题行里的中文不是路径,
|
|
# 真正可请求的行就是行首 `METHOD /path` 那一行。
|
|
ops = {}
|
|
entry_re = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/\S*)\s*$")
|
|
with open(SPEC_MD, encoding="utf-8") as f:
|
|
for ln in f:
|
|
m = entry_re.match(ln)
|
|
if not m:
|
|
continue
|
|
ops[(m.group(1).upper(), norm(m.group(2)))] = ln.strip()
|
|
return ops
|
|
|
|
|
|
def load_frontend():
|
|
"""从 api.ts / auth.ts 抽取 request() 调用:返回 [(method, normpath, funcname, file, line)]
|
|
|
|
注意:泛型参数常跨多行书写(如 request<{ accessToken: string;\n ... }>('/auth/login')),
|
|
因此必须在「整文件文本」上匹配,逐行扫描会整片漏掉。
|
|
"""
|
|
calls = []
|
|
call_re = re.compile(
|
|
r"request\s*(?:<[^>]*>)?\s*\(\s*(?P<q>['\"`])(?P<path>[^'\"`]+)(?P=q)",
|
|
re.DOTALL,
|
|
)
|
|
func_re = re.compile(
|
|
r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>\w+)\s*\(", re.MULTILINE
|
|
)
|
|
for file in API_FILES:
|
|
with open(file, encoding="utf-8") as f:
|
|
text = f.read()
|
|
# 函数名 → 起始偏移,用于把调用归属到所在函数
|
|
func_starts = [(m.start(), m.group("name")) for m in func_re.finditer(text)]
|
|
for cm in call_re.finditer(text):
|
|
path = norm(cm.group("path"))
|
|
after = text[cm.end() : cm.end() + 200]
|
|
mm = re.search(r"method:\s*['\"](GET|POST|PUT|PATCH|DELETE)['\"]", after)
|
|
method = mm.group(1) if mm else "GET"
|
|
name = "(module)"
|
|
for start, fname in func_starts:
|
|
if start <= cm.start():
|
|
name = fname
|
|
else:
|
|
break
|
|
line = text.count("\n", 0, cm.start()) + 1
|
|
calls.append((method, path, name, file, line))
|
|
return calls
|
|
|
|
|
|
def main():
|
|
ops = load_spec()
|
|
calls = load_frontend()
|
|
used = {(m, p) for m, p, *_ in calls}
|
|
|
|
covered = sorted(k for k in ops if k in used)
|
|
missing = sorted(k for k in ops if k not in used)
|
|
phantom = sorted({(m, p, fn, f, ln) for m, p, fn, f, ln in calls if (m, p) not in ops})
|
|
|
|
print(f"后端端点总数: {len(ops)}")
|
|
print(f"前端 request() 调用数: {len(calls)}(去重路径 {len(used)})")
|
|
print(f"已接入: {len(covered)} 未接入: {len(missing)} 幽灵调用(后端无此端点): {len(phantom)}")
|
|
print()
|
|
|
|
print("=== 幽灵调用(前端发了但后端 spec 没有,必改)===")
|
|
for m, p, fn, f, ln in phantom:
|
|
print(f" {m:6} {p:52} {fn} ({f}:{ln})")
|
|
print()
|
|
|
|
by_domain = defaultdict(list)
|
|
for m, p in missing:
|
|
by_domain[p.strip("/").split("/")[0]].append((m, p))
|
|
print("=== 未接入端点(按域)===")
|
|
for d in sorted(by_domain, key=lambda k: -len(by_domain[k])):
|
|
print(f"\n[{d}] {len(by_domain[d])} 个")
|
|
for m, p in sorted(by_domain[d]):
|
|
print(f" {m:6} {p}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|