798 lines
43 KiB
Python
798 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
r"""pipeline_gate.py — 工作流程流水线闸(待办清单条目状态机+roadmap 迭代总表+需求卡片口径+一次交叉核)。
|
||
|
||
职责:机检待办清单条目(标题元数据与可选优先级档名/四属性齐备与旧属性名防回潮/状态词白名单/
|
||
改动面四档白名单/条件凭据/无设计线段条目的证据指认/状态段累积制/特批/正文限长双判据/零命中)
|
||
与 roadmap 迭代总表(六列/状态五形态/卡片与报告存在性/编入时间与预计周期的
|
||
分档放行)/需求卡片口径(C1 文档义务/C2 设计事实源/C3 改动—验收对照表/C4 验收档申报/
|
||
C5 判据出处行锚/C6 复核记录)+一次交叉核。
|
||
约束:每红项一行「文件:行号 判据名 一句白话说明」,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*[高中低]")
|
||
# C5 判据出处行锚(2026-11-05 立;test-conventions §8.3:需求点格须带现行文档判据句锚)
|
||
ANCHOR_RULE_DATE = (2026, 11, 5)
|
||
ANCHOR_RE = re.compile(r"([^\s`'\"()(),,、::|]+\.md)\s*:\s*(\d+)") # 路径段放行中文名(系统文档全是中文名,test-conventions §8.3)
|
||
# C6 复核记录(2026-11-05 立,生效 2026-11-06;req-card 必备结构第 17 节+纪律「复核发现四类分流」)
|
||
REVIEW_RULE_DATE = (2026, 11, 6)
|
||
REVIEW_HEADING_RE = re.compile(r"^#{2,4}\s*\d*\.?\s*复核记录")
|
||
REVIEW_TARGET_WORDS = ("自判", "补写", "待裁", "退回上游")
|
||
REVIEW_HEADER_WORDS = ("轮次", "去向")
|
||
# 待办条目正文限长双判据(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 anchor_valid(root, rel, lineno):
|
||
"""判据出处行锚有效性:文档存在且行号在行数范围内(相对仓库根解析)。"""
|
||
p = root / rel.replace("\\", "/")
|
||
if not p.is_file():
|
||
return False
|
||
return 1 <= lineno <= len(p.read_text(encoding="utf-8-sig").splitlines())
|
||
|
||
|
||
def check_ledger(text, path, when, issues, root):
|
||
"""C3 改动—验收对照表结构检查(生效 2026-10-21);C5 判据出处行锚(生效 2026-11-05)。
|
||
|
||
只查:对照表节在不在、表头有没有五个必需列、有没有数据行、必填格空不空;
|
||
C5=生效日后需求点格须带现行文档行锚且指向存在(test-conventions §8.3)。
|
||
不判断测试是否真的覆盖改动——那是独立验收审计的职责,机器闸不猜语义。
|
||
"""
|
||
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 when >= ANCHOR_RULE_DATE:
|
||
point_cell = cells[idx["需求点"]] if idx.get("需求点", 99) < len(cells) else ""
|
||
anchors = ANCHOR_RE.findall(point_cell)
|
||
if not anchors:
|
||
issues.append((str(path), i + 1, "判据出处行锚缺失",
|
||
"「需求点」格须带判据出处行锚 `<现行文档相对路径>:<行号>`"
|
||
"(2026-11-05 起,test-conventions §8.3)"))
|
||
elif not any(anchor_valid(root, rel, int(no)) for rel, no in anchors):
|
||
issues.append((str(path), i + 1, "判据出处行锚不可核",
|
||
"锚指向的文档或行号不存在:%s" % point_cell[:40]))
|
||
if rows == 0:
|
||
issues.append((str(path), header_line, "验收对照表零行",
|
||
"表内没有数据行"))
|
||
|
||
|
||
def check_review(lines, start, path, issues, root):
|
||
"""C6 复核记录的单节校验(req-card 必备结构第 17 节,生效 2026-11-06)。
|
||
|
||
只查结构与可核性:节内每条记录行都要带四类去向之一(自判/补写/待裁/退回上游),
|
||
表格行的处置结果格(末格)非空;复核凭据=本节自身,不另设报告文件(2026-11-06 用户裁)。
|
||
分流分得对不对属语义判断,本闸不判。返回节末行下标(不含)。
|
||
"""
|
||
end = len(lines)
|
||
for i in range(start + 1, len(lines)):
|
||
if lines[i].strip().startswith("#"):
|
||
end = i
|
||
break
|
||
records = 0
|
||
for i in range(start + 1, end):
|
||
s = lines[i].strip()
|
||
if not s or s.startswith("|") and SEP_ROW_RE.match(s):
|
||
continue
|
||
is_row = s.startswith("|")
|
||
is_bullet = bool(re.match(r"^[-*]\s+", s))
|
||
if not (is_row or is_bullet):
|
||
continue
|
||
if any(mark in s for mark in REVIEW_HEADER_WORDS):
|
||
continue
|
||
if not any(word in s for word in REVIEW_TARGET_WORDS):
|
||
issues.append((str(path), i + 1, "复核去向未标",
|
||
"该条复核记录缺四类去向之一(自判/补写/待裁/退回上游)——"
|
||
"先按 req-card「复核发现四类分流」判归属,前两类禁转成问句呈报用户"))
|
||
continue
|
||
if is_row:
|
||
cells = row_cells(s)
|
||
if not cells or not cells[-1].strip():
|
||
issues.append((str(path), i + 1, "复核处置未填",
|
||
"处置结果格为空——须写改了卡内哪一节、退给了哪一段、或用户裁决的结论"))
|
||
records += 1
|
||
if records == 0:
|
||
issues.append((str(path), start + 1, "复核记录空节",
|
||
"「复核记录」节内没有一条发现记录——卡片必经对抗复核并逐轮登记"))
|
||
return end
|
||
|
||
|
||
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);至少一行;必填格非空;全文含「验收审计」声明。
|
||
语义覆盖不在此判。
|
||
C6 判据(生效 2026-11-06):有「复核记录」节;节内每条发现记录带四类去向之一
|
||
(自判/补写/待裁/退回上游)且表格行处置格非空;节内申报 reports/复核-*.md 且文件在盘。
|
||
"""
|
||
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 >= REVIEW_RULE_DATE:
|
||
lines = text.splitlines()
|
||
head = next((i for i, raw in enumerate(lines)
|
||
if REVIEW_HEADING_RE.match(raw.strip())), None)
|
||
if head is None:
|
||
issues.append((str(path), 1, "复核记录缺失",
|
||
"缺「复核记录」节——卡片必经对抗复核,逐轮发现与处置须登记在案"
|
||
"(req-card 必备结构第 17 节,2026-11-06 起生效)"))
|
||
else:
|
||
check_review(lines, head, path, issues, root)
|
||
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())
|