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