Merge commit '3d83740f88b6aaba6962256be517656496b83f25' into lsp-server
This commit is contained in:
@@ -10,13 +10,15 @@ tests/
|
||||
├── cli/ # Python CLI 测试(unittest)
|
||||
│ └── test_playbook_cli.py # playbook.py 基础功能测试
|
||||
├── test_format_md_action.py # format_md 动作测试
|
||||
├── test_gitea_workflow_bootstrap.py # Gitea workflow 自举顺序回归测试
|
||||
├── test_firstparty_skills_quality.py # first-party skills 元数据与结构质量测试
|
||||
├── test_gitattributes_modes.py # gitattr_mode 行为测试
|
||||
├── test_no_backup_flags.py # no_backup 行为测试
|
||||
├── test_playbook_typing_imports.py # playbook.py typing 导入兼容性测试
|
||||
├── test_sync_directory_actions.py # sync_memory_bank/sync_prompts 行为测试
|
||||
├── test_vendor_snapshot_templates.py # vendor 快照模板完整性测试
|
||||
├── test_plan_progress_cli.py # plan_progress CLI 测试
|
||||
├── test_superpowers_list_sync.py # superpowers 列表一致性测试
|
||||
├── test_superpowers_workflows.py # superpowers 工作流配置校验
|
||||
├── test_main_loop_cli.py # main_loop CLI 测试
|
||||
├── test_thirdparty_skills_pipeline.py # thirdparty skills 流水线配置与同步产物测试
|
||||
├── test_sync_templates_placeholders.py # 占位符替换测试(sync_rules/sync_standards)
|
||||
├── test_toml_edge_cases.py # TOML 解析边界测试
|
||||
├── templates/ # 模板验证测试
|
||||
|
||||
@@ -6,31 +6,124 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
CUSTOM_DEPLOY_ROOT = "custom/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
def run_script(script, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
return run_script(SCRIPT, *args)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
def bash_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
if sys.platform != "win32":
|
||||
return resolved.as_posix()
|
||||
drive = resolved.drive.rstrip(":").lower()
|
||||
rest = resolved.as_posix()[2:]
|
||||
return f"/mnt/{drive}{rest}"
|
||||
|
||||
|
||||
class PlaybookCliTests(unittest.TestCase):
|
||||
def assert_style_cleanup_tsl_docs_prefix(
|
||||
self, root: Path, agents_home: Path, docs_prefix: str
|
||||
) -> None:
|
||||
agents_index = (root / ".agents" / "tsl" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"`{docs_prefix}/tsl/index.md`", agents_index)
|
||||
self.assertNotIn("`docs/tsl/index.md`", agents_index)
|
||||
|
||||
skill_file = (agents_home / "skills" / "style-cleanup" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(f"`{docs_prefix}/tsl/code_style.md`", skill_file)
|
||||
self.assertNotIn("`docs/tsl/code_style.md`", skill_file)
|
||||
|
||||
def test_help_shows_usage(self):
|
||||
result = run_cli("-h")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("Usage:", result.stdout + result.stderr)
|
||||
|
||||
def test_record_spec_updates_progress_workflow_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text("# 当前进展\n", encoding="utf-8")
|
||||
|
||||
result = run_cli(
|
||||
"-record-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"-progress",
|
||||
str(progress),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: planning", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
|
||||
def test_record_plan_updates_progress_workflow_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Workflow State",
|
||||
"",
|
||||
"<!-- workflow-state:start -->",
|
||||
"phase: planning",
|
||||
"spec: docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"<!-- workflow-state:end -->",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"-record-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-progress",
|
||||
str(progress),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: planning", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
|
||||
def test_missing_config_is_error(self):
|
||||
result = run_cli()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("-config", result.stdout + result.stderr)
|
||||
|
||||
def test_action_order(self):
|
||||
config_body = """
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[format_md]
|
||||
|
||||
@@ -47,8 +140,68 @@ langs = ["tsl"]
|
||||
self.assertIn("sync_standards", output)
|
||||
self.assertIn("format_md", output)
|
||||
|
||||
def test_format_md_only_does_not_require_deploy_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
|
||||
[format_md]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_vendor_creates_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
snapshot = root / CUSTOM_DEPLOY_ROOT / "SOURCE.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(snapshot.is_file())
|
||||
|
||||
def test_vendor_docs_index_uses_new_tsl_entrypoints(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
docs_index = root / CUSTOM_DEPLOY_ROOT / "docs/index.md"
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = docs_index.read_text(encoding="utf-8")
|
||||
self.assertIn("`tsl/index.md`", text)
|
||||
self.assertIn("`tsl/syntax/index.md`", text)
|
||||
self.assertIn("`tsl/finance/index.md`", text)
|
||||
self.assertIn("`tsl/modules/index.md`", text)
|
||||
self.assertIn("`tsl/reference/index.md`", text)
|
||||
self.assertNotIn("`tsl/syntax_book/index.md`", text)
|
||||
|
||||
def test_external_clone_requires_explicit_deploy_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
@@ -56,20 +209,19 @@ project_root = "{tmp_dir}"
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
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())
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("deploy_root", result.stdout + result.stderr)
|
||||
|
||||
def test_sync_memory_bank_creates_memory_bank(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
@@ -88,6 +240,7 @@ project_name = "Demo"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
@@ -101,11 +254,77 @@ langs = ["tsl"]
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(agents_index.is_file())
|
||||
|
||||
def test_sync_standards_updates_agents_index_when_langs_expand(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
|
||||
first_config = root / "playbook-first.toml"
|
||||
first_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
first_result = run_cli("-config", str(first_config))
|
||||
self.assertEqual(first_result.returncode, 0)
|
||||
|
||||
second_config = root / "playbook-second.toml"
|
||||
second_config.write_text(
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "cpp"]
|
||||
no_backup = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
second_result = run_cli("-config", str(second_config))
|
||||
self.assertEqual(second_result.returncode, 0)
|
||||
|
||||
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/index.md`", agents_index)
|
||||
self.assertIn("`.agents/cpp/index.md`", agents_index)
|
||||
|
||||
def test_sync_standards_agents_index_only_lists_configured_langs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
|
||||
agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/`:TSL 相关规则集", agents_index)
|
||||
self.assertIn("`.agents/markdown/`:Markdown 相关规则集", agents_index)
|
||||
self.assertNotIn("`.agents/cpp/`", agents_index)
|
||||
self.assertNotIn("`.agents/python/`", agents_index)
|
||||
self.assertNotIn("`.agents/typescript/`", agents_index)
|
||||
|
||||
def test_sync_standards_agents_block_has_blank_lines(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
@@ -131,6 +350,7 @@ langs = ["tsl"]
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
@@ -146,12 +366,98 @@ skills = ["brainstorming"]
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertTrue(skill_file.is_file())
|
||||
|
||||
def test_install_generated_thirdparty_karpathy_skill_after_sync(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
mirror = tmp_root / "origin.git"
|
||||
repo = tmp_root / "repo"
|
||||
target = tmp_root / "agents"
|
||||
|
||||
clone_mirror = subprocess.run(
|
||||
["git", "clone", "--mirror", str(ROOT), str(mirror)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(clone_mirror.returncode, 0, msg=clone_mirror.stderr)
|
||||
|
||||
clone_repo = subprocess.run(
|
||||
["git", "clone", str(mirror), str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(clone_repo.returncode, 0, msg=clone_repo.stderr)
|
||||
|
||||
set_remote = subprocess.run(
|
||||
["git", "-C", str(repo), "remote", "set-url", "origin", bash_path(mirror)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(set_remote.returncode, 0, msg=set_remote.stderr)
|
||||
|
||||
manifest_src = ROOT / ".gitea" / "ci" / "thirdparty_skills.json"
|
||||
manifest_dst = repo / ".gitea" / "ci" / "thirdparty_skills.json"
|
||||
manifest_dst.write_text(manifest_src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
sync_src = ROOT / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
|
||||
sync_dst = repo / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
|
||||
sync_dst.write_text(sync_src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
sync_result = subprocess.run(
|
||||
["bash", ".gitea/ci/sync_thirdparty_skills.sh"],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr
|
||||
)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_root}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["karpathy-guidelines"]
|
||||
"""
|
||||
config_path = tmp_root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_script(repo / "scripts" / "playbook.py", "-config", str(config_path))
|
||||
|
||||
skill_file = target / "skills" / "karpathy-guidelines" / "SKILL.md"
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue(skill_file.is_file())
|
||||
|
||||
def test_install_skills_rejects_removed_tsl_guide(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{target}"
|
||||
mode = "list"
|
||||
skills = ["tsl-guide"]
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("skill not found: tsl-guide", result.stdout + result.stderr)
|
||||
|
||||
def test_install_skills_rejects_codex_home(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target = Path(tmp_dir) / "codex"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
codex_home = "{target}"
|
||||
@@ -166,5 +472,263 @@ skills = ["brainstorming"]
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("codex_home", result.stdout + result.stderr)
|
||||
|
||||
def test_external_clone_flow_rewrites_links_with_configured_deploy_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents-home"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["style-cleanup"]
|
||||
"""
|
||||
config_path = write_config(root, "playbook.toml", config_body)
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue((root / CUSTOM_DEPLOY_ROOT / "SOURCE.md").is_file())
|
||||
self.assert_style_cleanup_tsl_docs_prefix(
|
||||
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
|
||||
)
|
||||
|
||||
def test_deployed_snapshot_rewrites_links_from_snapshot_location(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
vendor_config = write_config(
|
||||
root,
|
||||
"vendor.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
""",
|
||||
)
|
||||
|
||||
vendor_result = run_cli("-config", str(vendor_config))
|
||||
self.assertEqual(vendor_result.returncode, 0, msg=vendor_result.stdout + vendor_result.stderr)
|
||||
|
||||
vendored_script = root / CUSTOM_DEPLOY_ROOT / "scripts" / "playbook.py"
|
||||
agents_home = root / "local-agents"
|
||||
sync_config = write_config(
|
||||
root,
|
||||
"sync.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
no_backup = true
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["style-cleanup"]
|
||||
""",
|
||||
)
|
||||
|
||||
sync_result = run_script(vendored_script, "-config", str(sync_config))
|
||||
self.assertEqual(sync_result.returncode, 0, msg=sync_result.stdout + sync_result.stderr)
|
||||
self.assert_style_cleanup_tsl_docs_prefix(
|
||||
root, agents_home, f"{CUSTOM_DEPLOY_ROOT}/docs"
|
||||
)
|
||||
|
||||
def test_sync_claude_md_creates_when_no_claude_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
self.assertTrue(claude_md.exists())
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
|
||||
def test_sync_claude_md_appends_block_to_existing_claude_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
claude_md.write_text("# My project\n\nSome existing content.\n", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
self.assertIn("Some existing content.", text)
|
||||
|
||||
def test_sync_claude_md_updates_existing_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"# My project\n\n"
|
||||
"<!-- playbook:claude:start -->\n"
|
||||
"@AGENTS.md\n"
|
||||
"<!-- playbook:claude:end -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
|
||||
|
||||
def test_sync_claude_md_skips_when_already_references_agents(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
claude_md = Path(tmp_dir) / "CLAUDE.md"
|
||||
original = "# My project\n\n@AGENTS.md\n"
|
||||
claude_md.write_text(original, encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertEqual(claude_md.read_text(encoding="utf-8"), original)
|
||||
|
||||
|
||||
def test_install_skills_creates_symlink_when_skill_link_configured(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
link_home = root / "claude"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
skill_link = "{link_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
skills_dst = agents_home / "skills"
|
||||
link_path = link_home / "skills"
|
||||
self.assertTrue(skills_dst.is_dir())
|
||||
self.assertTrue(link_path.is_dir(), "link_path should be accessible as dir")
|
||||
self.assertEqual(link_path.resolve(), skills_dst.resolve())
|
||||
self.assertTrue((link_path / "commit-message" / "SKILL.md").is_file())
|
||||
|
||||
def test_install_skills_symlink_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
link_home = root / "claude"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
skill_link = "{link_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
run_cli("-config", str(config_path))
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertTrue((link_home / "skills").is_dir())
|
||||
|
||||
def test_install_skills_no_symlink_when_skill_link_absent(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
agents_home = root / "agents"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{agents_home}"
|
||||
mode = "list"
|
||||
skills = ["commit-message"]
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assertFalse(any(
|
||||
p.is_symlink() for p in (agents_home / "skills").iterdir()
|
||||
if p.is_symlink()
|
||||
) if (agents_home / "skills").exists() else False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -79,7 +79,7 @@ echo ""
|
||||
echo "🔍 验证 memory-bank 模板"
|
||||
|
||||
MEMORY_BANK_DIR="$TEMPLATES_DIR/memory-bank"
|
||||
for name in project-brief tech-stack architecture progress decisions; do
|
||||
for name in project-brief tech-context system-patterns active-context progress decisions; do
|
||||
validate_file_exists "$MEMORY_BANK_DIR/$name.template.md" "memory-bank/$name.template.md"
|
||||
done
|
||||
|
||||
@@ -90,9 +90,10 @@ PROMPTS_DIR="$TEMPLATES_DIR/prompts"
|
||||
validate_file_exists "$PROMPTS_DIR/README.md" "prompts/README.md"
|
||||
validate_file_exists "$PROMPTS_DIR/system/agent-behavior.template.md" "prompts/system/agent-behavior.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/clarify.template.md" "prompts/coding/clarify.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/review.template.md" "prompts/coding/review.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/verify-change.template.md" "prompts/coding/verify-change.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/close-task.template.md" "prompts/coding/close-task.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/update-memory.template.md" "prompts/coding/update-memory.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/coding/code-review.template.md" "prompts/coding/code-review.template.md"
|
||||
validate_file_exists "$PROMPTS_DIR/meta/prompt-generator.template.md" "prompts/meta/prompt-generator.template.md"
|
||||
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -53,17 +53,21 @@ validate_toml_syntax() {
|
||||
|
||||
if python3 << EOF
|
||||
import sys
|
||||
try:
|
||||
import tomli
|
||||
except ImportError:
|
||||
parser = None
|
||||
for module_name in ("tomli", "tomllib", "toml"):
|
||||
try:
|
||||
import tomllib as tomli
|
||||
parser = __import__(module_name)
|
||||
break
|
||||
except ImportError:
|
||||
import toml as tomli
|
||||
continue
|
||||
|
||||
if parser is None:
|
||||
print("缺少 TOML 解析器(需要 tomli、tomllib 或 toml)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
with open("$file", "rb") as f:
|
||||
tomli.load(f)
|
||||
parser.load(f)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"TOML 语法错误: {e}", file=sys.stderr)
|
||||
@@ -74,8 +78,14 @@ EOF
|
||||
VALIDATION_PASSED=$((VALIDATION_PASSED + 1))
|
||||
return 0
|
||||
else
|
||||
echo " ❌ $description: TOML 语法错误"
|
||||
echo "TOML 语法错误: $file" >> "$ERRORS_FILE"
|
||||
status=$?
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo " ❌ $description: 缺少 TOML 解析器"
|
||||
echo "缺少 TOML 解析器: $file" >> "$ERRORS_FILE"
|
||||
else
|
||||
echo " ❌ $description: TOML 语法错误"
|
||||
echo "TOML 语法错误: $file" >> "$ERRORS_FILE"
|
||||
fi
|
||||
VALIDATION_FAILED=$((VALIDATION_FAILED + 1))
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DEPLOY_ROOT = Path("docs/standards/playbook")
|
||||
CUSTOM_DEPLOY_ROOT = Path("custom/playbook")
|
||||
REPO_COPY_IGNORE = shutil.ignore_patterns(
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
)
|
||||
|
||||
|
||||
def run_script(
|
||||
script: Path, *args: str, cwd: Path | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def write_config(root: Path, name: str, body: str) -> Path:
|
||||
config_path = root / name
|
||||
config_path.write_text(body.strip() + "\n", encoding="utf-8")
|
||||
return config_path
|
||||
|
||||
|
||||
def copy_repo(target: Path) -> Path:
|
||||
shutil.copytree(ROOT, target, ignore=REPO_COPY_IGNORE)
|
||||
return target
|
||||
|
||||
|
||||
class DeploymentRoutesE2ETests(unittest.TestCase):
|
||||
def assert_core_project_files(self, project_root: Path) -> None:
|
||||
expected = [
|
||||
"AGENTS.md",
|
||||
"AGENT_RULES.md",
|
||||
"AGENT_RULES.local.md",
|
||||
"memory-bank/progress.md",
|
||||
"docs/prompts/system/agent-behavior.md",
|
||||
".agents/index.md",
|
||||
".agents/tsl/index.md",
|
||||
".agents/markdown/index.md",
|
||||
".gitattributes",
|
||||
]
|
||||
for rel in expected:
|
||||
with self.subTest(path=rel):
|
||||
self.assertTrue((project_root / rel).exists(), f"missing: {rel}")
|
||||
|
||||
def assert_docs_prefix(self, project_root: Path, docs_prefix: str) -> None:
|
||||
agents_index = (project_root / ".agents" / "index.md").read_text(encoding="utf-8")
|
||||
self.assertIn("`.agents/tsl/index.md`", agents_index)
|
||||
self.assertIn("`.agents/markdown/index.md`", agents_index)
|
||||
self.assertIn(f"- {docs_prefix}", agents_index)
|
||||
|
||||
tsl_index = (project_root / ".agents" / "tsl" / "index.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(f"`{docs_prefix}/tsl/index.md`", tsl_index)
|
||||
self.assertNotIn("`docs/tsl/index.md`", tsl_index)
|
||||
|
||||
def test_subtree_style_deployment_syncs_project_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
project_root = tmp_root / "project"
|
||||
playbook_root = project_root / DEFAULT_DEPLOY_ROOT
|
||||
project_root.mkdir()
|
||||
copy_repo(playbook_root)
|
||||
|
||||
config_path = write_config(
|
||||
project_root,
|
||||
"playbook.toml",
|
||||
"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
|
||||
[sync_rules]
|
||||
no_backup = true
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
|
||||
[sync_prompts]
|
||||
no_backup = true
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
no_backup = true
|
||||
""",
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
playbook_root / "scripts" / "playbook.py",
|
||||
"-config",
|
||||
str(config_path),
|
||||
cwd=project_root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
self.assert_core_project_files(project_root)
|
||||
self.assert_docs_prefix(project_root, "docs/standards/playbook/docs")
|
||||
|
||||
claude_md = project_root / "CLAUDE.md"
|
||||
self.assertTrue(claude_md.is_file())
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("@AGENTS.md", text)
|
||||
self.assertIn("@AGENT_RULES.md", text)
|
||||
self.assertIn("<!-- playbook:claude:start -->", text)
|
||||
|
||||
def test_external_clone_deployment_vendors_snapshot_and_updates_claude(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
external_clone = tmp_root / "playbook"
|
||||
project_root = tmp_root / "project"
|
||||
copy_repo(external_clone)
|
||||
project_root.mkdir()
|
||||
|
||||
claude_md = project_root / ".claude" / "CLAUDE.md"
|
||||
claude_md.parent.mkdir()
|
||||
claude_md.write_text("# Existing Claude\n\nKeep this.\n", encoding="utf-8")
|
||||
|
||||
config_path = write_config(
|
||||
project_root,
|
||||
"playbook.toml",
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "."
|
||||
deploy_root = "{CUSTOM_DEPLOY_ROOT.as_posix()}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl", "markdown"]
|
||||
|
||||
[sync_rules]
|
||||
no_backup = true
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
|
||||
[sync_prompts]
|
||||
no_backup = true
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl", "markdown"]
|
||||
no_backup = true
|
||||
""",
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
external_clone / "scripts" / "playbook.py",
|
||||
"-config",
|
||||
str(config_path),
|
||||
cwd=project_root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
||||
|
||||
snapshot_root = project_root / CUSTOM_DEPLOY_ROOT
|
||||
self.assertTrue((snapshot_root / "SOURCE.md").is_file())
|
||||
self.assertTrue((snapshot_root / "scripts" / "playbook.py").is_file())
|
||||
self.assertTrue((snapshot_root / "templates" / "README.md").is_file())
|
||||
|
||||
self.assert_core_project_files(project_root)
|
||||
self.assert_docs_prefix(project_root, "custom/playbook/docs")
|
||||
|
||||
text = claude_md.read_text(encoding="utf-8")
|
||||
self.assertIn("Keep this.", text)
|
||||
self.assertIn("@../AGENTS.md", text)
|
||||
self.assertIn("@../AGENT_RULES.md", text)
|
||||
self.assertEqual(text.count("<!-- playbook:claude:start -->"), 1)
|
||||
self.assertFalse((project_root / "CLAUDE.md").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS_ROOT = ROOT / "skills"
|
||||
FIRST_PARTY_SKILLS = {
|
||||
"commit-message": SKILLS_ROOT / "commit-message" / "SKILL.md",
|
||||
"style-cleanup": SKILLS_ROOT / "style-cleanup" / "SKILL.md",
|
||||
"bulk-refactor-workflow": SKILLS_ROOT / "bulk-refactor-workflow" / "SKILL.md",
|
||||
}
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def normalize_space(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> dict[str, str]:
|
||||
match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
|
||||
if match is None:
|
||||
raise AssertionError("missing YAML frontmatter")
|
||||
block = match.group(1)
|
||||
data: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
current_value: list[str] = []
|
||||
for raw_line in block.splitlines():
|
||||
if raw_line.startswith(" ") and current_key is not None:
|
||||
current_value.append(raw_line.strip())
|
||||
continue
|
||||
if current_key is not None:
|
||||
data[current_key] = " ".join(current_value).strip().strip('"')
|
||||
current_key = None
|
||||
current_value = []
|
||||
key, value = raw_line.split(":", 1)
|
||||
current_key = key.strip()
|
||||
current_value = [value.strip()]
|
||||
if current_key is not None:
|
||||
data[current_key] = " ".join(current_value).strip().strip('"')
|
||||
return data
|
||||
|
||||
|
||||
class FirstPartySkillsQualityTests(unittest.TestCase):
|
||||
def test_first_party_skill_frontmatter_is_minimal_and_named_consistently(self):
|
||||
for name, path in FIRST_PARTY_SKILLS.items():
|
||||
with self.subTest(skill=name):
|
||||
frontmatter = parse_frontmatter(read_text(path))
|
||||
self.assertEqual(set(frontmatter), {"name", "description"})
|
||||
self.assertEqual(frontmatter["name"], name)
|
||||
self.assertRegex(frontmatter["name"], r"^[a-z0-9-]+$")
|
||||
|
||||
def test_first_party_skill_descriptions_are_trigger_focused(self):
|
||||
for name, path in FIRST_PARTY_SKILLS.items():
|
||||
with self.subTest(skill=name):
|
||||
description = parse_frontmatter(read_text(path))["description"]
|
||||
self.assertTrue(description.startswith("Use when"))
|
||||
self.assertLessEqual(len(description), 500)
|
||||
self.assertNotIn("Triggers:", description)
|
||||
|
||||
def test_first_party_skills_have_required_sections(self):
|
||||
required_sections = (
|
||||
"## Overview",
|
||||
"## When to Use",
|
||||
"## When Not to Use",
|
||||
"## Inputs",
|
||||
"## Procedure",
|
||||
"## Output Contract",
|
||||
"## Success Criteria",
|
||||
"## Failure Handling",
|
||||
)
|
||||
for name, path in FIRST_PARTY_SKILLS.items():
|
||||
text = read_text(path)
|
||||
with self.subTest(skill=name):
|
||||
for section in required_sections:
|
||||
self.assertIn(section, text)
|
||||
|
||||
def test_commit_message_skill_handles_missing_or_mixed_staging_states(self):
|
||||
text = normalize_space(read_text(FIRST_PARTY_SKILLS["commit-message"]))
|
||||
self.assertIn("If nothing is staged", text)
|
||||
self.assertIn("If only unstaged changes exist", text)
|
||||
self.assertIn("strongly recommend splitting the commit", text)
|
||||
self.assertIn("Do not run `git commit`", text)
|
||||
|
||||
def test_style_cleanup_skill_has_clear_non_goals_and_verification_loop(self):
|
||||
text = normalize_space(read_text(FIRST_PARTY_SKILLS["style-cleanup"]))
|
||||
self.assertIn("not for semantic refactors", text)
|
||||
self.assertIn("not for introducing a new formatter or lint configuration", text)
|
||||
self.assertIn("formatter -> lint/check -> lint --fix -> final check", text)
|
||||
self.assertIn("second formatter run produces no additional diff", text)
|
||||
|
||||
def test_bulk_refactor_skill_is_dirty_aware_and_delegates_final_cleanup(self):
|
||||
text = normalize_space(read_text(FIRST_PARTY_SKILLS["bulk-refactor-workflow"]))
|
||||
self.assertIn("Dirty worktrees are allowed", text)
|
||||
self.assertIn("Do not revert unrelated changes", text)
|
||||
self.assertIn("Use `style-cleanup` for the final formatting/lint pass", text)
|
||||
self.assertIn("apply the transformation in bounded batches", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,14 +26,22 @@ class FormatMdActionTests(unittest.TestCase):
|
||||
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
prettier = bin_dir / "prettier"
|
||||
prettier.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(\".prettier_called\").write_text(\"ok\")\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
prettier.chmod(0o755)
|
||||
if os.name == "nt":
|
||||
prettier = bin_dir / "prettier.cmd"
|
||||
prettier.write_text(
|
||||
"@echo off\r\n"
|
||||
"echo ok> .prettier_called\r\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
prettier = bin_dir / "prettier"
|
||||
prettier.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"from pathlib import Path\n"
|
||||
"Path(\".prettier_called\").write_text(\"ok\")\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
prettier.chmod(0o755)
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
@@ -47,7 +55,7 @@ project_root = \"{tmp_dir}\"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{bin_dir}:{env.get('PATH', '')}"
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
result = run_cli("-config", str(config_path), env=env)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
SOURCE_GITATTR = ROOT / ".gitattributes"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
@@ -33,6 +34,7 @@ class GitattributesModeTests(unittest.TestCase):
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{root}\"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"tsl\"]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEST_WORKFLOW = ROOT / ".gitea" / "workflows" / "test.yml"
|
||||
STANDARDS_WORKFLOW = ROOT / ".gitea" / "workflows" / "standards-check.yml"
|
||||
UPDATE_THIRDPARTY_WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-skills.yml"
|
||||
TEMPLATE_STANDARDS_WORKFLOW = (
|
||||
ROOT / "templates" / "ci" / "gitea" / ".gitea" / "workflows" / "standards-check.yml"
|
||||
)
|
||||
|
||||
|
||||
class GiteaWorkflowBootstrapTests(unittest.TestCase):
|
||||
def test_repo_workflows_do_not_call_repo_local_prepare_script_before_checkout(self):
|
||||
for workflow in (TEST_WORKFLOW, STANDARDS_WORKFLOW):
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
with self.subTest(workflow=workflow.name):
|
||||
self.assertNotIn("bash .gitea/ci/prepare_repo.sh", text)
|
||||
self.assertIn('git clone "$REPO_URL" "$REPO_DIR"', text)
|
||||
self.assertIn('echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV"', text)
|
||||
|
||||
def test_workflows_use_isolated_repo_dirs_per_job(self):
|
||||
for workflow in (
|
||||
TEST_WORKFLOW,
|
||||
STANDARDS_WORKFLOW,
|
||||
UPDATE_THIRDPARTY_WORKFLOW,
|
||||
TEMPLATE_STANDARDS_WORKFLOW,
|
||||
):
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
with self.subTest(workflow=workflow.name):
|
||||
self.assertNotIn('REPO_DIR="${WORKSPACE_DIR}/${REPO_NAME}"', text)
|
||||
self.assertNotIn('REPO_DIR="${{ env.WORKSPACE_DIR }}/$REPO_NAME"', text)
|
||||
self.assertIn('mktemp -d', text)
|
||||
self.assertTrue(
|
||||
'mkdir -p "$WORKSPACE_DIR"' in text
|
||||
or 'mkdir -p "${{ env.WORKSPACE_DIR }}"' in text
|
||||
)
|
||||
self.assertIn('echo "REPO_DIR=$REPO_DIR" >>', text)
|
||||
self.assertIn("if: always()", text)
|
||||
self.assertIn('rm -rf "$REPO_DIR"', text)
|
||||
|
||||
def test_test_workflow_installs_tomli_for_python_template_validation(self):
|
||||
text = TEST_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("python3 -m pip install yamllint tomli", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,584 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "main_loop.py"
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location("playbook_main_loop", SCRIPT)
|
||||
assert _SPEC and _SPEC.loader
|
||||
MAIN_LOOP = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(MAIN_LOOP)
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
class MainLoopCliTests(unittest.TestCase):
|
||||
def _current_env(self) -> str:
|
||||
system = platform.system().lower()
|
||||
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
|
||||
if system not in mapping:
|
||||
self.skipTest(f"Unsupported environment: {system}")
|
||||
return mapping[system]
|
||||
|
||||
def test_claim_seeds_progress_and_marks_first_plan_in_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-old.md").write_text("old", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-new.md").write_text("new", encoding="utf-8")
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(
|
||||
result.stdout.strip(),
|
||||
"PLAN=docs/superpowers/plans/2026-01-01-old.md",
|
||||
)
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("<!-- workflow-state:start -->", text)
|
||||
self.assertIn("<!-- workflow-state:end -->", text)
|
||||
self.assertIn("phase: executing", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-01-01-old.md", text)
|
||||
self.assertIn("<!-- plan-status:start -->", text)
|
||||
self.assertIn("<!-- plan-status:end -->", text)
|
||||
self.assertIn("`2026-01-01-old.md` in-progress", text)
|
||||
self.assertIn("`2026-01-02-new.md` pending", text)
|
||||
|
||||
def test_claim_preserves_human_progress_sections_when_plan_block_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-demo.md").write_text("demo", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Current Focus",
|
||||
"",
|
||||
"- keep-this-focus",
|
||||
"",
|
||||
"## Recent Changes",
|
||||
"",
|
||||
"- keep-this-change",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("- keep-this-focus", text)
|
||||
self.assertIn("- keep-this-change", text)
|
||||
self.assertIn("<!-- workflow-state:start -->", text)
|
||||
self.assertIn("<!-- plan-status:start -->", text)
|
||||
self.assertIn("`2026-01-01-demo.md` in-progress", text)
|
||||
|
||||
def test_claim_returns_existing_in_progress_before_pending(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-a.md").write_text("a", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-b.md").write_text("b", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-01-02-b.md` pending",
|
||||
"- [ ] `2026-01-01-a.md` in-progress",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(
|
||||
result.stdout.strip(),
|
||||
"PLAN=docs/superpowers/plans/2026-01-01-a.md",
|
||||
)
|
||||
|
||||
def test_claim_skips_stale_progress_entries_for_deleted_plans(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-02-live.md").write_text("live", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- workflow-state:start -->",
|
||||
"phase: planning",
|
||||
"<!-- workflow-state:end -->",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-01-01-deleted.md` pending",
|
||||
"- [ ] `2026-01-02-live.md` pending",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(
|
||||
result.stdout.strip(),
|
||||
"PLAN=docs/superpowers/plans/2026-01-02-live.md",
|
||||
)
|
||||
|
||||
def test_claim_resumes_env_blocked_plan_and_preserves_note(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-05-env.md").write_text("env", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
env = self._current_env()
|
||||
note = f"env:{env}:Task1,Task3"
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
f"- [ ] `2026-01-05-env.md` blocked: {note}",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(
|
||||
result.stdout.strip(),
|
||||
"\n".join(
|
||||
[
|
||||
"PLAN=docs/superpowers/plans/2026-01-05-env.md",
|
||||
f"NOTE={note}",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn(f"`2026-01-05-env.md` in-progress: {note}", text)
|
||||
|
||||
def test_finish_updates_line(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-01-03-demo.md` in-progress",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"finish",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-01-03-demo.md",
|
||||
"-status",
|
||||
"done",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("- [x] `2026-01-03-demo.md` done", text)
|
||||
self.assertEqual(
|
||||
text.count("- [x] `2026-01-03-demo.md` done"),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_finish_updates_workflow_phase_and_preserves_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Workflow State",
|
||||
"",
|
||||
"<!-- workflow-state:start -->",
|
||||
"phase: executing",
|
||||
"spec: docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"plan: docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"executor: executing-plans",
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
"<!-- workflow-state:end -->",
|
||||
"",
|
||||
"## Plan Status",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-05-18-demo.md` in-progress",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"finish",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-status",
|
||||
"done",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: done", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
|
||||
def test_record_updates_workflow_state_block(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 当前进展",
|
||||
"",
|
||||
"## Plan Status",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-phase",
|
||||
"planning",
|
||||
"-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-executor",
|
||||
"executing-plans",
|
||||
"-constraints",
|
||||
"karpathy-guidelines,.agents,AGENT_RULES",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("<!-- workflow-state:start -->", text)
|
||||
self.assertIn("phase: planning", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
|
||||
def test_record_claim_finish_workflow_chain(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "superpowers" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-05-18-demo.md").write_text("demo", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-phase",
|
||||
"planning",
|
||||
"-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
cwd=root,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-phase",
|
||||
"planning",
|
||||
"-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-executor",
|
||||
"executing-plans",
|
||||
"-constraints",
|
||||
"karpathy-guidelines,.agents,AGENT_RULES",
|
||||
cwd=root,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
result = run_cli(
|
||||
"claim",
|
||||
"-plans",
|
||||
"docs/superpowers/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
result = run_cli(
|
||||
"finish",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-status",
|
||||
"done",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: done", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
self.assertIn("- [x] `2026-05-18-demo.md` done", text)
|
||||
|
||||
def test_concurrent_record_preserves_spec_and_plan_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
|
||||
original_load = MAIN_LOOP.load_progress_lines
|
||||
first_load = {"seen": False}
|
||||
gate = threading.Lock()
|
||||
|
||||
def delayed_load(progress_path):
|
||||
lines = original_load(progress_path)
|
||||
with gate:
|
||||
if not first_load["seen"]:
|
||||
first_load["seen"] = True
|
||||
threading.Event().wait(0.2)
|
||||
return lines
|
||||
|
||||
MAIN_LOOP.load_progress_lines = delayed_load
|
||||
try:
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=MAIN_LOOP.record_workflow_state,
|
||||
args=(
|
||||
progress,
|
||||
"planning",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
),
|
||||
threading.Thread(
|
||||
target=MAIN_LOOP.record_workflow_state,
|
||||
args=(
|
||||
progress,
|
||||
"planning",
|
||||
None,
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"executing-plans",
|
||||
"karpathy-guidelines,.agents,AGENT_RULES",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
finally:
|
||||
MAIN_LOOP.load_progress_lines = original_load
|
||||
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: planning", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
|
||||
def test_cross_process_record_lock_preserves_spec_and_plan_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
|
||||
slow_env = dict(os.environ)
|
||||
slow_env["PLAYBOOK_MAIN_LOOP_HOLD_LOCK_MS"] = "300"
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"record",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-phase",
|
||||
"planning",
|
||||
"-spec",
|
||||
"docs/superpowers/specs/2026-05-18-demo-design.md",
|
||||
],
|
||||
cwd=root,
|
||||
env=slow_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
"-phase",
|
||||
"planning",
|
||||
"-plan",
|
||||
"docs/superpowers/plans/2026-05-18-demo.md",
|
||||
"-executor",
|
||||
"executing-plans",
|
||||
"-constraints",
|
||||
"karpathy-guidelines,.agents,AGENT_RULES",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=5)
|
||||
self.assertEqual(proc.returncode, 0, msg=stderr or stdout)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("phase: planning", text)
|
||||
self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text)
|
||||
self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text)
|
||||
self.assertIn("executor: executing-plans", text)
|
||||
self.assertIn(
|
||||
"constraints: karpathy-guidelines,.agents,AGENT_RULES",
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
@@ -26,6 +27,7 @@ class NoBackupFlagsTests(unittest.TestCase):
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_rules]
|
||||
force = true
|
||||
@@ -54,6 +56,7 @@ no_backup = true
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["tsl"]
|
||||
@@ -71,6 +74,36 @@ no_backup = true
|
||||
git_backups = list(root.glob(".gitattributes.bak.*"))
|
||||
self.assertEqual(git_backups, [])
|
||||
|
||||
def test_install_skills_no_backup_replaces_existing_skill_without_backup(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
skills_root = root / "agents" / "skills"
|
||||
existing = skills_root / "brainstorming"
|
||||
existing.mkdir(parents=True)
|
||||
(existing / "stale.txt").write_text("old", encoding="utf-8")
|
||||
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[install_skills]
|
||||
agents_home = "{root / 'agents'}"
|
||||
mode = "list"
|
||||
skills = ["brainstorming"]
|
||||
no_backup = true
|
||||
"""
|
||||
config_path = root / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
backups = list(skills_root.glob("brainstorming.bak.*"))
|
||||
self.assertEqual(backups, [])
|
||||
self.assertFalse((existing / "stale.txt").exists())
|
||||
self.assertTrue((existing / "SKILL.md").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "plan_progress.py"
|
||||
|
||||
|
||||
def run_cli(*args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
class PlanProgressCliTests(unittest.TestCase):
|
||||
def _current_env(self) -> str:
|
||||
system = platform.system().lower()
|
||||
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
|
||||
if system not in mapping:
|
||||
self.skipTest(f"Unsupported environment: {system}")
|
||||
return mapping[system]
|
||||
|
||||
def test_select_seeds_progress_when_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-old.md").write_text("old", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-new.md").write_text("new", encoding="utf-8")
|
||||
|
||||
result = run_cli(
|
||||
"select",
|
||||
"-plans",
|
||||
"docs/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-01-old.md")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("<!-- plan-status:start -->", text)
|
||||
self.assertIn("<!-- plan-status:end -->", text)
|
||||
self.assertIn("`2026-01-01-old.md` pending", text)
|
||||
self.assertIn("`2026-01-02-new.md` pending", text)
|
||||
|
||||
def test_select_returns_first_pending_in_order(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-01-a.md").write_text("a", encoding="utf-8")
|
||||
(plans_dir / "2026-01-02-b.md").write_text("b", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-01-02-b.md` pending",
|
||||
"- [ ] `2026-01-01-a.md` pending",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"select",
|
||||
"-plans",
|
||||
"docs/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-02-b.md")
|
||||
|
||||
def test_select_returns_env_blocked_plan_without_flag(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
plans_dir = root / "docs" / "plans"
|
||||
plans_dir.mkdir(parents=True)
|
||||
(plans_dir / "2026-01-05-env.md").write_text("env", encoding="utf-8")
|
||||
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
env = self._current_env()
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
f"- [ ] `2026-01-05-env.md` blocked: env:{env}:Task1",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"select",
|
||||
"-plans",
|
||||
"docs/plans",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-05-env.md")
|
||||
|
||||
def test_record_updates_line(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
progress = root / "memory-bank" / "progress.md"
|
||||
progress.parent.mkdir(parents=True)
|
||||
progress.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Plan 状态",
|
||||
"",
|
||||
"<!-- plan-status:start -->",
|
||||
"- [ ] `2026-01-03-demo.md` pending",
|
||||
"<!-- plan-status:end -->",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = run_cli(
|
||||
"record",
|
||||
"-plan",
|
||||
"docs/plans/2026-01-03-demo.md",
|
||||
"-status",
|
||||
"done",
|
||||
"-progress",
|
||||
"memory-bank/progress.md",
|
||||
cwd=root,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("- [x] `2026-01-03-demo.md` done", text)
|
||||
self.assertEqual(text.count("2026-01-03-demo.md"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
|
||||
|
||||
def load_playbook_module():
|
||||
spec = importlib.util.spec_from_file_location("playbook_script", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class PlaybookDocsIndexTests(unittest.TestCase):
|
||||
def test_build_docs_index_lines_uses_canonical_sections(self):
|
||||
playbook = load_playbook_module()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
source = Path(tmp_dir) / "index.md"
|
||||
source.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# 文档导航(Docs Index)",
|
||||
"",
|
||||
"仓库级说明。",
|
||||
"",
|
||||
"## 跨语言(common)",
|
||||
"",
|
||||
"- 公共入口:`common/commit_message.md`",
|
||||
"",
|
||||
"## TSL(tsl/tsf)",
|
||||
"",
|
||||
"- 自定义 TSL 入口:`tsl/custom.md`",
|
||||
"",
|
||||
"## Python(python)",
|
||||
"",
|
||||
"- Python 入口:`python/style_guide.md`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
lines = playbook.build_docs_index_lines(["tsl"], source)
|
||||
|
||||
self.assertEqual(lines[0], "# 文档导航(Docs Index)")
|
||||
self.assertEqual(lines[2], "本快照为裁剪版 Playbook(langs: tsl)。")
|
||||
self.assertIn("## 跨语言(common)", lines)
|
||||
self.assertIn("- 公共入口:`common/commit_message.md`", lines)
|
||||
self.assertIn("## TSL(tsl/tsf)", lines)
|
||||
self.assertIn("- 自定义 TSL 入口:`tsl/custom.md`", lines)
|
||||
self.assertNotIn("## Python(python)", lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLAYBOOK_SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
|
||||
|
||||
class PlaybookTypingImportTests(unittest.TestCase):
|
||||
def test_optional_annotation_names_are_imported(self):
|
||||
tree = ast.parse(PLAYBOOK_SCRIPT.read_text(encoding="utf-8"))
|
||||
|
||||
imported_names: set[str] = set()
|
||||
referenced_names: set[str] = set()
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imported_names.add(alias.asname or alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
imported_names.add(alias.asname or alias.name)
|
||||
elif isinstance(node, ast.Name):
|
||||
referenced_names.add(node.id)
|
||||
|
||||
if "Optional" in referenced_names:
|
||||
self.assertIn("Optional", imported_names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,42 +0,0 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS_MD = ROOT / "SKILLS.md"
|
||||
SOURCES_LIST = ROOT / "codex" / "skills" / ".sources" / "superpowers.list"
|
||||
|
||||
|
||||
def read_sources_list() -> list[str]:
|
||||
return [
|
||||
line.strip()
|
||||
for line in SOURCES_LIST.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
|
||||
|
||||
def read_skills_md_list() -> list[str]:
|
||||
lines = SKILLS_MD.read_text(encoding="utf-8").splitlines()
|
||||
start = "<!-- superpowers:skills:start -->"
|
||||
end = "<!-- superpowers:skills:end -->"
|
||||
try:
|
||||
start_idx = lines.index(start) + 1
|
||||
end_idx = lines.index(end)
|
||||
except ValueError as exc:
|
||||
raise AssertionError("superpowers markers missing in SKILLS.md") from exc
|
||||
|
||||
items = []
|
||||
for line in lines[start_idx:end_idx]:
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("-"):
|
||||
continue
|
||||
items.append(stripped.lstrip("- ").strip())
|
||||
return items
|
||||
|
||||
|
||||
class SuperpowersListSyncTests(unittest.TestCase):
|
||||
def test_superpowers_list_matches_skills_md(self):
|
||||
self.assertEqual(read_sources_list(), read_skills_md_list())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,56 +0,0 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SYNC_WORKFLOW = ROOT / ".gitea" / "workflows" / "sync-superpowers.yml"
|
||||
AUTO_UPDATE_WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-superpowers.yml"
|
||||
AUTO_UPDATE_SCRIPT = ROOT / ".gitea" / "ci" / "update_thirdparty_superpowers.sh"
|
||||
SYNC_SCRIPT = ROOT / ".gitea" / "ci" / "sync_superpowers.sh"
|
||||
|
||||
|
||||
class SuperpowersWorkflowTests(unittest.TestCase):
|
||||
def test_sync_workflow_uses_manual_trigger(self):
|
||||
text = SYNC_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("workflow_dispatch:", text)
|
||||
|
||||
def test_sync_workflow_runs_from_latest_main(self):
|
||||
text = SYNC_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn('TARGET_BRANCH: "main"', text)
|
||||
self.assertIn('git fetch origin "${{ env.TARGET_BRANCH }}"', text)
|
||||
self.assertIn(
|
||||
'git checkout -B "${{ env.TARGET_BRANCH }}" "origin/${{ env.TARGET_BRANCH }}"',
|
||||
text,
|
||||
)
|
||||
|
||||
def test_auto_update_workflow_triggers_on_main_push(self):
|
||||
text = AUTO_UPDATE_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("push:", text)
|
||||
self.assertIn("- main", text)
|
||||
self.assertIn("workflow_dispatch:", text)
|
||||
|
||||
def test_auto_update_workflow_runs_update_script(self):
|
||||
text = AUTO_UPDATE_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("bash .gitea/ci/update_thirdparty_superpowers.sh", text)
|
||||
|
||||
def test_auto_update_script_targets_thirdparty_branch(self):
|
||||
text = AUTO_UPDATE_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn('TARGET_BRANCH="${TARGET_BRANCH:-thirdparty/skill}"', text)
|
||||
self.assertIn("api.github.com/repos", text)
|
||||
self.assertIn("ls-remote", text)
|
||||
self.assertIn('git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"', text)
|
||||
|
||||
def test_ci_scripts_use_ci_bot_identity(self):
|
||||
sync_text = SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
update_text = AUTO_UPDATE_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('COMMIT_AUTHOR_NAME="${COMMIT_AUTHOR_NAME:-ci-bot}"', sync_text)
|
||||
self.assertIn('COMMIT_AUTHOR_EMAIL="${COMMIT_AUTHOR_EMAIL:-ci-bot@local}"', sync_text)
|
||||
self.assertIn('COMMIT_AUTHOR_NAME="${COMMIT_AUTHOR_NAME:-ci-bot}"', update_text)
|
||||
self.assertIn(
|
||||
'COMMIT_AUTHOR_EMAIL="${COMMIT_AUTHOR_EMAIL:-ci-bot@local}"',
|
||||
update_text,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
@@ -28,6 +29,7 @@ class SyncDirectoryActionsTests(unittest.TestCase):
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
@@ -52,6 +54,7 @@ project_name = "Demo"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_prompts]
|
||||
"""
|
||||
@@ -78,6 +81,7 @@ project_root = "{tmp_dir}"
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "Demo"
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
@@ -26,14 +27,69 @@ def run_script(script_path: Path, *args, cwd: Path | None = None):
|
||||
|
||||
|
||||
class SyncTemplatesPlaceholdersTests(unittest.TestCase):
|
||||
def test_main_language_placeholder_replaced(self):
|
||||
def test_templates_no_longer_expose_main_language_placeholder(self):
|
||||
example_text = (ROOT / "playbook.toml.example").read_text(encoding="utf-8")
|
||||
self.assertNotIn("main_language", example_text)
|
||||
|
||||
templates_readme = (ROOT / "templates" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", templates_readme)
|
||||
self.assertNotIn("{{LANGUAGE_1}}", templates_readme)
|
||||
|
||||
agents_template = (ROOT / "templates" / "AGENTS.template.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", agents_template)
|
||||
|
||||
tech_context_template = (
|
||||
ROOT / "templates" / "memory-bank" / "tech-context.template.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", tech_context_template)
|
||||
self.assertNotIn("{{LANGUAGE_1}}", tech_context_template)
|
||||
self.assertNotIn("**主要语言**", tech_context_template)
|
||||
|
||||
update_memory_template = (
|
||||
ROOT / "templates" / "prompts" / "coding" / "update-memory.template.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("workflow-state", update_memory_template)
|
||||
self.assertIn("plan-status", update_memory_template)
|
||||
|
||||
close_task_template = (
|
||||
ROOT / "templates" / "prompts" / "coding" / "close-task.template.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("main_loop.py finish", close_task_template)
|
||||
self.assertIn("workflow-state.phase", close_task_template)
|
||||
|
||||
verify_change_template = (
|
||||
ROOT / "templates" / "prompts" / "coding" / "verify-change.template.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("workflow-state.phase", verify_change_template)
|
||||
self.assertIn("plan-status", verify_change_template)
|
||||
|
||||
prompts_readme = (
|
||||
ROOT / "templates" / "prompts" / "README.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("playbook.py -record-spec", prompts_readme)
|
||||
self.assertIn("playbook.py -record-plan", prompts_readme)
|
||||
|
||||
agent_behavior_template = (
|
||||
ROOT / "templates" / "prompts" / "system" / "agent-behavior.template.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("playbook.py -record-spec", agent_behavior_template)
|
||||
self.assertIn("playbook.py -record-plan", agent_behavior_template)
|
||||
|
||||
def test_sync_templates_replaces_playbook_scripts_without_main_language_support(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = \"{tmp_dir}\"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_rules]
|
||||
|
||||
[sync_memory_bank]
|
||||
|
||||
[sync_standards]
|
||||
langs = [\"cpp\", \"tsl\"]
|
||||
"""
|
||||
@@ -48,10 +104,30 @@ langs = [\"cpp\", \"tsl\"]
|
||||
self.assertIn(".agents/cpp/index.md", text)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", text)
|
||||
|
||||
tech_context = Path(tmp_dir) / "memory-bank" / "tech-context.md"
|
||||
tech_context_text = tech_context.read_text(encoding="utf-8")
|
||||
self.assertNotIn("{{LANGUAGE_1}}", tech_context_text)
|
||||
self.assertNotIn("{{MAIN_LANGUAGE}}", tech_context_text)
|
||||
self.assertNotIn("**主要语言**", tech_context_text)
|
||||
|
||||
rules_md = Path(tmp_dir) / "AGENT_RULES.md"
|
||||
rules_text = rules_md.read_text(encoding="utf-8")
|
||||
self.assertIn("docs/standards/playbook/scripts/plan_progress.py", rules_text)
|
||||
self.assertIn(
|
||||
"docs/standards/playbook/scripts/main_loop.py claim",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn("docs/superpowers/plans", rules_text)
|
||||
self.assertNotIn("plan_progress.py", rules_text)
|
||||
self.assertIn("记录 `phase=planning` 与 `spec=<path>`", rules_text)
|
||||
self.assertIn(
|
||||
"记录 `plan=<path>`、`executor=executing-plans`、",
|
||||
rules_text,
|
||||
)
|
||||
self.assertIn("未领取 Plan 前,不得直接进入 `$executing-plans`", rules_text)
|
||||
self.assertIn("默认执行使用 `$executing-plans`", rules_text)
|
||||
self.assertIn("不是默认执行器", rules_text)
|
||||
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules_text)
|
||||
self.assertFalse(rules_text.endswith("\n\n"))
|
||||
|
||||
def test_sync_standards_rewrites_typescript_docs_prefix_for_vendored_playbook(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -61,6 +137,7 @@ langs = [\"cpp\", \"tsl\"]
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["typescript"]
|
||||
@@ -76,6 +153,7 @@ langs = ["typescript"]
|
||||
f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_standards]
|
||||
langs = ["typescript"]
|
||||
@@ -96,6 +174,52 @@ langs = ["typescript"]
|
||||
self.assertIn("`docs/standards/playbook/docs/typescript/", text)
|
||||
self.assertNotIn("`docs/typescript/", text)
|
||||
|
||||
def test_sync_memory_bank_includes_active_context_and_human_readable_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[sync_rules]
|
||||
|
||||
[sync_memory_bank]
|
||||
project_name = "MyProject"
|
||||
"""
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(config_body, encoding="utf-8")
|
||||
|
||||
result = run_cli("-config", str(config_path))
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
active_context = Path(tmp_dir) / "memory-bank" / "active-context.md"
|
||||
self.assertTrue(active_context.is_file())
|
||||
|
||||
progress = Path(tmp_dir) / "memory-bank" / "progress.md"
|
||||
progress_text = progress.read_text(encoding="utf-8")
|
||||
self.assertIn("## Current Focus", progress_text)
|
||||
self.assertIn("## 状态块示例", progress_text)
|
||||
self.assertIn("phase: planning", progress_text)
|
||||
self.assertIn("executor: executing-plans", progress_text)
|
||||
self.assertIn("<!-- workflow-state:start -->", progress_text)
|
||||
self.assertIn("<!-- workflow-state:end -->", progress_text)
|
||||
self.assertIn("## Plan Status", progress_text)
|
||||
self.assertIn("<!-- plan-status:start -->", progress_text)
|
||||
self.assertIn("<!-- plan-status:end -->", progress_text)
|
||||
|
||||
system_patterns = Path(tmp_dir) / "memory-bank" / "system-patterns.md"
|
||||
system_patterns_text = system_patterns.read_text(encoding="utf-8")
|
||||
self.assertIn("# 系统模式与约束", system_patterns_text)
|
||||
self.assertIn("## 核心不变量", system_patterns_text)
|
||||
|
||||
agents_md = Path(tmp_dir) / "AGENTS.md"
|
||||
agents_text = agents_md.read_text(encoding="utf-8")
|
||||
self.assertIn("memory-bank/active-context.md", agents_text)
|
||||
|
||||
rules_md = Path(tmp_dir) / "AGENT_RULES.md"
|
||||
rules_text = rules_md.read_text(encoding="utf-8")
|
||||
self.assertIn("memory-bank/active-context.md", rules_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / ".gitea" / "ci" / "thirdparty_skills.json"
|
||||
WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-skills.yml"
|
||||
LEGACY_WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-superpowers.yml"
|
||||
UPDATE_SCRIPT = ROOT / ".gitea" / "ci" / "update_thirdparty_skills.sh"
|
||||
SYNC_SCRIPT = ROOT / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
|
||||
SKILLS_MD = ROOT / "SKILLS.md"
|
||||
SUPERPOWERS_LIST = ROOT / "skills" / "thirdparty" / ".sources" / "superpowers.list"
|
||||
UI_UX_PRO_MAX_LIST = ROOT / "skills" / "thirdparty" / ".sources" / "ui-ux-pro-max.list"
|
||||
UI_UX_PRO_MAX_DIR = ROOT / "skills" / "thirdparty" / "ui-ux-pro-max"
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def bash_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
if os.name != "nt":
|
||||
return resolved.as_posix()
|
||||
drive = resolved.drive.rstrip(":").lower()
|
||||
rest = resolved.as_posix()[2:]
|
||||
return f"/mnt/{drive}{rest}"
|
||||
|
||||
|
||||
def run_command(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
list(args),
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ThirdpartySkillsPipelineTests(unittest.TestCase):
|
||||
def test_manifest_declares_all_thirdparty_sources(self):
|
||||
data = load_manifest()
|
||||
self.assertEqual(
|
||||
[entry["id"] for entry in data["sources"]],
|
||||
["superpowers", "ui-ux-pro-max", "andrej-karpathy-skills"],
|
||||
)
|
||||
|
||||
def test_karpathy_manifest_uses_copy_skill_dirs_sync_mode(self):
|
||||
data = load_manifest()
|
||||
karpathy = next(
|
||||
item for item in data["sources"] if item["id"] == "andrej-karpathy-skills"
|
||||
)
|
||||
self.assertEqual(karpathy["sync_mode"], "copy_skill_dirs")
|
||||
self.assertEqual(karpathy["snapshot_dir"], "andrej-karpathy-skills")
|
||||
self.assertEqual(karpathy["skills_subdir"], "skills")
|
||||
self.assertEqual(
|
||||
karpathy["source_list"], "skills/thirdparty/.sources/andrej-karpathy-skills.list"
|
||||
)
|
||||
|
||||
def test_ui_ux_pro_max_uses_render_skill_sync_mode(self):
|
||||
data = load_manifest()
|
||||
ui_skill = next(item for item in data["sources"] if item["id"] == "ui-ux-pro-max")
|
||||
self.assertEqual(ui_skill["sync_mode"], "render_skill")
|
||||
self.assertEqual(ui_skill["snapshot_dir"], "ui-ux-pro-max")
|
||||
|
||||
def test_superpowers_manifest_prunes_non_superpowers_paths(self):
|
||||
data = load_manifest()
|
||||
superpowers = next(item for item in data["sources"] if item["id"] == "superpowers")
|
||||
self.assertEqual(superpowers["remove_paths"], ["skills/ui-ux-pro-max"])
|
||||
|
||||
def test_workflow_uses_generic_scripts_and_single_serial_job(self):
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertFalse(LEGACY_WORKFLOW.exists())
|
||||
self.assertIn("update_and_sync:", text)
|
||||
self.assertNotIn("\n update:\n", text)
|
||||
self.assertNotIn("\n sync:\n", text)
|
||||
self.assertIn("bash .gitea/ci/update_thirdparty_skills.sh", text)
|
||||
self.assertIn("bash .gitea/ci/sync_thirdparty_skills.sh", text)
|
||||
self.assertNotIn("git merge", text)
|
||||
self.assertNotIn("git pull", text)
|
||||
|
||||
def test_workflow_has_serial_concurrency_and_literal_generic_paths(self):
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertIn("concurrency:", text)
|
||||
self.assertIn("update-thirdparty-${{ github.repository }}", text)
|
||||
self.assertIn('MANIFEST_PATH: ".gitea/ci/thirdparty_skills.json"', text)
|
||||
self.assertIn('TARGET_BRANCH="$THIRDPARTY_BRANCH" bash .gitea/ci/update_thirdparty_skills.sh', text)
|
||||
self.assertIn('TARGET_BRANCH="main" \\', text)
|
||||
self.assertIn('MANIFEST_PATH="$MANIFEST_PATH" \\', text)
|
||||
|
||||
def test_generic_scripts_exist_and_use_manifest(self):
|
||||
update_text = UPDATE_SCRIPT.read_text(encoding="utf-8")
|
||||
sync_text = SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn('MANIFEST_PATH="${MANIFEST_PATH:-.gitea/ci/thirdparty_skills.json}"', update_text)
|
||||
self.assertIn('MANIFEST_PATH="${MANIFEST_PATH:-.gitea/ci/thirdparty_skills.json}"', sync_text)
|
||||
self.assertIn('TARGET_BRANCH="${TARGET_BRANCH:-thirdparty/skill}"', update_text)
|
||||
self.assertIn('TARGET_BRANCH="${TARGET_BRANCH:-main}"', sync_text)
|
||||
self.assertIn(':package: deps(thirdparty): update snapshots', update_text)
|
||||
self.assertIn(':package: deps(skills): sync thirdparty skills', sync_text)
|
||||
|
||||
def test_skills_doc_points_to_generic_thirdparty_sources(self):
|
||||
text = SKILLS_MD.read_text(encoding="utf-8")
|
||||
self.assertIn("## 9. Third-party Skills", text)
|
||||
self.assertIn("来源:`skills/thirdparty/.sources/`(第三方来源清单目录)。", text)
|
||||
self.assertNotIn("Third-party Skills (superpowers)", text)
|
||||
|
||||
def test_superpowers_and_ui_ux_pro_max_source_lists_exist(self):
|
||||
self.assertTrue(SUPERPOWERS_LIST.is_file())
|
||||
self.assertTrue(UI_UX_PRO_MAX_LIST.is_file())
|
||||
self.assertIn("using-superpowers", SUPERPOWERS_LIST.read_text(encoding="utf-8"))
|
||||
self.assertIn("ui-ux-pro-max", UI_UX_PRO_MAX_LIST.read_text(encoding="utf-8"))
|
||||
|
||||
def test_ui_ux_pro_max_output_exists_with_data_and_scripts(self):
|
||||
self.assertTrue((UI_UX_PRO_MAX_DIR / "SKILL.md").is_file())
|
||||
self.assertTrue((UI_UX_PRO_MAX_DIR / "data").is_dir())
|
||||
self.assertTrue((UI_UX_PRO_MAX_DIR / "scripts").is_dir())
|
||||
|
||||
def test_update_script_materializes_manifest_before_target_checkout(self):
|
||||
text = UPDATE_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn('manifest_copy="$tmp_dir/thirdparty_skills.json"', text)
|
||||
self.assertIn('cp "$MANIFEST_PATH" "$manifest_copy"', text)
|
||||
self.assertIn('MANIFEST_PATH="$manifest_copy"', text)
|
||||
self.assertIn("remove_paths", text)
|
||||
self.assertIn('remove_snapshot_paths "$snapshot_dir" "$remove_paths"', text)
|
||||
self.assertIn("- Remove-Paths:", text)
|
||||
self.assertIn('if ! emit_sources_tsv > "$sources_file"; then', text)
|
||||
self.assertNotIn("done < <(emit_sources_tsv)", text)
|
||||
self.assertLess(
|
||||
text.index('cp "$MANIFEST_PATH" "$manifest_copy"'),
|
||||
text.index('git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH"'),
|
||||
)
|
||||
|
||||
def test_sync_script_assumes_thirdparty_snapshot_is_already_clean(self):
|
||||
text = SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn('"\\x1f".join(', text)
|
||||
self.assertIn("while IFS=$'\\x1f' read -r", text)
|
||||
self.assertNotIn("while IFS=$'\\t' read -r", text)
|
||||
self.assertNotIn("exclude_skill_dirs", text)
|
||||
self.assertNotIn("is_excluded_skill_dir", text)
|
||||
|
||||
def test_sync_script_generates_karpathy_outputs_in_temp_repo(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_root = Path(tmp_dir)
|
||||
mirror = tmp_root / "origin.git"
|
||||
work = tmp_root / "work"
|
||||
|
||||
clone_mirror = run_command("git", "clone", "--mirror", str(ROOT), str(mirror))
|
||||
self.assertEqual(clone_mirror.returncode, 0, msg=clone_mirror.stderr)
|
||||
|
||||
clone_work = run_command("git", "clone", str(mirror), str(work))
|
||||
self.assertEqual(clone_work.returncode, 0, msg=clone_work.stderr)
|
||||
|
||||
set_remote = run_command(
|
||||
"git", "-C", str(work), "remote", "set-url", "origin", bash_path(mirror)
|
||||
)
|
||||
self.assertEqual(set_remote.returncode, 0, msg=set_remote.stderr)
|
||||
|
||||
shutil.copy2(MANIFEST, work / ".gitea" / "ci" / "thirdparty_skills.json")
|
||||
shutil.copy2(SYNC_SCRIPT, work / ".gitea" / "ci" / "sync_thirdparty_skills.sh")
|
||||
|
||||
sync_result = run_command("bash", ".gitea/ci/sync_thirdparty_skills.sh", cwd=work)
|
||||
self.assertEqual(
|
||||
sync_result.returncode,
|
||||
0,
|
||||
msg=sync_result.stdout + sync_result.stderr,
|
||||
)
|
||||
|
||||
generated_list = (
|
||||
work / "skills" / "thirdparty" / ".sources" / "andrej-karpathy-skills.list"
|
||||
)
|
||||
generated_skill = (
|
||||
work / "skills" / "thirdparty" / "karpathy-guidelines" / "SKILL.md"
|
||||
)
|
||||
self.assertTrue(generated_list.is_file())
|
||||
self.assertTrue(generated_skill.is_file())
|
||||
self.assertIn(
|
||||
"karpathy-guidelines", generated_list.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,6 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts import playbook
|
||||
|
||||
@@ -18,6 +20,44 @@ key = 1
|
||||
with self.assertRaises(ValueError):
|
||||
playbook.loads_toml_minimal(raw)
|
||||
|
||||
def test_load_config_preserves_windows_users_path_in_basic_string(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\Users\\demo\\workspace"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace")
|
||||
|
||||
def test_load_config_preserves_windows_escape_like_segments_for_path_keys(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\tmp\\notes"\n\n'
|
||||
'[install_skills]\nagents_home = "D:\\new\\tab"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\tmp\notes")
|
||||
self.assertEqual(data["install_skills"]["agents_home"], r"D:\new\tab")
|
||||
|
||||
def test_load_config_keeps_already_escaped_windows_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "playbook.toml"
|
||||
config_path.write_text(
|
||||
'[playbook]\nproject_root = "C:\\\\Users\\\\demo\\\\workspace"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = playbook.load_config(config_path)
|
||||
|
||||
self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RULESET_TSL = ROOT / "rulesets" / "tsl" / "index.md"
|
||||
README = ROOT / "README.md"
|
||||
PLAYBOOK_EXAMPLE = ROOT / "playbook.toml.example"
|
||||
SKILLS_DOC = ROOT / "SKILLS.md"
|
||||
TEMPLATES_CI_README = ROOT / "templates" / "ci" / "README.md"
|
||||
TEMPLATES_README = ROOT / "templates" / "README.md"
|
||||
REMOVED_TSL_GUIDE = ROOT / "skills" / "tsl-guide"
|
||||
|
||||
class TslEntrypointsConsistencyTests(unittest.TestCase):
|
||||
def test_ruleset_lists_canonical_tsl_layers(self):
|
||||
text = RULESET_TSL.read_text(encoding="utf-8")
|
||||
self.assertIn("docs/tsl/index.md", text)
|
||||
self.assertIn("docs/tsl/syntax/index.md", text)
|
||||
self.assertIn("docs/tsl/finance/index.md", text)
|
||||
self.assertIn("docs/tsl/modules/index.md", text)
|
||||
self.assertIn("docs/tsl/reference/index.md", text)
|
||||
|
||||
def test_readme_only_lists_two_official_deployment_routes(self):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
self.assertIn("方式一:git subtree", text)
|
||||
self.assertIn("方式二:外部 clone 后执行部署", text)
|
||||
self.assertIn("`project_root`:目标项目根目录", text)
|
||||
self.assertIn("`deploy_root`:相对于 `project_root` 的项目内目标目录", text)
|
||||
self.assertIn("不是外部 clone 出来的 Playbook 仓库路径", text)
|
||||
self.assertIn("外部 clone 场景下必须显式填写 `deploy_root`", text)
|
||||
self.assertNotIn("方式二:手动复制快照", text)
|
||||
self.assertNotIn("方式三:CLI 裁剪复制", text)
|
||||
self.assertNotIn("如果省略 `deploy_root`,默认仍部署到 `docs/standards/playbook`", text)
|
||||
|
||||
def test_playbook_example_defines_deploy_root_as_target_path(self):
|
||||
text = PLAYBOOK_EXAMPLE.read_text(encoding="utf-8")
|
||||
self.assertIn('deploy_root = "docs/standards/playbook"', text)
|
||||
self.assertIn("相对于 project_root", text)
|
||||
self.assertIn("不是外部 clone 的 playbook 路径", text)
|
||||
self.assertIn("从外部 clone 执行时必填", text)
|
||||
self.assertNotIn("target_dir", text)
|
||||
|
||||
def test_deployment_docs_do_not_reference_legacy_terms(self):
|
||||
self.assertNotIn("vendoring", README.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("`.tmp`", README.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("vendoring", SKILLS_DOC.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("vendoring", TEMPLATES_CI_README.read_text(encoding="utf-8"))
|
||||
|
||||
def test_repo_docs_use_platform_neutral_skills_source_dir(self):
|
||||
readme_text = README.read_text(encoding="utf-8")
|
||||
skills_text = SKILLS_DOC.read_text(encoding="utf-8")
|
||||
templates_text = TEMPLATES_README.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("`skills/`", readme_text)
|
||||
self.assertIn("`skills/`", skills_text)
|
||||
self.assertIn("├── skills/", templates_text)
|
||||
|
||||
self.assertNotIn("`codex/skills/`", readme_text)
|
||||
self.assertNotIn("`codex/skills/`", skills_text)
|
||||
self.assertNotIn("├── codex/skills/", templates_text)
|
||||
|
||||
def test_repo_no_longer_ships_tsl_guide_skill(self):
|
||||
self.assertFalse(REMOVED_TSL_GUIDE.exists())
|
||||
self.assertNotIn("tsl-guide", README.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("tsl-guide", SKILLS_DOC.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("$tsl-guide", RULESET_TSL.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playbook.py"
|
||||
DEFAULT_DEPLOY_ROOT = "docs/standards/playbook"
|
||||
|
||||
|
||||
def run_cli(*args):
|
||||
@@ -22,6 +23,7 @@ class VendorSnapshotTemplatesTests(unittest.TestCase):
|
||||
config_body = f"""
|
||||
[playbook]
|
||||
project_root = "{tmp_dir}"
|
||||
deploy_root = "{DEFAULT_DEPLOY_ROOT}"
|
||||
|
||||
[vendor]
|
||||
langs = ["tsl"]
|
||||
@@ -38,6 +40,8 @@ langs = ["tsl"]
|
||||
self.assertTrue((snapshot / "templates/README.md").is_file())
|
||||
self.assertTrue((snapshot / "templates/memory-bank").is_dir())
|
||||
self.assertTrue((snapshot / "templates/prompts").is_dir())
|
||||
self.assertTrue((snapshot / "skills").is_dir())
|
||||
self.assertFalse((snapshot / "codex").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user