from __future__ import annotations import importlib.util import os import shutil import subprocess import tempfile import unittest from io import StringIO from pathlib import Path from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts" CHECK_PY = SCRIPTS / "check.py" TEMPLATE = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.builder" spec = importlib.util.spec_from_file_location("builder_check", CHECK_PY) assert spec is not None and spec.loader is not None builder_check = importlib.util.module_from_spec(spec) spec.loader.exec_module(builder_check) def git(cwd: Path, *args: str) -> None: result = subprocess.run( ["git", *args], cwd=cwd, text=True, capture_output=True, check=False, env={ **os.environ, "GIT_AUTHOR_NAME": "Test", "GIT_AUTHOR_EMAIL": "test@example.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@example.com", }, ) if result.returncode != 0: raise AssertionError(f"git {args} failed: {result.stderr}") def init_repo(path: Path) -> None: git(path, "init", "-b", "main") git(path, "config", "user.email", "test@example.com") git(path, "config", "user.name", "Test") (path / "README").write_text("x\n", encoding="utf-8") git(path, "add", "README") git(path, "commit", "-m", "init") def write_contract_makefile(project: Path) -> None: text = TEMPLATE.read_text(encoding="utf-8") text = text.replace( "include $(HOME)/.pouch/skills/builder/scripts/version.mk", f"include {SCRIPTS / 'version.mk'}", ) (project / "makefile.builder").write_text(text, encoding="utf-8") def run_check( project: Path, *flags: str, env: dict[str, str] | None = None, which: dict[str, str | None] | None = None, ) -> tuple[int, str]: merged = os.environ.copy() if env: merged.update(env) merged["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent) stdout = StringIO() stderr = StringIO() real_which = shutil.which def fake_which(name: str, *args: object, **kwargs: object) -> str | None: if which is not None and name in which: return which[name] return real_which(name) with mock.patch.dict(os.environ, merged, clear=True): with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): with mock.patch.object(builder_check.shutil, "which", side_effect=fake_which): code = builder_check.main([str(project), *flags]) return code, stdout.getvalue() + stderr.getvalue() class BuilderCheckTests(unittest.TestCase): def test_skill_documents_init_and_does_not_call_create_makefile(self) -> None: skill = (REPO_ROOT / "skills" / "builder" / "SKILL.md").read_text(encoding="utf-8") contract = ( REPO_ROOT / "skills" / "builder" / "references" / "contract.md" ).read_text(encoding="utf-8") self.assertIn("## 初始化", skill) self.assertIn("check.py", skill) self.assertIn("--ready", skill) self.assertIn("makefile.builder", skill) self.assertIn(".env.builder", skill) self.assertIn("不要改用户的 `Makefile`", skill) self.assertIn("不要调用 create-makefile", skill) self.assertIn("不要用 create-makefile", contract) self.assertIn("makefile.builder", contract) self.assertIn(".env.builder", contract) self.assertIn("不要读取或改写用户 `.env`", contract) def test_no_makefile_without_ready_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) code, text = run_check(project) self.assertEqual(code, 2) self.assertIn("no makefile.builder", text) def test_ready_without_makefile_fails_with_repair_hint(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) code, text = run_check(project, "--ready") self.assertEqual(code, 1) self.assertIn("[FAIL] 1. makefile.builder 存在", text) self.assertIn("makefile.builder", text) self.assertIn("RESULT: FAILED", text) def test_contract_makefile_without_env_passes_build_and_skips_publish_keys(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) code, text = run_check( project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} ) self.assertEqual(code, 0, text) self.assertIn("RESULT: PASSED", text) self.assertIn("[SKIP] 11. 发布环境变量键名", text) self.assertIn("DEB_SERVER_URL: MISSING", text) self.assertIn("blocks publish, not build", text) self.assertNotRegex(text, r"DEB_TOKEN: (?!MISSING|present).+") def test_env_keys_present_without_printing_values(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) (project / ".env.builder").write_text( "DEB_SERVER_URL=https://secret.example.com\n" "DEB_TOKEN=super-secret-token-value\n" "DEB_REPOSITORY=main\n", encoding="utf-8", ) code, text = run_check( project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} ) self.assertEqual(code, 0, text) self.assertIn("DEB_SERVER_URL: present", text) self.assertIn("DEB_TOKEN: present", text) self.assertNotIn("super-secret-token-value", text) self.assertNotIn("https://secret.example.com", text) def test_user_dotenv_is_ignored(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) (project / ".env").write_text( "DEB_SERVER_URL=https://user.example.com\n" "DEB_TOKEN=user-env-secret-token\n" "DEB_REPOSITORY=main\n", encoding="utf-8", ) code, text = run_check( project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} ) self.assertEqual(code, 0, text) self.assertIn("DEB_SERVER_URL: MISSING", text) self.assertNotIn("user-env-secret-token", text) self.assertNotIn("https://user.example.com", text) def test_empty_env_builder_values_count_as_missing(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) (project / ".env.builder").write_text( "DEB_SERVER_URL=\nDEB_TOKEN=\nDEB_REPOSITORY=\n", encoding="utf-8", ) code, text = run_check( project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} ) self.assertEqual(code, 0, text) self.assertIn("DEB_TOKEN: MISSING", text) def test_ready_fails_when_docker_track_missing_docker(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) (project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") code, text = run_check( project, "--ready", which={"docker": None, "dpkg-deb": "/usr/bin/dpkg-deb"}, ) self.assertEqual(code, 1, text) self.assertIn("[FAIL] 10. 轨道工具链", text) self.assertIn("docker: MISSING", text) def test_user_makefile_does_not_satisfy_contract(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) (project / "Makefile").write_text( "help:\n\t@echo user\nbuild:\n\t@echo user-build\n", encoding="utf-8", ) code, text = run_check(project, "--ready") self.assertEqual(code, 1, text) self.assertIn("no makefile.builder", text) def test_user_makefile_is_ignored_when_builder_file_exists(self) -> None: with tempfile.TemporaryDirectory() as temp: project = Path(temp) init_repo(project) write_contract_makefile(project) (project / "Makefile").write_text( "TOKEN=super-secret-user-makefile-token\n" "help:\n\t@echo hijacked\n" "docker:\n\tdocker push example:latest\n", encoding="utf-8", ) code, text = run_check( project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} ) self.assertEqual(code, 0, text) self.assertNotIn("super-secret-user-makefile-token", text) self.assertNotIn("hijacked", text) self.assertNotIn(":latest", text) if __name__ == "__main__": unittest.main()