2026-10-07 00:48:07 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""四段流水线**命名一致性**自检(纯静态文本比对,不需要渲染、不需要外部依赖)。
|
|
|
|
|
|
|
|
|
|
|
|
只查一件事:**四段的段名 / 子步名,在总入口、各段 SKILL.md、各段 references 之间有没有漂。**
|
2026-10-08 22:10:12 +08:00
|
|
|
|
⛔ 本脚本**不查视觉、几何、配色** —— 那些判据归③段 §供给·工艺数值判据,本脚本一概不碰。
|
2026-10-07 00:48:07 +08:00
|
|
|
|
|
|
|
|
|
|
为什么需要它(不是"为了严谨",是实测过的两次真实事故):
|
|
|
|
|
|
- 2026-10-02:段名从「需求调研/功能规划/界面与交付」改成「产品需求/产品功能/界面交互」,
|
|
|
|
|
|
`SKILL.md` 改了,但 `references/grill-me.md` 的 H1 仍写「需求澄清(grill-me · 调研段)」。
|
|
|
|
|
|
- 同日:`stage-requirements/SKILL.md` 的子步表写「2b 功能流转」,正文 H2 却是「## 2b 状态机」;
|
|
|
|
|
|
`product-planning` 的④段子步列只写「4a 说明区/ 演示引导」,漏了 4b / 4c。
|
|
|
|
|
|
这三处**没有任何门禁会报错** —— 只能靠回读,而回读会漏。
|
|
|
|
|
|
|
|
|
|
|
|
用法:
|
2026-10-07 16:06:02 +08:00
|
|
|
|
python scripts/check_naming.py --ws <工作区> # 查技能包自身 + 该工作区的产出白名单
|
2026-10-07 00:48:07 +08:00
|
|
|
|
python scripts/check_naming.py --root <某个技能根> # 只查一份
|
|
|
|
|
|
退出码:0 = 全过;1 = 有 FAIL。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import os
|
2026-10-07 16:06:02 +08:00
|
|
|
|
import pathlib
|
2026-10-07 00:48:07 +08:00
|
|
|
|
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": "# ③ 界面交互",
|
2026-10-08 22:10:12 +08:00
|
|
|
|
"subs": ["3a 视觉规范", "3b 原型", "3c GPT参考版", "3d 审查打磨"],
|
2026-10-07 00:48:07 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"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-08 22:10:12 +08:00
|
|
|
|
# ⛔ 已废止的目录形态。它们若出现在正文的「产出 / 只读 / 必读」位置 ⇒ 真残留(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": "停线规则(哪几种情况停)",
|
2026-10-08 22:11:35 +08:00
|
|
|
|
"authority": "SKILL.md",
|
2026-10-08 22:10:12 +08:00
|
|
|
|
"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-07 00:48:07 +08:00
|
|
|
|
# ⛔ 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 "")
|
|
|
|
|
|
|
2026-10-08 22:10:12 +08:00
|
|
|
|
# 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`。
|
2026-10-07 00:48:07 +08:00
|
|
|
|
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:
|
2026-10-08 22:10:12 +08:00
|
|
|
|
if old in tt:
|
|
|
|
|
|
lines = [i for i, l in enumerate(tt.split("\n"), 1) if old in l]
|
2026-10-07 00:48:07 +08:00
|
|
|
|
add("HINT", f"[{label}] {os.path.relpath(p, root)} 出现旧名 `{old}`",
|
|
|
|
|
|
f"行 {lines[:6]} —— 确认是否落在「废止理由块」内(正当)还是真残留")
|
|
|
|
|
|
|
2026-10-08 22:10:12 +08:00
|
|
|
|
# 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 "")
|
|
|
|
|
|
|
2026-10-07 00:48:07 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-07 16:06:02 +08:00
|
|
|
|
help="技能根目录,可重复;默认只查本技能包自身")
|
|
|
|
|
|
ap.add_argument("--ws", default=None,
|
|
|
|
|
|
help="工作区根(含 docs/pm 的那一层);默认从 cwd 向上找")
|
2026-10-07 00:48:07 +08:00
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
2026-10-07 16:06:02 +08:00
|
|
|
|
# 本文件位于 <技能包>/scripts/ ⇒ 上溯 2 层 = 技能包(SKILL.md 就在这层)
|
2026-10-07 00:48:07 +08:00
|
|
|
|
skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
2026-10-07 16:06:02 +08:00
|
|
|
|
# 🔴🔴 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()
|
2026-10-07 00:48:07 +08:00
|
|
|
|
|
|
|
|
|
|
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())
|