feat(vendor): add playbook snapshot generation

This commit is contained in:
csh 2026-01-23 14:30:30 +08:00
parent 8cfcc25f98
commit 49bbfa13e4
2 changed files with 233 additions and 1 deletions

View File

@ -1,10 +1,15 @@
#!/usr/bin/env python3
import sys
from datetime import datetime, timezone
from pathlib import Path
from shutil import copy2, copytree
import subprocess
import tomllib
ORDER = ["vendor", "sync_templates", "sync_standards", "install_skills", "format_md"]
SCRIPT_DIR = Path(__file__).resolve().parent
PLAYBOOK_ROOT = SCRIPT_DIR.parent
def usage() -> str:
@ -15,8 +20,217 @@ def load_config(path: Path) -> dict:
return tomllib.loads(path.read_text(encoding="utf-8"))
def log(message: str) -> None:
print(message)
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def normalize_langs(raw: object) -> list[str]:
if raw is None:
return ["tsl"]
if isinstance(raw, str):
langs = [raw]
else:
langs = list(raw)
cleaned: list[str] = []
for lang in langs:
item = str(lang).strip()
if not item:
continue
if "/" in item or "\\" in item or ".." in item:
raise ValueError(f"invalid lang: {item}")
cleaned.append(item)
if not cleaned:
raise ValueError("langs is empty")
return cleaned
def read_git_commit(root: Path) -> str:
try:
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=True,
)
except (OSError, subprocess.CalledProcessError):
return "N/A"
return result.stdout.strip() or "N/A"
def write_docs_index(dest_prefix: Path, langs: list[str]) -> None:
lines = [
"# 文档导航Docs Index",
"",
f"本快照为裁剪版 Playbooklangs: {','.join(langs)})。",
"",
"## 跨语言common",
"",
"- 提交信息与版本号:`common/commit_message.md`",
]
for lang in langs:
if lang == "tsl":
lines += [
"",
"## TSLtsl",
"",
"- 代码风格:`tsl/code_style.md`",
"- 命名规范:`tsl/naming.md`",
"- 语法手册:`tsl/syntax_book/index.md`",
"- 工具链与验证命令(模板):`tsl/toolchain.md`",
]
elif lang == "cpp":
lines += [
"",
"## C++cpp",
"",
"- 代码风格:`cpp/code_style.md`",
"- 命名规范:`cpp/naming.md`",
"- 工具链与验证命令(模板):`cpp/toolchain.md`",
"- 第三方依赖Conan`cpp/dependencies_conan.md`",
"- clangd 配置:`cpp/clangd.md`",
]
elif lang == "python":
lines += [
"",
"## Pythonpython",
"",
"- 代码风格:`python/style_guide.md`",
"- 工具链:`python/tooling.md`",
"- 配置清单:`python/configuration.md`",
]
elif lang == "markdown":
lines += [
"",
"## Markdownmarkdown",
"",
"- 代码块与行内代码格式:`markdown/index.md`",
]
docs_index = dest_prefix / "docs/index.md"
ensure_dir(docs_index.parent)
docs_index.write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_snapshot_readme(dest_prefix: Path, langs: list[str]) -> None:
lines = [
"# Playbook裁剪快照",
"",
f"本目录为从 Playbook vendoring 的裁剪快照langs: {','.join(langs)})。",
"",
"## 使用",
"",
"在目标项目根目录执行:",
"",
"```sh",
"python docs/standards/playbook/scripts/playbook.py -config playbook.toml",
"```",
"",
"配置示例:`docs/standards/playbook/playbook.toml.example`",
"",
"文档入口:",
"",
"- `docs/standards/playbook/docs/index.md`",
"- `.agents/index.md`",
]
(dest_prefix / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def write_source_file(dest_prefix: Path, langs: list[str]) -> None:
commit = read_git_commit(PLAYBOOK_ROOT)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = [
"# SOURCE",
"",
f"- Source: {PLAYBOOK_ROOT}",
f"- Commit: {commit}",
f"- Date: {timestamp}",
f"- Langs: {','.join(langs)}",
"- Generated-by: scripts/playbook.py",
]
(dest_prefix / "SOURCE.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def vendor_action(config: dict, context: dict) -> int:
try:
langs = normalize_langs(config.get("langs"))
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
target_dir = config.get("target_dir", "docs/standards/playbook")
target_path = Path(target_dir)
if target_path.is_absolute() or ".." in target_path.parts:
print(f"ERROR: invalid target_dir: {target_dir}", file=sys.stderr)
return 2
project_root: Path = context["project_root"]
dest_prefix = project_root / target_path
dest_standards = dest_prefix.parent
ensure_dir(dest_standards)
if dest_prefix.exists():
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup = dest_standards / f"{dest_prefix.name}.bak.{timestamp}"
dest_prefix.rename(backup)
log(f"Backed up existing snapshot -> {backup}")
ensure_dir(dest_prefix)
gitattributes_src = PLAYBOOK_ROOT / ".gitattributes"
if gitattributes_src.is_file():
copy2(gitattributes_src, dest_prefix / ".gitattributes")
copytree(PLAYBOOK_ROOT / "scripts", dest_prefix / "scripts")
copytree(PLAYBOOK_ROOT / "codex", dest_prefix / "codex")
copy2(PLAYBOOK_ROOT / "SKILLS.md", dest_prefix / "SKILLS.md")
common_docs = PLAYBOOK_ROOT / "docs/common"
if common_docs.is_dir():
copytree(common_docs, dest_prefix / "docs/common")
rulesets_root = PLAYBOOK_ROOT / "rulesets"
ensure_dir(dest_prefix / "rulesets")
if (rulesets_root / "index.md").is_file():
copy2(rulesets_root / "index.md", dest_prefix / "rulesets/index.md")
templates_ci = PLAYBOOK_ROOT / "templates/ci"
if templates_ci.is_dir():
copytree(templates_ci, dest_prefix / "templates/ci")
for lang in langs:
docs_src = PLAYBOOK_ROOT / "docs" / lang
rules_src = PLAYBOOK_ROOT / "rulesets" / lang
if not docs_src.is_dir():
print(f"ERROR: docs not found for lang={lang}", file=sys.stderr)
return 2
if not rules_src.is_dir():
print(f"ERROR: rulesets not found for lang={lang}", file=sys.stderr)
return 2
copytree(docs_src, dest_prefix / "docs" / lang)
copytree(rules_src, dest_prefix / "rulesets" / lang)
templates_src = PLAYBOOK_ROOT / "templates" / lang
if templates_src.is_dir():
copytree(templates_src, dest_prefix / "templates" / lang)
example_config = PLAYBOOK_ROOT / "playbook.toml.example"
if example_config.is_file():
copy2(example_config, dest_prefix / "playbook.toml.example")
write_docs_index(dest_prefix, langs)
write_snapshot_readme(dest_prefix, langs)
write_source_file(dest_prefix, langs)
log(f"Vendored snapshot -> {dest_prefix}")
return 0
def run_action(name: str, config: dict, context: dict) -> int:
_ = config, context
if name == "vendor":
return vendor_action(config, context)
print(f"[action] {name}")
return 0

View File

@ -47,5 +47,23 @@ langs = ["tsl"]
self.assertIn("sync_standards", output)
self.assertIn("format_md", output)
def test_vendor_creates_snapshot(self):
with tempfile.TemporaryDirectory() as tmp_dir:
config_body = f"""
[playbook]
project_root = "{tmp_dir}"
[vendor]
langs = ["tsl"]
"""
config_path = Path(tmp_dir) / "playbook.toml"
config_path.write_text(config_body, encoding="utf-8")
result = run_cli("-config", str(config_path))
snapshot = Path(tmp_dir) / "docs/standards/playbook/SOURCE.md"
self.assertEqual(result.returncode, 0)
self.assertTrue(snapshot.is_file())
if __name__ == "__main__":
unittest.main()