"""검사할 것이 없을 때 관문이 무엇을 내는가 (CLAUDE.md 「검사」 절의 표). 봤고 괜찮다 exit 0 문제 없음 · error 0 대상이 성립하지 않는다 exit 2 대상이 성립하지 않는다 — <이유> 볼 것이 아직 없다 exit 0 기록 0건 — … 가운데 줄이 없으면 오타 한 번으로 검사를 다 돈 것처럼 보인다. 실제로 그랬다 — 여섯 중 넷이 없는 프로젝트에 초록을 냈고 `verify-tech-log-tree.py` 는 그것을 「프로젝트 1 · error 0 · PASS」 로 셌다. """ from __future__ import annotations import contextlib import json import os import shutil import subprocess import sys import tempfile import unittest ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # `docs/` 안에 만드는 픽스처의 접두사. 실재 프로젝트와 섞이지 않게 `zz-` 로 시작한다 FIXTURE_PREFIX = "zz-no-contract-" def _sweep_stale_fixtures(docs: str) -> None: """지난 실행이 남긴 픽스처를 치운다. `finally` 는 SIGKILL 을 못 막는다. 한 번 남으면 그 폴더가 **진짜 프로젝트로 세어져서** `verify-pipeline.py` 가 프로젝트 수를 하나 더 세고 계약이 없다고 error 를 낸다 — 시험이 저장소를 고장 낸 것처럼 보인다. 지우는 것은 이 접두사로 시작하는 것뿐이라 실재 프로젝트를 건드리지 않는다. """ for name in os.listdir(docs): if name.startswith(FIXTURE_PREFIX): shutil.rmtree(os.path.join(docs, name), ignore_errors=True) PY_CHECKERS = ( "check-figure-text.py", "check-figure-overlap.py", "audit-records.py", "verify-tech-log-tree.py", "verify-project-layout.py", ) MJS_CHECKER = os.path.join(".agents", "skills", "writing-tech-log-records", "scripts", "check_evidence.mjs") ABSENT = "no-such-project-r4-regression" @contextlib.contextmanager def _a_contract_with_no_records(): """계약은 있고 기록이 0건인 프로젝트를 만들어 준다. 이 상태를 살아 있는 프로젝트 이름으로 가리키면, 누가 그 프로젝트에 기록 한 편을 쓰는 순간 시험이 깨진다. 실제로 그렇게 깨졌다. 이름이 아니라 상태가 필요한 시험이므로 여기서 그 상태를 만든다. """ docs = os.path.join(ROOT, "docs") path = tempfile.mkdtemp(prefix="zz-no-records-", dir=docs) try: studio = os.path.join(path, "tech-log-studio") os.makedirs(studio) name = os.path.basename(path) with open(os.path.join(studio, "tech-log-tree.json"), "w", encoding="utf-8") as fh: json.dump({"schemaVersion": 4, "project": name, "ssot": "final/document.md", "topics": {}, "candidates": []}, fh) yield name finally: shutil.rmtree(path, ignore_errors=True) @contextlib.contextmanager def _a_project_without_a_contract(): """SSOT 는 있고 분해 계약이 없는 프로젝트를 만들어 준다. 이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간 시험이 조용히 `skip` 으로 넘어간다. 실제로 그렇게 됐다 — `ca-tmpl` 을 지웠다. 이름이 아니라 상태가 필요한 시험이므로 여기서 그 상태를 만든다. """ docs = os.path.join(ROOT, "docs") _sweep_stale_fixtures(docs) path = tempfile.mkdtemp(prefix=FIXTURE_PREFIX, dir=docs) try: final = os.path.join(path, "final") os.makedirs(final) with open(os.path.join(final, "document.md"), "w", encoding="utf-8") as fh: fh.write("# 계약 없는 프로젝트\n\n## §1 아무것도 아니다\n\n본문.\n") yield os.path.basename(path) finally: shutil.rmtree(path, ignore_errors=True) def _run(cmd: list[str]) -> subprocess.CompletedProcess: return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=300) class NoTargetExitCode(unittest.TestCase): """대상이 성립하지 않으면 exit 2 와 그 문구를 낸다.""" def test_python_checkers_reject_absent_project(self) -> None: for name in PY_CHECKERS: with self.subTest(checker=name): run = _run([sys.executable, os.path.join("scripts", name), ABSENT]) self.assertEqual(run.returncode, 2, run.stdout + run.stderr) self.assertIn("대상이 성립하지 않는다", run.stdout + run.stderr) def test_check_evidence_rejects_absent_project(self) -> None: if not os.path.exists(os.path.join(ROOT, MJS_CHECKER)): self.skipTest("check_evidence.mjs 가 없다") run = _run(["node", MJS_CHECKER, ABSENT]) self.assertEqual(run.returncode, 2, run.stdout + run.stderr) self.assertIn("대상이 성립하지 않는다", run.stdout + run.stderr) def test_project_without_contract_is_not_a_target(self) -> None: """계약이 없는 실재 프로젝트도 「대상이 성립하지 않는다」다.""" with _a_project_without_a_contract() as project: for name in ("audit-records.py", "verify-tech-log-tree.py"): with self.subTest(checker=name): run = _run([sys.executable, os.path.join("scripts", name), project]) self.assertEqual(run.returncode, 2, run.stdout + run.stderr) class EmptyTargetIsNotGreen(unittest.TestCase): """계약은 있고 기록이 0건인 것은 결함이 아니다. 다만 「문제 없음」이라고 쓰지 않는다.""" def test_zero_records_says_so(self) -> None: with _a_contract_with_no_records() as project: run = _run([sys.executable, os.path.join("scripts", "audit-records.py"), project]) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) self.assertIn("기록 0건", run.stdout) self.assertNotIn("문제 없음", run.stdout) class FileTargets(unittest.TestCase): """`--file` 로 준 경로도 같은 규칙을 지킨다 (R13). 프로젝트 이름만 고치면 `--file` 로 오타를 내는 순간 다시 조용히 0건이 된다. """ FILE_CHECKERS = ("check-figure-text.py", "check-figure-overlap.py", "preview-figure.py") ABSENT_FILE = os.path.join("docs", "no-such-project", "no-such-figure.svg") def test_absent_file_is_not_a_target(self) -> None: for name in self.FILE_CHECKERS: with self.subTest(checker=name): run = _run([sys.executable, os.path.join("scripts", name), "--file", self.ABSENT_FILE]) self.assertEqual(run.returncode, 2, run.stdout + run.stderr) self.assertIn("대상이 성립하지 않는다", run.stdout + run.stderr) def test_present_file_still_passes(self) -> None: """정상 경로를 막으면 안 된다 — 무조건 거절은 결함이다.""" svg = os.path.join("docs", "n+1liner", "final", "assets", "diagrams", "eager-lazy-query-sequence", "eager-lazy-query-sequence.svg") if not os.path.exists(os.path.join(ROOT, svg)): self.skipTest("대조에 쓸 그림이 이 저장소에 없다") for name in ("check-figure-text.py", "check-figure-overlap.py"): with self.subTest(checker=name): run = _run([sys.executable, os.path.join("scripts", name), "--file", svg]) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) class ContractlessProjectIsCounted(unittest.TestCase): """계약이 없는 프로젝트가 전체 훑기의 목록에서 사라지면 안 된다 (R6). `verify_projects()` 가 `docs/*/tech-log-studio` 만 훑던 동안 계약 없는 프로젝트는 `TECH LOG TREES` 블록에 아예 안 나왔다. CLAUDE.md 는 「계약 미채택도 error」라고 적었는데 그 error 를 셀 자리가 없었다. """ def test_missing_contract_is_an_error(self) -> None: with _a_project_without_a_contract(): run = _run([sys.executable, os.path.join("scripts", "verify-pipeline.py")]) self.assertIn("분해 계약 없음", run.stdout) block = run.stdout.split("TECH LOG TREES:", 1) self.assertEqual(len(block), 2, "TECH LOG TREES 블록이 없다") head = block[1].splitlines()[0] self.assertNotIn("error 0", head, f"계약 없는 프로젝트가 안 세졌다 — {head}") if __name__ == "__main__": unittest.main()