"""종류가 요구하는 내용이 채워졌는지 보는 검사기. 고정 사례는 **여섯** 종류마다 둘이다 — 채운 것과 하나를 뺀 것. 「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다. """ import contextlib import importlib.util import json import os import shutil import tempfile import unittest ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content") # `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) _spec = importlib.util.spec_from_file_location( "check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py")) crc = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(crc) # 계약의 `RecordKind` 와 같은 여섯이다. 손으로 세지 않는다 — 이 목록에 종류를 빠뜨리면 # 그 종류의 고정 사례가 있어도 **아무도 열어 보지 않는다** KINDS = tuple(crc.techlog.KINDS) @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 는 있고 분해 계약이 없는 프로젝트를 만들어 준다. `_sweep_stale_fixtures` 를 먼저 부르는 이유는 `finally` 가 못 도는 경우가 있어서다. 이 시험이 SIGKILL 을 맞으면(메모리 부족 등) 픽스처가 `docs/` 에 남고, 그러면 **그것이 진짜 프로젝트로 세어진다** — `verify-pipeline.py` 가 「프로젝트 7」로 찍고 계약이 없으니 error 를 낸다. 실제로 그렇게 됐다. 저장소를 어지럽히지 않는 자리에 두는 것이 낫지만, `check_evidence.mjs <프로젝트>` 가 `docs/` 아래에서만 프로젝트를 찾으므로 여기 두어야 한다. 그래서 다음 실행이 스스로 치운다. 이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간 시험이 엉뚱한 이유로 통과한다 — 「계약이 없다」가 「그런 프로젝트가 없다」로 바뀐다. 둘 다 exit 2 라 종료 코드만 보면 구분되지 않는다. 그래서 상태를 여기서 만든다. """ 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 _report(sub, name): rep = crc.techlog.Report("fixture") crc.check_record(os.path.join(FIXTURES, sub, f"{name}.md"), rep) return rep class RequiredContentTest(unittest.TestCase): def test_every_kind_passes_when_filled(self): for kind in KINDS: with self.subTest(kind=kind): rep = _report("ok", kind) self.assertEqual(0, rep.error_count, f"{kind}: {dict(rep.errors)}") def test_every_kind_fails_when_a_required_part_is_missing(self): for kind in KINDS: with self.subTest(kind=kind): rep = _report("missing", kind) self.assertGreater(rep.error_count, 0, f"{kind} 의 누락 사례가 통과했다") def test_the_missing_part_is_named(self): """무엇이 빠졌는지 말한다. 「어딘가 잘못됐다」로 끝나지 않는다.""" expected = { "case": "결론", "concept": "basisVersion", "reference": "적용 조건", "question": "다음 검증", "decision": "판단 이유", "setup": "pinnedVersions", } for kind, part in expected.items(): with self.subTest(kind=kind): rules = " / ".join(_report("missing", kind).errors) self.assertIn(part, rules) def test_decision_kind_is_project_decision_in_frontmatter(self): """decision/ 폴더의 기록은 kind: PROJECT_DECISION 이다 (templates/decision.md).""" self.assertEqual("decision", crc.KIND_ALIASES["PROJECT_DECISION"]) rep = _report("ok", "decision") self.assertNotIn("kind 를 모르겠다", rep.errors) def test_an_unanswered_question_is_not_an_error(self): """답이 없는 QUESTION 자체는 결함이 아니다. 답을 구할 방법이 없는 것이 결함이다.""" rep = _report("ok", "question") self.assertEqual(0, rep.error_count) def test_body_markers_belong_to_case_concept_and_setup(self): """**본문이 있는 종류는 셋이다.** 환경 구성의 본문도 Case 와 같은 파서를 탄다.""" self.assertEqual({"case", "concept", "setup"}, crc.BODY_KINDS) def test_every_kind_has_a_fixture_pair(self): """종류를 더하면서 고정 사례를 안 만들면 위 시험들이 조용히 그 종류를 건너뛴다. `_report` 가 없는 파일을 열면 터지지만, 그 전에 **어느 종류가 비었는지**를 말해 준다. 「볼 것이 없어서 통과」를 「문제 없음」으로 쓰지 않는 것과 같은 자리다. """ for sub in ("ok", "missing"): for kind in KINDS: with self.subTest(sub=sub, kind=kind): self.assertTrue( os.path.isfile(os.path.join(FIXTURES, sub, f"{kind}.md")), f"{sub}/{kind}.md 이 없다 — 종류를 더했으면 고정 사례도 둘 만든다") def _cli(self, *args): import subprocess return subprocess.run( ["python3", os.path.join(ROOT, "scripts", "check-required-content.py"), *args], cwd=ROOT, capture_output=True, text=True) def test_a_missing_project_is_not_reported_as_clean(self): """대상이 없으면 통과가 아니다. 오타 하나로 관문이 무효가 되면 안 된다.""" p = self._cli("nonexistent-project") self.assertEqual(2, p.returncode) self.assertIn("대상이 성립하지 않는다", p.stderr) def test_a_project_without_a_contract_does_not_come_back_green(self): """계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다.""" with _a_project_without_a_contract() as project: p = self._cli(project) self.assertEqual(2, p.returncode) self.assertIn("tech-log-tree.json 이 없다", p.stderr) def test_a_contract_with_no_records_yet_is_not_an_error(self): """아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다.""" with _a_contract_with_no_records() as project: p = self._cli(project) self.assertEqual(0, p.returncode) self.assertIn("아직 안 쓰였다", p.stdout) def test_one_ungrounded_target_stops_the_whole_run(self): """성립하는 것과 안 하는 것을 함께 주면 통과로 뭉개지 않는다. 성립하는 쪽으로 `keycloak` 을 쓴다. `verify-pipeline.py` 의 계약이 scripts/ 안에 저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은 프로젝트를 테스트에 적으면 계약 검사가 깨진다. """ with _a_project_without_a_contract() as project: p = self._cli("keycloak", project) self.assertEqual(2, p.returncode) if __name__ == "__main__": unittest.main()