#!/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())