diff --git a/scripts/playbook.py b/scripts/playbook.py index 6984d61..d444b4c 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -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"本快照为裁剪版 Playbook(langs: {','.join(langs)})。", + "", + "## 跨语言(common)", + "", + "- 提交信息与版本号:`common/commit_message.md`", + ] + for lang in langs: + if lang == "tsl": + lines += [ + "", + "## TSL(tsl)", + "", + "- 代码风格:`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 += [ + "", + "## Python(python)", + "", + "- 代码风格:`python/style_guide.md`", + "- 工具链:`python/tooling.md`", + "- 配置清单:`python/configuration.md`", + ] + elif lang == "markdown": + lines += [ + "", + "## Markdown(markdown)", + "", + "- 代码块与行内代码格式:`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 diff --git a/tests/cli/test_playbook_cli.py b/tests/cli/test_playbook_cli.py index 3ad0878..e091624 100644 --- a/tests/cli/test_playbook_cli.py +++ b/tests/cli/test_playbook_cli.py @@ -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()