Browse Source

fix(scripts): 修正覆盖率审计的泛型解析缺陷,覆盖率不再被低估

- 原用正则 `request\s*(?:<[^>]*>)?\s*\(` 解析调用,泛型遇到嵌套 `>`
  (如 request<{items: Omit<X,'id'>[]}>)在第一个 `>` 就收尾,随后要求 '('
  而实际是 ' & {',整个调用被静默漏检
- 改为平衡尖括号解析(_skip_generics,忽略 => 箭头),并显式标注
  「路径非字面量则无法静态判定」的跳过条件
- 影响:已接入 83 → 85,未接入 165 → 163(修正前后差 2 个端点)
master
doublex001 4 weeks ago
parent
commit
6349740c0a
  1. 69
      scripts/audit_coverage.py

69
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<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)]
注意:泛型参数常跨多行书写(如 request<{ accessToken: string;\n ... }>('/auth/login')),
因此必须在「整文件文本」上匹配,逐行扫描会整片漏掉。
注意:泛型参数常跨多行书写,且可能嵌套 `>`,因此必须在「整文件文本」上做
平衡括号解析,逐行扫描或朴素正则都会漏检。
"""
calls = []
call_re = re.compile(
r"request\s*(?:<[^>]*>)?\s*\(\s*(?P<q>['\"`])(?P<path>[^'\"`]+)(?P=q)",
re.DOTALL,
)
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
)
@ -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

Loading…
Cancel
Save