Files
workbuddy_skills/product-planning/scripts/check_naming.py
T
admin 777f7fe5d0 重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。
browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
2026-10-07 16:06:02 +08:00

343 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<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="技能根目录,可重复;默认只查本技能包自身")
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())