- product-planning:新建 references/manifest.md 作资源唯一登记处(资源/落点/引用点/口径); 顶层 SKILL 加两条硬规矩,design-capture、stage-delivery、execution-runbook、stage-requirements 四处把散落路径改为「落点见 references/manifest.md」 - session-mechanism:01-文档索引 新增 §四「包内资源索引」,并说明不放 manifest.md 的理由 (那份由 install.py 本机生成、被 .gitignore 挡着) - 新增两包共用的 scripts/check_resource_index.py(双向对账)与 product-planning/scripts/check_figures.py(图片机检), selftest 补对应用例,06-技能优化规程 写入写法规矩 - .gitignore:新增 _留痕/ 不入库(改造前整包快照,180K/688K,库内从无先例)
122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
#!/usr/bin/env python
|
||
"""产品规划文档的「开头必须有图」机检(单一真源:SKILL.md §产出文档开头必须有图)。
|
||
|
||
用法:
|
||
python scripts/check_figures.py --ws <工作区或产品根>
|
||
|
||
判定三件事(任一不过即 FAIL):
|
||
① 每份落盘 md(`产品规划/` 下,排除 `归档-过期版/`)开头 8 行内有 ``
|
||
② 引用的图文件在位(相对该 md 所在目录解析)
|
||
③ 同一张图没有被两份文档共用(一份文档一张自己的图)
|
||
|
||
输出:逐份一行;末尾给 PASS/FAIL 与计数。退出码 0=全过,1=有 FAIL。
|
||
"""
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
SKIP_DIRS = {"归档-过期版", "node_modules", ".git"}
|
||
FIG_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)")
|
||
|
||
|
||
def fmt(p: Path) -> str:
|
||
try:
|
||
return str(p.relative_to(p.anchor and Path(p.anchor) or Path(".")))
|
||
except Exception:
|
||
return str(p)
|
||
|
||
|
||
def resolve_product_root(ws: Path) -> Path | None:
|
||
"""认产品根:自身含 产品规划/ 即产品根;否则在 项目/<名>/ 或下一层里找唯一一个。"""
|
||
if (ws / "产品规划").is_dir():
|
||
return ws
|
||
cands = [d for d in (ws / "项目").glob("*") if (d / "产品规划").is_dir()] if (ws / "项目").is_dir() else []
|
||
if not cands:
|
||
cands = [d for d in ws.glob("*/产品规划") if d.is_dir()]
|
||
cands = [d.parent for d in cands]
|
||
if len(cands) == 1:
|
||
return cands[0]
|
||
if not cands:
|
||
return None
|
||
print("⚠️ 找到多个候选产品根,请直接传产品根路径:")
|
||
for c in cands:
|
||
print(" ", c)
|
||
return None
|
||
|
||
|
||
def head_lines(p: Path, n: int = 8) -> list[str]:
|
||
out = []
|
||
try:
|
||
with p.open(encoding="utf-8", errors="replace") as f:
|
||
for line in f:
|
||
if line.strip():
|
||
out.append(line.rstrip("\n"))
|
||
if len(out) >= n:
|
||
break
|
||
except Exception as e:
|
||
print(" 读取失败:%s" % e)
|
||
return out
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--ws", required=True, help="工作区根或产品根")
|
||
a = ap.parse_args()
|
||
|
||
root = resolve_product_root(Path(a.ws).resolve())
|
||
if root is None:
|
||
print("FAIL 认不出产品根:%s" % a.ws)
|
||
return 1
|
||
base = root / "产品规划"
|
||
print("产品根 = %s" % root)
|
||
|
||
docs = [p for p in base.rglob("*.md")
|
||
if not (set(p.parts) & SKIP_DIRS)
|
||
and not p.name.startswith(("索引", "本轮回报"))]
|
||
docs.sort()
|
||
if not docs:
|
||
print("FAIL 产品规划下没有 md 文档")
|
||
return 1
|
||
|
||
used: dict[str, list[Path]] = {}
|
||
fails = 0
|
||
for d in docs:
|
||
rel = d.relative_to(root)
|
||
hit = None
|
||
for line in head_lines(d):
|
||
m = FIG_RE.search(line)
|
||
if m:
|
||
hit = m.group(1)
|
||
break
|
||
if not hit:
|
||
print(" ❌ 无图 %s(开头 8 行没有 )" % rel)
|
||
fails += 1
|
||
continue
|
||
target = (d.parent / hit).resolve()
|
||
if not target.exists():
|
||
print(" ❌ 图不在位 %s → %s" % (rel, hit))
|
||
fails += 1
|
||
continue
|
||
key = str(target).lower()
|
||
used.setdefault(key, []).append(rel)
|
||
print(" ✅ %s → %s" % (rel, hit))
|
||
|
||
shared = {k: v for k, v in used.items() if len(v) > 1}
|
||
for k, v in shared.items():
|
||
names = "、".join(str(x) for x in v)
|
||
print(" ❌ 图被共用 %s 被 %d 份文档共用(%s)" % (Path(k).name, len(v), names))
|
||
fails += len(v) - 1
|
||
|
||
print("—" * 40)
|
||
print("文档 %d 份,图 %d 张,问题 %d 处" % (len(docs), len(used), fails))
|
||
if fails:
|
||
print("FAIL")
|
||
return 1
|
||
print("PASS")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|