108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
||
"""校验执行域 verify/doc/files 一致性。
|
||
|
||
该脚本是“离线门禁”的一部分:发现 doc_id 三元契约不一致时应 fail-fast,
|
||
并输出可定位的冲突清单(doc_id + 路径集合)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||
VERIFY_DIR = ROOT / "ansible" / "playbooks" / "verify"
|
||
DOCS_DIR = ROOT / "docs"
|
||
EXEC_ID_RE = re.compile(r"^(0[1-9]|[1-9][0-9])-(0[1-9]|[1-9][0-9])$")
|
||
|
||
|
||
def is_exec_domain(doc_id: str) -> bool:
|
||
return EXEC_ID_RE.fullmatch(doc_id) is not None
|
||
|
||
|
||
def main() -> None:
|
||
if not VERIFY_DIR.is_dir() or not DOCS_DIR.is_dir():
|
||
print("ERR: verify/docs 目录缺失", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
# --- scan verify playbooks (.yml/.yaml) ---
|
||
verify_by_doc_id: dict[str, list[Path]] = {}
|
||
invalid_verify_names: list[str] = []
|
||
|
||
for p in sorted(VERIFY_DIR.iterdir()):
|
||
if not p.is_file():
|
||
continue
|
||
if p.name.startswith("_"):
|
||
# private helpers are allowed but not part of doc_id contract
|
||
continue
|
||
if p.suffix not in {".yml", ".yaml"}:
|
||
continue
|
||
|
||
# Only accept <doc_id>.yml|yaml
|
||
stem = p.stem
|
||
if len(stem) != len("00-00") or stem[2:3] != "-":
|
||
invalid_verify_names.append(p.name)
|
||
continue
|
||
|
||
if not is_exec_domain(stem):
|
||
invalid_verify_names.append(p.name)
|
||
continue
|
||
|
||
verify_by_doc_id.setdefault(stem, []).append(p)
|
||
|
||
# Same doc_id with multiple verify entrypoints is a hard conflict (EC1).
|
||
verify_conflicts: dict[str, list[str]] = {}
|
||
for did, paths in verify_by_doc_id.items():
|
||
if len(paths) > 1:
|
||
verify_conflicts[did] = [str(p.relative_to(ROOT)) for p in sorted(paths)]
|
||
|
||
missing_docs: list[str] = []
|
||
missing_files_dir: list[str] = []
|
||
weak_doc_exec_refs: list[str] = []
|
||
multi_docs: dict[str, list[str]] = {}
|
||
|
||
for did in sorted(verify_by_doc_id.keys()):
|
||
matches = sorted(DOCS_DIR.glob(f"{did}-*.md"))
|
||
if not matches:
|
||
missing_docs.append(did)
|
||
continue
|
||
if len(matches) > 1:
|
||
multi_docs[did] = [str(p.relative_to(ROOT)) for p in matches]
|
||
doc = matches[0]
|
||
content = doc.read_text(encoding="utf-8", errors="ignore")
|
||
if f"ansible/files/{did}/" not in content and "```yaml" in content:
|
||
weak_doc_exec_refs.append(did)
|
||
# Execution-domain doc_id must always have a files truth-source directory (ADR-002 / R4).
|
||
if not (ROOT / "ansible" / "files" / did).is_dir():
|
||
missing_files_dir.append(did)
|
||
|
||
if invalid_verify_names:
|
||
print(f"ERR: verify 仅允许执行域命名: {sorted(invalid_verify_names)}", file=sys.stderr)
|
||
sys.exit(2)
|
||
if verify_conflicts:
|
||
print("ERR: verify 入口冲突(同一 doc_id 多个入口,必须 fail-fast):", file=sys.stderr)
|
||
for did in sorted(verify_conflicts.keys()):
|
||
paths = verify_conflicts[did]
|
||
print(f" - {did}: {paths}", file=sys.stderr)
|
||
sys.exit(2)
|
||
if missing_docs:
|
||
print(f"ERR: 缺少 docs/<doc_id>-*.md: {missing_docs}", file=sys.stderr)
|
||
sys.exit(2)
|
||
if multi_docs:
|
||
print("ERR: docs 命名冲突(同一 doc_id 匹配到多篇 docs/<doc_id>-*.md):", file=sys.stderr)
|
||
for did in sorted(multi_docs.keys()):
|
||
print(f" - {did}: {multi_docs[did]}", file=sys.stderr)
|
||
sys.exit(2)
|
||
if missing_files_dir:
|
||
print(f"ERR: 缺少 ansible/files/<doc_id>/ 目录: {missing_files_dir}", file=sys.stderr)
|
||
sys.exit(2)
|
||
if weak_doc_exec_refs:
|
||
print(f"ERR: 文档 YAML 未映射 ansible/files/<doc_id>/ 真源: {weak_doc_exec_refs}", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
print(f"[OK] 执行域 verify/doc/files 一致性通过({len(verify_by_doc_id)} 条)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|