"""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()