#!/usr/bin/env python3 """批量 VS(Validate Story):校验 stories-by-doc/story-doc-*.md 与仓库契约一致。 对应 BMad `bmad-create-story:validate` 在本仓库的**可自动化**子集(结构、路径、noop 与 playbook 一致)。 深度内容评审(对照 PRD/架构全文)仍需人工或单篇会话。 用法: python3 ansible/tools/validate_stories_by_doc.py python3 ansible/tools/validate_stories_by_doc.py --write-report python3 ansible/tools/validate_stories_by_doc.py --json """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent STORIES_DIR = ROOT / "_bmad-output" / "implementation-artifacts" / "stories-by-doc" VERIFY_DIR = ROOT / "ansible" / "playbooks" / "verify" DOCS_DIR = ROOT / "docs" FILES_PREFIX = ROOT / "ansible" / "files" STORY_NAME_RE = re.compile(r"^story-doc-([0-9]{2}-[0-9]{2})\.md$") META_DOC_RE = re.compile(r"\|\s*\*\*文档\*\*\s*\|\s*`([^`]+)`\s*\|") META_PB_RE = re.compile(r"\|\s*\*\*verify playbook\*\*\s*\|\s*`([^`]+)`\s*\|") META_ID_RE = re.compile(r"\|\s*\*\*doc_id\*\*\s*\|\s*`([0-9]{2}-[0-9]{2})`\s*\|") META_NOOP_RE = re.compile(r"\|\s*\*\*playbook 形态\*\*\s*\|\s*(.+)\s*\|") def playbook_is_noop(doc_id: str) -> bool: yml = VERIFY_DIR / f"{doc_id}.yml" if not yml.is_file(): return False content = yml.read_text(encoding="utf-8", errors="ignore") return "noop verify" in content.lower() or "noop-doc-verify" in content def meta_declares_noop(meta_line: str) -> bool: """与 gen_stories_by_doc.py 一致:noop 篇写「noop + verify_common 基线」,勿用子串 noop(「非纯 noop」会误判)。""" m = meta_line.lower() return "noop + verify_common" in meta_line or "noop+verify_common" in m.replace(" ", "") @dataclass class Row: doc_id: str ok: bool errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) def validate_one(path: Path) -> Row: m = STORY_NAME_RE.match(path.name) if not m: return Row(doc_id="?", ok=False, errors=[f"文件名不符合 story-doc-XX-YY.md:{path.name}"]) doc_id = m.group(1) row = Row(doc_id=doc_id, ok=True) text = path.read_text(encoding="utf-8", errors="replace") if "Status: ready-for-dev" not in text: row.errors.append("缺少 Status: ready-for-dev") row.ok = False for sec in ("## Meta", "## Acceptance Criteria", "## Tasks / Subtasks", "## Story completion status"): if sec not in text: row.errors.append(f"缺少章节 {sec}") row.ok = False mid = META_ID_RE.search(text) if not mid: row.errors.append("Meta 表中无法解析 doc_id") row.ok = False elif mid.group(1) != doc_id: row.errors.append(f"Meta doc_id `{mid.group(1)}` 与文件名 `{doc_id}` 不一致") row.ok = False md = META_DOC_RE.search(text) if not md: row.errors.append("Meta 表中无法解析 **文档**") row.ok = False else: rel = md.group(1).strip() doc_path = ROOT / rel if not doc_path.is_file(): row.errors.append(f"文档路径不存在:{rel}") row.ok = False mp = META_PB_RE.search(text) if not mp: row.errors.append("Meta 表中无法解析 **verify playbook**") row.ok = False else: rel_pb = mp.group(1).strip() pb_path = ROOT / rel_pb if not pb_path.is_file(): row.errors.append(f"playbook 不存在:{rel_pb}") row.ok = False mn = META_NOOP_RE.search(text) if mn: meta_line = mn.group(1).strip() expected_noop = playbook_is_noop(doc_id) declared_noop = meta_declares_noop(meta_line) if expected_noop != declared_noop: row.errors.append( f"playbook 形态与 verify 实际不一致:期望 noop={expected_noop},Meta 表述 noop={declared_noop}" ) row.ok = False else: row.warnings.append("未解析 playbook 形态行,跳过 noop 一致性检查") files_dir = FILES_PREFIX / doc_id if not files_dir.is_dir(): row.errors.append(f"缺少目录 ansible/files/{doc_id}/") row.ok = False if f"verify.sh run {doc_id}" not in text and f"run {doc_id}" not in text: row.warnings.append("正文中未出现 verify.sh run {doc_id} 类引用(可忽略若已改写)") return row def main() -> int: ap = argparse.ArgumentParser(description="批量校验 story-doc-*.md(VS 自动化子集)") ap.add_argument("--write-report", action="store_true", help=f"写入 {STORIES_DIR}/VS-all-report.md") ap.add_argument("--json", action="store_true", help="stdout 输出 JSON") args = ap.parse_args() paths = sorted(STORIES_DIR.glob("story-doc-*.md")) if not paths: print("ERR: 未发现 story-doc-*.md", file=sys.stderr) return 2 rows: list[Row] = [validate_one(p) for p in paths] failed = [r for r in rows if not r.ok] if args.json: print( json.dumps( [ { "doc_id": r.doc_id, "ok": r.ok, "errors": r.errors, "warnings": r.warnings, } for r in rows ], ensure_ascii=False, indent=2, ) ) else: print(f"VS-all(自动化):共 {len(rows)} 篇,通过 {len(rows) - len(failed)},失败 {len(failed)}") for r in rows: if r.ok and not r.warnings: continue print(f"\n## {r.doc_id}") if r.errors: for e in r.errors: print(f" ERR: {e}") for w in r.warnings: print(f" WARN: {w}") if not failed: print("\n全部通过(结构 + 路径 + noop 一致性)。") if args.write_report: lines = [ "# VS-all 报告(Validate Story 自动化子集)", "", f"- 校验篇数:**{len(rows)}**", f"- 通过:**{len(rows) - len(failed)}**", f"- 失败:**{len(failed)}**", "", "本报告由 `python3 ansible/tools/validate_stories_by_doc.py --write-report` 生成。", "覆盖:文件名与 Meta `doc_id`、必备章节、`docs` / `verify` / `ansible/files` 路径、`noop` 与 playbook 一致。", "不覆盖:对照 PRD/架构的语义深度评审(请单篇使用 BMad VS 或人工)。", "", "---", "", ] for r in rows: status = "✅" if r.ok else "❌" lines.append(f"- {status} **`{r.doc_id}`**") for e in r.errors: lines.append(f" - ERR: {e}") for w in r.warnings: lines.append(f" - WARN: {w}") out = STORIES_DIR / "VS-all-report.md" out.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"\n[OK] 已写入 {out.relative_to(ROOT)}") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())