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.
161 lines
6.0 KiB
161 lines
6.0 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 _skip_generics(text: str, i: int) -> int:
|
|
"""从 text[i] 开始(i 指向 '<')跳过一层平衡尖括号,返回 '>' 之后的位置。
|
|
|
|
⚠️ 不能简单用正则 `<[^>]*>`:泛型里常嵌套 `>`,例如
|
|
request<{ items: Omit<ApplicationItem, 'id'>[] }>('/applications/me', ...)
|
|
↑ 正则会在第一个 '>' 就收尾,随后要求 '(' 而实际是 ' & {',
|
|
整个调用被静默漏检 → 覆盖率被系统性低估。
|
|
同时要忽略 `=>`(箭头函数类型),它的 '>' 不参与配对。
|
|
括号不平衡时返回 -1,表示这不是一个泛型。
|
|
"""
|
|
depth = 0
|
|
k = i
|
|
n = len(text)
|
|
while k < n:
|
|
ch = text[k]
|
|
if ch == ">" and k > 0 and text[k - 1] == "=": # `=>` 箭头,跳过
|
|
k += 1
|
|
continue
|
|
if ch == "<":
|
|
depth += 1
|
|
elif ch == ">":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return k + 1
|
|
k += 1
|
|
return -1
|
|
|
|
|
|
def _call_paren(text: str, pos: int) -> int:
|
|
"""text[pos:] 以 `request` 开头时,返回其后调用括号 '(' 的下标,否则 -1。"""
|
|
i = pos + len("request")
|
|
n = len(text)
|
|
while i < n and text[i].isspace():
|
|
i += 1
|
|
if i < n and text[i] == "<":
|
|
i = _skip_generics(text, i)
|
|
if i < 0:
|
|
return -1
|
|
while i < n and text[i].isspace():
|
|
i += 1
|
|
return i if i < n and text[i] == "(" else -1
|
|
|
|
|
|
def load_frontend():
|
|
"""从 api.ts / auth.ts 抽取 request() 调用:返回 [(method, normpath, funcname, file, line)]
|
|
|
|
注意:泛型参数常跨多行书写,且可能嵌套 `>`,因此必须在「整文件文本」上做
|
|
平衡括号解析,逐行扫描或朴素正则都会漏检。
|
|
"""
|
|
calls = []
|
|
head_re = re.compile(r"\brequest\b")
|
|
arg_re = re.compile(r"\s*(?P<q>['\"`])(?P<path>[^'\"`]+)(?P=q)")
|
|
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 hm in head_re.finditer(text):
|
|
paren = _call_paren(text, hm.start())
|
|
if paren < 0:
|
|
continue
|
|
am = arg_re.match(text, paren + 1)
|
|
if not am:
|
|
continue # 路径不是字面量(变量/拼接),无法静态判定
|
|
path = norm(am.group("path"))
|
|
after = text[am.end() : am.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 <= hm.start():
|
|
name = fname
|
|
else:
|
|
break
|
|
line = text.count("\n", 0, hm.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()
|
|
|