""" 前后端接口覆盖率审计。 用途:比对后端 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[] }>('/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['\"`])(?P[^'\"`]+)(?P=q)") func_re = re.compile( r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P\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()