Files
gplan-2/tools/pipeline_gate.py
T

718 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""pipeline_gate.py — 工作流程流水线闸(待办清单条目状态机+roadmap 迭代总表+需求卡片口径+一次交叉核)。
职责:机检待办清单条目(标题元数据与可选优先级档名/四属性齐备与旧属性名防回潮/状态词白名单/
改动面四档白名单/条件凭据/无设计线段条目的证据指认/状态段累积制/特批/正文限长双判据/零命中)
与 roadmap 迭代总表(六列/状态五形态/卡片与报告存在性/编入时间与预计周期的
分档放行)/需求卡片口径(C1 文档义务/C2 设计事实源/C3 改动—验收对照表/C4 验收档申报)+一次交叉核。
约束:每红项一行「文件:行号 判据名 一句白话说明」,exit 0=全绿 / 1=有红 / 2=输入件缺失;
卡片与报告存在性相对 --repo-root 解析(缺省=本文件上两级);待办清单与 roadmap 均已为新格式
(2026-10-18 数据整改批),本闸已注册进 tools/run_all_gates.sh 文档域(流水线闸)。
用法:python tools/pipeline_gate.py [--todo PATH] [--roadmap PATH] [--repo-root PATH]
"""
import argparse
import re
import sys
from pathlib import Path
DEFAULT_TODO = Path("待办清单.md")
DEFAULT_ROADMAP = Path("roadmap.md")
SOURCE_WORDS = ("playnote", "会话提及", "审计发现", "批次移交")
PRIORITY_WORDS = ("P0", "P1", "P2")
SURFACE_WORDS = ("文档", "美术素材", "游戏代码", "工具代码")
STATUS_WORDS = (
"待文档设计", "文档设计中", "文档设计已闭合",
"待美术设计", "美术设计中", "美术设计已闭合",
"待写卡", "已写卡", "已编迭代", "待裁决", "已写入", "待执行",
)
# 这些状态 = 设计未闭合,条目必须带「**缺口**」属性;其余状态不得带
GAP_REQUIRED = ("待文档设计", "文档设计中", "待美术设计", "美术设计中")
# 状态段累积制(doc-conventions §12.1):下游段在场时禁残留未闭合设计段
DOWNSTREAM_WORDS = ("待写卡", "已写卡", "已编迭代")
DOC_DESIGN_WORDS = ("待文档设计", "文档设计中", "文档设计已闭合")
ART_DESIGN_WORDS = ("待美术设计", "美术设计中", "美术设计已闭合")
DESIGN_WORDS = DOC_DESIGN_WORDS + ART_DESIGN_WORDS
FANBU_WORDS = ("待裁决", "已写入") # 反哺点专条用词,不走设计线也不走下游序列
# 工程形态条目的证据指认形态:审计报告路径/文件:行号址/「证据=」句/审计 P 编号
EVIDENCE_RE = re.compile(r"reports/\S+|[\w/.\-]+:\d{2,}|证据\s*[==]|审计\s*P\d")
ROADMAP_HEADER = ("迭代代号", "状态", "需求卡片", "编入时间", "预计周期", "实现报告")
RESERVED_RETIRED = "已退役(git 历史可考)"
# ---- 卡片机检(2026-10-18 卡片冲突修订批补)----
CARD_GLOB = "需求卡片-*.md"
CARD_DIR = "reports"
SYNC_RULE_DATE = (2026, 10, 17) # req-card §6:实现批禁写 design/ 与根规则文件,义务一律列反哺点
SOURCE_RULE_DATE = (2026, 10, 18) # req-card §2:设计事实源必填
SOURCE_MARKS = ("设计事实源", "唯一事实源") # 两种合格写法,与库内既有卡片实际形态对齐
SYNC_HEADING_RE = re.compile(r"^#{2,3}\s*\d*\.?\s*关联文档同步面")
BANNED_DOC_MARKS = ("design/", "../gdd/", "../registry/", "../research/", "../qa/",
"../engineering/", "../ux/", "../art/", "roadmap.md", "AGENTS.md")
# C3 改动—验收对照表(2026-10-21 验收闭合批,生效 2026-10-21)
LEDGER_RULE_DATE = (2026, 10, 21)
LEDGER_HEADING_RE = re.compile(r"^#{2,4}\s*.*(验收对照表|改动[—\-]验收)")
LEDGER_REQUIRED = ("需求点", "改动", "验收测试", "真实入口")
LEDGER_PROOF_MARKS = ("撤销", "证明")
BINDING_RULE_DATE = (2026, 10, 28) # req-card §8:换代为判据绑定面列,撤销/变异证明列同日禁列
BANNED_PROOF_MARKS = ("撤销证明", "变异证明")
AUDIT_MARK = "验收审计"
CAP_ROW_RULE_DATE = (2026, 10, 29) # req-card §8:每行标能力归属(对齐 test-conventions §8.9 三层模型)
CAP_ROW_RE = re.compile(r"^(既有|新增)\s*[::]\s*(.+)$")
CAP_README = Path("game/tests/README.md")
# C4 验收档申报(2026-11-04 验收强度分级批,生效 2026-11-05;test-conventions §8.3)
GRADE_RULE_DATE = (2026, 11, 5)
GRADE_RE = re.compile(r"验收档\s*[::]\s*[高中低]")
# 待办条目正文限长双判据(2026-11-04 立,doc-conventions §12.1:行数抓清单蔓延、字数抓单行堆砌)
ENTRY_MAX_LINES = 12
ENTRY_MAX_CHARS = 700
DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
VER_RE = re.compile(r"[vV]\d+(?:\.\d+)*")
RESEARCH_RE = re.compile(r"reports/需求卡片-[^\s()(),,]+")
ITER_RE = re.compile(r"迭代\s*(\d+)")
APPROVAL_RE = re.compile(r"〔用户特批([^〕]*)〕")
CRED_RE = re.compile(r"[((]([^()()]*)[))]")
CARD_SEP_RE = re.compile(r"<br\s*/?>|\n|、") # 迭代行卡片列一格多卡的分隔(§12.2 一格一行一卡)
ATTR_LINE_RE = re.compile(r"^\*\*(实况|缺口|改动面|状态)\*\*\s*[::]\s*(.*)$")
LEGACY_ATTR_RE = re.compile(r"^\*\*(现状|归属)\*\*") # 已废除的属性名,出现即红
PRIORITY_RE = re.compile(r"优先级\s*[==]\s*\**([A-Za-z0-9]+)\**")
SURFACE_SEP_RE = re.compile(r"+|\+")
HEADING_RE = re.compile(r"^(#{1,4})\s+")
SEP_ROW_RE = re.compile(r"^\|[\s\-|:]+$")
STATUS_COL_RE = re.compile(
r"^(待开工|冻结待凑批|进行中|已完成\(\d{4}-\d{2}-\d{2}\)|已终止\(\d{4}-\d{2}-\d{2}\))$")
PERIOD_RE = re.compile(r"^≈(\d+(?:\.\d+)?)\s*个工作日$")
ITER_CODE_COL_RE = re.compile(r"^迭代\s*(\d+)")
def norm_parens(text):
"""全角括号归一成半角,供状态/保留字形态比对。"""
return text.replace("(", "(").replace(")", ")")
def display(path_str, root):
"""红项输出用的路径:仓库根内的相对 posix 路径,根外原样。"""
p = Path(path_str)
try:
return p.resolve().relative_to(root).as_posix()
except (ValueError, OSError):
return path_str.replace("\\", "/")
def parse_todo(text):
"""按标题切条目:#### 开行为条目起点,更高层级标题(#/##/###)同为上条终点。"""
entries = []
cur = None
for lineno, raw in enumerate(text.splitlines(), start=1):
m = HEADING_RE.match(raw)
if m:
if cur is not None:
entries.append(cur)
cur = None
if len(m.group(1)) == 4:
cur = {"line": lineno, "title": raw.lstrip("#").strip(), "body": []}
elif cur is not None:
cur["body"].append((lineno, raw))
if cur is not None:
entries.append(cur)
return entries
def check_title(title, line, issues, where):
"""判据 1:标题须含登记日期与「来源=」四选一;优先级可选、写则须三档;禁残留「设计面=」。"""
if not DATE_RE.search(title):
issues.append((where, line, "标题元数据", "标题缺登记日期(YYYY-MM-DD 形态)"))
m = re.search(r"来源=([^\s,,(())]+)", title)
if not m:
issues.append((where, line, "标题元数据", "标题缺「来源=」标记"))
elif m.group(1) not in SOURCE_WORDS:
issues.append((where, line, "标题元数据",
"来源词「%s」不在四选一(playnote/会话提及/审计发现/批次移交)" % m.group(1)))
if "设计面=" in title:
issues.append((where, line, "标题残留设计面",
"「设计面=」不是条目属性:所属设计线看状态段用词,产出面写「**改动面**」(§12.1)"))
m = PRIORITY_RE.search(title)
if m and m.group(1) not in PRIORITY_WORDS:
issues.append((where, line, "优先级档名",
"优先级「%s」不在三档(P0/P1/P2;不写=P2,档名只由用户给)" % m.group(1)))
def check_segment(word, line, issues, where):
"""判据 3 的单段校验。返回 (合法状态词|None, 已编迭代号|None)。
状态行只写状态词(§12.1),括号凭据已废止——闭合证据改到实况关联文档清单
核对(check_todo 内),本函数只认状态词本身。
"""
def red(name, msg):
issues.append((where, line, name, msg))
im = re.match(r"^已编迭代\s*(\d+)$", word)
base = "已编迭代" if word.startswith("已编迭代") else word
if im is None and base == "已编迭代":
red("状态词非法", "「%s」缺迭代号(须「已编迭代 N」)" % word)
return None, None
if base not in STATUS_WORDS:
red("状态词非法", "「%s」不在合法状态词白名单" % base)
return None, None
if im is not None:
return base, int(im.group(1))
return base, None
def check_status(raw, line, issues, where):
"""判据 3+状态段累积制(§12.1)+特批/备选。返回 (状态词列表, 已编迭代号列表|[])。
累积制:按+切段后段数不限,逐段各自合法;再查一条结构约束——下游段
(待写卡/已写卡/已编迭代)在场时禁残留未闭合设计段(带日期特批可豁免)。
所属设计线由状态段用词自身表明,闸不读标题声明。
状态行只写状态词(§12.1):去掉特批标记与(备选)后缀后,残留任何括号即红——
证据写实况关联文档清单,由 check_todo 到正文核对;此处点名病因后短路,
不再产出碎片「状态词非法」误导红。
"""
content = raw.strip()
m = re.match(r"^\*\*(.+)\*\*$", content)
if m:
content = m.group(1).strip()
dated_approval = False
for am in APPROVAL_RE.finditer(content):
if DATE_RE.search(am.group(1)):
dated_approval = True
else:
issues.append((where, line, "特批标记", "〔用户特批〕缺日期(带 YYYY-MM-DD 才放行跳轨)"))
content = APPROVAL_RE.sub("", content)
content = content.replace("(备选)", "").replace("(备选)", "").strip()
if re.search(r"[(())]", content):
issues.append((where, line, "状态行含括号内容",
"括号里的信息(路径/版本/说明)写进实况关联文档清单,状态行只留状态词(§12.1)"))
return [], None
segs = [s.strip() for s in content.split("+") if s.strip()]
if not segs:
issues.append((where, line, "状态词非法", "状态行内容为空"))
return [], None
words, iter_nos = [], []
for seg in segs:
w, it = check_segment(seg, line, issues, where)
if w:
words.append(w)
if it is not None:
iter_nos.append(it)
# 同一设计线只留最新一段:并现两段说明推进时忘了收旧段,或把另一条线的段
# 误写进本线条目;跨条设计线的未闭合依赖(如文档线已闭合写卡、美术线仍待设计)合法。
for track, name in ((DOC_DESIGN_WORDS, "文档线"), (ART_DESIGN_WORDS, "美术线")):
hit = [w for w in words if w in track]
if len(hit) > 1:
issues.append((where, line, "设计线段并现",
"%s同时出现 %s(同线只留最新一段,§12.1)" % (name, "、".join(hit))))
return words, iter_nos
def check_todo(text, issues, where, root):
"""待办清单全部判据。返回交叉核链(已编迭代条目 → (行号, 迭代号, 卡片路径|None))。
闭合类状态词的证据核对只在实况关联文档清单内进行(§12.1「状态行只写状态词」):
已写卡对 reports/ 卡片路径+磁盘存在性,文档设计已闭合/已写入对 .md 路径+版本号,
美术设计已闭合对 art-bible 版本凭据。工程形态条目(状态无设计段且已进下游段)
必须在实况指认证据。
"""
entries = parse_todo(text)
links = []
if not entries:
issues.append((where, 1, "零命中自检",
"未解析出任何 #### 条目(路径错/文件空/纯垃圾文本=假绿防线)"))
return links
for e in entries:
line = e["line"]
check_title(e["title"], line, issues, where)
# 条目正文限长(doc-conventions §12.1 限长规则):行数与字数双判据,不含标题与空行
body_stripped = [raw for _, raw in e["body"] if raw.strip()]
if len(body_stripped) > ENTRY_MAX_LINES:
issues.append((where, line, "条目超长",
"正文 %d 行超上限 %d(doc-conventions §12.1:过程叙事归 git 提交信息,"
"正文只留现行状态+证据指针)" % (len(body_stripped), ENTRY_MAX_LINES)))
body_chars = sum(len(re.sub(r"\s", "", raw)) for raw in body_stripped)
if body_chars > ENTRY_MAX_CHARS:
issues.append((where, line, "条目超长",
"正文约 %d 字超上限 %d(去空白计,doc-conventions §12.1 限长)"
% (body_chars, ENTRY_MAX_CHARS)))
attrs, attr_line = {}, {}
for ln, raw in e["body"]:
s = raw.strip()
m = ATTR_LINE_RE.match(s)
if m:
if m.group(1) not in attrs:
attrs[m.group(1)] = m.group(2)
attr_line[m.group(1)] = ln
elif LEGACY_ATTR_RE.match(s):
issues.append((where, ln, "属性名已废除",
"「%s」不在条目四属性内(现行=实况/缺口/改动面/状态,§12.1)"
% s[2:s.index("**")]))
for req in ("实况", "改动面", "状态"):
if req not in attrs:
issues.append((where, line, "属性缺失", "条目缺「**%s**」属性" % req))
if "状态" not in attrs:
continue
words, iter_nos = check_status(attrs["状态"], attr_line["状态"], issues, where)
# 改动面:四档取多,+连接,禁重复、禁自造词
if "改动面" in attrs:
parts = [p.strip() for p in SURFACE_SEP_RE.split(attrs["改动面"]) if p.strip()]
if not parts:
issues.append((where, attr_line["改动面"], "改动面缺档",
"「**改动面**」为空(须写四档取多:文档/美术素材/游戏代码/工具代码)"))
else:
bad = [p for p in parts if p not in SURFACE_WORDS]
if bad:
issues.append((where, attr_line["改动面"], "改动面档名非法",
"「%s」不在四档(文档/美术素材/游戏代码/工具代码,§12.1)"
% "、".join(bad)))
dup = sorted({p for p in parts if parts.count(p) > 1})
if dup:
issues.append((where, attr_line["改动面"], "改动面重复档",
"重复写「%s」(每档只写一次)" % "、".join(dup)))
# 实况属性全文(含清单续行):证据核对只看实况,其他属性不算数
live_lines, in_live = [], False
for ln, raw in e["body"]:
m = ATTR_LINE_RE.match(raw.strip())
if m:
in_live = m.group(1) == "实况"
if in_live:
live_lines.append(m.group(2))
elif in_live:
live_lines.append(raw)
live = "\n".join(live_lines)
live_line = attr_line.get("实况", line)
# 证据核对一:已写卡 → 实况清单须有卡片路径,逐条验磁盘存在
cards = []
if "已写卡" in words:
cards = RESEARCH_RE.findall(live)
if not cards:
issues.append((where, live_line, "正文缺卡片路径",
"状态段「已写卡」在场,但实况关联文档清单里没有 "
"reports/需求卡片-*.md 路径(§12.1:证据写正文)"))
else:
for rp in cards:
if not (root / rp).is_file():
issues.append((where, line, "卡片路径不存在",
"实况清单里的卡片路径 %s 相对仓库根不存在" % rp))
# 证据核对二:文档线闭合/反哺已写入 → 实况清单须有文档路径+版本号
# (待办清单正文里的指针是相对路径,形如 systems/… 或 design/…,只认 .md 收尾+版本号)
if "文档设计已闭合" in words or "已写入" in words:
if not (re.search(r"[^\s ,,()()]+\.md", live) and VER_RE.search(live)):
issues.append((where, live_line, "正文缺设计定稿指针",
"状态段含文档线闭合词,但实况清单里没有带版本号的文档路径(§12.1:证据写正文)"))
# 证据核对三:美术线闭合 → 实况清单须有 art-bible 版本凭据
if "美术设计已闭合" in words:
if not ("art-bible" in live.lower() and VER_RE.search(live)):
issues.append((where, live_line, "正文缺美术凭据",
"状态段「美术设计已闭合」在场,但实况清单里没有 "
"art-bible 版本凭据(§12.1:证据写正文)"))
# 无设计线段条目的证据指认:需求性工程条目(待写卡及以后)与直改类条目(待执行)
# 都跳过设计线,实况必须给得出证据;反哺点专条不在此列。
if (words and not any(w in DESIGN_WORDS for w in words)
and not any(w in FANBU_WORDS for w in words)
and not EVIDENCE_RE.search(live)):
issues.append((where, live_line, "无设计线段缺证据指认",
"状态整行无设计线段(需求性工程条目或直改类条目),实况须指认证据"
"(reports/ 路径/文件:行号址/「证据=」句/审计 P 编号,§12.1)"))
need_gap = any(w in GAP_REQUIRED for w in words)
if need_gap and "缺口" not in attrs:
issues.append((where, line, "缺口属性",
"状态为设计未闭合态(%s),须有「**缺口**」属性" % "、".join(words)))
if not need_gap and words and "缺口" in attrs:
issues.append((where, line, "缺口属性",
"状态为 %s,设计已闭合/后段不得残留「**缺口**」属性" % "、".join(words)))
if iter_nos:
for no in iter_nos:
links.append((line, no, cards))
return links
def row_cells(row):
return [c.strip() for c in row.split("|")[1:-1]]
def cell_shape(cell, card_col=False):
"""判据 7:单元格白名单外的形态说明;合法返回 None。
需求卡片列按多卡分隔符逐段看叙述、总长不设限(§12.2 一格一行一卡是合法形态),
路径存在性由列规则逐卡承担;其余列维持整格叙述检查。
"""
if cell == "":
return "空白单元格"
if re.fullmatch(r"〔[^〕]*〕", cell):
return None if DATE_RE.search(cell) else "特批标记缺日期"
if card_col:
if any(mark in part for part in CARD_SEP_RE.split(cell) for mark in ("。", ",")):
return "成段叙述(卡片列一格一行一卡,禁叙述文字)"
return None
if "。" in cell or "," in cell or len(cell) > 60:
return "成段叙述(只许路径/日期/状态词/保留字/—/≈N 个工作日/特批标记)"
return None
def check_iter_table(block, issues, where, root):
"""判据 1-7 逐行查。返回 {迭代号: (行号, 需求卡片列原文)} 供交叉核。"""
index = {}
hdr_line, hdr = block[0]
cells = row_cells(hdr)
if tuple(cells) != ROADMAP_HEADER:
issues.append((where, hdr_line, "总表表头",
"表头须为「%s」(现:%s)" % ("|".join(ROADMAP_HEADER), "、".join(cells))))
data = [(ln, r) for ln, r in block[1:] if not SEP_ROW_RE.match(r)]
if not data:
issues.append((where, hdr_line, "零命中自检", "迭代总表解析出 0 数据行"))
return index
for ln, r in data:
cells = row_cells(r)
if len(cells) != 6:
issues.append((where, ln, "六列数", "本行 %d 列(须恰六列)" % len(cells)))
continue
code, status, cards, date, period, report = cells
for ci, cell in enumerate(cells):
bad = cell_shape(cell, card_col=(ci == 2))
if bad:
issues.append((where, ln, "单元格白名单",
"第 %d 列「%s」:%s" % (ci + 1, cell[:24], bad)))
im = ITER_CODE_COL_RE.match(code)
if not im:
issues.append((where, ln, "迭代代号", "「%s」缺迭代号(须「迭代 N」形态)" % code))
status_n = norm_parens(status)
if not STATUS_COL_RE.match(status_n):
issues.append((where, ln, "总表状态列",
"「%s」不在五形态(待开工/冻结待凑批/进行中/已完成(YYYY-MM-DD)/已终止(YYYY-MM-DD))" % status))
# 需求卡片列:路径逐卡查存在性;退役保留字与历史行「—」(无卡片制时期)豁免
if norm_parens(cards) not in (norm_parens(RESERVED_RETIRED), "—"):
for part in CARD_SEP_RE.split(cards):
part = part.strip()
if part and not (root / part).is_file():
issues.append((where, ln, "卡片路径不存在",
"需求卡片列 %s 相对仓库根不存在" % part))
if date != "—" and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
issues.append((where, ln, "编入时间", "「%s」须为 YYYY-MM-DD(历史迭代不可考填「—」)" % date))
# 预计周期:≈N 个工作日(估时中值精确值);>20 硬判红(特批不放行);10<x≤20 须格内带日期特批标记(§12.2/§12.4);<5 ⇒ 行状态须为「冻结待凑批」(2026-10-25 两层模型:卡片无门槛、迭代层冻结线);历史行「—」跳过
per_marks = APPROVAL_RE.findall(period)
per_core = APPROVAL_RE.sub("", period).strip()
pm = None if per_core == "—" else PERIOD_RE.match(per_core)
if per_core != "—" and pm is None:
issues.append((where, ln, "预计周期",
"「%s」须为「≈N 个工作日」形态(精确值不取整;历史迭代不可考填「—」)" % period))
elif pm is not None:
wd = float(pm.group(1))
if wd < 5 and "冻结待凑批" not in status:
issues.append((where, ln, "预计周期",
"「%s」低于 5 个工作日且未处「冻结待凑批」态——小卡可编入,迭代总量不足须冻结待凑批" % period))
elif wd > 20:
issues.append((where, ln, "预计周期",
"「%s」超过 20 个工作日硬上限,特批标记不放行,须拆批" % period))
elif wd > 10 and not any(DATE_RE.search(mk) for mk in per_marks):
issues.append((where, ln, "预计周期",
"「%s」超 10 个工作日且无带日期特批标记〔用户特批 YYYY-MM-DD …〕" % period))
# 实现报告:已完成=存在路径或退役保留字(历史报告已按「证据只留最新」退役),禁「—」
done = status_n.startswith("已完成(")
report_n = norm_parens(report)
if report_n == "—":
if done:
issues.append((where, ln, "实现报告",
"状态已完成但实现报告为「—」(须为存在路径或退役保留字)"))
elif report_n == norm_parens(RESERVED_RETIRED):
pass # 退役保留字合法(含已完成的历史迭代)
elif not (report.startswith("reports/") and (root / report).is_file()):
issues.append((where, ln, "实现报告",
"「%s」须为「—」/退役保留字/reports/ 下存在路径" % report))
if im:
index.setdefault(int(im.group(1)), (ln, cards))
return index
def check_roadmap(text, issues, where, root):
"""定位迭代总表(表头含「迭代代号」的表格)并逐行查。返回交叉核索引。"""
index = {}
lines = text.splitlines()
found = False
i = 0
while i < len(lines):
if lines[i].lstrip().startswith("|"):
block = []
while i < len(lines) and lines[i].lstrip().startswith("|"):
block.append((i + 1, lines[i].strip()))
i += 1
if not found and "迭代代号" in row_cells(block[0][1]):
found = True
index = check_iter_table(block, issues, where, root)
else:
i += 1
if not found:
issues.append((where, 1, "零命中自检",
"未找到迭代总表(表头须含「迭代代号」的六列表格)"))
return index
def cross_check(links, index, issues, where):
"""唯一一处交叉核:已编迭代 N ↔ 总表迭代 N 行的需求卡片列。
一条目可有多张已写卡分编多个迭代(状态段累积「已编迭代 40+已编迭代 41」):
对每个已编迭代号核两件——总表该行存在;该行卡片列含本条目至少一张已写卡路径。
哪张卡编哪个迭代的卡级映射由编排四查与人工核验,闸不猜卡级映射。
"""
for line, no, cards in links:
row = index.get(no)
if row is None:
issues.append((where, line, "交叉核", "迭代总表无迭代 %d 行" % no))
elif not cards:
issues.append((where, line, "交叉核",
"已编迭代 %d 但条目无可引的已写卡路径(须先已写卡)" % no))
elif not any(c in row[1] for c in cards):
issues.append((where, line, "交叉核",
"迭代 %d 行卡片列不含本条目任何已写卡路径(%s)"
% (no, "/".join(cards[:3]))))
def card_date(path):
"""文件名尾日期 → (y, m, d) 元组;取不到返回 None(该卡跳过两条新规机检)。"""
found = DATE_RE.findall(path.stem)
if not found:
return None
return tuple(int(x) for x in found[-1].split("-"))
def capability_names(root):
"""game/tests/README.md 能力表首列行名集合;文件或表缺失返回空集(调用方据此判红)。"""
p = root / CAP_README
if not p.is_file():
return set()
names = set()
header = None
for raw in p.read_text(encoding="utf-8").splitlines():
s = raw.strip()
if not s.startswith("|"):
header = None
continue
cells = row_cells(s)
if header is None:
header = cells
continue
if SEP_ROW_RE.match(s):
continue
if any("代表用例" in c for c in header) and cells:
names.add(cells[0].strip("`").strip())
return names
def check_ledger(text, path, when, issues, root):
"""C3 改动—验收对照表结构检查(生效 2026-10-21)。
只查:对照表节在不在、表头有没有五个必需列、有没有数据行、必填格空不空。
不判断测试是否真的覆盖改动——那是独立验收审计的职责,机器闸不猜语义。
"""
lines = text.splitlines()
start = None
for i, raw in enumerate(lines):
if LEDGER_HEADING_RE.match(raw.strip()):
start = i
break
if start is None:
issues.append((str(path), 1, "验收对照表缺失",
"须含「改动—验收对照表」节(2026-10-21 起,req-card §8)"))
return
header = None
header_line = 0
for i in range(start + 1, len(lines)):
s = lines[i].strip()
if s.startswith("#") and not LEDGER_HEADING_RE.match(s):
break
if s.startswith("|") and not SEP_ROW_RE.match(s):
header = row_cells(s)
header_line = i + 1
break
if header is None:
issues.append((str(path), start + 1, "验收对照表缺表",
"节内未找到表格"))
return
idx = {}
for name in LEDGER_REQUIRED:
hit = next((k for k, cell in enumerate(header) if name in cell), None)
if hit is None:
issues.append((str(path), header_line, "验收对照表缺列",
"缺「%s」列" % name))
return
idx[name] = hit
banned = next((cell for cell in header
if any(mark in cell for mark in BANNED_PROOF_MARKS)), None)
binding_hit = next((k for k, cell in enumerate(header)
if "判据绑定面" in cell), None)
if when >= BINDING_RULE_DATE:
if banned is not None:
issues.append((str(path), header_line, "验收对照表禁列",
"「%s」列已随 2026-10-24/28 两裁禁列(req-card §8.4,"
"验收断言绑真实入口+判据绑定面)" % banned))
return
if binding_hit is None:
issues.append((str(path), header_line, "验收对照表缺列",
"缺「判据绑定面」列(2026-10-28 判据口径换代,req-card §8)"))
return
idx["绑定面"] = binding_hit
else:
proof_hit = next((k for k, cell in enumerate(header)
if any(mark in cell for mark in LEDGER_PROOF_MARKS)), None)
if proof_hit is None:
issues.append((str(path), header_line, "验收对照表缺列",
"缺「撤销证明」列"))
return
idx["证明"] = proof_hit
cap_hit = None
cap_names = None
if when >= CAP_ROW_RULE_DATE:
cap_hit = next((k for k, cell in enumerate(header) if "能力行" in cell), None)
if cap_hit is None:
issues.append((str(path), header_line, "验收对照表缺列",
"缺「能力行」列(2026-10-29 起:每行写 既有:<能力名> 或 "
"新增:<能力名>,对齐能力清单)"))
return
idx["能力行"] = cap_hit
cap_names = capability_names(root)
rows = 0
for i in range(header_line, len(lines)):
s = lines[i].strip()
if not s:
continue
if not s.startswith("|"):
break
if SEP_ROW_RE.match(s):
continue
cells = row_cells(s)
if len(cells) < len(header):
issues.append((str(path), i + 1, "验收对照表缺格",
"列数少于表头(%d<%d)" % (len(cells), len(header))))
continue
rows += 1
for name, k in idx.items():
if k >= len(cells) or not cells[k].strip():
issues.append((str(path), i + 1, "验收对照表空格",
"「%s」列为空" % name))
if cap_hit is not None:
cap_cell = cells[cap_hit] if cap_hit < len(cells) else ""
for seg in [x.strip() for x in re.split(r"[;;]", cap_cell) if x.strip()]:
m = CAP_ROW_RE.match(seg)
if not m:
issues.append((str(path), i + 1, "能力行格式",
"「能力行」格须写 既有:<能力名>/新增:<能力名>:%s"
% seg[:24]))
continue
# 2026-10-29 能力清单改版:写卡与入表同 commit——「新增:」同样要求该能力行
# 已在 game/tests/README.md 能力表在场(写卡时当场入表,test-conventions §8.3)。
cap_name = m.group(2).strip("`").strip()
if cap_name not in cap_names:
if m.group(1) == "新增":
issues.append((str(path), i + 1, "新增能力未入表",
"新增能力「%s」不在 game/tests/README.md 能力表——"
"写卡须同批把能力行写入能力清单" % cap_name[:24]))
else:
issues.append((str(path), i + 1, "能力行不可核",
"既有能力「%s」不在 game/tests/README.md 能力表"
% cap_name[:24]))
if rows == 0:
issues.append((str(path), header_line, "验收对照表零行",
"表内没有数据行"))
def check_cards(root, issues):
"""C1 文档义务口径(生效 2026-10-17)+C2 设计事实源声明(生效 2026-10-18)
+C3 改动—验收对照表结构(生效 2026-10-21)。
只检文件名日期达生效日的卡——更早的卡属已实现批次的历史留档,不回头改。
C1 判据:「关联文档同步面」表内,目标格指向 design/ 或根规则文件、而义务格仍写
「随批」者红(2026-10-17 req-card §6 改口径后,这类点只能列 reports/ 反哺点清单)。
C2 判据:全文既无「设计事实源」也无「唯一事实源」者红(两种写法都与库内实态对齐)。
C3 判据:有「改动—验收对照表」节;表头含需求点/改动/验收测试/真实入口+证明列
(2026-10-28 起换代为「判据绑定面」列,并禁「撤销证明/变异证明」列);
能力行列(2026-10-29 起)既有与新增的名称都须在 game/tests/README.md 能力表在场
(新增=写卡同批入表,test-conventions §8.3);至少一行;必填格非空;全文含「验收审计」声明。
语义覆盖不在此判。
"""
dirn = root / CARD_DIR
if not dirn.is_dir():
issues.append((str(dirn), 1, "零命中自检", "卡片目录不存在,C1/C2 无卡可检"))
return 0
n = 0
for path in sorted(dirn.glob(CARD_GLOB)):
when = card_date(path)
if when is None:
continue
n += 1
text = path.read_text(encoding="utf-8-sig")
if when >= SOURCE_RULE_DATE and not any(mark in text for mark in SOURCE_MARKS):
issues.append((str(path), 1, "设计事实源缺失",
"须含「设计事实源」或「唯一事实源」声明句(req-card §2 三选一,"
"GDD 节+版本号/样张+art-bible 版本/工程形态证据指认)"))
if when >= LEDGER_RULE_DATE:
check_ledger(text, path, when, issues, root)
if AUDIT_MARK not in text:
issues.append((str(path), 1, "独立验收审计未声明",
"须在卡内写明独立验收审计入口与报告文件(2026-10-21 起,"
"test-conventions §9.7)"))
if when >= GRADE_RULE_DATE and not GRADE_RE.search(text):
issues.append((str(path), 1, "验收档未申报",
"须在卡片头部信息区申报「验收档:高/中/低」(2026-11-05 起,"
"test-conventions §8.3)"))
if when < SYNC_RULE_DATE:
continue
in_sec = False
for lineno, raw in enumerate(text.splitlines(), start=1):
stripped = raw.strip()
if SYNC_HEADING_RE.match(stripped):
in_sec = True
continue
if in_sec and stripped.startswith("#"):
in_sec = False
continue
if not in_sec or not stripped.startswith("|") or SEP_ROW_RE.match(stripped):
continue
cells = row_cells(stripped)
if len(cells) < 3 or cells[0] in ("文档", ""):
continue
if any(mark in cells[0] for mark in BANNED_DOC_MARKS) and "随批" in cells[2]:
issues.append((str(path), lineno, "文档义务口径",
"目标「%s」属实现批禁写面,义务却写「随批」——改标反哺点并列进 "
"reports/ 清单(req-card §6,2026-10-17 起)" % cells[0][:36]))
return n
def main():
ap = argparse.ArgumentParser(
description="工作流程流水线闸:待办清单条目状态机+roadmap 迭代总表+一次交叉核")
ap.add_argument("--todo", help="待办清单路径(缺省=<仓库根>/待办清单.md)")
ap.add_argument("--roadmap", help="roadmap 路径(缺省=<仓库根>/roadmap.md)")
ap.add_argument("--repo-root", help="仓库根(卡片/报告存在性的相对基准;缺省=本文件上两级)")
args = ap.parse_args()
root = (Path(args.repo_root).resolve() if args.repo_root
else Path(__file__).resolve().parent.parent)
todo_path = Path(args.todo) if args.todo else root / DEFAULT_TODO
roadmap_path = Path(args.roadmap) if args.roadmap else root / DEFAULT_ROADMAP
missing = [p for p in (todo_path, roadmap_path) if not p.is_file()]
if missing:
print("输入件缺失: %s" % "、".join(str(m) for m in missing), file=sys.stderr)
return 2
issues = [] # (文件, 行号, 判据名, 一句白话说明)
todo_text = todo_path.read_text(encoding="utf-8-sig")
roadmap_text = roadmap_path.read_text(encoding="utf-8-sig")
links = check_todo(todo_text, issues, str(todo_path), root)
index = check_roadmap(roadmap_text, issues, str(roadmap_path), root)
cross_check(links, index, issues, str(todo_path))
n_cards = check_cards(root, issues)
for where, line, name, msg in issues:
print("%s:%d %s %s" % (display(where, root), line, name, msg))
if issues:
print("流水线闸 FAIL:红项 %d" % len(issues))
return 1
print("流水线闸 PASS:待办清单条目 %d · 总表数据行 %d · 交叉核 %d 链 · 卡片口径 %d 张全过"
% (len(parse_todo(todo_text)), len(index), len(links), n_cards))
return 0
if __name__ == "__main__":
sys.exit(main())