Files

443 lines
23 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""四段流水线**命名一致性**自检(纯静态文本比对,不需要渲染、不需要外部依赖)。
只查一件事:**四段的段名 / 子步名,在总入口、各段 SKILL.md、各段 references 之间有没有漂。**
⛔ 本脚本**不查视觉、几何、配色** —— 那些判据归③段 §供给·工艺数值判据,本脚本一概不碰。
为什么需要它(不是"为了严谨",是实测过的两次真实事故):
- 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"]
# ⛔ 已废止的目录形态。它们若出现在正文的「产出 / 只读 / 必读」位置 ⇒ 真残留(FAIL)。
# 2026-10-08 实测:`usage-scenario.md` 的产出行、`stage-proto-doc/SKILL.md` 的输入表、
# 总入口的「只读」清单,三处还指着已废的 `strategy/` 与 `ux/`,而当时没有任何门禁会报。
# 豁免:行内出现下面任一废止标记时视为正当留痕(说明"原来叫什么、现在改成什么")。
DEAD_PATHS = {
r"strategy/30-|strategy/31-|docs/pm/[^`\s]*/strategy/":
"已废的 ①段 strategy/ 目录(现全部落 research/1a~1e)",
r"ux/state-machine|ux/diagrams|docs/pm/[^`\s]*/ux/":
"已废的 ②段 ux/ 目录(状态流转已移进 prd/2a 的功能条目)",
}
DEAD_PATH_EXEMPT = ("旧", "已废", "已改名", "曾", "历史上", "移进", "移到", "改为",
"废止", "原 ", "原标题", "历史:")
# ⛔ 单一可信源登记表(2026-10-08 立)。每条 = 一个「会变的事实」的唯一定义处。
# 判据:除定义处外,其余任何 md(不含 `归档/`、`_留痕/`、`assets/`、`_superseded*`)
# 里出现该模式 ⇒ FAIL。`authority: None` 表示全包禁写(该措辞本身就不该再出现)。
# 为什么需要它:复述才是漂移的根因 —— 实测一个设定被复述约 40 处,
# 改一次动三份文件 17 处仍漏 13 处口径(2026-10-08 用户:「是不是 本身就是问题」)。
# ⚠️ 加新条目前先确认「这个事实真的会变」;不会变的(如历史沿革、用户原话留痕)别往里塞。
AUTHORITY = [
{"fact": "②③边界(②段管什么 / ③段管什么)",
"authority": "references/stage-requirements/SKILL.md",
"pattern": r"归②段([^)]*)\s*\|\s*归(?:③段|本段)(",
"why": "两侧对照表只许在②段 SKILL.md 的 §②—③ 交接口 定义一次"},
{"fact": "停线规则(哪几种情况停)",
"authority": "SKILL.md",
"pattern": r"只有[三四]种情况停",
"why": "⛔ 全包禁写 —— 口径只在本包 SKILL.md 的「执行规则」,各段一律引用"},
{"fact": "可选档骨架名单(营销页向那 8 个)",
"authority": "references/stage-delivery/references/layouts-tooling.md",
"pattern": r"hero\s*/\s*features|hero/features",
"why": "骨架名单与「为什么工具型不能套」只许在 layouts-tooling.md 开头写一次"},
{"fact": "说人话门槛数值",
"authority": "SKILL.md",
"pattern": r"45\s*/\s*50",
"why": "门槛只许在本包 SKILL.md 的「执行规则」内容准则条 定义一次"},
{"fact": "对比度门槛数值",
"authority": "references/stage-delivery/references/execution-runbook.md",
"pattern": r"4\.5:1",
"why": "数值只许登记在 runbook 的数值判据段"},
{"fact": "设计契约字段的展开清单",
"authority": "references/stage-delivery/references/execution-runbook.md",
"pattern": r"重复项的信息量(",
"why": "十二字段的逐条展开只许在 runbook §2.1"},
{"fact": "②—③ 定案用户原话",
"authority": "references/stage-requirements/SKILL.md",
"pattern": r"一会一个样子",
"why": "用户原话只许在②段 §②—③ 交接口 出现一次(可追溯性由那一处承担)"},
{"fact": "1b 两体例的定案用户原话",
"authority": "references/stage-discovery/references/competitor-analysis.md",
"pattern": r"有独立分析也有汇总分析",
"why": "只许在 1b 方法论里写一次,其余引 §0.4"},
{"fact": "grill-me 的 A / B 节火力点",
"authority": "references/stage-discovery/references/grill-me.md",
"pattern": r"做成什么样、哪些情况不成立、状态怎么流转",
"why": "火力点定义只许在 grill-me.md"},
{"fact": "选定套的组件文件名(默认档与可选档不同名)",
"authority": "references/stage-delivery/references/execution-runbook.md",
"pattern": r"\+\s*`?components\.html`?",
"why": "⛔ 照可选档(153 套)的命名去找默认档的文件会扑空 —— 默认档那套没有 components.html。"
"2026-10-08 实测:D1 步骤照这个名字找 ⇒ 版式结构与组件族整层漏掉 ⇒ 「用了它的样式、没有它的排版」。"
"只许在 runbook §2.2「D1 读什么」里作为反面留痕出现"},
]
# ⛔ 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/<slug>/,不再是技能根下的平级目录。
# 本函数按「技能包 = 一个目录,SKILL.md 在最外层」的形态定位:
# <root>/SKILL.md ← 总入口(曾为 <root>/product-planning/SKILL.md)
# <root>/references/<slug>/SKILL.md ← 四段(曾为 <root>/<slug>/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 "")
# 5) 单一可信源:会变的事实只许在定义处出现,别处出现即复述(复述必然漂)
for spec in AUTHORITY:
auth = (os.path.normpath(os.path.join(root, spec["authority"]))
if spec["authority"] else None)
bad = []
for dp, dn, fn in os.walk(root):
dn[:] = [d for d in dn
if d not in ("__pycache__", "_留痕", "归档", "assets")]
for f in fn:
if not f.endswith(".md") or f.startswith("_superseded"):
continue
p = os.path.join(dp, f)
if auth and os.path.normpath(p) == auth:
continue
tt = read(p)
if not tt:
continue
for i, l in enumerate(tt.split("\n"), 1):
if re.search(spec["pattern"], l) and not any(
w in l for w in ("已于", "历史", "废止", "留痕")):
bad.append(f"{os.path.relpath(p, root).replace(chr(92), '/')}#{i}")
add("FAIL" if bad else "PASS",
f"[{label}] 单一可信源:{spec['fact']}",
(f"⛔ 复述出现在 {';'.join(bad[:6])} —— {spec['why']}" if bad else ""))
# 4) 提示级:旧名 / 已删项出现在哪(供人工判上下文,不计入 FAIL)
# ⚠️ 2026-10-08 修 bug:原实现先跑了一个空循环(只把文件名读进 `t`、什么都不做),
# 紧接着的 DEPRECATED 判断又误用了那个上一轮残留的 `t`(该用本轮读到的 `tt`)
# ⇒ 旧名提示恒不触发,`strategy/30-产品策略.md` 这类真残留被静默放过(假绿)。
# 现合并成一个循环,一律用本轮读到的 `tt`。
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 tt:
lines = [i for i, l in enumerate(tt.split("\n"), 1) if old in l]
add("HINT", f"[{label}] {os.path.relpath(p, root)} 出现旧名 `{old}`",
f"行 {lines[:6]} —— 确认是否落在「废止理由块」内(正当)还是真残留")
# 4c) 旧产物路径残留:已废目录形态出现在正文里就是真残留(告示/留痕里的除外)
# ⛔ 不要指望 DEPRECATED 的 HINT —— 它只提示不拦;路径错会让下游整段读空文件。
for dp, dn, fn in os.walk(root):
dn[:] = [d for d in dn if d not in ("__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 pat, why in DEAD_PATHS.items():
hits = [(i, l.strip()[:80]) for i, l in enumerate(tt.split("\n"), 1)
if re.search(pat, l) and not any(w in l for w in DEAD_PATH_EXEMPT)]
add("FAIL" if hits else "PASS",
f"[{label}] {os.path.relpath(p, root)} 无悬空旧路径 `{why}`",
";".join(f"行{i}: {l}" for i, l in hits[:4]) if hits else "")
def check_whitelist(ws, label):
"""①②段产出白名单:`docs/pm/<项目>/<dir>/` 里出现白名单外的 .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())