diff --git a/CLAUDE.md b/CLAUDE.md index 3ca6db10..d9d78364 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,8 @@ | 时机 | 权威文档 | |---|---| -| 批次开工 / 流程纪律 | `design/dev/process-conventions.md`——worktree 铁律、三段开发流程、批次清单、测试两入口与 MC/闸机细则、像素纪律 | +| 批次开工 / 流程纪律 | `design/dev/process-conventions.md`——worktree 铁律、五段流水线、批次清单、测试两入口与 MC/闸机细则、像素纪律 | +| 流水线段入口 | 用户级技能对照——doc-design=文档设计/asset-ingest=美术录入/req-card=写卡/iter-plan=迭代编排/iter=实现(登记不立技能);格式与状态机权威=`design/dev/doc-conventions.md` §12 | | 写代码 / 改代码前 | `design/dev/code-conventions.md`——结构/契约/语言/数值/流程五类硬纪律(反例库 = 同目录 code-conventions-cases.md) | | 写测试 / 改测试前 | `design/dev/test-conventions.md`——探针标配 / 回归断言 / 全量验证 / 验收清单落码 / 变异验证 | | 写文档 / 改文档前 | `design/dev/doc-conventions.md`——白话写作 / 载体分工 / 用语 / 结构骨架 / 会话记录与生命周期 | @@ -55,7 +56,8 @@ - 主工作区禁止直接改动与提交——一切改动走独立 worktree(豁免:active.md 会话收口、.zcode 钩子配置) - 测试执行两入口铁规——只有迭代 loop 开发与用户点名两个触发入口;其余会话零测试,需测试支撑先请示并注明未跑测 - 交付前验收 = 跑 `tools/run_all_gates.sh` **按改动域**全绿(唯一入口、唯一闸清单、唯一域划分,禁在文档另列);合回 master 由 worktree-guard 按 diff 推域校验回执强制(process-conventions §3) -- **两类批**——**迭代批**(代码实现,`/iter` 发射):**禁写 `design/` 整个目录**(含新增/修改/删除),设计文档对它只读、是写测试与实现代码的输入依据;产出物只准写 `reports/`;批末只跑代码域闸。**非迭代批**(工具/规则/文档改动):不占 roadmap 迭代号,按改动域跑闸。文档内容的落地由用户在会话中讨论后进行,**无专用的文档编写入口/skill** +- **两类批**——**迭代批**(代码实现,`/iter` 发射):**禁写 `design/` 整个目录**(含新增/修改/删除),设计文档对它只读、是写测试与实现代码的输入依据;产出物只准写 `reports/`;批末只跑代码域闸。**非迭代批**(工具/规则/文档改动):不占 roadmap 迭代号,按改动域跑闸。文档内容的落地走五段流水线相应段(文档设计段入口=用户级技能 doc-design;登记条目格式由流水线闸强制) +- 五段流水线铁律——一切工作按「登记 → 文档设计/美术设计 → 写需求卡片 → 迭代编排 → 实现」推进,段间不自动流转、每段转进由用户发令;编入迭代的卡片必须设计闭合+零待裁点,流水线闸 `tools/pipeline_gate.py` 强制(工具并行开发中,注册进 run_all_gates.sh 文档域归第二批);实现段零裁断——卡片没写清=上游缺陷,停该卡退回上游,禁内联求裁(process-conventions §2,格式与状态机=doc-conventions §12) - MC/模拟:无数值改动零跑;档位硬闸见 `sim-data/README.md`「MC 采集档位表」,一档只能由用户发起 - 闸机轮次纪律:新码先孤立验证 → 出红只跑红件最小单元 → 长批先最小参数试车 → 开工扫坑册(process-conventions §6) - 变异验证——新增/改写验收函数必须变异变红才算验收成立;无变异红记录只写「已实现,拦截能力未证」(test-conventions §6) diff --git a/design/dev/doc-conventions.md b/design/dev/doc-conventions.md index 221253be..f410f6d4 100644 --- a/design/dev/doc-conventions.md +++ b/design/dev/doc-conventions.md @@ -87,7 +87,8 @@ | `03-内容规模.md` | 几个关卡、几种敌人、几种道具——做多少砍多少 | | `04-美术音频方向.md` | 参考图、参考游戏、风格关键词 | | `05-不做清单.md` | 明确写:这些我不要 | -| `06-待办清单.md` | 全项目待办唯一登记处;各系统文档「待办」节只留指路存根 | +| `06-待办清单.md` | 流水线唯一登记处:条目=登记元数据标题+四属性正文,状态随流水线段推进(格式与状态机见第 12.1 节);各系统文档「待办」节只留指路存根 | +| `roadmap.md`(根目录) | 里程碑表+迭代总表,一行一迭代,记录编排与实现进度(六列格式见第 12.2 节) | **系统文档骨架**(`systems/` 下统一采用):文首引言(一句话定位+状态+旧版链接)/设计目的(2-4 句白话)/系统范围(负责/不负责两表)/在核心循环中的位置(字符画一行+两句白话)/核心规则(领域章节 ×2–6,每节一页纸)/依赖与输出(表格;引用纪律见 concept-conventions)/P0 最小闭环与 P1·P2 后置/调参表(取值域+provisional 标注)/验收标准(可验证判据)/待办(指路存根)/版本历史(表格)。 @@ -119,3 +120,103 @@ | 证据只留最新 | 测试证据只保留最新一次迭代或最新一轮同类审计版本;新证据落位后同批删上一份;待人清单过人闸后同规退役 | **删除方式**:按 `design/dev/concept-conventions.md` 第 2 节删除概念 SOP 口径——先查引用面(grep 文件名于阻塞域),改写或删除指路句,再删文件,收尾跑死链检查全绿。 + +## 12. 流程文档格式 + +五段流水线(登记 → 文档设计/美术设计 → 写卡 → 迭代编排 → 实现,流程纪律见 `design/dev/process-conventions.md` §2)靠流程文档运转:06-待办清单登记要做的事,roadmap 记录迭代编排结果,artwork 目录承载美术在途素材。本节是这些流程文档的格式与状态机权威,也是流水线闸(`tools/pipeline_gate.py`,自动检查 06 与 roadmap 两文件格式及互相引用的机检闸)的判据依据。 + +### 12.1 06-待办清单条目格式与状态机 + +06-待办清单是流水线的唯一登记处:要做的事先登记一条,随流水线段推进改状态,做完整条删除。一条条目分两层——**标题=登记元数据**(何时登记、从哪来、走哪条设计轨,登记后不再变),**正文=四个属性**(随进程演进)。 + +标题格式(尖括号处按实际填写): + +``` +#### <条目名>(登记,来源=,设计面=<文档轨/美术轨/双轨/工程直入/反哺点>) +``` + +正文四属性模板: + +``` +**现状**:现在缺什么、为什么是问题(关键处带 file:line 或文档指针)。工程直入条目必须在现状里指认证据(bug 证据或工程债条目)。 +**缺口**:还差哪一次设计讨论/哪份设计产物才能写卡。尚有未闭合的设计轨时此属性必须存在;全部设计轨闭合后删除。 +**归属**:主责系统文档或线别。 +**状态**:按状态机取词。 +``` + +状态机按标题里的设计面分轨,状态一律取下例用词。状态行按**状态段累积制**书写:条目每推进一段,就把新状态段接在状态行末尾,段与段用+号连接,书写顺序=推进顺序;已过去的段一律保留、禁删——闭合凭据要留在行内,编排四查与开工核验拿它比对。每段各自按状态词白名单与凭据规则校验,+号只作状态段连接符。 + +``` +各轨推进序列(双轨条目设计阶段两段并列推进,一文档轨一美术轨): +文档轨:待文档设计 → 文档设计中 → 文档设计已闭合(凭据=文档路径+版本号, + 例:文档设计已闭合(design/gdd/systems/xxx.md v1.2)) +美术轨:待美术设计 → 美术设计中 → 美术设计已闭合(凭据=样张过审、art-bible 条款版本, + 例:美术设计已闭合(样张过审、art-bible v3.x)) +双轨条目设计阶段示例:文档设计已闭合(design/gdd/systems/xxx.md v0.6)+美术设计中 +进入下游段(待写卡及之后)的前置条件——双轨:两个设计轨段都在场且都已闭合(带凭据);单轨:本轨闭合段(带凭据)在场 +下游共用序列:待写卡 → 已写卡(design/research/ 下的卡片路径)→ 已编迭代 N → 完成即整条删除 +单轨累积示例(已编迭代):文档设计已闭合(design/gdd/systems/xxx.md v1.2)+已写卡(design/research/xxx.md)+已编迭代 2 +``` + +凭据括号内禁用+号——+只作状态段连接符,凭据内需要并列多项时用顿号(如上例「样张过审、art-bible v3.x」),用+会被流水线闸误判为状态段。已编迭代 N 段的交叉核依据=同状态行内「已写卡」段括号里的卡片路径。 + +三条特殊通道: + +| 设计面 | 通道规则 | +|---|---| +| 工程直入 | 缺陷修复/工程债/测试基建/重构四类工程条目:登记即「待写卡」,凭现状里指认的证据跳过设计轨 | +| 反哺点专条 | 待裁决 → 已写入(落点=文档+版本)→ 删除 | +| 备选库条目 | 暂不排期、留在备选库的条目:状态词后缀「(备选)」 | + +「已闭合」不带凭据不算合法状态词。特批标记见 12.4。 + +### 12.2 roadmap 迭代行格式 + +roadmap 由顶部里程碑表+迭代总表组成,迭代总表一行一个迭代,回答一个问题:这个迭代编入了哪些卡、做到哪一步。迭代行六列: + +``` +| 迭代号 | 状态 | 需求卡片 | 编入时间 | 预计周期 | 实现报告 | +``` + +| 列 | 规则 | +|---|---| +| 迭代号 | 写「迭代 N」形态(N 为阿拉伯数字);这是流水线闸交叉核匹配的依据 | +| 状态 | 待开工 / 进行中 / 已完成(YYYY-MM-DD)/ 已终止(YYYY-MM-DD) | +| 需求卡片 | 只写文件路径;一格内一行一卡,格内排列顺序=执行顺序;已退役填保留字「已退役(git 历史可考)」 | +| 编入时间 | YYYY-MM-DD(该迭代最近一次编入卡片的日期) | +| 预计周期 | ≈N 周(0.5 步进,N ≤ 2);超过 2 周须随带日期的用户特批标记(格式见 12.4),流水线闸见标记放行、无标记判红 | +| 实现报告 | `reports/iterN-实现报告-YYYY-MM-DD.md`;未完成=「—」;退役=「已退役(git 历史可考)」 | + +迭代行禁入内容:事项说明、卡序叙述、T 段明细、读数、闸数字、裁点记录、门依赖。卡间依赖写在卡片自身,行内只放上表六列的事实。特批标记同样可用于 roadmap 格(须带日期,见 12.4)。 + +### 12.3 美术工作区目录与命名 + +美术素材走两条目录线:在途工作在 `artwork/`(整目录不入 git),定稿进 `game/assets/art/`(入包被游戏加载)。图片永不进 `design/`。 + +| 位置 | 规则 | +|---|---| +| `artwork//` | 批次工作区,一次美术批一个目录,整目录不入 git | +| `artwork/<批次工作区>/samples/` | 样张子目录 | +| `artwork/archive/<主题>-<日期>/` | 批次完结归档 | +| `game/assets/art/<用途子目录>/` | 终稿,入包被游戏加载 | + +命名总则:全小写英文+下划线,禁中文/空格/大写。 + +| 对象 | 文件名规则 | +|---|---| +| 终稿 | 三段式 `<对象>_<部位或状态>_<规格>.png`;终稿文件名=代码消费名——先定 ui_kit/资源路径常量,素材名跟它走,防断链 | +| 样张 | `sample_<屏名>_<版本>.png` | +| 同名迭代 | 直接覆盖,不留 v2 副本 | + +美术内容不做机检,只保留命名与目录检查。 + +### 12.4 特批标记 + +特批标记是流程格式例外的唯一放行凭证:`〔用户特批 YYYY-MM-DD 跳过××〕`。只有用户明示才能给,用于 06 条目状态与 roadmap 格(见 12.1、12.2)。 + +执行技能一律两段式: + +1. 对象已有内容 → 直接写入标记。 +2. 对象没有任何内容 → 必须回呈实况,经用户再次确认后才写标记。 + +标记必须带日期,无日期=非法。流水线闸见合法特批标记放行对应判据。 diff --git a/design/dev/process-conventions.md b/design/dev/process-conventions.md index 78e350c4..e9d1bf59 100644 --- a/design/dev/process-conventions.md +++ b/design/dev/process-conventions.md @@ -1,6 +1,6 @@ # 流程规范(Dungeon Sweeper) -> 约束范围:一切批次的流程纪律——分支与 worktree、三段开发流程、批次工作流、测试执行细则、像素件工作纪律。 +> 约束范围:一切批次的流程纪律——分支与 worktree、五段流水线、批次工作流、测试执行细则、像素件工作纪律。 > 分工:硬性红线的一句话索引在 CLAUDE.md「硬性红线」;本文承载其操作细则;批次开工前通读本文。 **开工任何批次前必须通读本文。** @@ -14,15 +14,21 @@ 3. **豁免面**:仅根目录 `active.md`(会话完成)、`.zcode/`(钩子与配置,入库)与 `.qoder/`(本机 harness 配置,gitignored 不入 git,走 worktree 反而带不进去)允许主工作区直改;其余一律走 worktree,主工作区 git commit 被钩子拦截 4. 本铁律由 PreToolUse 钩子 `.zcode/hooks/worktree-guard.sh` 强制兜底;钩子只约束 agent,用户手工改动不受限 -## 2. 三段开发流程 +## 2. 五段流水线 -一切要做的东西按三段走:设计 → 写需求卡片 → 实现。段间不自动流转,每段转进由用户发令。 +一切要做的东西按五段流水线走:**登记 → 文档设计/美术设计(双轨)→ 写需求卡片 → 迭代编排 → 实现**。段间不自动流转,每段转进由用户发令;每段一个用户级技能入口,登记不立技能(条目格式由流水线闸强制)。设计工作在会话内进行、用户主导、禁无监管自跑。 -| 段 | 产出 | 出入口铁律 | -|---|---|---| -| 一·设计 | GDD 文档或 art-bible | 用户提起或从 `06-待办清单` 取出;会话内进行、用户主导、禁无监管自跑;经用户点头确认心智一致才许写入文档档;完成判据=待裁点清零,带未裁点的是讨论稿;不自动进写卡段,写卡须用户另行发令 | -| 二·写需求卡片 | 需求卡片(写作规范见用户级技能 req-card) | 设计定稿后用户发令;例外=缺陷修复/工程债/测试基建/重构凭 bug 证据或 06 条目直接进入;新玩法/新系统/新内容必须有设计定稿前置;卡片必经 grilling 多轮复核、零待裁点才成立;入库必在 06 登记一条(防遗忘);编入后续未完成迭代(roadmap 迭代行),无空位与用户确认开新一轮 | -| 三·实现 | 代码与配置,可运行的游戏 | 需求卡片永不单独施工,统一由用户指定迭代号触发(=U0 开工令),按 /iter 技能与本文 §3 执行;**本段是代码实现批:禁写 `design/` 与根规则文件,产出物只落 `reports/`(见 §3 第 13 条)**;设计权威文档(GDD/art-bible)批内一律不更新;完成人闸=实机目检清单+代拍板追认单登记交用户即完成批内义务 | +| 段 | 入口技能 | 产出 | 出入口铁律 | +|---|---|---|---| +| 一·登记 | 无(闸管格式) | 06-待办清单条目 | 条目=标题登记元数据+正文四属性(现状/缺口/归属/状态);标题登记后不再变,正文随进程演进。格式与状态机权威=doc-conventions 第 12 节 | +| 二·文档设计/美术设计 | doc-design(文档轨,兼任反哺点写入)/asset-ingest(美术录入;样张素材由用户主导产出) | GDD 文档或 art-bible/过审样张素材 | 用户提起或从 06 取出;会话内进行、用户主导、禁无监管自跑;经用户点头确认心智一致才许写入文档;完成判据=待裁点清零,带未裁点的是讨论稿;轨闭合凭据记入 06 状态(文档轨=文档路径+版本号,美术轨=样张过审+art-bible 条款版本) | +| 三·写需求卡片 | req-card | 需求卡片 | 轨闭合后用户发令;新玩法/新系统/新内容必须有设计定稿前置,例外=工程直入四类(缺陷修复/工程债/测试基建/重构)凭证据跳过设计轨;卡片必经 grilling 多轮复核、零待裁点才成立;写完在 06 状态记「已写卡」+卡片路径(防遗忘) | +| 四·迭代编排 | iter-plan | roadmap 迭代行 | 编排四查:①每卡卡片文件存在 ②每卡设计闭合凭据齐(或工程直入证据)③总估时 ≤2 周 ④写完 roadmap 行后跑流水线闸确认绿;编入未完成迭代,新开迭代行经用户确认 | +| 五·实现 | iter | 代码与配置,可运行的游戏 | 需求卡片永不单独施工,统一由用户指定迭代号触发(=U0 开工令),按 iter 技能与本文 §3 执行;**本段是代码实现批:禁写 `design/` 与根规则文件,产出物只落 `reports/`(见 §3 第 13 条)**;设计权威文档(GDD/art-bible)批内一律不更新;完成人闸=实机目检清单+代拍板追认单登记交用户即完成批内义务 | + +**实现段零裁断**:实现段的输入在编排段出口已锁死(卡片零待裁点+设计闭合凭据齐+编排四查过)。实现中冒出卡片没写清的分歧=上游缺陷——停该卡、06 状态回退+缺口补记,退回上游;禁自答、禁内联提问求裁断;该卡停摆不阻塞同迭代其他卡。环境故障与测试红走异常分级(自救重试/记录继续/停机呈报)。代拍板白名单保留:授权发生在卡片审问阶段,实现只执行预授权;批后追认单=备案确认。 + +**流水线闸**:`tools/pipeline_gate.py` 机检 06-待办清单与 roadmap 两文件的格式与互相引用;将注册进 `run_all_gates.sh` 文档域(注册归第二批,本批不注册)。流程文档的条目格式、迭代行格式与状态机细则见 doc-conventions 第 12 节。 ## 3. 批次工作流标准清单 diff --git a/tools/pipeline_gate.py b/tools/pipeline_gate.py new file mode 100644 index 00000000..a4f58868 --- /dev/null +++ b/tools/pipeline_gate.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""pipeline_gate.py — 工作流程流水线闸(06 条目状态机+roadmap 迭代总表+一次交叉核)。 + +职责:机检 06 条目(标题元数据/属性齐备/状态词白名单/条件凭据/状态段累积制在场约束/特批/ + 零命中)与 roadmap 迭代总表(六列/状态四形态/卡片与报告存在性/周期≤2 周可特批豁免/ + 单元格白名单/零命中);「已编迭代↔已写卡段↔迭代行卡片列」交叉核只此一处。 +约束:每红项一行「文件:行号 判据名 一句白话说明」,exit 0=全绿 / 1=有红 / 2=输入件缺失; + 卡片与报告存在性相对 --repo-root 解析(缺省=本文件上两级);现行 06 与 roadmap 尚为旧格式, + 对其红属预期(第二批数据整改后才注册上闸)。 +用法: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("design/gdd/06-待办清单.md") +DEFAULT_ROADMAP = Path("roadmap.md") + +SOURCE_WORDS = ("playnote", "会话提及", "审计发现", "批次移交") +FACE_WORDS = ("文档轨", "美术轨", "双轨", "工程直入", "反哺点") +STATUS_WORDS = ( + "待文档设计", "文档设计中", "文档设计已闭合", + "待美术设计", "美术设计中", "美术设计已闭合", + "待写卡", "已写卡", "已编迭代", "待裁决", "已写入", +) +# 这些状态 = 设计未闭合,条目必须带「**缺口**」属性;其余状态不得带 +GAP_REQUIRED = ("待文档设计", "文档设计中", "待美术设计", "美术设计中") +# 状态段累积制(doc-conventions §12.1):下游段在场时的上游闭合段在场约束 +DOWNSTREAM_WORDS = ("待写卡", "已写卡", "已编迭代") +PRESENCE_FACES = ("文档轨", "美术轨", "双轨") +DOC_DESIGN_WORDS = ("待文档设计", "文档设计中", "文档设计已闭合") +ART_DESIGN_WORDS = ("待美术设计", "美术设计中", "美术设计已闭合") +ROADMAP_HEADER = ("迭代代号", "状态", "需求卡片", "编入时间", "预计周期", "实现报告") +RESERVED_RETIRED = "已退役(git 历史可考)" + +DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") +VER_RE = re.compile(r"[vV]\d+(?:\.\d+)*") +RESEARCH_RE = re.compile(r"design/research/[^\s()(),,]+") +ITER_RE = re.compile(r"迭代\s*(\d+)") +APPROVAL_RE = re.compile(r"〔用户特批([^〕]*)〕") +CRED_RE = re.compile(r"[((]([^()()]*)[))]") +CARD_SEP_RE = re.compile(r"|\n|、") # 迭代行卡片列一格多卡的分隔(§12.2 一格一行一卡) +ATTR_LINE_RE = re.compile(r"^\*\*(现状|缺口|归属|状态)\*\*\s*[::]\s*(.*)$") +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}\))$") +WEEKS_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:标题须含日期、来源=四选一、设计面=五选一。返回设计面词(可能 None)。""" + if not DATE_RE.search(title): + issues.append((where, line, "标题元数据", "标题缺登记日期(YYYY-MM-DD 形态)")) + face = None + 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))) + m = re.search(r"设计面=([^\s,,(())]+)", title) + if not m: + issues.append((where, line, "标题元数据", "标题缺「设计面=」标记")) + elif m.group(1) not in FACE_WORDS: + issues.append((where, line, "标题元数据", + "设计面词「%s」不在五选一(文档轨/美术轨/双轨/工程直入/反哺点)" % m.group(1))) + else: + face = m.group(1) + return face + + +def check_segment(word, creds, line, issues, where, root): + """判据 3 的单段校验。返回 (合法状态词|None, 已写卡路径|None, 已编迭代号|None)。""" + 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, None + if base not in STATUS_WORDS: + red("状态词非法", "「%s」不在合法状态词白名单" % word) + return None, None, None + cred_text = ";".join(creds) + if base in ("文档设计已闭合", "已写入"): + if not (VER_RE.search(cred_text) and ("/" in cred_text or ".md" in cred_text)): + red("凭据缺失", "「%s」须带括号落点凭据(文档路径+版本号形态)" % base) + elif base == "美术设计已闭合": + if not ("art-bible" in cred_text.lower() and VER_RE.search(cred_text)): + red("凭据缺失", "「美术设计已闭合」凭据须含 art-bible 路径+版本号") + elif base == "已写卡": + rp = None + for c in creds: + m = RESEARCH_RE.search(c) + if m: + rp = m.group(0) + break + if rp is None: + red("凭据缺失", "「已写卡」括号内须为 design/research/ 下的卡片路径") + elif not (root / rp).is_file(): + red("卡片路径不存在", "已写卡路径 %s 相对仓库根不存在" % rp) + return base, rp, None + if im is not None: + return base, None, int(im.group(1)) + return base, None, None + + +def check_status(raw, line, face, issues, where, root): + """判据 3+状态段累积制(§12.1)+特批/备选。返回 (状态词列表, 已写卡路径列表, 已编迭代号|None)。 + + 累积制:按+切段后段数不限,逐段各自合法;再查两条结构约束—— + 下游段(待写卡/已写卡/已编迭代)在场时上游闭合段必须在场(带日期特批可豁免); + 双轨设计阶段(无下游段)必须恰两段且一文档一美术。 + """ + 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() + segs = [s.strip() for s in content.split("+") if s.strip()] + if not segs: + issues.append((where, line, "状态词非法", "状态行内容为空")) + return [], [], None + words, cards, iter_no = [], [], None + for seg in segs: + creds = CRED_RE.findall(seg) + word = CRED_RE.sub("", seg).strip() + w, rp, it = check_segment(word, creds, line, issues, where, root) + if w: + words.append(w) + if rp: + cards.append(rp) + if it is not None: + iter_no = it + downstream = [w for w in words if w in DOWNSTREAM_WORDS] + if downstream and face in PRESENCE_FACES and not dated_approval: + need = [] + if face in ("文档轨", "双轨") and "文档设计已闭合" not in words: + need.append("文档设计已闭合(凭据)") + if face in ("美术轨", "双轨") and "美术设计已闭合" not in words: + need.append("美术设计已闭合(凭据)") + if need: + issues.append((where, line, "在场约束", + "下游段(%s)在场但缺上游闭合段 %s(带日期特批可豁免)" + % ("、".join(downstream), "、".join(need)))) + if face == "双轨" and not downstream: + n_doc = sum(1 for w in words if w in DOC_DESIGN_WORDS) + n_art = sum(1 for w in words if w in ART_DESIGN_WORDS) + if len(segs) != 2 or n_doc != 1 or n_art != 1: + issues.append((where, line, "双轨段数", + "设计面=双轨设计阶段须恰两段且一文档一美术(现 %d 段:文档轨 %d、美术轨 %d)" + % (len(segs), n_doc, n_art))) + return words, cards, iter_no + + +def check_todo(text, issues, where, root): + """06 全部判据。返回交叉核链(已编迭代条目 → (行号, 迭代号, 卡片路径|None))。""" + entries = parse_todo(text) + links = [] + if not entries: + issues.append((where, 1, "零命中自检", + "未解析出任何 #### 条目(路径错/文件空/纯垃圾文本=假绿防线)")) + return links + for e in entries: + line = e["line"] + face = check_title(e["title"], line, issues, where) + attrs, attr_line = {}, {} + for ln, raw in e["body"]: + m = ATTR_LINE_RE.match(raw.strip()) + if m and m.group(1) not in attrs: + attrs[m.group(1)] = m.group(2) + attr_line[m.group(1)] = ln + for req in ("现状", "归属", "状态"): + if req not in attrs: + issues.append((where, line, "属性缺失", "条目缺「**%s**」属性" % req)) + if "状态" not in attrs: + continue + words, cards, iter_no = check_status( + attrs["状态"], attr_line["状态"], face, issues, where, root) + 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_no is not None: + links.append((line, iter_no, cards[0] if cards else None)) + 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, weeks, 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) != 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 not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + issues.append((where, ln, "编入时间", "「%s」须为 YYYY-MM-DD" % date)) + # 预计周期:≈N 周(N≤2);超 2 周须格内带日期特批标记(§12.2/§12.4) + week_marks = APPROVAL_RE.findall(weeks) + week_core = APPROVAL_RE.sub("", weeks).strip() + wm = WEEKS_RE.match(week_core) + if not wm: + issues.append((where, ln, "预计周期", + "「%s」须为「≈N 周」形态(N≤2,允许 0.5 步进)" % weeks)) + elif float(wm.group(1)) > 2 and not any(DATE_RE.search(mk) for mk in week_marks): + issues.append((where, ln, "预计周期", + "「%s」超 2 周且无带日期特批标记〔用户特批 YYYY-MM-DD …〕" % weeks)) + 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): + if done: + issues.append((where, ln, "实现报告", "状态已完成但实现报告为退役保留字(须为存在路径)")) + 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 行的需求卡片列。""" + for line, no, card in links: + row = index.get(no) + if row is None: + issues.append((where, line, "交叉核", "迭代总表无迭代 %d 行" % no)) + elif card is None: + issues.append((where, line, "交叉核", + "已编迭代 %d 但条目无可引的已写卡路径(须先已写卡)" % no)) + elif card not in row[1]: + issues.append((where, line, "交叉核", + "迭代 %d 行卡片列不含本条目已写卡路径 %s" % (no, card))) + + +def main(): + ap = argparse.ArgumentParser( + description="工作流程流水线闸:06 条目状态机+roadmap 迭代总表+一次交叉核") + ap.add_argument("--todo", help="06-待办清单路径(缺省=<仓库根>/design/gdd/06-待办清单.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)) + 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:06 条目 %d · 总表数据行 %d · 交叉核 %d 链全过" + % (len(parse_todo(todo_text)), len(index), len(links))) + return 0 + + +if __name__ == "__main__": + sys.exit(main())