Files
workbuddy_skills/product-planning/scripts/check_naming.py
T
admin 7a476f9400 术语更名第一步:执行会话/任务会话 ⇒ 项目会话(口径块 + 触发词表)+ 落地 9 份"说人话"新稿
一、用户令(逐字)
1. `1、A 方案`
2. `把会话技能中 执行会话(协作会话)改为 项目会话 # 用法改为输入 使用项目会话完成目标:XXXXX`

二、A 方案:只落"说人话"新稿 —— ⚠️ 实测**只能落 9 份**,7 份必须合并
· 16 份新稿(`contentm_agent` 那条目标产出)逐份比对现文件:**9 份差异只在措辞**(0~2 行)⇒ ✅ 已落地,
  逐份 md5 与稿子一致;**原件已备份**到 `待清理/技能包-说人话落地前备份-20261008/`。
· 🔴 **7 份现文件含稿子里没有的内容**(最多的少 106 行!)—— 且 `stage-delivery/SKILL.md`、
  `execution-runbook.md`、`SKILL.md` 是**今天 15:28/15:29 刚被别的会话改过** ⇒ 直接覆盖会**丢掉今天的新内容**。
  ⇒ 按「改技能包前先看别人在途改动、⛔ 别硬覆盖」的规定**没动这 7 份**,留待合并(见回复待拍板项)。

三、术语更名(先落低风险那一半,让新用法今天可用)
· `SKILL.md` 第一屏加**术语更名口径块**:**项目会话(现行)= 任务会话(旧称)= 执行会话/协作会话(更旧称)**,
  新文字一律用「项目会话」;⛔ 三个词的旧正文**按本块读**(同 10-02 那次的做法)。
· 三类会话表里那一行改为「**项目会话**(旧称任务会话/执行会话)」。
· 第 0 步触发句加 **`使用项目会话完成目标:XXXXX`**(用户口径的现行写法)。
· `skill-load-guard.py` 的词表**加**项目会话族(`使用项目会话`/`项目会话完成`/`项目会话`/`创建项目会话`/`派项目会话`),
  ⛔ **旧词一个不删**(用户与新会话都可能用旧词,收窄词表=把已授权的触发句漏掉,本项目栽过)。
  复核:`使用项目会话完成目标:XXXXX` 命中 `['使用项目会话','项目会话完成']` ✓;`用执行会话完成 X` 仍命中 ✓。
· 🔴 **机器前缀 `[执行]` 暂不变** —— 它是角色解析与看板的键(`parse_session_name`/`board.py::_role_of_title`/
  在跑的排期名),属影响面变更,**等用户拍板**。
· ⚠️ **全量更名尚未做完**:实测 **416 处 / 22 个文件**(`collabd.py` 71 / `selftest.py` 63 / `SKILL.md` 50 /
  `board.html` 46 / `pitfalls.md` 36 / `board.py` 33 …)⇒ 按机制自己的三类规矩(注释文档 / `@case` 标题 / 看板文案)逐步收尾。

四、验收
selftest.py rc=0 PASS 106 / FAIL 0;manifest 76 份、语法失败 0。
2026-10-08 22:11:35 +08:00

444 lines
23 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 之间有没有漂。**
⛔ 本脚本**不查视觉、几何、配色** —— 那些判据归③段 §供给·工艺数值判据,本脚本一概不碰。
为什么需要它(不是"为了严谨",是实测过的两次真实事故):
- 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())