12.2 门禁的分级与阻断策略

同一份 7 条 finding 的清单,喂给三种阻断策略会得到三种结论:strict 全拦、ratchet 只拦新引入的、quota 因 warn 超配额拦。本节把策略抽成配置,用同一份清单跑出三种门禁结论,并用两条负例证明门禁不是恒返回 pass 的空壳——删掉 baseline 一条必须拦、把 warn 移出 blockOn 必须放。

12.2 门禁的分级与阻断策略

12.1 的引擎给出 7 条 finding、5 条硬失败——但「给出结论」和「阻断流水线」之间还差一层:门禁拿什么当阻断条件。同一份清单,策略不同,结论可以从「全拦」到「全放」。本节把策略抽成配置,用三种典型策略跑同一份清单,给出三种不同结论。

级别语义沿用第 3 章的三级(见 3.3 导入失败的分级与修复 ):block 阻断、warn 标注、info 记录。门禁策略只决定哪些级别在什么条件下变成阻断,不改级别本身。

12.2.1 三种策略,同一份清单

策略blockOnwarnQuotabaselineAware语义
strict["block"]0否见 block 即拦,最严
ratchet["block"]0是已知问题进 baseline,只拦新引入的
quota["block","warn"]1是在 ratchet 基础上再给 warn 设配额

三种策略读同一份 finding 清单,差别只在两个旋钮:blockOn(哪些级别算阻断)与 baselineAware(要不要把已知问题排除在外)。quota 多了 warnQuota——存量项目里 warn 往往成百上千,一刀切会让门禁要么形同虚设、要么天天红,配额是折中。

12.2.2 策略是配置,不是代码

策略写在 config/ta12_policy.json 里。baseline 是 5 条 (code, id) 对——正是 12.1 那 5 条硬失败,代表「这些是已知存量,先记账」。注意 baseline 只对 baselineAware: true 的策略生效,strict 看都不看它:

{
  "policySet": "ci-gate",
  "specVersion": 1,
  "levels": ["block", "warn", "info"],
  "actionByLevel": { "block": "fail", "warn": "annotate", "info": "log" },
  "note": "卷二 12.2 门禁策略。级别沿用第 3 章的 block / warn / info 三级语义(block 阻断、warn 标注、info 记录)。三种阻断策略共用同一份 finding 清单,差别只在「拿什么当阻断条件」:strict 见 block 即拦;ratchet 把已知问题放进 baseline,只拦新引入的;quota 额外给 warn 设配额。baseline 是存量项目落地 CI 的现实路径。",
  "strategies": [
    { "id": "strict", "blockOn": ["block"], "warnQuota": 0, "baselineAware": false },
    { "id": "ratchet", "blockOn": ["block"], "warnQuota": 0, "baselineAware": true },
    { "id": "quota", "blockOn": ["block", "warn"], "warnQuota": 1, "baselineAware": true }
  ],
  "baseline": [
    { "code": "E_BUDGET_OVER", "id": "ui" },
    { "code": "E_NAME", "id": "tex_legacy_upper" },
    { "code": "E_REF_CYCLE", "id": "furn_lamp_01" },
    { "code": "E_REF_DANGLING", "id": "mat_counter_wood" },
    { "code": "E_RESERVED", "id": "icon_coin_copy" }
  ]
}

12.2.3 S 层:一份清单,三种结论

读 12.1 的 out/ta12_engine.json 与策略配置,对每种策略调 decide()。decide 只做两件事:把 baseline 转成集合(若该策略 baselineAware),然后数「不在 baseline 里的」block 与 warn,按 blockOn 与 warnQuota 判定:

#!/usr/bin/env python3
# file: sim/ta12_gate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta12_gate.py
"""卷二 12.2 门禁分级与阻断策略(S 层)。读 12.1 的 out/ta12_engine.json 与
config/ta12_policy.json,把同一份 finding 清单喂给三种阻断策略:

* strict  —— 见 block 级 finding 即拦(空 baseline);
* ratchet —— 已知问题在 baseline 里,只拦新引入的(增量门禁);
* quota   —— 在 ratchet 基础上再给 warn 设配额,超过配额也拦。

策略是**配置**不是代码:三者的差别只在「拿什么当阻断条件」。级别沿用第 3 章的 block /
warn / info 三级语义。落 out/ta12_gate.json 供 P 层逐位对照。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def decide(findings, strategy, baseline):
    """按一个策略算门禁结论。findings 是 12.1 的产物(已带 severity)。"""
    base = {(b["code"], b["id"]) for b in baseline} if strategy["baselineAware"] else set()
    new_block = [f for f in findings
                 if f["severity"] == "block" and (f["code"], f["id"]) not in base]
    new_warn = [f for f in findings
                if f["severity"] == "warn" and (f["code"], f["id"]) not in base]
    reasons = []
    if "block" in strategy["blockOn"] and new_block:
        reasons.append("block")
    if "warn" in strategy["blockOn"] and len(new_warn) > strategy["warnQuota"]:
        reasons.append("warn-quota")
    return {
        "strategy": strategy["id"],
        "gate": "block" if reasons else "pass",
        "newBlock": len(new_block),
        "newWarn": len(new_warn),
        "reasons": reasons,
    }


def main():
    engine = json.load(open(os.path.join(ROOT, "out/ta12_engine.json"), encoding="utf-8"))
    policy = json.load(open(os.path.join(ROOT, "config/ta12_policy.json"), encoding="utf-8"))
    findings = engine["findings"]
    decisions = [decide(findings, s, policy["baseline"]) for s in policy["strategies"]]
    decisions.sort(key=lambda d: d["strategy"])

    levels = {lv: 0 for lv in policy["levels"]}
    for f in findings:
        levels[f["severity"]] += 1
    lines = [f"{d['strategy']}:{d['gate']}:{d['newBlock']}:{d['newWarn']}" for d in decisions]
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
    rep = {
        "project": engine["project"],
        "policySet": policy["policySet"],
        "findingCount": len(findings),
        "levelCounts": levels,
        "baselineCount": len(policy["baseline"]),
        "strategies": [s["id"] for s in policy["strategies"]],
        "decisions": decisions,
        "digest": digest,
    }
    with open(os.path.join(ROOT, "out/ta12_gate.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"findings={len(findings)} block={levels['block']} warn={levels['warn']} "
          f"baseline={rep['baselineCount']}")
    for d in decisions:
        print(f"  {d['strategy']:8s} gate={d['gate']:5s} newBlock={d['newBlock']} "
              f"newWarn={d['newWarn']} reasons={d['reasons']}")
    print(f"digest={digest}")
    print("wrote out/ta12_gate.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta12_gate.py:

findings=7 block=5 warn=2 baseline=5
  quota    gate=block newBlock=0 newWarn=2 reasons=['warn-quota']
  ratchet  gate=pass  newBlock=0 newWarn=2 reasons=[]
  strict   gate=block newBlock=5 newWarn=2 reasons=['block']
digest=289016ccde42dcfb
wrote out/ta12_gate.json

三种策略给出三种结论。strict 见 5 条 block 全拦(newBlock=5);ratchet 因为这 5 条都在 baseline 里,newBlock=0,放行;quota 也是 newBlock=0,但 2 条新 warn 超过了配额 1,仍拦(reason=warn-quota)。同一份清单、同一个问题库,策略不同结论完全不同——这就是为什么策略必须是配置而不是硬编码的 if。

12.2.4 P 层:TS 内核逐条对应

TS 内核与 Python 逐条对应,结论只含字符串与整数,因此 digest 必须逐位一致:

// file: core/src/ta12/gate.ts
// tier: P
// verify: node --test 'core/test/ta12_*.test.ts'
// 卷二 12.2 门禁分级与阻断策略的 TS 侧实现。与 sim/ta12_gate.py 同一套策略语义:strict /
// ratchet / quota 三种策略读同一份 finding 清单,差别只在「拿什么当阻断条件」。结论只含
// 字符串与整数 → 两侧逐位一致。只允许可擦除 TS 语法。
export type Finding = { code: string; id: string; detail: string; severity: string; hardFail: boolean };
export type Strategy = { id: string; blockOn: string[]; warnQuota: number; baselineAware: boolean };
export type BaselineEntry = { code: string; id: string };
export type Decision = { strategy: string; gate: string; newBlock: number; newWarn: number; reasons: string[] };

const key = (code: string, id: string): string => `${code}::${id}`;

/** 按一个策略算门禁结论。findings 是 12.1 的产物(已带 severity)。 */
export function decide(findings: Finding[], strategy: Strategy, baseline: BaselineEntry[]): Decision {
  const base = new Set<string>();
  if (strategy.baselineAware) for (const b of baseline) base.add(key(b.code, b.id));
  const isNew = (f: Finding): boolean => !base.has(key(f.code, f.id));
  const newBlock = findings.filter((f) => f.severity === "block" && isNew(f)).length;
  const newWarn = findings.filter((f) => f.severity === "warn" && isNew(f)).length;
  const reasons: string[] = [];
  if (strategy.blockOn.includes("block") && newBlock > 0) reasons.push("block");
  if (strategy.blockOn.includes("warn") && newWarn > strategy.warnQuota) reasons.push("warn-quota");
  return { strategy: strategy.id, gate: reasons.length > 0 ? "block" : "pass", newBlock, newWarn, reasons };
}

实跑 node --test 'core/test/ta12_*.test.ts'(本节 4 条):

gate digest = 289016ccde42dcfb
✔ 跨语言:三种策略的门禁结论 == Python 产物(逐位一致)
✔ 三种策略给出三种结论:strict 拦 / ratchet 放 / quota 因 warn 超配额拦
✔ 负例:baseline 漏掉一条 block,ratchet 必须拦下这条新引入的
✔ 负例:warn 不设进 blockOn 时永远不拦;配额放大后 quota 也放行
ℹ pass 12
ℹ fail 0

两侧 digest 都是 289016ccde42dcfb。跨语言一致不是靠「两边都写一遍」——decide 的逻辑只有十几行,真正的风险在边界:newBlock > 0 还是 >= 0、newWarn > warnQuota 还是 >= warnQuota。所以测试的重点在负例。

12.2.5 负例断言:证明门禁是活的

门禁最容易的失败模式是「恒返回 pass」——写错了也看不出来。所以测试里放了两条负例:把 baseline 里的 E_NAME / tex_legacy_upper 删掉,ratchet 必须把它当新引入的拦下(newBlock=1);把 warn 从 blockOn 里去掉、或把配额放大到 5,quota 必须放行。两条负例一正一反夹住 decide 的边界:

// file: core/test/ta12_gate.test.ts
// tier: P
// verify: node --test 'core/test/ta12_*.test.ts'
// 卷二 12.2 跨语言对照:读 sim/ta12_gate.py 落盘的 out/ta12_gate.json,断言 TS 侧三种策略的
// 门禁结论与之一致(字符串 + 整数,逐位一致)。负例:baseline 漏一条必须拦;warn 不设进
// blockOn 时永远不拦。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { decide, type BaselineEntry, type Finding, type Strategy } from "../src/ta12/gate.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const load = (p: string): any => JSON.parse(readFileSync(resolve(ROOT, p), "utf8"));
const engine = load("out/ta12_engine.json");
const policy = load("config/ta12_policy.json");
const py = load("out/ta12_gate.json");
const findings: Finding[] = engine.findings;
const baseline: BaselineEntry[] = policy.baseline;
const strategy = (id: string): Strategy => policy.strategies.find((s: Strategy) => s.id === id);
const digest16 = (ls: string[]): string => createHash("sha256").update(ls.join("\n"), "utf8").digest("hex").slice(0, 16);
const lines = (ds: { strategy: string; gate: string; newBlock: number; newWarn: number }[]): string[] =>
  ds.map((d) => `${d.strategy}:${d.gate}:${d.newBlock}:${d.newWarn}`);

test("跨语言:三种策略的门禁结论 == Python 产物(逐位一致)", () => {
  const mine = policy.strategies.map((s: Strategy) => decide(findings, s, baseline));
  mine.sort((a, b) => (a.strategy < b.strategy ? -1 : 1));
  assert.deepEqual(mine, py.decisions);
  assert.equal(digest16(lines(mine)), py.digest);
  console.log(`gate digest = ${py.digest}`);
});

test("三种策略给出三种结论:strict 拦 / ratchet 放 / quota 因 warn 超配额拦", () => {
  const strict = decide(findings, strategy("strict"), baseline);
  const ratchet = decide(findings, strategy("ratchet"), baseline);
  const quota = decide(findings, strategy("quota"), baseline);
  assert.deepEqual([strict.gate, ratchet.gate, quota.gate], ["block", "pass", "block"]);
  assert.deepEqual(strict.reasons, ["block"]);
  assert.deepEqual(ratchet.reasons, []);
  assert.deepEqual(quota.reasons, ["warn-quota"]);
});

test("负例:baseline 漏掉一条 block,ratchet 必须拦下这条新引入的", () => {
  const short = baseline.filter((b) => !(b.code === "E_NAME" && b.id === "tex_legacy_upper"));
  const d = decide(findings, strategy("ratchet"), short);
  assert.equal(d.gate, "block");
  assert.equal(d.newBlock, 1);
  assert.deepEqual(d.reasons, ["block"]);
});

test("负例:warn 不设进 blockOn 时永远不拦;配额放大后 quota 也放行", () => {
  const warnOnly = decide([{ code: "E_SIZE_POT", id: "x", detail: "", severity: "warn", hardFail: false }], strategy("ratchet"), []);
  assert.equal(warnOnly.gate, "pass");
  const relaxed = decide(findings, { ...strategy("quota"), warnQuota: 5 }, baseline);
  assert.equal(relaxed.gate, "pass");
});

12.2.6 落地路径:先记账,再收紧

存量项目第一天就上 strict 会立刻红——因为库里本来就有一堆已知问题。现实路径是 ratchet:先把当前所有 block 记进 baseline,门禁只拦新引入的;然后按迭代节奏一条条清 baseline,每清一条就从 baseline 里删一条,门禁自动变严。这就是「棘轮」——只进不退。等 baseline 清空,ratchet 与 strict 等价。

但记账本身要有纪律:baseline 不是垃圾桶,每条都得有理由、有负责人、有到期日,否则它会悄悄变成一个永久的例外清单——而这正是下一节的主题:白名单的治理。

阅读导航:上一节:12.1 规则引擎:命名/尺寸/引用/预算 · 下一节:12.3 误报与白名单治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则