日常更新
This commit is contained in:
201
ansible/tools/validate_stories_by_doc.py
Normal file
201
ansible/tools/validate_stories_by_doc.py
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user