28-模块四-AI代码审核实战 第28讲-代码质量评分体系 - 可维护性 可读性 可测试性的量化指标

0 阅读21分钟

模块四-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

代码实战:ComplexityCalculatorQualityScorer 完整实现

下面给出单文件可运行示例 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_versionalgorithm_version 分字段记录。

第十五,回滚策略:算法升级导致门禁大面积失败时,一键回退权重配置比改代码更快。

第十六,权限:趋势看板可能泄露业务繁忙度;设置访问控制。

第十七,测试:为 cyclomatic_increment 准备金标准用例(单 if、多 except、match)。

第十八,国际化:Markdown 报告若给海外团队,可提供英文模板。

第十九,移动端仓库:若含 Kotlin/Swift,接入对应工具后映射统一 schema。

第二十,持续复盘:每季度评估指标与缺陷相关性,删除无效规则。


本讲小结(Mermaid Mindmap)

mindmap
  root((第28讲\n质量评分))
    指标
      圈复杂度
      认知复杂度
      MI 近似
      覆盖率
      搅动
      债务比
    工程
      可配置权重
      等级映射
      趋势与分位
    交付
      ComplexityCalculator
      QualityScorer
      JSON+Markdown 报告
    原则
      可解释
      防扭曲
      增量优先

思考题

  1. 你为什么认为「峰值复杂度」往往比「平均复杂度」更适合作为门禁输入?在什么业务场景下平均值更重要?
  2. 如果团队通过拆分函数把 CC 降低但引入更深调用链,质量分数可能变好还是变差?平台应如何捕捉这种权衡?
  3. 覆盖率在异步与并发代码里常出现「行覆盖看似充分但状态空间未覆盖」的问题,你会引入哪些补充指标(例如变异测试)?

下一讲预告

下一讲进入 Git 工作流集成:我们将用 Webhook 接收 PR/MR 事件,验证签名,拉取 diff,向 GitHub/GitLab 写评论与提交状态,并用状态机管理审核生命周期,让 CodeSentinel 真正「长在代码托管平台上」。