diff --git a/scripts/audit_coverage.py b/scripts/audit_coverage.py index 6aa088b..73aa585 100644 --- a/scripts/audit_coverage.py +++ b/scripts/audit_coverage.py @@ -46,17 +46,58 @@ def load_spec(): 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)] - 注意:泛型参数常跨多行书写(如 request<{ accessToken: string;\n ... }>('/auth/login')), - 因此必须在「整文件文本」上匹配,逐行扫描会整片漏掉。 + 注意:泛型参数常跨多行书写,且可能嵌套 `>`,因此必须在「整文件文本」上做 + 平衡括号解析,逐行扫描或朴素正则都会漏检。 """ calls = [] - call_re = re.compile( - r"request\s*(?:<[^>]*>)?\s*\(\s*(?P['\"`])(?P[^'\"`]+)(?P=q)", - re.DOTALL, - ) + 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 ) @@ -65,18 +106,24 @@ def load_frontend(): 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] + 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 <= cm.start(): + if start <= hm.start(): name = fname else: break - line = text.count("\n", 0, cm.start()) + 1 + line = text.count("\n", 0, hm.start()) + 1 calls.append((method, path, name, file, line)) return calls