이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 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>
197 lines
9.2 KiB
Python
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()
|