Files
document-haness/scripts/tests/test_required_content.py
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:02:02 +09:00

197 lines
9.2 KiB
Python

"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
고정 사례는 **여섯** 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
"""
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()