一、新增技能 - product-planning(253 文件):四阶段产品方案流水线(调研方案 / 需求结构 / 界面交付 / 说明文档), 含 references/stage-* 与内置的 diagram-design 子技能、scripts/ - oil-ui-pro(26 文件):UI/UX 设计 · 评审 · 多风格对比,含 assets/style-explorer.html、scripts/shoot.mjs、tests/ 二、session-mechanism 收尾(承接 e5dfb6e) - references/作业规矩/03-多棒接力编排.md、04-去AI味与说话方式.md:交叉引用表里的「参考 01」改为新落点 (该不该问/结论骨架 → references/02-功能优先协作协议.md;回复排版 → references/03-回复排版-核心块.md) - references/manifest.md 随生成器重算(含上述两行的 md5 与字节数) 三、入库前检查 - 三个技能内均无令牌 / 私钥 / 密钥赋值;.neodata_token 类落点已由 .gitignore 挡住 - 无运行期产物混入(logs/ · tmp/ · __pycache__ · *.log) - 暂存 282 个文件 = 279 新增(253 + 26)+ 3 修改
325 lines
16 KiB
Python
325 lines
16 KiB
Python
#!/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 # 查 product-planning/ 与 pm-skills/skills/
|
||
python scripts/check_naming.py --root <某个技能根> # 只查一份
|
||
退出码:0 = 全过;1 = 有 FAIL。
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
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/<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 "")
|
||
|
||
# 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/<项目>/<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="技能根目录,可重复;默认查本技能包与 pm-skills/skills/")
|
||
args = ap.parse_args()
|
||
|
||
# 本文件位于 <技能包>/scripts/ ⇒ 上溯 2 层 = 技能包(整合后 SKILL.md 就在这层),3 层 = 仓库根
|
||
skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
ws = os.path.dirname(skill_root)
|
||
roots = args.root or [
|
||
skill_root,
|
||
os.path.join(ws, "pm-skills", "skills"),
|
||
]
|
||
|
||
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())
|