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 #!/usr/bin/env python3
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from shutil import copy2, copytree
import subprocess
import tomllib import tomllib
ORDER = ["vendor", "sync_templates", "sync_standards", "install_skills", "format_md"] ORDER = ["vendor", "sync_templates", "sync_standards", "install_skills", "format_md"]
SCRIPT_DIR = Path(__file__).resolve().parent
PLAYBOOK_ROOT = SCRIPT_DIR.parent
def usage() -> str: def usage() -> str:
@ -15,8 +20,217 @@ def load_config(path: Path) -> dict:
return tomllib.loads(path.read_text(encoding="utf-8")) 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: def run_action(name: str, config: dict, context: dict) -> int:
_ = config, context if name == "vendor":
return vendor_action(config, context)
print(f"[action] {name}") print(f"[action] {name}")
return 0 return 0

View File

@ -47,5 +47,23 @@ langs = ["tsl"]
self.assertIn("sync_standards", output) self.assertIn("sync_standards", output)
self.assertIn("format_md", 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__": if __name__ == "__main__":
unittest.main() unittest.main()