#!/usr/bin/env python3 # -*- coding: utf-8 -*- """四段流水线**命名一致性**自检(纯静态文本比对,不需要渲染、不需要外部依赖)。 只查一件事:**四段的段名 / 子步名,在总入口、各段 SKILL.md、各段 references 之间有没有漂。** ⛔ 本脚本**不查视觉、几何、配色** —— 那些判据归 `open-design/craft/`,本脚本一概不碰。 为什么需要它(不是"为了严谨",是实测过的两次真实事故): - 2026-10-02:段名从「需求调研/功能规划/界面与交付」改成「产品需求/产品功能/界面交互」, `SKILL.md` 改了,但 `references/grill-me.md` 的 H1 仍写「需求澄清(grill-me · 调研段)」。 - 同日:`stage-requirements/SKILL.md` 的子步表写「2b 功能流转」,正文 H2 却是「## 2b 状态机」; `product-planning` 的④段子步列只写「4a 说明区/ 演示引导」,漏了 4b / 4c。 这三处**没有任何门禁会报错** —— 只能靠回读,而回读会漏。 用法: python scripts/check_naming.py --ws <工作区> # 查技能包自身 + 该工作区的产出白名单 python scripts/check_naming.py --root <某个技能根> # 只查一份 退出码:0 = 全过;1 = 有 FAIL。 """ import argparse import os import pathlib import re import sys # ── 权威口径表。改段名/子步名时,先改这里,再改文档 ────────────────────────── STAGES = [ { "slug": "stage-discovery", "cn": "产品需求", "h1": "# ① 产品需求", # 2026-10-06 用户定案:①段收为五子步五产出,编号 1a~1e,全部落 research/ # 旧「1a 需求定义 / 1b 需求取证 / 1c 产品定位与场景」三子步废止(见 DEPRECATED) "subs": ["1a 需求文档", "1b 竞品分析", "1c 用户画像", "1d 产品策略", "1e 使用场景"], }, { "slug": "stage-requirements", "cn": "产品功能", "h1": "# ② 产品功能", # 2026-10-06 用户定案:②段收窄为「只细化功能」,2b/2c 删除,三子步并为一个 # 2026-10-07 用户定案:②段一拆二 —— 产品功能 / 界面布局 两个子步两文档 "subs": ["2a 产品功能", "2b 界面布局"], }, { "slug": "stage-delivery", "cn": "界面交互", "h1": "# ③ 界面交互", "subs": ["3a 视觉规范", "3b 原型", "3c GPT会诊", "3d 审查打磨"], }, { "slug": "stage-proto-doc", "cn": "原型说明文档", "h1": "# ④ 原型说明文档", # 4b/4c 允许只在一处出现完整(总入口与段内都写全),但**两处都必须能读到** "subs": ["4a 场景盘点", "4b 说明区", "4c 演示引导"], }, ] # 已废止的旧名。出现在「废止理由块」里是正当的(那是留痕), # 所以这里只做**提示级**检查(不计入 FAIL),人工确认上下文即可。 DEPRECATED = [ "需求调研", "功能规划", "需求与结构", "界面与交付", "1a 需求澄清", "2a 需求压测", "2b 状态机", "2c 图示", "1b 调研", "1c 方案", # 2026-10-06 ②段收窄:以下三个子步名与旧产出名一并废止 "2a 功能定义与压测", "2b 功能流转", "2c 功能图示", "1c 产品定位与商业设计", # 2026-10-06 ①段收为五子步:旧三子步名废止(产出改落 research/1a~1e) "1a 需求定义", "1b 需求取证", "1c 产品定位与场景", # 2026-10-06 ①段旧产出名废止:00/01 合并为 1a,其余按组号改子步号 "00-问题定义.md", "01-需求澄清.md", "10-参考实装拆解.md", "11-竞品分析.md", "12-用户画像.md", "strategy/30-产品策略.md", "strategy/31-使用场景.md", ] # ⛔ 2026-10-06 用户定案:①段**产出白名单**——本段只许这五份,第 6 份须用户明确要求。 # 判据:扫 `docs/pm/<项目>/research/` 的实际文件名,出现白名单外的 `.md` 即 FAIL。 # ⛔ 用 os.walk 时**必须逐层拆开**:`research/` 与 `prd/` 是两个不同的白名单。 WHITELIST = { "stage-discovery": { "dir": "research", "allow": ["1a-需求文档.md", "1b-竞品分析.md", "1c-用户画像.md", "1d-产品策略.md", "1e-使用场景.md"], }, # 🔴 2026-10-07 用户定案:「2 产品功能 和 界面布局不就是两个文档嘛」 # ⇒ ②段同样是白名单制:`prd/` 里**只许这两份**(⛔ 不许退回单份 `PRD.md`)。 "stage-requirements": { "dir": "prd", "allow": ["2a-产品功能.md", "2b-界面布局.md"], }, } # 已从磁盘移除的资产。文档里提到它们时,只允许出现在**说明性文字**里 # ("原主干 X 已不存在"这类),一旦出现在「必读表/调用指令」位置就是悬空引用。 REMOVED_ASSETS = ["ui-page-design"] # ⛔ 2026-10-02 用户定案**永久删除**的产出与方法论(防被加回来) # 判据:命中位置若落在「已删除的产出」留痕块 / 废止说明里 → 正常,只报 HINT。 DEAD_OUTPUTS = { "opportunity-solution-tree": "机会树(用户:完全没用,都是编的)", "20-机会树.md": "机会树产物(用户:完全没用,都是编的)", "value-proposition": "价值主张方法论(用户:不如改为使用场景)", "strategy/31-价值主张.md": "价值主张产物(已改名为 31-使用场景.md)", } results = [] # (level, check, detail) def add(level, check, detail): results.append((level, check, detail)) def read(path): if not os.path.isfile(path): return None with open(path, encoding="utf-8", errors="ignore") as f: return f.read() def frontmatter_ok(text): """frontmatter 是否闭合。判法:第 1 行是 ---,且其后 12 行内能找到闭合 ---。 ⛔ 别用 text.split('---')[0] 取 frontmatter —— 那是**开标记之前**的片段, 逻辑写反,会把每个文件都误报成「无 frontmatter」。 """ lines = text.split("\n") if not lines or lines[0].strip() != "---": return False return any(l.strip() == "---" for l in lines[1:12]) def check_root(root, label): root = os.path.abspath(root) if not os.path.isdir(root): add("FAIL", f"[{label}] 技能根存在", root) return add("PASS", f"[{label}] 技能根存在", root) # 1) 每段:frontmatter 闭合 + H1 与权威表一致 + 每个子步都有 H2 # ⚠️ 2026-10-05 整合后:四段收进 references//,不再是技能根下的平级目录。 # 本函数按「技能包 = 一个目录,SKILL.md 在最外层」的形态定位: # /SKILL.md ← 总入口(曾为 /product-planning/SKILL.md) # /references//SKILL.md ← 四段(曾为 //SKILL.md) # ⛔ 不许改回平级探测:那会让整合形态静默退回「一目录套多技能」而门禁照样全绿。 for st in STAGES: p = os.path.join(root, "references", st["slug"], "SKILL.md") text = read(p) if text is None: add("FAIL", f"[{label}] references/{st['slug']}/SKILL.md 存在", p) continue rel = f"references/{st['slug']}/SKILL.md" add("PASS" if frontmatter_ok(text) else "FAIL", f"[{label}] {rel} frontmatter 闭合", "") h1s = [l.rstrip() for l in text.split("\n") if l.startswith("# ") and "产品" in l or l.startswith("# ") and "界面" in l or l.startswith("# ") and "原型说明" in l] if st["h1"] in text: add("PASS", f"[{label}] {rel} H1 = `{st['h1']}`", "") else: add("FAIL", f"[{label}] {rel} H1 应为 `{st['h1']}`", "实际 H1 行: " + " | ".join(l.strip() for l in text.split("\n") if l.startswith("# ")[:2])) for sub in st["subs"]: key = sub.split()[0] # "1a" / "3b" ... has_h2 = any(l.startswith(f"## {key} ") for l in text.split("\n")) add("PASS" if has_h2 else "FAIL", f"[{label}] {rel} 有 H2 章节 `{sub}`", "" if has_h2 else f"找不到 `## {key} ` 开头的章节 —— 子步表与正文不一致") # 2) 总入口:段名四件套 + 子步列表完整 + 交接口表 # ⚠️ 2026-10-05 整合后总入口就是技能包自己的 SKILL.md(不再有 product-planning/ 子目录) pp = os.path.join(root, "SKILL.md") text = read(pp) if text is None: add("FAIL", f"[{label}] SKILL.md 存在", pp) return rel = "SKILL.md" for st in STAGES: num = STAGES.index(st) + 1 circled = "①②③④"[num - 1] # 段名必须以「圈号 + 空格 + 段名」成对出现,避免只匹配到散落正文 ok = f"**{circled} {st['cn']}**" in text add("PASS" if ok else "FAIL", f"[{label}] {rel} 四段表含 `{circled} {st['cn']}`", "") # 子步:总入口表里每个子步都要能被逐个读到(防"4a 说明区/ 演示引导"这类漏列) for st in STAGES: for sub in st["subs"]: if sub.split()[0] in ("3d", "4b", "4c"): # 3d/4b/4c 在总入口可能与相邻子步合并书写,只要求"子步号 + 关键词"能被搜到 kw = sub.split(" ", 1)[1] if " " in sub else sub ok = (sub in text) or (kw in text) else: ok = sub in text add("PASS" if ok else "FAIL", f"[{label}] {rel} 四段表子步列含 `{sub}`", "") # 交接口表:三条边必须都在 for edge in ("① → ②", "② → ③", "③ → ④"): add("PASS" if edge in text else "FAIL", f"[{label}] {rel} 交接口表含 `{edge}`", "") # 3) 悬空引用:已移除资产**不许出现在必读表的数据行里** # ⛔ 只扫表格数据行(以 `|` 开头且含路径形态),不扫章节前的告示段 —— # 告示里写"原必读表指向 X,该目录已不在磁盘上"是**正当留痕**,不是悬空引用。 # 判据形态:形如 `| **D0** | ...ui-page-design... | ... |` 才算。 runbook = read(os.path.join(root, "references", "stage-delivery", "references", "execution-runbook.md")) if runbook: m = re.search(r"## 1\. 必须加载的文件(.*?)(?=\n## )", runbook, re.S) section = m.group(1) if m else "" table_rows = [l for l in section.split("\n") if l.strip().startswith("|") and "---" not in l] for asset in REMOVED_ASSETS: bad = [l.strip()[:90] for l in table_rows if asset in l and "取代原" not in l] add("FAIL" if bad else "PASS", f"[{label}] runbook 必读表数据行未引用已移除资产 `{asset}`", ";".join(bad) if bad else "") # 4) 提示级:旧名出现在哪(供人工判上下文,不计入 FAIL) for dp, dn, fn in os.walk(root): dn[:] = [d for d in dn if d != "__pycache__"] for f in fn: if not f.endswith(".md"): continue p = os.path.join(dp, f) t = read(p) if not t: continue # 4b)永久删除项:命中若落在「已删除的产出」留痕/ 废止说明里 → 只报 HINT for dp, dn, fn in os.walk(root): dn[:] = [d for d in dn if d != "__pycache__"] for f in fn: if not f.endswith(".md"): continue p = os.path.join(dp, f) tt = read(p) if not tt: continue for key, why in DEAD_OUTPUTS.items(): if key in tt: lines = [i for i, l in enumerate(tt.split(chr(10)), 1) if key in l] add("HINT", f"[{label}] {os.path.relpath(p, root)} 出现已删除项 `{key}`", f"{why}|行 {lines[:5]} —— 确认是否落在废止说明/留痕里(正当)") for old in DEPRECATED: if old in t: lines = [i for i, l in enumerate(t.split("\n"), 1) if old in l] add("HINT", f"[{label}] {os.path.relpath(p, root)} 出现旧名 `{old}`", f"行 {lines[:6]} —— 确认是否落在「废止理由块」内(正当)还是真残留") def check_whitelist(ws, label): """①②段产出白名单:`docs/pm/<项目>//` 里出现白名单外的 .md ⇒ FAIL。 ⛔ 这是**产出层**门禁,与命名门禁不同源:命名管"文档里写的名字有没有漂", 本函数管"磁盘上到底多出了什么文件"。2026-10-06 事故:某轮自作主张多产了一份 《参考实装拆解》,用户反复看到"冒出来的不相关的文档" ⇒ 从此用机器判。 ✅ 2026-10-07 扩到②段(`prd/` 只许 `2a-产品功能.md` / `2b-界面布局.md`)—— 两段同为白名单制,⛔ 不出现"①段严、②段松"这种各说各话。 """ import glob for slug, rule in WHITELIST.items(): pat = os.path.join(ws, "docs", "pm", "*", rule["dir"]) for d in glob.glob(pat): if not os.path.isdir(d): continue proj = os.path.basename(os.path.dirname(d)) extra = sorted(f for f in os.listdir(d) if f.endswith(".md") and f not in rule["allow"]) rel = os.path.relpath(d, ws).replace("\\", "/") seg = "①段" if slug == "stage-discovery" else "②段" add("FAIL" if extra else "PASS", f"[{label}] {seg}产出白名单 `{rel}`", f"白名单外文件:{extra} —— ⛔ 该段只许 {len(rule['allow'])} 份({rule['allow']})," f"多出的须用户明确要求" if extra else "无白名单外文件") def main(): ap = argparse.ArgumentParser() ap.add_argument("--root", action="append", default=None, help="技能根目录,可重复;默认只查本技能包自身") ap.add_argument("--ws", default=None, help="工作区根(含 docs/pm 的那一层);默认从 cwd 向上找") args = ap.parse_args() # 本文件位于 <技能包>/scripts/ ⇒ 上溯 2 层 = 技能包(SKILL.md 就在这层) skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 🔴🔴 2026-10-07 技能迁全局后的**必须修正**: # 旧写法 `ws = os.path.dirname(skill_root)` 是按「技能包在工作区根」写的。 # 技能搬到 `E:/ProgramData/.workbuddy/skills/product-planning/` 之后, # `ws` 会算成 `E:/ProgramData/.workbuddy/skills` ⇒ **`docs/pm/*` 一个都扫不到** # ⇒ `check_whitelist()` 静默扫零个目录、**恒绿通过**(假绿比报红更危险)。 # ⇒ 工作区一律**显式给**:`--ws <工作区>`;没给就向上找「含 docs/pm 的那一层」, # 再找不到就**明确报 FAIL**,⛔ 绝不静默跳过。 roots = args.root or [skill_root] ws = args.ws if not ws: _p = pathlib.Path.cwd() for _ in range(6): if (_p / "docs" / "pm").is_dir(): ws = str(_p) break _p = _p.parent if not ws or not os.path.isdir(os.path.join(ws, "docs", "pm")): add("FAIL", "找得到工作区(`docs/pm` 那一层)", "⛔ 未找到 ⇒ 产出白名单**没被真正检查**。请显式传 `--ws <工作区>`。") ws = ws or os.getcwd() for r in roots: check_root(r, os.path.basename(os.path.dirname(r.rstrip("/")) if r.endswith("skills") else r)) # 产出白名单只查一次(它是**磁盘状态**,与技能根有几份无关) check_whitelist(ws, "产出白名单") fails = [x for x in results if x[0] == "FAIL"] hints = [x for x in results if x[0] == "HINT"] passes = [x for x in results if x[0] == "PASS"] for lvl, check, detail in results: if lvl == "HINT": print(f"[提示] {check}" + (f" —— {detail}" if detail else "")) print() for lvl, check, detail in fails: print(f"[FAIL] {check}" + (f" —— {detail}" if detail else "")) for lvl, check, detail in passes: print(f"[过] {check}") print() print(f"小计:通过 {len(passes)} | 失败 {len(fails)} | 提示 {len(hints)}") if fails: print("⛔ 有 FAIL —— 上面的不一致必须修完再跑流水线。") return 1 print("✅ 命名一致性全过。") if hints: print("ℹ️ 提示项需人工看一眼上下文(旧名出现在废止理由块里是正当留痕)。") return 0 if __name__ == "__main__": sys.exit(main())