12.2 门禁的分级与阻断策略
12.1 的引擎给出 7 条 finding、5 条硬失败——但「给出结论」和「阻断流水线」之间还差一层:门禁拿什么当阻断条件。同一份清单,策略不同,结论可以从「全拦」到「全放」。本节把策略抽成配置,用三种典型策略跑同一份清单,给出三种不同结论。
级别语义沿用第 3 章的三级(见 3.3 导入失败的分级与修复
):block 阻断、warn 标注、info 记录。门禁策略只决定哪些级别在什么条件下变成阻断,不改级别本身。
12.2.1 三种策略,同一份清单
| 策略 | blockOn | warnQuota | baselineAware | 语义 |
|---|---|---|---|---|
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 误报与白名单治理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。