merge: master 吸收(收口机制批+闸收敛批二+10-15 文档落库)——冲突五件解毕:术语表/02/数据配置取本批超集(用语规范节/同源两行/死链史实版),元进度 v1.12 与天赋树 v0.5 取主区侧(更名记载显示文字保真),doc_term_audit 语境豁免并集(主区六组+本批 buff 组)

This commit is contained in:
DKChan committed 2026-10-15 23:32:01 +08:00
commit c963004236
26 files changed
+756 -515

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""audit_active_state.py — 根目录 active.md 体量闸(doc-conventions §10 的机械面)
状态文件只记当下:条目只增不减就是腐烂。本脚本查五条机械可判的膨胀签名——
状态记录条目数超上限、快照节行数超上限、条目编号重复、快照整行自我重复、
指向中间文档(design/qa、design/research)的死指针。
退出码:0=全绿;1=有红(逐条打印差额,供收口批据此删条目)。
用法:python tools/audit_active_state.py [--file PATH] [--quiet]
"""
import argparse
import os
import re
import sys
ENTRY_LIMIT = 30 # §10:状态记录超过 30 条必须清
SNAPSHOT_LIMIT = 10 # §10:当前状态快照 ≤10 行
INTERMEDIATE_PREFIX = ("design/qa/", "design/research/")
ENTRY_RE = re.compile(r"^- \*\*(\d+)( 续)?")
BULLET_RE = re.compile(r"^- .{20,}")
PATH_RE = re.compile(r"design/(?:qa|research)/[^\s)`,,。)]+?\.md")
def split_sections(text):
"""按二级标题切段;标题前的内容归入 '' 键(文件头元信息,本闸不判)。"""
out, key = {}, ""
for line in text.splitlines():
if line.startswith("## "):
key = line[3:].strip()
out[key] = []
continue
out.setdefault(key, []).append(line)
return out
def find_key(sections, prefix):
for name in sections:
if name.startswith(prefix):
return name
return None
def audit(path, repo):
with open(path, encoding="utf-8") as f:
sections = split_sections(f.read())
reds = []
snap_key = find_key(sections, "当前状态快照")
rec_key = find_key(sections, "状态记录")
if snap_key is None:
reds.append("A0 快照节缺失:状态文件必须含「当前状态快照」节")
if rec_key is None:
reds.append("A0 状态记录节缺失:状态文件必须含「状态记录」节")
snap = [ln for ln in sections.get(snap_key, []) if BULLET_RE.match(ln)]
if len(snap) > SNAPSHOT_LIMIT:
reds.append("A1 快照节 %d 条,超上限 %d——先删被新一轮取代的旧行,别只往顶部加"
% (len(snap), SNAPSHOT_LIMIT))
seen = {}
for ln in snap:
seen[ln] = seen.get(ln, 0) + 1
dups = [ln for ln, n in seen.items() if n > 1]
if dups:
reds.append("A2 快照节有 %d 行整行自我重复(换代不彻底的签名):%s"
% (len(dups), dups[0][:60] + " …"))
nums, conts = [], []
for ln in sections.get(rec_key, []):
m = ENTRY_RE.match(ln)
if m:
(conts if m.group(2) else nums).append(int(m.group(1)))
if len(nums) > ENTRY_LIMIT:
reds.append("A3 状态记录 %d 条,超硬上限 %d——按 §10 判据删除已完结批次条目"
% (len(nums), ENTRY_LIMIT))
repeated = sorted({n for n in nums if nums.count(n) > 1})
if repeated:
reds.append("A4 条目编号重复(并行侧写撞号):%s"
% "、".join(str(n) for n in repeated))
body = "\n".join(sections.get(snap_key, []) + sections.get(rec_key, [])
+ sections.get(find_key(sections, "未决面"), []))
dead = sorted({p for p in PATH_RE.findall(body)
if not os.path.exists(os.path.join(repo, p.replace("`", "")))})
if dead:
reds.append("A5 指向中间文档的死指针 %d 处(文件已按 §11 退役,条目却还在引):%s"
% (len(dead), "、".join(dead)))
return reds, len(nums), len(snap)
def main():
ap = argparse.ArgumentParser(description="active.md 体量闸")
ap.add_argument("--file", default=None, help="待审文件(默认为本仓库根目录 active.md)")
ap.add_argument("--quiet", action="store_true", help="只输出结论行")
args = ap.parse_args()
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = args.file or os.path.join(repo, "active.md")
if not os.path.exists(path):
print("[active.md 体量闸] 找不到文件:%s" % path)
return 1
reds, entries, snap = audit(path, repo)
if reds:
print("[active.md 体量闸] FAIL:%d 项——状态记录 %d 条 / 快照 %d 行" % (len(reds), entries, snap))
for r in reds:
print(" " + r)
return 1
if not args.quiet:
print("[active.md 体量闸] PASS:状态记录 %d 条 / 快照 %d 行,编号唯一、指针全活" % (entries, snap))
return 0
if __name__ == "__main__":
try:
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sys.exit(main())
+16 -1
View File
@@ -96,9 +96,24 @@ def scan_supplement(paths):
# 充能:挖掘期/战斗段充能退役(2026-09-19),但秘宝主动件「每冒险充能」(同调件
# 使用次数账,迭代 11 起在役)系现行机制共用该词——秘宝/同调/主动件等语境不算命中。
TERM_CONTEXT_EXEMPT = {
"充能": re.compile(r"秘宝|同调|主动件|每冒险|冒险开始|充能账|充能维护|attunement"),
"充能": re.compile(r"秘宝|同调|主动件|每冒险|冒险开始|充能账|充能维护|attunement"
r"|incense_stick|sky_eye|reveal_tiles|战斗级充能|关卡级充能|每战一次|每关一次"),
# buff:反引号行内代码(字段/键名)原样保留(STC 6.2),只查散文用词
r"\bbuff\b": re.compile(r"`[^`]*buff[^`]*`"),
# 否定式/反照式引用:正文在说明「该类别已退役/不做/不存在」,退役词出现在句中是
# 陈述本身的一部分,替换掉反而毁掉句意(2026-10-15 用户令「统一替换」问询的判定)。
"道具窗口|可选类道具|已消耗未生效|使用被拒|窗口打开锚行|窗口跳过锚行": re.compile(
r"无可选类|不存在|已随|整体删除|现行池内|不进背包"),
"雾格": re.compile(r"不做清单|战争迷雾类机制"),
# heavy_multiplier 字段与 ambusher 敌人行同于 2026-09-20 八型批整体退役(enemy_table.gd
# 与 enemy.tres 均零命中);battle.md「敌人意图线索映射目录」是 2026-09-12 成文占位表,
# 现行敌人为 goblin/hobgoblin/bugbear/giant_spider/drider/duergar 系,占位行里的退役
# 敌人 id 只能读作历史留档(真正的现行规格在 敌人系统.md「目标顺位与意图线索」节)。
"伏击": re.compile(r"ambusher|成文占位"),
"heavy_multiplier|重击倍率": re.compile(r"ambusher|heavy_multiplier|成文占位"),
# 拔旗(2026-09-19 退役「旗无移除手段」):06 待办清单「件派生动词白名单」条目说的是
# 「将来件池 ≥30 种后可能开放的第三盘面动作」,是待评估的设计意图而非在用机制。
"拔旗|撤旗": re.compile(r"件派生动词白名单|第三盘面动作|非信息类"),
}
# 行级白名单信号:命中任一即归"留档类"
+42 -19
View File
@@ -8,12 +8,13 @@ r"""gdd_lint.py — GDD lean 格式门禁
规则(与 .claude/docs/templates/game-design-document.md 纪律一一对应):
R1 散文段落 ≤ 240 字(代码块/表格/列表/引用不计)
R2 表格字符占比 ≥ 60%(代码块不计入分母)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免)
R4 必须有「版本历史」节且含表格
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid)
R2 表格字符占比(词条表分域豁免,见 R2_EXEMPT 注)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免;
登记处/清单按 RULE_EXEMPT 分域豁免)
R4 必须有「版本历史」节且含表格(登记处按 RULE_EXEMPT 分域豁免)
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid);清单节/词条节按 RULE_EXEMPT 分域豁免
R6 表格列数一致(含转义 \| 识别)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号;登记处/清单按 RULE_EXEMPT 分域豁免)
R8 核心规则表:规则列必须是"谁·何时·做什么"陈述句所在的可读表——说明/要点列
禁止出现编号引用(AC-x/F-x/EC-x/E-x/OQ-x/Rule-x/义务),引用只允许在依据类列
R9 一页纸节点:任一标题节点的直接内容(到下一标题为止)≤ 80 行——装不下就拆子节点
@@ -50,6 +51,24 @@ R2_EXEMPT = {
"05-不做清单.md", # 清单文:条目散文为体裁
"06-待办清单.md",
"07-体验验证清单.md",
"术语表.md", # 词条表:表格为主体形态,表格占比高是体裁本身
}
# 规则粒度分域豁免(2026-10-15 用户令「清单类文件与术语表不做 GDD 规范性检查」)。
# 原则:**按规则豁免,不按文件拉黑**——体裁规则(R3/R4/R5/R7)对登记处/清单/词条表
# 不适用,但机械可判的完整性规则(R6/R8/R9/R10/R11)一律不豁免,真缺陷不得藏起来。
# 各条理由:
# R3 正文禁修订注记——登记处/清单的条目「带日期+裁决来源」是设计目的本身
# (条目要能回答「这条谁在何时裁的」),禁它等于禁登记处的核心价值。
# R4 必须有版本历史节——06 是「解决即移除」的状态登记,条目不按版本累积,
# 强塞版本历史表反而制造第二套需同步的真相。
# R5 流程/状态类节须含代码块图——节名含「流程/生命周期」即触发,但 07 的
# 「㉖…全流程走查」「㉗…目检」是目检清单节、术语表的「开发流程」是词条节,
# 两者都不是流程描述,属节名误匹配。
# R7 括号跨度 ≤80 字——长括注在登记处/清单里是常态(现状+裁决+归属挤在一行)。
RULE_EXEMPT = {
"06-待办清单.md": {"R3", "R4", "R7"},
"07-体验验证清单.md": {"R3", "R5", "R7"},
"术语表.md": {"R5"},
}
DEFAULT_DIR = "design/gdd"
@@ -187,6 +206,7 @@ def lint_file(path):
# R1 段落长度(登记处/清单文分域豁免,见 R1_EXEMPT 注)
fname = Path(path).name
ex = RULE_EXEMPT.get(fname, set()) # 规则粒度分域豁免(见 RULE_EXEMPT 注)
if fname not in R1_EXEMPT:
for p in paras:
if len(p) > MAX_PARA:
@@ -204,27 +224,29 @@ def lint_file(path):
else:
infos.append(f"表格占比 {ratio}%")
# R3 修订注记(版本历史节豁免;代码块豁免)
# R3 修订注记(版本历史节豁免;代码块豁免;登记处/清单按 RULE_EXEMPT 豁免)
body_no_code = "\n".join(
l for l in text.splitlines() if l not in code_lines and l not in version_lines
)
for m in re.finditer(r"~~.+?~~", body_no_code):
for m in (() if "R3" in ex else re.finditer(r"~~.+?~~", body_no_code)):
issues.append(f"R3 删除线残留: …{m.group(0)[:40]}…")
if re.search(r"作废", body_no_code):
if "R3" not in ex and re.search(r"作废", body_no_code):
line_no = next(
i + 1
for i, l in enumerate(body_no_code.splitlines())
if "作废" in l
)
issues.append(f"R3 \"作废\"注记残留(第 {line_no} 行附近)")
for m in DATE_KEYWORD_RE.finditer(body_no_code):
issues.append(f"R3 正文修订注记: {m.group(0)[:50]}")
if "R3" not in ex:
for m in DATE_KEYWORD_RE.finditer(body_no_code):
issues.append(f"R3 正文修订注记: {m.group(0)[:50]}")
# R4 版本历史
if VERSION_SECTION not in text:
issues.append(f"R4 缺「{VERSION_SECTION}」节")
elif not any(l.strip().startswith("|") for l in version_lines):
issues.append(f"R4 「{VERSION_SECTION}」节无表格")
# R4 版本历史(登记处按 RULE_EXEMPT 豁免——条目「解决即移除」,不按版本累积)
if "R4" not in ex:
if VERSION_SECTION not in text:
issues.append(f"R4 缺「{VERSION_SECTION}」节")
elif not any(l.strip().startswith("|") for l in version_lines):
issues.append(f"R4 「{VERSION_SECTION}」节无表格")
# R10 版本历史版本号唯一性(实证撞号:v0.19 曾被并行两批同时占用)
ver_seen = {} # key: 归一版本号 → [首次原样 token, 出现次数]
@@ -248,20 +270,21 @@ def lint_file(path):
if count > 1:
issues.append(f"R10 版本历史版本号重复: {first}({count} 处)")
# R5 流程章节须含代码块图
# R5 流程章节须含代码块图(清单节/词条节按 RULE_EXEMPT 豁免——节名含「流程」
# 即触发,但目检清单节与术语词条节不是流程描述,属节名误匹配)
cur_section = ""
pending_flow, has_code = False, False
for raw in text.splitlines():
stripped = raw.strip()
if raw.startswith("## "):
if pending_flow and not has_code:
if pending_flow and not has_code and "R5" not in ex:
issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)")
cur_section = stripped.lstrip("#").strip()
pending_flow = bool(FLOW_SECTION_RE.search(cur_section)) and cur_section != VERSION_SECTION
has_code = False
elif stripped.startswith("```") and cur_section:
has_code = True
if pending_flow and not has_code:
if pending_flow and not has_code and "R5" not in ex:
issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)")
# R6 表格列数一致
@@ -320,7 +343,7 @@ def lint_file(path):
# R7 括号跨度
longest = max((paren_spans(p) for p in paras), key=lambda s: max((len(x) for x in s), default=0), default=[])
longest_span = max((len(s) for s in longest), default=0)
if longest_span > MAX_PAREN_SPAN:
if longest_span > MAX_PAREN_SPAN and "R7" not in ex:
issues.append(f"R7 最长括号跨度 {longest_span} 字(>{MAX_PAREN_SPAN})——疑似审计注记挤进正文")
infos.append(f"最长括号跨度 {longest_span} 字")
+5 -3
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# 渲染闸正主(迭代 19 卡① 自动化验收补齐批 2026-10-08 落码——T1/T2)——窗口化自动闸。
# 迭代 15③ 挂账「渲染五闸」机器补跑半随本闸落籍(shot_probe/golden_compare/board_shot 三件入闸;
# fx_probe 逻辑层归 probe_gate --full、ui_layout_probe 归其自身两档断言——归宿对账见卡①任务书 §一)。
# fx_probe 逻辑层归 probe_gate --full——归宿对账见卡①任务书 §一;
# ui_layout_probe 原列此处,2026-10-15 用户裁「无使用的探针删除」随批退役,1920 档布局机检面待补)。
# 骨架复用 probe_gate:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿
# Godot(按命令行精确匹配本 game 路径,不误杀其他 worktree 会话)③末尾残留清点=批末闸(必须 0)。
# 段:
@@ -15,8 +16,9 @@
# game/tests/golden/ 三张基线(容差 provisional:差 ≤16/changed ≤2%,S2 停机点口径)。
# 素材缺位=SKIP 不判红(Q5 运行时口径;基线生成期素材缺位=L3 停机——基线禁锚回退态)。
# ④ 基准档 1280 单档(Q4)。1920 档注记:16:9 全屏下 stretch=canvas_items/aspect=keep 等比
# 1.35×+左右黑边各 96px,构图与基准档一致(迭代 9 分辨率案甲)——分辨率维度不重复建闸,
# ui_layout_probe 两档断言(T5)承担 1920 布局机检面。
# 1.35×+左右黑边各 96px,构图与基准档一致(迭代 9 分辨率案甲)——分辨率维度不重复建闸。
# (原由 ui_layout_probe 两档断言承担,探针已随 2026-10-15 裁「无使用的探针删除」退役,
# 1920 布局机检面现无承载,待补。)
# 过程产物纪律(Q2):一切截图 PNG 落 mktemp 临时区,闸毕即弃——任何过程 PNG 禁入 git;
# 例外=game/tests/golden/ 基线(测试资产入库,.gitignore 已核不挡)。
# 窗口化运行会短暂弹 Godot 窗口(本机可开窗口径,2026-10-07 用户令);禁改 --headless 跑本闸
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# 重闸唯一入口(2026-10-15 用户令「迭代完成前必须全量跑一遍所有测试」)——
# 闸清单的唯一定义处。文档只写「收口跑本脚本」,禁在文档里另列闸件清单(那正是 iter27
# 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。
#
# 跑完写回执 game/tests/.gates/receipt.json(commit + 各闸退出码 + 存量基线比对 + 时间戳);
# .zcode/hooks/worktree-guard.sh 在主工作区 git merge 时校验本回执,缺或红即拦截。
#
# 用法:tools/run_all_gates.sh [--quick]
# --quick 跳过渲染闸(窗口化,需开显示器)与静态扫描(纯文本扫描),供迭代中段自检;
# 收口出库禁用,必须不带本参数跑全量。
#
# 判定口径:每道闸与 game/tests/.gates/baseline.json 里的存量基线比对——
# 基线绿 + 现绿 → 绿
# 基线红 + 现红且清单未变 → 「存量红·对基线零新增」,不判失败(但会在回执里留名)
# 基线红 + 现红但清单新增 → 红(新增红项)
# 任意闸 exit 与基线不符 → 红
# 基线文件首次自动生成(把当次结果当基线),此后每次跑批与之比。
#
# 退出码:0=全绿或仅存量红零新增;1=有新增红/闸况变化;2=用法错
set -u
REPO="$(cd "$(dirname "$0")/.." && pwd)"
GAME="$REPO/game"
STAMP_DIR="$GAME/tests/.gates"
RECEIPT="$STAMP_DIR/receipt.json"
BASELINE="$STAMP_DIR/baseline.json"
QUICK=0
[ "${1:-}" = "--quick" ] && QUICK=1
[ $# -gt 1 ] && { echo "用法: tools/run_all_gates.sh [--quick]"; exit 2; }
# 回执与日志是中间产物(不入库),但 .gitignore 本身必须入库——漏了就等于把它们纳入版本管理。
# 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走(2026-10-15 实踩)。
mkdir -p "$STAMP_DIR"
if [ ! -f "$STAMP_DIR/.gitignore" ]; then
printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、*.log 是跑批当场日志——两者都是中间产物,不入库。\nreceipt.json\n*.log\n' \
> "$STAMP_DIR/.gitignore"
echo "[gates] 已自愈重建 $STAMP_DIR/.gitignore"
fi
# ── python 解释器解析:优先带 PIL 的 gamedev venv(渲染闸的 shot_checker/golden_compare 依赖),
# 回落 PATH 上的 python。渲染闸的三个脚本要 PIL,用错解释器会假红。
PY=""
for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "$(command -v python 2>/dev/null)" "python"; do
[ -z "$cand" ] && continue
if "$cand" -c "import PIL" >/dev/null 2>&1; then PY="$cand"; break; fi
[ -z "$PY" ] && PY="$cand"
done
GODOT="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}"
# 把带 PIL 的解释器目录前置到 PATH——render_gate.sh / shot_checker.py / golden_compare.py /
# golden_board_crop.py 内部各自调 `python`,继承本 PATH;用错解释器会因缺 PIL 假红。
case "$PY" in
*/Scripts/python.exe|*/Scripts/python) export PATH="$(dirname "$PY"):$PATH" ;;
esac
declare -a NAMES=() EXITS=() LOGS=()
FAILED=0
echo "重闸全量跑批 · 仓库 $REPO"
echo "起跑 commit: $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '非git目录')"
echo "python: $PY"
echo "godot: $GODOT"
# ── 闸件执行:每道闸的输出落独立日志,供基线比对取指纹 ──
gate() { # gate <名称> <日志名> <命令...>
local name="$1" log="$2"; shift 2
echo ""
echo "════════════════════════════════════════════"
echo " $name"
echo "════════════════════════════════════════════"
"$@" >"$STAMP_DIR/$log" 2>&1
local rc=$?
# 失败时把关键行回显到终端(红必须看得见)
if [ $rc -ne 0 ]; then
grep -E "FAIL|红|Error|error" "$STAMP_DIR/$log" | tail -12 | sed 's/^/ | /'
fi
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("$log")
return 0
}
gate_unit() { # run_all 是 --script 模式,退出码为唯一判据
local name="02 单元套件"
echo ""
echo "════════════════════════════════════════════"
echo " $name(--script 模式,退出码为唯一判据)"
echo "════════════════════════════════════════════"
( cd "$REPO" && "$GODOT" --headless --path "$(cygpath -m "$GAME")" --script res://tests/run_all.gd ) \
>"$STAMP_DIR/unit.log" 2>&1
local rc=$?
[ $rc -ne 0 ] && tail -12 "$STAMP_DIR/unit.log" | sed 's/^/ | /'
NAMES+=("$name"); EXITS+=("$rc"); LOGS+=("unit.log")
return 0
}
# ── 1. 系统级断言面(冒烟三闸:退出码+摘要失败数+SCRIPT ERROR 扫描,判定无洞)──
gate "01 冒烟全量" "smoke.log" "$PY" "$REPO/tools/run_smoke.py"
# ── 2. 单元套件 ──
gate_unit
# ── 3. 场景探针 · 行为走查段 ──
gate "03 探针 --click" "probe_click.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --click
# ── 4. 场景探针 · 断言段(iter27 漏跑的就是这一段)──
gate "04 探针 --full" "probe_full.log" bash "$REPO/tools/probe_gate.sh" "$GAME" --full
# ── 5. 渲染面(窗口化,依赖 PIL)──
if [ $QUICK -eq 0 ]; then
gate "05 渲染闸" "render.log" bash "$REPO/tools/render_gate.sh" "$GAME"
else
NAMES+=("05 渲染闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 05 渲染闸 → SKIP(--quick)"
fi
# ── 6. 文档三闸 ──
gate "06 gdd_lint" "gdd_lint.log" "$PY" "$REPO/tools/gdd_lint.py"
gate "07 doc_link" "doc_link.log" "$PY" "$REPO/tools/doc_link_audit.py" --strict
gate "08 doc_term" "doc_term.log" "$PY" "$REPO/tools/doc_term_audit.py"
# ── 6b. 状态文件体量闸(active.md 只记现在:条目数/快照行数/编号/死指针四条硬上限)──
gate "08b active.md 体量" "active_state.log" "$PY" "$REPO/tools/audit_active_state.py"
# ── 7. 静态扫描域 + 熵增闸 ──
if [ $QUICK -eq 0 ]; then
gate "09 静态扫描" "static_scan.log" bash "$GAME/tests/static_scan.sh"
gate "10 熵增闸" "entropy.log" "$PY" "$REPO/tools/repo_structure_audit.py"
else
NAMES+=("09 静态扫描"); EXITS+=("SKIP"); LOGS+=("")
NAMES+=("10 熵增闸"); EXITS+=("SKIP"); LOGS+=("")
echo ""; echo "[gates] 09 静态扫描 / 10 熵增闸 → SKIP(--quick)"
fi
# ── 与存量基线比对 ──
"$PY" - "$BASELINE" "$RECEIPT" "$QUICK" "$REPO" "${NAMES[@]}" -- "${EXITS[@]}" -- "${LOGS[@]}" <<'PYEOF'
import hashlib, json, os, subprocess, sys
baseline_path, receipt_path, quick, repo = sys.argv[1:5]
rest = sys.argv[5:]
sep = rest.index("--")
names = rest[:sep]
tail = rest[sep + 1:]
sep2 = tail.index("--")
exits = tail[:sep2]
logs = tail[sep2 + 1:]
stamp_dir = os.path.dirname(receipt_path)
def head():
try:
return subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=5).stdout.strip()
except Exception:
return "unknown"
def fingerprint(exit_code, log):
"""红项指纹:只取 exit!=0 时的 FAIL 关键行集合,忽略时序/耗时噪声。
绿闸一律记 'green'——绿闸日志里的「零红」「全绿」等字样不是红项,采进来只会造噪声。"""
if exit_code in ("0", "SKIP"):
return "green" if exit_code == "0" else "skip"
if not log:
return "red-nolog"
p = os.path.join(stamp_dir, log)
if not os.path.exists(p):
return "red-nolog"
keys = []
with open(p, encoding="utf-8", errors="replace") as f:
for line in f:
s = line.strip()
if not s or "FAIL" not in s:
continue
keys.append(s[:160])
if not keys:
return "red-nokeys"
return hashlib.sha1("\n".join(sorted(set(keys))).encode("utf-8")).hexdigest()[:12]
now = {}
for n, e, l in zip(names, exits, logs):
now[n] = {"exit": e, "fp": fingerprint(e, l)}
first_run = not os.path.exists(baseline_path)
if first_run:
with open(baseline_path, "w", encoding="utf-8") as f:
json.dump({"note": "存量基线·首次自动生成;闸况变化时本脚本会判红,需人工确认后重生成",
"gates": now}, f, ensure_ascii=False, indent=1)
base = dict(now)
else:
with open(baseline_path, encoding="utf-8") as f:
base = json.load(f).get("gates", {})
changed, inherited_red = [], []
for n in names:
b, c = base.get(n), now[n]
if b is None:
changed.append("%s:无基线记录(新增闸件)" % n)
continue
if b["exit"] == c["exit"] and b["fp"] == c["fp"]:
continue
if b["exit"] != "0" and c["exit"] != "0" and b["fp"] == c["fp"]:
continue # 同款存量红,清单未变
if b["exit"] == "0" and c["exit"] != "0":
changed.append("%s:基线绿 → 现红(exit=%s)" % (n, c["exit"]))
elif b["exit"] != "0" and c["exit"] == "0":
inherited_red.append("%s:基线红 → 现绿(改善)" % n)
else:
changed.append("%s:红项清单变化(基线 %s → 现 %s)" % (n, b["fp"], c["fp"]))
stock_red = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")]
all_green = not changed
with open(receipt_path, "w", encoding="utf-8") as f:
json.dump({
"commit": head(),
"commit_short": head()[:8],
"timestamp": subprocess.run(["date", "-Iseconds"], capture_output=True, text=True).stdout.strip(),
"quick_mode": quick == "1",
"first_baseline_run": first_run,
"gates": [{"name": n, "exit": e, "fingerprint": now[n]["fp"]} for n, e in zip(names, exits)],
"changed_vs_baseline": changed,
"inherited_red": inherited_red,
"all_green": all_green,
}, f, ensure_ascii=False, indent=1)
print("")
print("─" * 60)
if first_run:
print("首次跑批:已生成存量基线 %s" % baseline_path)
print("本次结果作为基线,后续与之比对。")
if changed:
print("【闸况变化,需处理】")
for c in changed:
print(" ✗ " + c)
if inherited_red:
print("【改善】")
for c in inherited_red:
print(" ↓ " + c)
stock = [n for n, e in zip(names, exits) if e not in ("0", "SKIP")]
if stock:
print("【存量红·对基线零新增】" + "、".join(stock))
if all_green:
print("【验收通过】闸况与基线一致,无新增红。")
sys.exit(0)
sys.exit(1)
PYEOF
RC=$?
echo ""
echo "════════════════════════════════════════════"
printf ' 回执:%s\n 基线:%s\n commit:%s\n' "$RECEIPT" "$BASELINE" "$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
echo "════════════════════════════════════════════"
exit $RC