Files
document-haness/scripts/tests/test_ssot_facts.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

192 lines
8.7 KiB
Python

"""SSOT 가 코드베이스를 옳게 읽었는지 보는 검사기.
이 검사기의 첫 판이 **거짓 error 를 두 종류 냈다.** 둘 다 SSOT 가 아니라 검사기가 틀린
것이었고, 그대로 뒀으면 「SSOT 가 67곳 틀렸다」는 보고가 나갔을 것이다. 아래 테스트의 절반은
그 둘을 고정한다.
· 「main Java 27 · test Java 18」의 27 을 Java **버전**으로 읽었다 → 이 문서에서 그것은
파일 수다. 버전 주장은 스택을 한 줄에 모아 적는 자리에만 있다
· SSOT 가 줄여 쓴 경로(`app-bootstrap/application.yml`)를 「그 리비전에 없다」로 셌다 →
있는 파일을 없다고 하는 것이다. `check-code-anchors.py` 가 `_undecidable` 로 막아 둔
실패와 같은 모양이다
"""
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TOOL = os.path.join(ROOT, "scripts", "check-ssot-facts.py")
_spec = importlib.util.spec_from_file_location("check_ssot_facts", TOOL)
sf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sf)
techlog = sf.techlog
class ReadingTheHeader(unittest.TestCase):
"""머리표에서 수치를 꺼낸다."""
DOC = ("# 제목\n\n> 머리말\n\n"
"| | |\n|---|---|\n"
"| 추적 파일 | 6,747 |\n"
"| main Java | 4,614 파일 / 320,318 LOC |\n"
"| finding 총계 | **462** — P1 30 · P2 147 · P3 285 |\n"
"\n## 1. 본문\n\n| 가족 | leaf |\n|---|---|\n| core | 5 |\n")
def test_reads_the_first_table_only(self) -> None:
claims = sf.header_claims(self.DOC)
self.assertIn("추적 파일", claims)
self.assertNotIn("가족", claims, "본문 표를 머리표로 읽으면 안 된다")
def test_num_strips_commas_and_bold(self) -> None:
self.assertEqual(sf._num("6,747"), 6747)
self.assertEqual(sf._num("**462** — P1 30"), 462)
self.assertIsNone(sf._num("없음"))
class InternalArithmetic(unittest.TestCase):
"""합계 = 부분의 합. 값이 옳은지가 아니라 자기 안에서 맞는지만 본다."""
def _run(self, text: str) -> techlog.Report:
rep = techlog.Report("t")
sf.check_arithmetic(rep, {"finding 총계": text})
return rep
def test_a_consistent_total_passes(self) -> None:
self.assertEqual(self._run("**462** — P1 30 · P2 147 · P3 285").error_count, 0)
def test_a_total_that_is_not_the_sum_fails(self) -> None:
rep = self._run("**999** — P1 30 · P2 147 · P3 285")
self.assertEqual(rep.error_count, 1)
class EvidenceCountsAreASnapshot(unittest.TestCase):
"""증거 수치는 리비전에 고정된 값이 아니다. 지금 파일 수와 견주는 것이 범주 오류였다.
소스는 `21234e38` 에 묶여 있어 추적 파일과 LOC 는 지금 다시 세도 같다. 증거는 이 저장소
안에 살고 뒤이은 배치가 계속 늘린다. 그래서 판정은 크고 작음이 아니라 **기준 시점을
선언했는가**다. 첫 판이 이 구분 없이 error 2건을 냈다.
"""
def _run(self, cell: str, base: str) -> techlog.Report:
rep = techlog.Report("t")
sf.check_evidence_counts(rep, {"증거": cell}, base)
return rep
def setUp(self) -> None:
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
for name, n in (("raw", 5), ("meta", 3)):
d = os.path.join(self.tmp.name, "final", "evidence", name)
os.makedirs(d)
for i in range(n):
open(os.path.join(d, f"{i}.txt"), "w").close()
def test_a_stale_number_without_a_basis_is_an_error(self) -> None:
rep = self._run("`evidence/raw` 370 · `evidence/meta` 14", self.tmp.name)
self.assertEqual(rep.error_count, 2, "기준 시점이 없으면 지금 값으로 읽힌다")
def test_the_same_number_with_a_declared_basis_passes(self) -> None:
rep = self._run("분석 시점(2026-08-31) 스냅샷 — `evidence/raw` 370 · `evidence/meta` 14",
self.tmp.name)
self.assertEqual(rep.error_count, 0)
def test_a_matching_number_needs_no_basis(self) -> None:
rep = self._run("`evidence/raw` 5 · `evidence/meta` 3", self.tmp.name)
self.assertEqual(rep.error_count, 0)
def test_both_numbers_are_reported_as_facts(self) -> None:
"""차이를 숨기지 않는다 — error 가 아니어도 머리표와 지금 값을 함께 낸다."""
rep = techlog.Report("t")
facts = sf.check_evidence_counts(
rep, {"증거": "스냅샷 — `evidence/raw` 370 · `evidence/meta` 14"}, self.tmp.name)
self.assertIn("머리표 370", facts["증거 raw"])
self.assertIn("지금 5", facts["증거 raw"])
class VersionClaimsAreNotFileCounts(unittest.TestCase):
"""「main Java 27」의 27 은 버전이 아니다. 첫 판이 이것을 버전으로 읽었다."""
def test_file_count_lines_are_not_stack_lines(self) -> None:
doc = "main Java 27 · test Java 18\n| 가족 | main Java 26 | test Java 13 |\n"
self.assertEqual(sf.stack_lines(doc).strip(), "",
"파일 수를 적은 줄을 버전 주장으로 읽으면 안 된다")
def test_the_stack_sentence_is_a_stack_line(self) -> None:
doc = "Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈.\n"
self.assertIn("Java 21", sf.stack_lines(doc))
def test_two_names_are_required(self) -> None:
self.assertEqual(sf.stack_lines("Gradle 9.0.0 을 쓴다\n").strip(), "")
class CitationsThatCannotBeDecided(unittest.TestCase):
"""판정할 수 있는 것만 판정한다. 못 보는 것은 세어서 낸다."""
TRACKED = {"src/app-bootstrap/src/main/resources/application.yml",
"src/app-bootstrap/build.gradle",
"src/config/architecture/modules.json"}
def _run(self, doc: str, base: str = "/nonexistent"):
rep = techlog.Report("t")
stats = sf.check_citations(rep, doc, "/nonexistent-repo", "deadbeef",
self.TRACKED, base)
return rep, stats
def test_an_exact_path_resolves(self) -> None:
rep, stats = self._run("`src/app-bootstrap/build.gradle`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["대조한 인용"], 1)
def test_a_src_relative_path_resolves_and_is_counted(self) -> None:
rep, stats = self._run("`app-bootstrap/build.gradle`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["src/ 접두사로 풀린 인용"], 1)
def test_a_shortened_path_is_a_warning_not_an_error(self) -> None:
"""첫 판이 이것을 error 로 냈다. 있는 파일을 없다고 한 것이다."""
rep, stats = self._run("`app-bootstrap/application.yml`")
self.assertEqual(rep.error_count, 0)
self.assertEqual(stats["못 대조한 인용"], 1)
def test_a_name_that_exists_nowhere_is_an_error(self) -> None:
rep, _ = self._run("`src/app-bootstrap/NoSuchThing.java`")
self.assertEqual(rep.error_count, 1)
def test_shapes_this_checker_does_not_decide(self) -> None:
for doc, why in (("`/tmp/CeProbe.java`", "저장소 밖 절대 경로"),
("`.../avro/AvroMessageCodec.java`", "축약된 경로"),
("`build/evidence/manifest.json`", "빌드 산출물")):
with self.subTest(why=why):
rep, stats = self._run(doc)
self.assertEqual(rep.error_count, 0, why)
self.assertEqual(stats["못 대조한 인용"], 1, why)
class NoTarget(unittest.TestCase):
"""「볼 것이 없어서 통과」를 「문제 없음」이라고 쓰지 않는다."""
def _run(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, TOOL, *args],
cwd=ROOT, capture_output=True, text=True, timeout=600)
def test_absent_project_exits_2(self) -> None:
run = self._run("no-such-project-ssot-facts")
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
self.assertIn("대상이 성립하지 않는다", run.stdout + run.stderr)
def test_the_limit_is_printed_before_any_verdict(self) -> None:
"""빠뜨린 finding 은 못 본다. 그 한계가 판정보다 먼저 보여야 한다."""
run = self._run("clean-architecture-backend-template")
self.assertIn("빠뜨린 finding 은 찾지 못한다", run.stdout)
self.assertLess(run.stdout.index("빠뜨린 finding"),
run.stdout.index("SSOT FACTS:"))
if __name__ == "__main__":
unittest.main()