模块四-AI代码审核实战 | 第28讲:代码质量评分体系 - 可维护性、可读性、可测试性的量化指标
本讲目标:理解「量化指标」如何把主观感受变成可治理信号;掌握从 Python AST 计算圈复杂度与认知复杂度的基本方法;实现可配置权重的
QualityScorer、历史趋势与团队基准对比接口;输出 JSON 与 Markdown 双语报告,嵌入 CodeSentinel 审核结论。本讲强调可解释:每个分数都能追溯到具体函数与规则。建议你把quality_report.md当作 PR 评论附件的生成原型,在团队评审会上走一遍阅读路径。
开场:没有度量,就没有改进;但只有度量,也没有意义
很多团队在引入自动代码审核后,会迅速遇到一个尴尬局面:评论越来越多,研发越来越烦,但质量是否变好却说不清楚。根因通常是两类缺失。第一类缺失是指标缺失:你只告诉对方「这里不好」,却没有说明「不好到什么程度、相对团队基线如何、修复后能改善多少」。第二类缺失是语境缺失:复杂度在核心领域服务里可能值得付出,在一次性脚本里却不值得同等对待;没有语境的分数会变成无意义的惩罚。
本讲要做的不是堆叠学术指标,而是搭建一套可落地、可配置、可复盘的评分体系,让它服务三个决策场景。场景一是 PR 级别的快速反馈:贡献者在十分钟内看到本次变更对质量分数的影响。场景二是团队级别的趋势管理:负责人能看到过去四周平均复杂度、覆盖率与债务比的变化。场景三是平台级别的规则迭代:当某条规则与缺陷率相关性弱,你可以用数据把它降级或删除。
我们会重点处理三类指标家族。第一类是结构复杂性(圈复杂度、认知复杂度):它们与缺陷密度、评审耗时、合并冲突率有广泛实证相关性。第二类是可维护性综合指标(Maintainability Index,MI):它把体量、复杂度、注释等压缩为一个可比较分数,但要注意不同语言实现差异。第三类是过程性指标(测试覆盖率、代码搅动、技术债务比):它们来自测试产物与版本历史,更能解释「为什么这次 PR 风险更高」。
下面先给出 CodeSentinel 质量评分在平台中的流水线位置,再展开计算公式与完整实现。完成本讲后,你应能把「感觉难维护」翻译成「哪一个函数把分数拉低」,并能把分数映射为 A/B/C/D/F 等等级用于门禁与看板。
全局视角:质量评分流水线(Mermaid)
flowchart TB
subgraph Sources["数据源"]
SRC["Python 源码\n(单文件/快照)"]
COV["coverage.json\n(可选)"]
GIT["git log\n(可选 churn)"]
DEBT["issues 估算\n(可选债务字段)"]
end
subgraph Calc["计算层"]
AST["AST 解析"]
CC["Cyclomatic\n圈复杂度"]
COG["Cognitive\n认知复杂度(简化)"]
MI["Maintainability\nIndex 近似"]
AGG["多维加权聚合"]
end
subgraph Score["评分层"]
QS["QualityScorer\n权重+阈值"]
GR["等级映射\nA-F"]
TR["趋势序列\n(时间桶)"]
BM["团队基准\n分位数)"]
end
subgraph Out["输出"]
JS["quality.json"]
MD["quality.md"]
API["HTTP 报告接口\n(可选)"]
end
SRC --> AST --> CC --> AGG
AST --> COG --> AGG
AST --> MI --> AGG
COV --> AGG
GIT --> AGG
DEBT --> AGG
AGG --> QS --> GR
QS --> TR
QS --> BM
GR --> JS
GR --> MD
GR --> API
核心原理:为什么这些指标值得进入 CodeSentinel
1. 圈复杂度(Cyclomatic Complexity)衡量「决策密度」
圈复杂度来源于控制流图:每个决策点(if、for、while、except、布尔短路、case 分支等)都会增加独立路径数量。经验法则是:函数 CC 超过 10~15,单元测试组合爆炸、缺陷概率上升。对 Python AST,可用经典算法:从函数节点子树开始,统计特定语句与表达式节点类型并加总。注意 and/or 短路在 AST 里是 BoolOp,是否计入取决于你们团队标准;本讲实现采用「计入 BoolOp」的较严格版本,可在配置里关闭。
2. 认知复杂度(Cognitive Complexity)衡量「人类阅读负担」
认知复杂度在圈复杂度基础上惩罚嵌套:深层 if 比平铺的并列 if 更难读。Sonar 的规则体系是行业参考。教学实现可以做简化版:每个嵌套层级对新增决策加权重;break/continue、多分支 match、以及 lambda 内嵌套按增量规则处理。目标是让分数与工程师直觉更一致,而不是与编译器优化一致。
3. 可维护性指数(Maintainability Index)是「压缩摘要」
经典 MI 形式类似:
[ \text{MI} = 171 - 5.2 \ln V - 0.23 G - 16.2 \ln L ]
其中 (V) 为 Halstead Volume(教学实现可用行数与运算符数量的近似),(G) 为圈复杂度,(L) 为有效代码行数。不同工具常做 0~100 归一与钳制。MI 的价值是:在仪表盘上给非技术干系人一个「趋势向好/向坏」的信号。缺点是:对短函数或小文件波动大,因此 PR 级别应看增量影响而不是绝对值。
4. 测试覆盖率:线覆盖与分支覆盖
覆盖率不是质量本身,而是未验证变更范围的代理指标。CodeSentinel 应读取 coverage.py 输出的 JSON(coverage json -o coverage.json),提取 totals.percent_covered 与文件级明细。分支覆盖更能抓 if 的另一半,但工具链更重。建议门禁策略:对核心包目录要求分支覆盖阈值,对工具脚本目录放宽。
5. 代码搅动(Code Churn)与缺陷相关性
搅动可以定义为:某文件在最近窗口期内被修改的次数或行数。实证研究常见结论是:高搅动文件缺陷率更高。实现上可用 git log --since=... -- path 统计。把 churn 作为「风险乘子」比作为绝对分数更稳:同一复杂度下,搅动高则降级。
6. 技术债务比(Technical Debt Ratio)
简化定义:
[ \text{TDR} = \frac{\text{estimated_fix_minutes}}{\text{development_minutes}} ]
在自动平台里,estimated_fix_minutes 可由规则引擎汇总(每条规则带修复成本估计),development_minutes 可由提交时间差或工单记录估计。教学实现允许外部传入,避免绑定特定项目管理工具。
7. 评分体系:A/B/C/D/F 与阈值
建议把综合分映射为百分制,再映射等级:
- A:≥ 90
- B:80~89
- C:70~79
- D:60~69
- F:< 60
阈值必须团队化:数据科学团队可能接受更高复杂度,基础设施团队可能对覆盖率更苛刻。QualityScorer 必须把权重与阈值做成配置,而不是写死在代码里。
8. 历史趋势与基准对比的工程含义
趋势不是画图好看,而是为了回答:「我们上周的治理是否有效」。实现上可用时间序列桶(按天/周)存储聚合均值与中位数。团队基准对比建议使用分位数:你的 PR 在团队当月分布的 P75 之上,才触发 warning,避免内卷式「永远不满意」。
9. 与 AI 生成代码相关的特殊点
生成代码往往「结构正确但过度防御」:大量 try/except、深层嵌套、以及重复分支,导致 CC/COG 同时飙升。评分体系应配合「允许的解释字段」:当模型引入额外分支是为满足规范(例如安全审计要求),应在 PR 描述里标注,平台支持标签降权。
10. 误用指标的典型反模式
把 MI 当唯一真理、把覆盖率当 KPI 强行 100%、把 churn 当个人绩效,这些都会扭曲行为。平台方要明确:指标服务于风险沟通,不服务于人事评判(除非你刻意设计且经过治理委员会批准)。
11. AST 计算的边界
AST 无法知道运行期分支频率;复杂度相同但业务关键度不同的函数需要人工标注权重。可以在文件级 docstring 约定 # codesentinel: criticality=high 之类扩展点(本讲不实现完整解析器,仅提示方向)。
12. 性能与缓存
对大仓库,AST 解析可并行;指标结果应按 (commit, path, hash) 缓存。Quality 报告生成应是增量式的:仅对变更文件重算,再与父提交聚合。
13. 多语言扩展
本讲聚焦 Python。其他语言应接入对应分析器(Sonar、CodeQL、原生工具),再映射到统一 schema:metric_pack + value + evidence。
14. 报告可读性
Markdown 报告要包含:总览表、Top 风险函数列表、与上一版本的差分。JSON 报告要稳定版本化:schema_version 字段必须有。
15. 与 findings 的耦合方式
质量分数不应替代安全 finding;建议并行输出:findings[] + quality_summary。合入门禁可以要求「无 high finding 且 grade >= C」。
16. 统计学谨慎
小样本团队的分位数不稳定;至少积累 N 个 PR 后再启用强门禁。可以先用「仅提示」模式。
17. 伦理与隐私
churn 统计可能暴露个人加班节奏;聚合到团队级更安全。
18. 与模块二审核聚合对齐
codesentinel-clean-lab 的 review 模型可追加 quality_score 字段或作为评论附件上传,避免破坏既有 API。
19. 版本升级策略
指标算法变更会改变历史可比性;记录 algorithm_version 并在变更时重建趋势。
20. 落地路径
第一周只计算 CC 与行数;第二周加入覆盖率;第三周加入 churn;第四周再引入 MI 与债务比,避免一次性信息过载。
21. 指标相关性与因果陷阱
即使圈复杂度与缺陷率统计相关,也不意味着「降低圈复杂度必然降低缺陷」。更合理的表述是:高复杂度函数更难测试、更难评审,从而提高缺陷存活概率。平台文案要避免暗示因果,否则会被资深工程师质疑并抵制。你可以把相关性研究作为内部培训材料,但不要把论文结论写进门禁错误信息里。
22. 组织行为:分数会如何改变协作方式
当 PR 显示综合分下降,评审者可能把讨论焦点从业务正确性转向风格争论。为避免失焦,建议规则是:先判定功能与安全,再讨论质量分数;质量分数只作为合并前的最后一道「卫生检查」。另外,给维护者提供「override」能力并强制写理由,可以避免平台成为瓶颈。
23. 代码生成器的偏差:模板化重复
生成代码常常复制粘贴相似结构,导致多个函数同时处于「中等复杂度」区间,分数看起来平庸但系统整体难以推理。对此可以引入重复度指标(与本讲不同,但可并行):当 AST 子树相似度高于阈值,提示抽象不足。该指标要小心误报常见 boilerplate。
24. 与 Code Review 评论模板结合
Markdown 报告建议固定结构:摘要一行、子分数表、Top 风险函数、与父版本 diff(若可获取)、以及「建议下一步」。评论模板越稳定,研发越能快速扫描。不要在评论里贴完整 JSON,除非调试。
25. 与静态类型检查的关系
类型完善会降低某些缺陷类型,但对复杂度不一定有效。Mypy 通过与否与 CC 无必然联系。平台应并行展示:类型错误属于「正确性」通道,复杂度属于「可维护性」通道,避免混为一谈。
26. 注释与文档字符串对 MI 的影响
不同工具对注释处理不同。若 MI 把注释当「提高可维护性」的正向因素,可能鼓励无意义注释。更健康的做法是:MI 主要反映结构与复杂度,文档质量用独立规则(例如公共 API 必须有 docstring)评估。
27. 分支覆盖的实现成本
分支覆盖需要覆盖率工具支持。若团队暂不具备,可先用行覆盖并在报告里明确标注「风险未完全暴露」。当业务关键模块稳定后,再引入分支覆盖门槛。
28. 搅动指标的窗口选择
窗口太短会噪声大,太长会迟钝。常见选择是 30 天与 90 天并行:30 天用于风险提示,90 天用于架构健康度。对发布频繁的服务,7 天窗口也有意义,但要标注季节性因素(例如大促)。
29. 债务比的估计误差
estimated_fix_minutes 若来自人工填写,容易乐观或悲观。更稳妥的是用历史修复耗时分布校准:同样 rule_id 的历史 PR 实际耗时中位数作为估计。平台需要工单系统或时间追踪数据支撑。
30. 与「可读性」的主观维度
可读性无法完全量化。认知复杂度是近似。团队可以做抽样:每月随机抽取十个 PR,让两名工程师独立打分可读性,与 COG 做对照,逐步调参。这个过程是「把组织偏好编码进工具」的关键。
31. 指标歧视风险
若把复杂度与绩效绑定,可能导致工程师规避必要分支(例如安全校验)。治理委员会应明确禁止不当使用场景,并在员工手册或研发规范里写明。
32. 开源合规
若你复用 Sonar 规则思想,注意许可证与版权声明;自研算法也要记录来源论文与参考实现,避免知识产权争议。
33. 与 Feature Flag 的交互
被 flag 保护的代码常有额外分支,复杂度上升但业务需要。建议 PR label:ff-heavy 触发权重调整或跳过某些提示。
34. 与数据科学的协作
若团队有数据科学角色,可以帮你做分位数基线、异常检测与因果推断(需谨慎)。质量平台的数据是长期资产,应进入数据仓库而不是散落在 CI 日志里。
35. 失败案例:门禁过严导致绕过平台
当平台比组织协作节奏更慢时,研发会绕过平台直接在分支合并。要保持门禁「快」:计算增量、并行、缓存。否则指标再科学也落不了地。
36. 与模块四整体叙事对齐
本讲分数应与性能 finding、安全 finding 并列展示,而不是互相覆盖。最终 PR 评论是一个拼盘:风险、性能、质量、规范合规四类信息分区展示,阅读体验最好。
37. 未来:LLM 作为解释器而非评分者
让模型解释「为什么该函数 COG 高」很有价值;让模型直接给 0~100 分则不稳定。平台应坚持确定性指标为主,模型为辅。
38. 与 IDE 集成
在 IDE 显示当前函数的实时复杂度,可以把问题前移到编码阶段。CodeSentinel 服务端评分与 IDE 插件评分可能略有差异,需要统一算法版本。
39. 跨仓库复制与搅动失真
同名工具类在不同仓库复制粘贴会导致搅动指标异常低(因为每份看起来稳定)。单仓 monorepo 更利于真实反映修改频率;多仓需要服务级聚合。
40. 本讲代码的演进路线
下一步可以把 FunctionMetrics 输出为 SARIF metrics 扩展字段;可以把 TrendStore 换成 PostgreSQL;可以把 fake_churn_score 替换为 git log 解析。保持模块边界清晰,避免把报告生成与 git 解析耦死在同一文件。
质量维度雷达:概念结构(Mermaid)
Mermaid 原生雷达图支持有限,本讲用「维度→信号→权重」示意图表达雷达评分概念。
flowchart TB
subgraph D1["可维护性"]
M1["MI 近似"]
M2["函数长度"]
end
subgraph D2["可读性/复杂性"]
R1["圈复杂度"]
R2["认知复杂度"]
end
subgraph D3["可测试性"]
T1["覆盖率"]
T2["分支覆盖(可选)"]
end
subgraph D4["过程风险"]
P1["代码搅动"]
P2["债务比(可选)"]
end
W["QualityScorer\n权重向量"]
D1 --> W
D2 --> W
D3 --> W
D4 --> W
W --> S["综合分 0-100"]
S --> G["等级 A-F"]
指标依赖关系(Mermaid)
flowchart LR
F["函数 AST 子树"] --> CC["cyclomatic"]
F --> COG["cognitive_simplified"]
F --> LOC["有效行数"]
CC --> MI["maintainability_index"]
LOC --> MI
HAL["Halstead 近似"] --> MI
COV["coverage.json"] --> SCORE["加权聚合"]
MI --> SCORE
CC --> SCORE
COG --> SCORE
CHURN["git churn"] --> SCORE
DEBT["debt minutes"] --> SCORE
代码实战:ComplexityCalculator 与 QualityScorer 完整实现
下面给出单文件可运行示例 quality_scorer.py(Python 3.10+)。它计算每个函数的圈复杂度与简化认知复杂度,近似 MI,读取可选 coverage.json,并生成 JSON/Markdown 报告。
# quality_scorer.py
from __future__ import annotations
import ast
import dataclasses
import json
import math
import statistics
from collections import defaultdict
from pathlib import Path
from typing import Any, Iterable
@dataclasses.dataclass(frozen=True)
class FunctionMetrics:
name: str
lineno: int
cyclomatic: int
cognitive: int
loc: int
mi: float
@dataclasses.dataclass
class FileMetrics:
path: str
functions: list[FunctionMetrics]
@dataclasses.dataclass
class QualityConfig:
w_cyclomatic: float = 0.25
w_cognitive: float = 0.25
w_mi: float = 0.25
w_coverage: float = 0.15
w_churn: float = 0.10
cc_soft_cap: int = 12
cog_soft_cap: int = 15
mi_low: float = 50.0
mi_high: float = 85.0
class ComplexityCalculator:
"""基于 AST 的复杂度计算器(教学实现)。"""
def __init__(self, source: str, *, path: str = "<memory>") -> None:
self.path = path
self._tree = ast.parse(source)
def analyze_file(self) -> FileMetrics:
funcs: list[FunctionMetrics] = []
for node in self._tree.body:
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
funcs.append(self._analyze_function(node))
return FileMetrics(path=self.path, functions=funcs)
def _analyze_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionMetrics:
cc = 1 + self._cyclomatic_increment(node)
cog = self._cognitive_score(node)
loc = self._count_loc(node)
halstead_vol = max(1.0, loc * 8.0) # 教学近似:避免 ln(0)
mi_raw = 171 - 5.2 * math.log(halstead_vol) - 0.23 * cc - 16.2 * math.log(max(1, loc))
mi = max(0.0, min(100.0, mi_raw))
return FunctionMetrics(
name=node.name,
lineno=node.lineno,
cyclomatic=cc,
cognitive=cog,
loc=loc,
mi=mi,
)
def _cyclomatic_increment(self, node: ast.AST) -> int:
inc = 0
for n in ast.walk(node):
if isinstance(n, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.ExceptHandler)):
inc += 1
elif isinstance(n, ast.BoolOp):
inc += len(n.values) - 1
elif isinstance(n, ast.comprehension):
inc += 1
elif isinstance(n, ast.Match):
inc += len(n.cases)
return inc
def _cognitive_score(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
"""简化认知复杂度:嵌套越深,新增决策代价越高。"""
def walk(stmt: ast.stmt, depth: int) -> int:
score = 0
def add_decision(cost: int = 1) -> None:
nonlocal score
score += cost + depth
if isinstance(stmt, ast.If):
add_decision(1)
for s in stmt.body:
score += walk(s, depth + 1)
for s in stmt.orelse:
score += walk(s, depth + 1)
return score
if isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)):
add_decision(1)
for s in stmt.body:
score += walk(s, depth + 1)
for s in stmt.orelse:
score += walk(s, depth + 1)
return score
if isinstance(stmt, ast.Try):
add_decision(1)
for s in stmt.body:
score += walk(s, depth + 1)
for h in stmt.handlers:
add_decision(1)
for s in h.body:
score += walk(s, depth + 1)
for s in stmt.orelse:
score += walk(s, depth + 1)
for s in stmt.finalbody:
score += walk(s, depth + 1)
return score
if isinstance(stmt, ast.Match):
add_decision(1)
for c in stmt.cases:
for s in c.body:
score += walk(s, depth + 1)
return score
if isinstance(stmt, ast.With):
for s in stmt.body:
score += walk(s, depth)
return score
if isinstance(stmt, ast.Assign | ast.AugAssign | ast.AnnAssign | ast.Expr | ast.Raise | ast.Return | ast.Pass | ast.Break | ast.Continue):
return score
if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
# 嵌套定义:不计入外层 walk 的简单路径(教学版直接忽略内部,避免双计)
return score
return score
total = 0
for s in node.body:
total += walk(s, 0)
return total
def _count_loc(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
start = node.lineno
end = max((getattr(n, "end_lineno", start) for n in ast.walk(node)), default=start)
return max(1, end - start + 1)
def load_coverage_percent(coverage_path: Path | None) -> float | None:
if coverage_path is None or not coverage_path.exists():
return None
data = json.loads(coverage_path.read_text(encoding="utf-8"))
totals = data.get("totals") or {}
pct = totals.get("percent_covered")
return float(pct) if pct is not None else None
def fake_churn_score(path: str) -> float:
"""教学占位:返回 0~1,越大越动荡。落地时替换为 git 统计。"""
return min(1.0, (len(path) % 7) / 7.0)
def fake_debt_ratio() -> float:
"""教学占位:0~1。落地时由规则引擎汇总修复耗时估计。"""
return 0.08
def clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
def cyclomatic_subscore(cc: float, soft_cap: float) -> float:
# cc 越小越好:超过 soft_cap 快速扣分
if cc <= soft_cap:
return 100.0 - 40.0 * (cc / soft_cap)
return max(0.0, 60.0 - 10.0 * (cc - soft_cap))
def cognitive_subscore(cog: float, soft_cap: float) -> float:
return cyclomatic_subscore(cog, soft_cap)
def mi_subscore(mi: float, low: float, high: float) -> float:
if mi >= high:
return 100.0
if mi <= low:
return 20.0
return 20.0 + 80.0 * ((mi - low) / (high - low))
class QualityScorer:
def __init__(self, cfg: QualityConfig | None = None) -> None:
self.cfg = cfg or QualityConfig()
def score_file(
self,
fm: FileMetrics,
*,
coverage_pct: float | None,
churn: float | None = None,
debt_ratio: float | None = None,
) -> dict[str, Any]:
churn = fake_churn_score(fm.path) if churn is None else churn
debt_ratio = fake_debt_ratio() if debt_ratio is None else debt_ratio
# 取最差函数代表风险峰值,同时记录均值用于解释
worst_cc = max((f.cyclomatic for f in fm.functions), default=1)
worst_cog = max((f.cognitive for f in fm.functions), default=0)
avg_mi = statistics.mean([f.mi for f in fm.functions]) if fm.functions else 50.0
s_cc = cyclomatic_subscore(float(worst_cc), float(self.cfg.cc_soft_cap))
s_cog = cognitive_subscore(float(worst_cog), float(self.cfg.cog_soft_cap))
s_mi = mi_subscore(float(avg_mi), self.cfg.mi_low, self.cfg.mi_high)
cov = coverage_pct if coverage_pct is not None else 55.0
s_cov = clamp(cov, 0.0, 100.0)
s_churn = 100.0 * (1.0 - clamp(churn, 0.0, 1.0))
s_debt = 100.0 * (1.0 - clamp(debt_ratio, 0.0, 1.0))
w = self.cfg
# 若覆盖率缺失,降低其权重并重新归一
weights = [w.w_cyclomatic, w.w_cognitive, w.w_mi, w.w_coverage, w.w_churn]
parts = [s_cc, s_cog, s_mi, s_cov, s_churn]
if coverage_pct is None:
weights[3] = 0.0
ws = sum(weights) or 1.0
weights = [x / ws for x in weights]
composite = sum(p * ww for p, ww in zip(parts, weights))
# 债务比可作为惩罚项叠加(教学版轻量)
composite = clamp(composite - 5.0 * debt_ratio, 0.0, 100.0)
grade = self._to_grade(composite)
return {
"schema_version": 1,
"path": fm.path,
"composite": round(composite, 2),
"grade": grade,
"subscores": {
"cyclomatic_peak": round(s_cc, 2),
"cognitive_peak": round(s_cog, 2),
"maintainability_avg": round(s_mi, 2),
"coverage": round(s_cov, 2),
"churn_inverse": round(s_churn, 2),
},
"raw": {
"worst_cyclomatic": worst_cc,
"worst_cognitive": worst_cog,
"avg_mi": round(avg_mi, 2),
"coverage_pct": coverage_pct,
"churn": churn,
"debt_ratio": debt_ratio,
},
"functions": [dataclasses.asdict(f) for f in fm.functions],
}
@staticmethod
def _to_grade(score: float) -> str:
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
class QualityReportGenerator:
def render_markdown(self, payload: dict[str, Any]) -> str:
lines: list[str] = []
lines.append(f"## 质量评分:`{payload['path']}`")
lines.append("")
lines.append(f"- **综合分**:{payload['composite']}(等级 **{payload['grade']}**)")
lines.append(f"- **最差圈复杂度**:{payload['raw']['worst_cyclomatic']}")
lines.append(f"- **最高认知复杂度**:{payload['raw']['worst_cognitive']}")
lines.append(f"- **平均 MI(近似)**:{payload['raw']['avg_mi']}")
if payload["raw"]["coverage_pct"] is None:
lines.append("- **覆盖率**:未提供 `coverage.json`,已使用保守默认值参与计算(建议在 CI 生成)。")
else:
lines.append(f"- **覆盖率**:{payload['raw']['coverage_pct']}%")
lines.append("")
lines.append("### Top 风险函数(按圈复杂度)")
funcs = sorted(payload["functions"], key=lambda x: x["cyclomatic"], reverse=True)
for f in funcs[:5]:
lines.append(
f"- `{f['name']}` @ L{f['lineno']}: CC={f['cyclomatic']}, COG={f['cognitive']}, MI≈{round(f['mi'],2)}, LOC={f['loc']}"
)
lines.append("")
lines.append("### 子分数分解")
for k, v in payload["subscores"].items():
lines.append(f"- {k}: {v}")
return "\n".join(lines) + "\n"
class TrendStore:
"""极简内存趋势存储:生产请换 TSDB/SQL。"""
def __init__(self) -> None:
self._series: dict[str, list[tuple[str, float]]] = defaultdict(list)
def append(self, team: str, ts_iso: str, composite: float) -> None:
self._series[team].append((ts_iso, composite))
def benchmark_p75(self, team: str) -> float | None:
vals = [v for _, v in self._series[team]]
if not vals:
return None
vals_sorted = sorted(vals)
idx = int(0.75 * (len(vals_sorted) - 1))
return vals_sorted[idx]
def demo() -> None:
src = '''
import asyncio
def a(x):
if x > 0:
if x > 10:
for i in range(x):
if i % 2 == 0 and i % 3 == 0:
print(i)
return x
async def b():
try:
await asyncio.sleep(0)
except Exception:
raise
'''
fm = ComplexityCalculator(src, path="demo.py").analyze_file()
scorer = QualityScorer()
cov = load_coverage_percent(Path("coverage.json")) # 可能不存在
payload = scorer.score_file(fm, coverage_pct=cov)
Path("quality_report.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
md = QualityReportGenerator().render_markdown(payload)
Path("quality_report.md").write_text(md, encoding="utf-8")
print(md)
if __name__ == "__main__":
demo()
注意:
demo()中引用了asyncio但未 import,教学演示ast.parse不执行代码;若你要运行python quality_scorer.py,可在字符串里加import asyncio或忽略。为严谨,下面给出可执行修正:在src顶部加入import asyncio。
把 demo() 的 src 第一行改为:
src = '''
import asyncio
def a(x):
...
生产环境实战:从「算得出来」到「用得下去」
第一,CI 集成:在测试阶段生成 coverage.json,在静态分析阶段运行 ComplexityCalculator,由 QualityScorer 产出 quality_report.json 作为构建产物上传。PR 评论只引用摘要与链接,避免超长评论。
第二,阈值与豁免:对遗留代码一次性清零不现实。采用「只针对变更行覆盖」与「基线对比」策略:当综合分低于父提交且没有合理解释标签时拦截。
第三,团队基准:TrendStore 仅示意。生产建议使用仓库维度 + 团队维度双基准,防止大团队吞噬小团队方差。
第四,与 LLM 结合:把 Top 风险函数列表作为提示词输入,让模型生成重构建议,但必须附带原始指标,避免「空口建议」。
第五,安全与合规:quality_report.json 可能包含函数名与路径,上传到外部 SaaS 前需审查。
第六,可重复构建:同一提交在 CI 应得到同一分数;注意不同 Python 版本 AST 差异对 end_lineno 的影响。
第七,大规模单体仓库:优先对变更包(changed files)计算,全量夜间任务再补齐趋势。
第八,与 DORA 指标关系:质量分数不是部署频率的对立面,而是帮助降低变更失败率。
第九,可视化:Grafana 展示综合分移动平均;异常尖刺联动到发布事件。
第十,教育与对齐:新成员入职培训应用 30 分钟解释「为什么我们看重认知复杂度」。
第十一,反作弊:不要鼓励通过注释行撑 MI;应使用有效代码行统计。
第十二,多包 monorepo:按 package 分报告,再 rollup。
第十三,与性能审核协同:高复杂度 + 高 DB 调用密度函数应优先人工复审。
第十四,版本化:schema_version 与 algorithm_version 分字段记录。
第十五,回滚策略:算法升级导致门禁大面积失败时,一键回退权重配置比改代码更快。
第十六,权限:趋势看板可能泄露业务繁忙度;设置访问控制。
第十七,测试:为 cyclomatic_increment 准备金标准用例(单 if、多 except、match)。
第十八,国际化:Markdown 报告若给海外团队,可提供英文模板。
第十九,移动端仓库:若含 Kotlin/Swift,接入对应工具后映射统一 schema。
第二十,持续复盘:每季度评估指标与缺陷相关性,删除无效规则。
本讲小结(Mermaid Mindmap)
mindmap
root((第28讲\n质量评分))
指标
圈复杂度
认知复杂度
MI 近似
覆盖率
搅动
债务比
工程
可配置权重
等级映射
趋势与分位
交付
ComplexityCalculator
QualityScorer
JSON+Markdown 报告
原则
可解释
防扭曲
增量优先
思考题
- 你为什么认为「峰值复杂度」往往比「平均复杂度」更适合作为门禁输入?在什么业务场景下平均值更重要?
- 如果团队通过拆分函数把 CC 降低但引入更深调用链,质量分数可能变好还是变差?平台应如何捕捉这种权衡?
- 覆盖率在异步与并发代码里常出现「行覆盖看似充分但状态空间未覆盖」的问题,你会引入哪些补充指标(例如变异测试)?
下一讲预告
下一讲进入 Git 工作流集成:我们将用 Webhook 接收 PR/MR 事件,验证签名,拉取 diff,向 GitHub/GitLab 写评论与提交状态,并用状态机管理审核生命周期,让 CodeSentinel 真正「长在代码托管平台上」。